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      setResult(await action(...args, ...additionalArgs));
113    },
114    [args, additionalArgs]
115  );
116
117  return (
118    <>
119      <HeadingText>{name}</HeadingText>
120      <Configurator parameters={parameters} onChange={updateArgument} value={args} />
121      {additionalParameters.length > 0 && (
122        <>
123          <Divider text="ADDITIONAL PARAMETERS" />
124          <Configurator
125            parameters={additionalParameters}
126            onChange={updateAdditionalArgs}
127            value={additionalArgs}
128          />
129        </>
130      )}
131      <View style={styles.container}>
132        <FunctionSignature namespace={namespace} name={name} parameters={parameters} args={args} />
133        <View style={styles.buttonsContainer}>
134          {actionsList.map(({ name, action }) => (
135            <ActionButton key={name} name={name} action={action} onPress={handlePress} />
136          ))}
137        </View>
138      </View>
139      {result && (
140        <>
141          <MonoTextWithCountdown onCountdownEnded={() => setResult(undefined)}>
142            {resultToString(result)}
143          </MonoTextWithCountdown>
144          {renderAdditionalResult?.(result)}
145        </>
146      )}
147    </>
148  );
149}
150
151function initialArgumentFromParameter(parameter: PrimitiveParameter | ConstantParameter) {
152  switch (parameter.type) {
153    case 'boolean':
154      return parameter.initial;
155    case 'string':
156    case 'number':
157      return parameter.values[0];
158    case 'enum':
159      return parameter.values[0].value;
160    case 'constant':
161      return parameter.value;
162  }
163}
164
165function initialArgumentsFromParameters(parameters: FunctionParameter[]) {
166  return parameters.map((parameter) => {
167    switch (parameter.type) {
168      case 'object':
169        return Object.fromEntries(
170          parameter.properties.map((property) => {
171            return [property.name, initialArgumentFromParameter(property)];
172          })
173        );
174      default:
175        return initialArgumentFromParameter(parameter);
176    }
177  });
178}
179
180/**
181 * Hook that handles function arguments' values.
182 * Initial value is constructed based on the description of each parameter.
183 */
184function useArguments(
185  parameters: FunctionParameter[]
186): [FunctionArgument[], OnArgumentChangeCallback] {
187  const [args, setArgs] = useState(initialArgumentsFromParameters(parameters));
188  const updateArgument = useCallback(
189    (name: ArgumentName, newValue: PrimitiveArgument) => {
190      const parameterIsObject = typeof name === 'object';
191      const argumentName = parameterIsObject ? name[0] : name;
192      const argumentIdx = parameters.findIndex((parameter) => parameter.name === argumentName);
193      setArgs((currentArgs) => {
194        const newArgs = [...currentArgs];
195        newArgs[argumentIdx] = parameterIsObject
196          ? {
197              ...(currentArgs[argumentIdx] as object),
198              [name[1]]: newValue,
199            }
200          : newValue;
201        return newArgs;
202      });
203    },
204    [parameters]
205  );
206  return [args, updateArgument];
207}
208
209function resultToString(result: unknown) {
210  if (result === null) {
211    return 'null';
212  }
213
214  if (result === 'undefined') {
215    return 'undefined';
216  }
217
218  if (typeof result === 'object') {
219    const trimmedResult = Object.fromEntries(
220      Object.entries(result).map(([key, value]) => [
221        key,
222        typeof value === 'string' && value.length > STRING_TRIM_THRESHOLD
223          ? `${value.substring(0, STRING_TRIM_THRESHOLD)}...`
224          : value,
225      ])
226    );
227
228    return JSON.stringify(trimmedResult, null, 2);
229  }
230
231  return String(result).length > STRING_TRIM_THRESHOLD
232    ? `${String(result).substring(0, STRING_TRIM_THRESHOLD)}...`
233    : String(result);
234}
235
236const styles = StyleSheet.create({
237  container: {
238    position: 'relative',
239    paddingBottom: 20,
240  },
241  buttonsContainer: {
242    position: 'absolute',
243    right: 0,
244    bottom: 3,
245    flexDirection: 'row',
246  },
247});
248