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 Platforms from './Platforms';
11import {
12  ActionFunction,
13  ArgumentName,
14  ConstantParameter,
15  FunctionArgument,
16  FunctionParameter,
17  OnArgumentChangeCallback,
18  Platform,
19  PrimitiveArgument,
20  PrimitiveParameter,
21} from './index.types';
22import { isCurrentPlatformSupported } from './utils';
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: unknown) => 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' | 'renderAdditionalResult'>;
64
65/**
66 * FunctionDemo is a component that allows visualizing the function call.
67 * It also allows the function's arguments manipulation and invoking the the function via the actions prop.
68 * Additionally it presents the result of the successful function call.
69 *
70 * @example
71 * ```tsx
72 * const FUNCTION_DESCRIPTION: FunctionDescription = {
73 *   name: 'functionName',
74 *   parameters: [
75 *     { name: 'param1', type: 'string', values: ['value1', 'value2'] },
76 *     ...
77 *   ],
78 *   additionalParameters: [
79 *     { name: 'additionalParameter', type: 'boolean', initial: false },
80 *     ...
81 *   ]
82 *   actions: [
83 *     {
84 *       name: 'actionName',
85 *       action: async (param1: string, ..., additionalParameter: boolean, ...) => {
86 *         ...
87 *         return someObject
88 *       }
89 *     },
90 *     ...
91 *   ]
92 * }
93 *
94 * function DemoComponent() {
95 *   return (
96 *     <FunctionDemo namespace="ModuleName" {...FUNCTION_DESCRIPTION} />
97 *   )
98 * }
99 * ```
100 */
101export default function FunctionDemo({ name, platforms = [], ...contentProps }: Props) {
102  const disabled = !isCurrentPlatformSupported(platforms);
103
104  return (
105    <View style={disabled && styles.demoContainerDisabled}>
106      <Platforms
107        platforms={platforms}
108        style={styles.platformBadge}
109        textStyle={styles.platformText}
110      />
111      <HeadingText style={disabled && styles.headerDisabled}>{name}</HeadingText>
112      {!disabled && <FunctionDemoContent name={name} {...contentProps} />}
113    </View>
114  );
115}
116
117function FunctionDemoContent({
118  namespace,
119  name,
120  parameters = [],
121  actions,
122  renderAdditionalResult,
123  additionalParameters = [],
124}: Props) {
125  const [result, setResult] = useState<unknown>(undefined);
126  const [args, updateArgument] = useArguments(parameters);
127  const [additionalArgs, updateAdditionalArgs] = useArguments(additionalParameters);
128  const actionsList = useMemo(
129    () => (Array.isArray(actions) ? actions : [{ name: 'RUN ▶️', action: actions }]),
130    [actions]
131  );
132
133  const handlePress = useCallback(
134    async (action: ActionFunction) => {
135      // force clear the previous result if exists
136      setResult(undefined);
137      const newResult = await action(...args, ...additionalArgs);
138      // undefined is a special value hiding the result box
139      // so we need to replace it with a string
140      setResult(newResult === undefined ? 'undefined' : newResult);
141    },
142    [args, additionalArgs]
143  );
144
145  return (
146    <>
147      <Configurator parameters={parameters} onChange={updateArgument} value={args} />
148      {additionalParameters.length > 0 && (
149        <>
150          <Divider text="ADDITIONAL PARAMETERS" />
151          <Configurator
152            parameters={additionalParameters}
153            onChange={updateAdditionalArgs}
154            value={additionalArgs}
155          />
156        </>
157      )}
158      <View style={styles.container}>
159        <FunctionSignature namespace={namespace} name={name} parameters={parameters} args={args} />
160        <View style={styles.buttonsContainer}>
161          {actionsList.map(({ name, action }) => (
162            <ActionButton key={name} name={name} action={action} onPress={handlePress} />
163          ))}
164        </View>
165      </View>
166      {result !== undefined && (
167        <>
168          <MonoTextWithCountdown onCountdownEnded={() => setResult(undefined)}>
169            {resultToString(result)}
170          </MonoTextWithCountdown>
171          {renderAdditionalResult?.(result)}
172        </>
173      )}
174    </>
175  );
176}
177
178function initialArgumentFromParameter(parameter: PrimitiveParameter | ConstantParameter) {
179  switch (parameter.type) {
180    case 'boolean':
181      return parameter.initial;
182    case 'string':
183    case 'number':
184      return parameter.values[0];
185    case 'enum':
186      return parameter.values[0].value;
187    case 'constant':
188      return parameter.value;
189  }
190}
191
192function initialArgumentsFromParameters(parameters: FunctionParameter[]) {
193  return parameters.map((parameter) => {
194    switch (parameter.type) {
195      case 'object':
196        return Object.fromEntries(
197          parameter.properties.map((property) => {
198            return [property.name, initialArgumentFromParameter(property)];
199          })
200        );
201      default:
202        return initialArgumentFromParameter(parameter);
203    }
204  });
205}
206
207/**
208 * Hook that handles function arguments' values.
209 * Initial value is constructed based on the description of each parameter.
210 */
211function useArguments(
212  parameters: FunctionParameter[]
213): [FunctionArgument[], OnArgumentChangeCallback] {
214  const [args, setArgs] = useState(initialArgumentsFromParameters(parameters));
215  const updateArgument = useCallback(
216    (name: ArgumentName, newValue: PrimitiveArgument) => {
217      const parameterIsObject = typeof name === 'object';
218      const argumentName = parameterIsObject ? name[0] : name;
219      const argumentIdx = parameters.findIndex((parameter) => parameter.name === argumentName);
220      setArgs((currentArgs) => {
221        const newArgs = [...currentArgs];
222        newArgs[argumentIdx] = parameterIsObject
223          ? {
224              ...(currentArgs[argumentIdx] as object),
225              [name[1]]: newValue,
226            }
227          : newValue;
228        return newArgs;
229      });
230    },
231    [parameters]
232  );
233  return [args, updateArgument];
234}
235
236function resultToString(result: unknown) {
237  if (result === null) {
238    return 'null';
239  }
240
241  if (result === 'undefined') {
242    return 'undefined';
243  }
244
245  if (typeof result === 'object') {
246    const trimmedResult = Object.fromEntries(
247      Object.entries(result).map(([key, value]) => [
248        key,
249        typeof value === 'string' && value.length > STRING_TRIM_THRESHOLD
250          ? `${value.substring(0, STRING_TRIM_THRESHOLD)}...`
251          : value,
252      ])
253    );
254
255    return JSON.stringify(trimmedResult, null, 2);
256  }
257
258  return String(result).length > STRING_TRIM_THRESHOLD
259    ? `${String(result).substring(0, STRING_TRIM_THRESHOLD)}...`
260    : String(result);
261}
262
263const styles = StyleSheet.create({
264  container: {
265    position: 'relative',
266    paddingBottom: 20,
267  },
268  buttonsContainer: {
269    position: 'absolute',
270    right: 0,
271    bottom: 3,
272    flexDirection: 'row',
273  },
274  platformBadge: {
275    position: 'absolute',
276    top: 5,
277  },
278  platformText: {
279    fontSize: 10,
280  },
281  headerDisabled: {
282    textDecorationLine: 'line-through',
283    color: '#999',
284  },
285  demoContainerDisabled: {
286    marginBottom: 10,
287  },
288});
289