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