1import React, { useState, useCallback, useMemo } from 'react';
2import { StyleSheet, View } from 'react-native';
3
4import HeadingText from '../HeadingText';
5import MonoTextWithCountdown from '../MonoTextWithCountdown';
6import ActionButton from './ActionButton';
7import Configurator from './Configurator';
8import Divider from './Divider';
9import FunctionSignature from './FunctionSignature';
10import {
11  ActionFunction,
12  ArgumentName,
13  ConstantParameter,
14  FunctionArgument,
15  FunctionParameter,
16  OnArgumentChangeCallback,
17  PrimitiveArgument,
18  PrimitiveParameter,
19} from './index.types';
20
21const STRING_TRIM_THRESHOLD = 300;
22
23type Props = {
24  /**
25   * Function namespace/scope (e.g. module name). Used in signature rendering.
26   */
27  namespace: string;
28  /**
29   * Function name. Used in signature rendering.
30   */
31  name: string;
32  /**
33   * Function-only parameters. Function's arguments are constructed based on these parameters and passed as-is to the actions callbacks.
34   * These should reflect the actual function signature (type of arguments, default values, order, etc.).
35   */
36  parameters?: FunctionParameter[];
37  /**
38   * Additional parameters that are directly mappable to the function arguments.
39   * If you need to add some additional logic to the function call you can do it here.
40   * The current value for these parameters is passed to the actions' callbacks as the additional arguments.
41   */
42  additionalParameters?: PrimitiveParameter[];
43  /**
44   * Single action or a list of actions that could be called by the user. Each action would be fetched with the arguments constructed from the parameters.
45   */
46  actions: ActionFunction | { name: string; action: ActionFunction }[];
47  /**
48   * Rendering function to render some additional components based on the function's result.
49   */
50  renderAdditionalResult?: (result: unknown) => JSX.Element | void;
51};
52
53/**
54 * Helper type for typing out the function description that is later passed to the `FunctionDemo` component.
55 */
56export type FunctionDescription = Omit<Props, 'namespace' | 'renderAdditionalResult'>;
57
58/**
59 * FunctionDemo is a component that allows visualizing the function call.
60 * It also allows the function's arguments manipulation and invoking the the function via the actions prop.
61 * Additionally it presents the result of the successful function call.
62 *
63 * @example
64 * ```tsx
65 * const FUNCTION_DESCRIPTION: FunctionDescription = {
66 *   name: 'functionName',
67 *   parameters: [
68 *     { name: 'param1', type: 'string', values: ['value1', 'value2'] },
69 *     ...
70 *   ],
71 *   additionalParameters: [
72 *     { name: 'additionalParameter', type: 'boolean', initial: false },
73 *     ...
74 *   ]
75 *   actions: [
76 *     {
77 *       name: 'actionName',
78 *       action: async (param1: string, ..., additionalParameter: boolean, ...) => {
79 *         ...
80 *         return someObject
81 *       }
82 *     },
83 *     ...
84 *   ]
85 * }
86 *
87 * function DemoComponent() {
88 *   return (
89 *     <FunctionDemo namespace="ModuleName" {...FUNCTION_DESCRIPTION} />
90 *   )
91 * }
92 * ```
93 */
94export default function FunctionDemo({
95  namespace,
96  name,
97  parameters = [],
98  actions,
99  renderAdditionalResult,
100  additionalParameters = [],
101}: Props) {
102  const [result, setResult] = useState<unknown>(undefined);
103  const [args, updateArgument] = useArguments(parameters);
104  const [additionalArgs, updateAdditionalArgs] = useArguments(additionalParameters);
105  const actionsList = useMemo(
106    () => (Array.isArray(actions) ? actions : [{ name: 'RUN ▶️', action: actions }]),
107    [actions]
108  );
109
110  const handlePress = useCallback(
111    async (action: ActionFunction) => {
112      // force clear the previous result if exists
113      setResult(undefined);
114      const newResult = await action(...args, ...additionalArgs);
115      // undefined is a special value hiding the result box
116      // so we need to replace it with a string
117      setResult(newResult === undefined ? 'undefined' : newResult);
118    },
119    [args, additionalArgs]
120  );
121
122  return (
123    <>
124      <HeadingText>{name}</HeadingText>
125      <Configurator parameters={parameters} onChange={updateArgument} value={args} />
126      {additionalParameters.length > 0 && (
127        <>
128          <Divider text="ADDITIONAL PARAMETERS" />
129          <Configurator
130            parameters={additionalParameters}
131            onChange={updateAdditionalArgs}
132            value={additionalArgs}
133          />
134        </>
135      )}
136      <View style={styles.container}>
137        <FunctionSignature namespace={namespace} name={name} parameters={parameters} args={args} />
138        <View style={styles.buttonsContainer}>
139          {actionsList.map(({ name, action }) => (
140            <ActionButton key={name} name={name} action={action} onPress={handlePress} />
141          ))}
142        </View>
143      </View>
144      {result !== undefined && (
145        <>
146          <MonoTextWithCountdown onCountdownEnded={() => setResult(undefined)}>
147            {resultToString(result)}
148          </MonoTextWithCountdown>
149          {renderAdditionalResult?.(result)}
150        </>
151      )}
152    </>
153  );
154}
155
156function initialArgumentFromParameter(parameter: PrimitiveParameter | ConstantParameter) {
157  switch (parameter.type) {
158    case 'boolean':
159      return parameter.initial;
160    case 'string':
161    case 'number':
162      return parameter.values[0];
163    case 'enum':
164      return parameter.values[0].value;
165    case 'constant':
166      return parameter.value;
167  }
168}
169
170function initialArgumentsFromParameters(parameters: FunctionParameter[]) {
171  return parameters.map((parameter) => {
172    switch (parameter.type) {
173      case 'object':
174        return Object.fromEntries(
175          parameter.properties.map((property) => {
176            return [property.name, initialArgumentFromParameter(property)];
177          })
178        );
179      default:
180        return initialArgumentFromParameter(parameter);
181    }
182  });
183}
184
185/**
186 * Hook that handles function arguments' values.
187 * Initial value is constructed based on the description of each parameter.
188 */
189function useArguments(
190  parameters: FunctionParameter[]
191): [FunctionArgument[], OnArgumentChangeCallback] {
192  const [args, setArgs] = useState(initialArgumentsFromParameters(parameters));
193  const updateArgument = useCallback(
194    (name: ArgumentName, newValue: PrimitiveArgument) => {
195      const parameterIsObject = typeof name === 'object';
196      const argumentName = parameterIsObject ? name[0] : name;
197      const argumentIdx = parameters.findIndex((parameter) => parameter.name === argumentName);
198      setArgs((currentArgs) => {
199        const newArgs = [...currentArgs];
200        newArgs[argumentIdx] = parameterIsObject
201          ? {
202              ...(currentArgs[argumentIdx] as object),
203              [name[1]]: newValue,
204            }
205          : newValue;
206        return newArgs;
207      });
208    },
209    [parameters]
210  );
211  return [args, updateArgument];
212}
213
214function resultToString(result: unknown) {
215  if (result === null) {
216    return 'null';
217  }
218
219  if (result === 'undefined') {
220    return 'undefined';
221  }
222
223  if (typeof result === 'object') {
224    const trimmedResult = Object.fromEntries(
225      Object.entries(result).map(([key, value]) => [
226        key,
227        typeof value === 'string' && value.length > STRING_TRIM_THRESHOLD
228          ? `${value.substring(0, STRING_TRIM_THRESHOLD)}...`
229          : value,
230      ])
231    );
232
233    return JSON.stringify(trimmedResult, null, 2);
234  }
235
236  return String(result).length > STRING_TRIM_THRESHOLD
237    ? `${String(result).substring(0, STRING_TRIM_THRESHOLD)}...`
238    : String(result);
239}
240
241const styles = StyleSheet.create({
242  container: {
243    position: 'relative',
244    paddingBottom: 20,
245  },
246  buttonsContainer: {
247    position: 'absolute',
248    right: 0,
249    bottom: 3,
250    flexDirection: 'row',
251  },
252});
253