1import React, { useState, useCallback, useMemo } from 'react';
2import { StyleSheet, View } from 'react-native';
3
4import ActionButton from './ActionButton';
5import Configurator from './Configurator';
6import Divider from './Divider';
7import FunctionSignature, { generateFunctionSignature } from './FunctionSignature';
8import Platforms from './Platforms';
9import {
10  ActionFunction,
11  ArgumentName,
12  ConstantParameter,
13  FunctionArgument,
14  FunctionParameter,
15  OnArgumentChangeCallback,
16  Platform,
17  PrimitiveArgument,
18  PrimitiveParameter,
19} from './index.types';
20import { isCurrentPlatformSupported } from './utils';
21import HeadingText from '../HeadingText';
22import MonoTextWithCountdown from '../MonoTextWithCountdown';
23
24const STRING_TRIM_THRESHOLD = 300;
25
26type Props = {
27  /**
28   * Function namespace/scope (e.g. module name). Used in signature rendering.
29   */
30  namespace: string;
31  /**
32   * Function name. Used in signature rendering.
33   */
34  name: string;
35  /**
36   * Supported platforms. Used in signature rendering and to grey-out unavailable functions.
37   */
38  platforms?: Platform[];
39  /**
40   * Function-only parameters. Function's arguments are constructed based on these parameters and passed as-is to the actions callbacks.
41   * These should reflect the actual function signature (type of arguments, default values, order, etc.).
42   */
43  parameters?: FunctionParameter[];
44  /**
45   * Additional parameters that are directly mappable to the function arguments.
46   * If you need to add some additional logic to the function call you can do it here.
47   * The current value for these parameters is passed to the actions' callbacks as the additional arguments.
48   */
49  additionalParameters?: PrimitiveParameter[];
50  /**
51   * 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.
52   */
53  actions: ActionFunction | { name: string; action: ActionFunction }[];
54  /**
55   * Rendering function to render some additional components based on the function's result.
56   */
57  renderAdditionalResult?: (result: any) => JSX.Element | void;
58};
59
60/**
61 * Helper type for typing out the function description that is later passed to the `FunctionDemo` component.
62 */
63export type FunctionDescription = Omit<Props, 'namespace'>;
64
65type Result =
66  | {
67      type: 'none';
68    }
69  | {
70      type: 'error';
71      error: unknown;
72    }
73  | {
74      type: 'success';
75      result: unknown;
76    };
77
78/**
79 * FunctionDemo is a component that allows visualizing the function call.
80 * It also allows the function's arguments manipulation and invoking the the function via the actions prop.
81 * Additionally it presents the result of the successful function call.
82 *
83 * @example
84 * ```tsx
85 * const FUNCTION_DESCRIPTION: FunctionDescription = {
86 *   name: 'functionName',
87 *   parameters: [
88 *     { name: 'param1', type: 'string', values: ['value1', 'value2'] },
89 *     ...
90 *   ],
91 *   additionalParameters: [
92 *     { name: 'additionalParameter', type: 'boolean', initial: false },
93 *     ...
94 *   ]
95 *   actions: [
96 *     {
97 *       name: 'actionName',
98 *       action: async (param1: string, ..., additionalParameter: boolean, ...) => {
99 *         ...
100 *         return someObject
101 *       }
102 *     },
103 *     ...
104 *   ]
105 * }
106 *
107 * function DemoComponent() {
108 *   return (
109 *     <FunctionDemo namespace="ModuleName" {...FUNCTION_DESCRIPTION} />
110 *   )
111 * }
112 * ```
113 */
114export default function FunctionDemo({ name, platforms = [], ...contentProps }: Props) {
115  const disabled = !isCurrentPlatformSupported(platforms);
116
117  return (
118    <View style={disabled && styles.demoContainerDisabled}>
119      <Platforms
120        platforms={platforms}
121        style={styles.platformBadge}
122        textStyle={styles.platformText}
123      />
124      <HeadingText style={disabled && styles.headerDisabled}>{name}</HeadingText>
125      {!disabled && <FunctionDemoContent name={name} {...contentProps} />}
126    </View>
127  );
128}
129
130function FunctionDemoContent({
131  namespace,
132  name,
133  parameters = [],
134  actions,
135  renderAdditionalResult,
136  additionalParameters = [],
137}: Props) {
138  const [result, setResult] = useState<Result>({ type: 'none' });
139  const [args, updateArgument] = useArguments(parameters);
140  const [additionalArgs, updateAdditionalArgs] = useArguments(additionalParameters);
141  const actionsList = useMemo(
142    () => (Array.isArray(actions) ? actions : [{ name: 'RUN ▶️', action: actions }]),
143    [actions]
144  );
145
146  const handlePress = useCallback(
147    async (action: ActionFunction) => {
148      // force clear the previous result if exists
149      setResult({ type: 'none' });
150      try {
151        const newResult = await action(...args, ...additionalArgs);
152        setResult({ type: 'success', result: newResult });
153      } catch (e) {
154        logError(e, generateFunctionSignature({ namespace, name, parameters, args }));
155        setResult({ type: 'error', error: e });
156      }
157    },
158    [args, additionalArgs]
159  );
160
161  return (
162    <>
163      <Configurator parameters={parameters} onChange={updateArgument} value={args} />
164      {additionalParameters.length > 0 && (
165        <>
166          <Divider text="ADDITIONAL PARAMETERS" />
167          <Configurator
168            parameters={additionalParameters}
169            onChange={updateAdditionalArgs}
170            value={additionalArgs}
171          />
172        </>
173      )}
174      <View style={styles.container}>
175        <FunctionSignature namespace={namespace} name={name} parameters={parameters} args={args} />
176        <View style={styles.buttonsContainer}>
177          {actionsList.map(({ name, action }) => (
178            <ActionButton key={name} name={name} action={action} onPress={handlePress} />
179          ))}
180        </View>
181      </View>
182      {result.type === 'success' ? (
183        <>
184          <MonoTextWithCountdown onCountdownEnded={() => setResult({ type: 'none' })}>
185            {resultToString(result.result)}
186          </MonoTextWithCountdown>
187          {renderAdditionalResult?.(result.result)}
188        </>
189      ) : result.type === 'error' ? (
190        <MonoTextWithCountdown
191          style={styles.errorResult}
192          onCountdownEnded={() => setResult({ type: 'none' })}>
193          {errorToString(result.error)}
194        </MonoTextWithCountdown>
195      ) : null}
196    </>
197  );
198}
199
200function logError(e: unknown, functionSignature: string) {
201  console.error(`
202${e}
203
204Function call that failed:
205
206  ${functionSignature.replace(/\n/g, '\n  ')}
207
208  `);
209}
210
211function initialArgumentFromParameter(parameter: PrimitiveParameter | ConstantParameter) {
212  switch (parameter.type) {
213    case 'boolean':
214      return parameter.initial;
215    case 'string':
216    case 'number':
217      return parameter.values[0];
218    case 'enum':
219      return parameter.values[0].value;
220    case 'constant':
221      return parameter.value;
222  }
223}
224
225function initialArgumentsFromParameters(parameters: FunctionParameter[]) {
226  return parameters.map((parameter) => {
227    switch (parameter.type) {
228      case 'object':
229        return Object.fromEntries(
230          parameter.properties.map((property) => {
231            return [property.name, initialArgumentFromParameter(property)];
232          })
233        );
234      default:
235        return initialArgumentFromParameter(parameter);
236    }
237  });
238}
239
240/**
241 * Hook that handles function arguments' values.
242 * Initial value is constructed based on the description of each parameter.
243 */
244export function useArguments(
245  parameters: FunctionParameter[]
246): [FunctionArgument[], OnArgumentChangeCallback] {
247  const [args, setArgs] = useState(initialArgumentsFromParameters(parameters));
248  const updateArgument = useCallback(
249    (name: ArgumentName, newValue: PrimitiveArgument) => {
250      const parameterIsObject = typeof name === 'object';
251      const argumentName = parameterIsObject ? name[0] : name;
252      const argumentIdx = parameters.findIndex((parameter) => parameter.name === argumentName);
253      setArgs((currentArgs) => {
254        const newArgs = [...currentArgs];
255        newArgs[argumentIdx] = parameterIsObject
256          ? {
257              ...(currentArgs[argumentIdx] as object),
258              [name[1]]: newValue,
259            }
260          : newValue;
261        return newArgs;
262      });
263    },
264    [parameters]
265  );
266  return [args, updateArgument];
267}
268
269function resultToString(result: unknown) {
270  if (result === null) {
271    return 'null';
272  }
273
274  if (result === 'undefined') {
275    return 'undefined';
276  }
277
278  if (typeof result === 'object') {
279    const trimmedResult = Object.fromEntries(
280      Object.entries(result).map(([key, value]) => [
281        key,
282        typeof value === 'string' && value.length > STRING_TRIM_THRESHOLD
283          ? `${value.substring(0, STRING_TRIM_THRESHOLD)}...`
284          : value,
285      ])
286    );
287
288    return JSON.stringify(trimmedResult, null, 2);
289  }
290
291  return String(result).length > STRING_TRIM_THRESHOLD
292    ? `${String(result).substring(0, STRING_TRIM_THRESHOLD)}...`
293    : String(result);
294}
295
296function errorToString(error: unknown) {
297  if (error instanceof Error) {
298    return `${error.name}: ${error.message}`;
299  }
300  return String(error);
301}
302
303const styles = StyleSheet.create({
304  container: {
305    position: 'relative',
306    paddingBottom: 20,
307  },
308  buttonsContainer: {
309    position: 'absolute',
310    right: 0,
311    bottom: 3,
312    flexDirection: 'row',
313  },
314  platformBadge: {
315    position: 'absolute',
316    top: 5,
317  },
318  platformText: {
319    fontSize: 10,
320  },
321  headerDisabled: {
322    textDecorationLine: 'line-through',
323    color: '#999',
324  },
325  demoContainerDisabled: {
326    marginBottom: 10,
327  },
328  errorResult: {
329    borderColor: 'red',
330  },
331});
332