feat(connect-explorer-nextra): complex/nested types handling, refactor

This commit is contained in:
Tomas Martykan
2024-03-12 14:07:11 +01:00
committed by Tomáš Martykán
parent cb82750924
commit 905756a4a5
24 changed files with 579 additions and 351 deletions

View File

@@ -19,6 +19,7 @@
"@trezor/connect-web": "workspace:^",
"@trezor/connect-webextension": "workspace:^",
"@trezor/protobuf": "workspace:^",
"@trezor/schema-utils": "workspace:^",
"@trezor/utils": "workspace:^",
"babel-plugin-styled-components": "^2.1.4",
"copy-webpack-plugin": "^12.0.2",

View File

@@ -1,8 +1,11 @@
import { TSchema } from '@sinclair/typebox';
import TrezorConnect from '@trezor/connect-web';
import { GetState, Dispatch, Field } from '../types';
export const SET_METHOD = 'method_set';
export const SET_SCHEMA = 'schema_set';
export const FIELD_CHANGE = 'method_field_change';
export const FIELD_DATA_CHANGE = 'method_field_data_change';
export const ADD_BATCH = 'method_add_batch';
@@ -11,6 +14,7 @@ export const RESPONSE = 'method_response';
export type MethodAction =
| { type: typeof SET_METHOD; methodConfig: any }
| { type: typeof SET_SCHEMA; method: keyof typeof TrezorConnect; schema: TSchema }
| { type: typeof FIELD_CHANGE; field: Field<any>; value: any }
| { type: typeof FIELD_DATA_CHANGE; field: Field<any>; data: any }
| { type: typeof ADD_BATCH; field: Field<any>; item: any }
@@ -22,6 +26,12 @@ export const onSetMethod = (methodConfig: any) => ({
methodConfig,
});
export const onSetSchema = (method: string, schema: TSchema) => ({
type: SET_SCHEMA,
method,
schema,
});
export const onFieldChange = (field: Field<any>, value: any) => ({
type: FIELD_CHANGE,
field,
@@ -66,6 +76,7 @@ export const onSubmit = () => async (dispatch: Dispatch, getState: GetState) =>
return;
}
// @ts-expect-error params type is unknown
const response = await connectMethod({
...method.params,
});
@@ -84,7 +95,7 @@ export const onVerify = () => (dispatch: Dispatch, getState: GetState) => {
message: method.params.message,
hex: undefined,
publicKey: undefined,
};
} as any;
// ethereum extra field
if ('hex' in method.params) {

View File

@@ -45,7 +45,8 @@ export const init =
// The event `WEBEXTENSION.CHANNEL_HANDSHAKE_CONFIRM` is coming from @trezor/connect-webextension/proxy
// that is replacing @trezor/connect-web when connect-explorer is run in connect-explorer-webextension
// so Typescript cannot recognize it.
(TrezorConnect.on as any)(WEBEXTENSION.CHANNEL_HANDSHAKE_CONFIRM, event => {
// @ts-expect-error
TrezorConnect.on(WEBEXTENSION.CHANNEL_HANDSHAKE_CONFIRM, event => {
if (event.type === WEBEXTENSION.CHANNEL_HANDSHAKE_CONFIRM) {
dispatch({ type: ACTIONS.ON_HANDSHAKE_CONFIRMED });
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { Optional, Kind, TSchema } from '@sinclair/typebox';
import { TSchema } from '@sinclair/typebox';
import { Card, CollapsibleBox, variables } from '@trezor/components';
@@ -33,96 +33,16 @@ const ApiPlaygroundWrapper = styled(Card)`
}
`;
const schemaToFields = (schema: TSchema) => {
console.log(schema);
if (schema[Kind] === 'Object') {
return Object.keys(schema.properties).flatMap(key => {
const field = schema.properties[key];
if (field[Kind] === 'Object') {
return [
{
name: key,
label: key,
type: 'json',
value: undefined,
},
];
}
return schemaToFields(field).map(field => {
return {
...field,
name: [key, field.name].filter(v => v).join('.'),
};
});
});
} else if (schema[Kind] === 'Array') {
const fields = schemaToFields(schema.items);
return [
{
name: '',
type: 'array',
batch: [
{
type: '',
fields,
},
],
items: schema[Optional] === 'Optional' ? [] : [fields],
},
];
} else if (schema[Kind] === 'Intersect') {
return schema.allOf?.flatMap(schemaToFields);
} else if (schema[Kind] === 'Union') {
const onlyLiterals = schema.anyOf?.every(s => s[Kind] === 'Literal');
if (onlyLiterals) {
const filtered = schema.anyOf?.filter((s, i) => s.const !== i.toString());
const options = filtered.length > 0 ? filtered : schema.anyOf;
return [
{
type: 'select',
value: schema.default,
optional: schema[Optional] === 'Optional',
data: options.map(s => ({ label: s.const, value: s.const })),
},
];
}
}
const typeMap: Record<string, string> = {
String: 'input',
Number: 'number',
Uint: 'number',
Boolean: 'checkbox',
};
return [
{
type: typeMap[schema[Kind]] ?? 'input',
value: schema.default,
optional: schema[Optional] === 'Optional',
},
];
};
interface ApiPlaygroundProps {
method: string;
schema: TSchema;
}
export const ApiPlayground = ({ method, schema }: ApiPlaygroundProps) => {
const actions = useActions({
onSetMethod: methodActions.onSetMethod,
onSetSchema: methodActions.onSetSchema,
});
useEffect(() => {
const fields = schemaToFields(schema);
actions.onSetMethod({
name: method,
fields,
submitButton: 'Submit',
});
actions.onSetSchema(method, schema);
}, [actions, method, schema]);
return (

View File

@@ -1,8 +1,8 @@
import styled from 'styled-components';
const copy = data => {
const copy = (data?: string) => {
const el = document.createElement('textarea');
el.value = data;
el.value = data ?? '';
el.setAttribute('readonly', '');
el.style.position = 'absolute';
el.style.left = '-9999px';
@@ -33,7 +33,10 @@ const ClipboardButton = styled.div`
}
`;
export const CopyToClipboard = props => (
interface CopyToClipboardProps {
data?: string;
}
export const CopyToClipboard = (props: CopyToClipboardProps) => (
<ClipboardButton title="Copy to clipboard" onClick={_event => copy(props.data)}>
<svg viewBox="0 0 24 24" preserveAspectRatio="xMidYMid">
<path d="M 5 2 C 3.9 2 3 2.9 3 4 L 3 17 L 5 17 L 5 4 L 15 4 L 15 2 L 5 2 z M 9 6 C 7.9 6 7 6.9 7 8 L 7 20 C 7 21.1 7.9 22 9 22 L 18 22 C 19.1 22 20 21.1 20 20 L 20 8 C 20 6.9 19.1 6 18 6 L 9 6 z M 9 8 L 18 8 L 18 20 L 9 20 L 9 8 z" />

View File

@@ -24,11 +24,16 @@ const getArray = (field: FieldWithBundle<any>, props: Props) => (
>
{field.items?.map((batch, index) => {
const key = `${field.name}-${index}`;
const children = batch.map((batchField: any) => getField(batchField, props));
// Move all booleans to the end while not breaking the order of other fields
const bools = batch.filter(f => f.type === 'checkbox');
const nonBools = batch.filter(f => f.type !== 'checkbox');
const boolsChildren = bools.map((batchField: any) => getField(batchField, props));
const children = nonBools.map((batchField: any) => getField(batchField, props));
return (
<BatchWrapper key={key} onRemove={() => props.actions.onBatchRemove(field, batch)}>
{children}
<Checkboxes>{boolsChildren}</Checkboxes>
</BatchWrapper>
);
})}
@@ -95,11 +100,11 @@ const Container = styled.div`
position: relative;
background: ${({ theme }) => theme.backgroundSurfaceElevation2};
border-radius: 12px;
flex: 1;
width: 100%;
padding: 10px;
word-wrap: break-word;
word-break: break-all;
min-height: 150px;
ul,
ol {
@@ -116,6 +121,12 @@ const Heading = styled(H3)`
font-weight: 600;
`;
const Checkboxes = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0 10px;
`;
interface VerifyButtonProps {
onClick: (url: string) => void;
name: string;
@@ -160,9 +171,7 @@ export const Method = () => {
<div>
<Heading>Params</Heading>
{nonBools.map(field => getField(field, { actions }))}
<div style={{ display: 'flex', flexWrap: 'wrap', padding: '10px 0' }}>
{bools.map(field => getField(field, { actions }))}
</div>
<Checkboxes>{bools.map(field => getField(field, { actions }))}</Checkboxes>
<Row>
<Button onClick={onSubmit} data-test="@submit-button">
{submitButton}

View File

@@ -6,8 +6,10 @@ import styled from 'styled-components';
import { Badge } from '@trezor/components';
interface ParamProps {
id?: string;
name: string;
type: string | React.ReactNode;
typeLink?: string;
required?: boolean;
description?: string;
children?: React.ReactNode;
@@ -22,7 +24,7 @@ const ParamWrapper = styled.div`
transparent
);
`;
const ParamRow = styled.div`
const ParamRow = styled.a`
display: flex;
flex-direction: row;
justify-content: center;
@@ -41,17 +43,26 @@ const ParamName = styled.h4`
font-family: monospace;
font-size: 0.875rem;
`;
const ParamType = styled.div`
const ParamType = styled.div<{
isLink?: boolean;
}>`
flex: 1;
font-size: 0.875rem;
${({ isLink, theme }) =>
isLink &&
`
color: ${theme.TYPE_GREEN};
text-decoration: underline;
`}
`;
export const Param = (props: ParamProps) => {
return (
<ParamWrapper>
<ParamRow>
<ParamWrapper id={props.id}>
<ParamRow href={props.typeLink}>
<ParamName>{props.name}</ParamName>
<ParamType>
<ParamType isLink={!!props.typeLink}>
{typeof props.type === 'string' ? (
<Markdown>{props.type}</Markdown>
) : (

View File

@@ -4,10 +4,6 @@ import { Hint, Kind, Optional, TIntersect, TObject, TSchema } from '@sinclair/ty
import { Param } from './Param';
type ParamsTableProps = {
schema: TObject | TIntersect | TSchema;
};
const descriptionDictionary: Record<string, string> = {
path: 'Derivation path',
showOnTrezor: 'Display the result on the Trezor device. Default is false',
@@ -26,15 +22,23 @@ const descriptionDictionary: Record<string, string> = {
const getTypeName = (value: TSchema, hasDescendants?: boolean) => {
let typeName = value[Kind];
if (value[Kind] === 'Array') {
typeName = `Array<${getTypeName(value.items)}>`;
const childTypeName = getTypeName(value.items);
typeName = childTypeName ? `Array<${childTypeName}>` : 'Array';
} else if (value[Kind] === 'Literal') {
if (typeof value.const === 'number') {
typeName = value.const.toString();
} else {
typeName = JSON.stringify(value.const);
}
if (value.$id) {
typeName = value.$id + ' (' + typeName + ')';
}
} else if (value[Kind] === 'Union' && !hasDescendants) {
const itemsFiltered = value.anyOf?.filter((v, i) => {
const itemsFiltered = value.anyOf?.filter((v: TSchema, i: number) => {
// Filter enum non-numbers
if (value[Hint] === 'Enum' && v[Kind] === 'Literal' && typeof v.const === 'string') {
return false;
}
// Filter union number indexes - unnecessary to display
if (v[Kind] === 'Literal' && (v.const === i || v.const === i.toString())) {
return false;
@@ -43,13 +47,13 @@ const getTypeName = (value: TSchema, hasDescendants?: boolean) => {
return true;
});
if (itemsFiltered.length > 0) {
typeName = itemsFiltered?.map(v => getTypeName(v)).join(' | ');
} else if (value[Hint] === 'Enum') {
// TODO handle enums - need to add $id in schema-utils
typeName = 'Enum';
typeName = itemsFiltered?.map((v: TSchema) => getTypeName(v)).join(' | ');
}
if (value[Hint] === 'Enum') {
typeName = 'Enum: ' + typeName;
}
} else if (value[Kind] === 'Intersect' && !hasDescendants) {
typeName = value.anyOf?.map(v => getTypeName(v)).join(' & ');
typeName = value.anyOf?.map((v: TSchema) => getTypeName(v)).join(' & ');
} else if (value[Kind] === 'Object' && value.$id) {
typeName = value.$id;
}
@@ -61,15 +65,30 @@ interface SingleParamProps {
name: string;
value: TSchema;
schema?: TSchema;
topLevelSchema: TObject | TIntersect | TSchema;
isTopLevel?: boolean;
}
const SingleParam = ({ name, value, schema }: SingleParamProps) => {
const SingleParam = ({ name, value, schema, topLevelSchema, isTopLevel }: SingleParamProps) => {
// Show descendants for complex objects
const complexObjects = ['Object', 'Union', 'Intersect'];
let hasDescendants = complexObjects.includes(value[Kind]);
let typeLink: string | undefined;
if (value[Kind] === 'Union') {
hasDescendants = value.anyOf.some(v => complexObjects.includes(v[Kind]));
// Show descendants for unions only if they contain complex objects
hasDescendants = value.anyOf.some((v: TSchema) => complexObjects.includes(v[Kind]));
} else if (value[Kind] === 'Array') {
// Show descendants for arrays only if they contain complex objects
hasDescendants = complexObjects.includes(value.items[Kind]);
} else if (
value[Kind] === 'Object' &&
topLevelSchema?.[Kind] === 'Object' &&
!isTopLevel &&
value.$id &&
Object.entries(topLevelSchema.properties).some(([_, val]: any) => val.$id === value.$id)
) {
// If the object is a reference to the top level object, don't show descendants
hasDescendants = false;
typeLink = `#${value.$id}`;
}
// Get the type name
const typeName = getTypeName(value, hasDescendants);
@@ -85,39 +104,55 @@ const SingleParam = ({ name, value, schema }: SingleParamProps) => {
return (
<>
<Param
id={isTopLevel ? value.$id : undefined}
key={name}
name={name}
type={typeName}
typeLink={typeLink}
required={isRequired}
description={value.description ?? descriptionDictionary[name]}
/>
{hasDescendants && (
<div style={{ marginLeft: 30 }}>
{/* eslint-disable-next-line @typescript-eslint/no-use-before-define */}
<ParamsTable schema={value} />
<ParamsTable schema={value} topLevelSchema={topLevelSchema} />
</div>
)}
</>
);
};
export const ParamsTable = ({ schema }: ParamsTableProps) => {
type ParamsTableProps = {
schema: TObject | TIntersect | TSchema;
topLevelSchema?: TObject | TIntersect | TSchema;
};
export const ParamsTable = ({ schema, topLevelSchema }: ParamsTableProps) => {
const topLevelSchemaCurrent = topLevelSchema ?? schema;
if (schema[Kind] === 'Union') {
return schema.anyOf?.map((param, i) => (
return schema.anyOf?.map((param: TSchema, i: number) => (
<>
{i > 0 && <h3>or</h3>}
<ParamsTable key={i} schema={param} />
<ParamsTable key={i} schema={param} topLevelSchema={topLevelSchemaCurrent} />
</>
));
} else if (schema[Kind] === 'Intersect') {
return schema.allOf?.map((param, i) => <ParamsTable key={i} schema={param} />);
return schema.allOf?.map((param: TSchema, i: number) => (
<ParamsTable key={i} schema={param} topLevelSchema={topLevelSchemaCurrent} />
));
} else if (schema[Kind] === 'Object') {
return Object.entries(schema.properties)?.map(([name, value]: [string, any]) => (
<SingleParam name={name} value={value} schema={schema} key={name} />
<SingleParam
name={name}
value={value}
schema={schema}
key={name}
topLevelSchema={topLevelSchemaCurrent}
isTopLevel={topLevelSchema === undefined}
/>
));
} else if (schema[Kind] === 'Array') {
return <SingleParam name="" value={schema.items} />;
return <SingleParam name="" value={schema.items} topLevelSchema={topLevelSchemaCurrent} />;
} else {
return <SingleParam name="" value={schema} />;
return <SingleParam name="" value={schema} topLevelSchema={topLevelSchemaCurrent} />;
}
};

View File

@@ -16,6 +16,7 @@ const AddBatchButton = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
`;
const AddButton = ({ field, onAdd, label }: AddButtonProps) => {

View File

@@ -2,22 +2,22 @@ import { ReactNode } from 'react';
import styled from 'styled-components';
import { THEME, Icon } from '@trezor/components';
import { Icon, Card } from '@trezor/components';
interface BatchWrapperProps {
children: ReactNode;
onRemove: () => void;
}
const Wrapper = styled.div`
const Wrapper = styled(Card)`
display: flex;
flex-direction: row;
border-bottom: 1px solid ${THEME.light.STROKE_GREY};
margin: 16px;
margin: 4px 0 8px 32px;
`;
const Fields = styled.div`
flex: 1;
margin-right: 8px;
`;
export const BatchWrapper = ({ children, onRemove }: BatchWrapperProps) => (

View File

@@ -1,23 +1,21 @@
import { Checkbox as CheckboxComponent } from '@trezor/components';
import { Card, Checkbox as CheckboxComponent } from '@trezor/components';
import type { Field } from '../../types';
import type { FieldBasic } from '../../types';
import { onFieldChange } from '../../actions/methodActions';
import { Row } from './Row';
interface CheckboxProps {
field: Field<boolean>;
field: FieldBasic<boolean>;
onChange: typeof onFieldChange;
}
const Checkbox = ({ field, onChange, ...rest }: CheckboxProps) => (
<Row style={{ width: '50%' }}>
<CheckboxComponent
onClick={_e => onChange(field, !field.value)}
isChecked={field.value}
{...rest}
>
{field.name}
</CheckboxComponent>
<Row>
<Card paddingType="small" onClick={() => onChange(field, !field.value)}>
<CheckboxComponent onClick={() => {}} isChecked={field.value} {...rest}>
{field.name}
</CheckboxComponent>
</Card>
</Row>
);

View File

@@ -1,11 +1,11 @@
import { Select } from '@trezor/components';
import type { Field } from '../../types';
import type { FieldBasic } from '../../types';
import { onFieldChange } from '../../actions/methodActions';
import { Row } from './Row';
interface CoinSelectProps {
field: Field<any>;
field: FieldBasic<any>;
onChange: typeof onFieldChange;
}

View File

@@ -1,18 +1,21 @@
import { ChangeEventHandler } from 'react';
import { Button } from '@trezor/components';
import { Row } from './Row';
import type { Field } from '../../types';
import type { FieldBasic } from '../../types';
interface FileProps {
field: Field<any>;
field: FieldBasic<any>;
disabled?: boolean;
onChange: (field: Field<any>, value: any) => any;
onChange: (field: FieldBasic<any>, value: any) => any;
}
const File = ({ disabled, field, onChange }: FileProps) => {
const onFilesAdded = evt => {
const onFilesAdded: ChangeEventHandler<HTMLInputElement> = evt => {
if (disabled) return;
const files = evt?.target.files;
if (!files || files.length === 0) return;
const file = files[0];
const reader = new FileReader();
reader.onload = event => {

View File

@@ -1,11 +1,11 @@
import { Input as InputComponent } from '@trezor/components';
import { Field } from '../../types';
import { FieldBasic } from '../../types';
import { Row } from './Row';
interface InputProps {
onChange: (field: Field<any>, value: string) => void;
field: Field<any>;
onChange: (field: FieldBasic<any>, value: string) => void;
field: FieldBasic<any>;
dataTest?: string;
}

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
import { Textarea as TextareaComponent } from '@trezor/components';
import { Field } from '../../types';
import { FieldBasic } from '../../types';
const Row = styled.div`
display: block;
@@ -10,8 +10,8 @@ const Row = styled.div`
`;
interface TextareaProps {
field: Field<string>;
onChange: (field: Field<string>, value: string) => any;
field: FieldBasic<string>;
onChange: (field: FieldBasic<string>, value: string) => any;
}
const Textarea = ({ field, onChange }: TextareaProps) => (

View File

@@ -0,0 +1,193 @@
import TrezorConnect from '@trezor/connect-web';
import { setDeepValue } from '@trezor/schema-utils/src/utils';
import { Field, FieldBasic } from '../types';
export interface MethodState {
name?: keyof typeof TrezorConnect;
submitButton?: string;
fields: Field<unknown>[];
params: Record<string, unknown>;
response?: unknown;
javascriptCode?: string;
addressValidation?: boolean;
}
export const initialState: MethodState = {
name: undefined,
submitButton: undefined,
fields: [],
params: {},
javascriptCode: undefined,
response: undefined,
addressValidation: false,
};
// Converts the fields into a params object
export const getParam = (field: FieldBasic<any>, $params: Record<string, any> = {}) => {
const params = $params;
if (field.omit) {
return params;
}
if (field.optional && ((!field.value && field.value !== 0) || field.value === '')) {
return params;
}
let value: any;
if ('defaultValue' in field) {
if (field.defaultValue !== field.value) {
value = field.value;
}
} else if (field.type === 'json') {
try {
if (typeof field.value === 'string') {
if (field.value.length > 0) {
value = JSON.parse(field.value);
} else {
value = undefined;
}
} else {
value = field.value;
}
} catch (error) {
value = `Invalid json, ${error.toString()}`;
}
} else if (field.type === 'function') {
try {
if (typeof field.value !== 'function') {
throw new Error('Invalid function');
}
value = field.value;
} catch (error) {
value = `Invalid function, ${error.toString()}`;
}
} else if (field.type === 'number') {
if (!Number.isNaN(Number.parseInt(field.value, 10))) {
value = Number.parseInt(field.value, 10);
}
} else {
value = field.value;
}
if (field.name) {
setDeepValue(params, field.name.split('.'), value);
} else {
return value;
}
return params;
};
// Updates the javascript output based on the current params
export const updateJavascript = (state: MethodState) => {
const code =
Object.keys(state.params).length > 0
? JSON.stringify(
state.params,
(_, value) => {
if (Object.prototype.toString.call(value) === '[object ArrayBuffer]') {
return 'ArrayBuffer';
}
return value;
},
2,
)
: '';
return {
...state,
javascriptCode: `TrezorConnect.${state.name}(${code});`,
};
};
// Update params inner function, recursively called for nested fields
export const updateParamsNested = (schema: Field<any>[]) => {
let params: Record<string, any> = {};
schema.forEach(field => {
if (field.type === 'array') {
const arr: Record<string, any>[] = [];
field.items?.forEach(batch => {
const batchParams = updateParamsNested(batch);
arr.push(batchParams);
});
if (arr.length > 0 || !field.optional) {
setDeepValue(params, field.name?.split('.'), arr);
}
} else {
params = getParam(field, params);
}
});
return params;
};
// Update params in the state
export const updateParams = (state: MethodState) => {
const params: Record<string, any> = updateParamsNested(state.fields);
return updateJavascript({
...state,
params,
});
};
// Set affected values
export const setAffectedValues = (state: MethodState, field: Field<unknown>) => {
if (!field.affect) return field;
const data = field.data?.find(d => d.value === field.value);
if (data && data.affectedValue) {
const affectedFieldNames = !Array.isArray(field.affect) ? [field.affect] : field.affect;
const values = !Array.isArray(data.affectedValue)
? [data.affectedValue]
: data.affectedValue;
let root: Field<any>[] | undefined;
if (typeof field.key === 'string') {
const key = field.key.split('-');
const bundle = state.fields.find(f => f.name === key[0]);
if (bundle && bundle.type === 'array' && bundle.items) {
root = bundle.items?.find((_batch, index) => index === Number.parseInt(key[1], 10));
}
} else {
root = state.fields;
}
affectedFieldNames.forEach((af, index) => {
const affectedField = root?.find(f => f.name === af);
if (affectedField && affectedField.type !== 'array') {
affectedField.value = values[index];
if (state.name === 'composeTransaction') {
affectedField.value = values;
}
}
});
} else if (field.affect && typeof field.affect === 'string' && field.value) {
const affectedField = state.fields.find(f => f.name === field.affect);
if (affectedField) {
// @ts-expect-error todo: what is this?
affectedField.value = field.value;
}
}
return field;
};
// Prepare bundle - set keys for nested fields
export const prepareBundle = (field: Field<unknown>) => {
if (field.type === 'array') {
field.items.forEach((batch, index) => {
batch.forEach(batchField => {
batchField.key = `${field.name}-${index}`;
if (field.key) {
batchField.key = `${field.key}-${batchField.key}`;
}
if (batchField.type === 'array') {
prepareBundle(batchField);
}
});
});
}
return field;
};

View File

@@ -0,0 +1,138 @@
import { Optional, Kind, TSchema } from '@sinclair/typebox';
import type TrezorConnect from '@trezor/connect-web';
import {
MethodState,
initialState,
prepareBundle,
setAffectedValues,
updateParams,
} from './methodCommon';
import { Field, FieldBasic } from '../types';
// Convert TypeBox schema to our fields
const schemaToFields = (schema: TSchema): Field<any>[] => {
if (schema[Kind] === 'Object') {
return Object.keys(schema.properties).flatMap(key => {
const field = schema.properties[key];
/* if (field[Kind] === 'Object') {
return [
{
name: key,
label: key,
type: 'json',
value: undefined,
},
];
} */
return schemaToFields(field).map(field => {
const output = {
...field,
name: [key, field.name].filter(v => v).join('.'),
optional: field.optional || schema[Optional] === 'Optional',
};
// TODO better handle optional parent
if (output.type === 'array') {
if (output.optional) {
output.items = [];
}
}
return output;
});
});
} else if (schema[Kind] === 'Array') {
const fields = schemaToFields(schema.items);
return [
{
name: '',
type: 'array',
batch: [
{
type: '',
fields,
},
],
items: schema[Optional] === 'Optional' ? [] : [fields],
},
];
} else if (schema[Kind] === 'Intersect') {
return schema.allOf?.flatMap(schemaToFields);
} else if (schema[Kind] === 'Union') {
const onlyLiterals = schema.anyOf?.every((s: TSchema) => s[Kind] === 'Literal');
if (onlyLiterals) {
const filtered = schema.anyOf?.filter(
(s: TSchema, i: number) => s.const !== i.toString(),
);
const options = filtered.length > 0 ? filtered : schema.anyOf;
return [
{
name: '',
type: 'select',
value: schema.default,
optional: schema[Optional] === 'Optional',
data: options.map((s: TSchema) => ({ label: s.const, value: s.const })),
},
];
}
if (schema.anyOf?.length > 0) {
// TODO complex union handling - currently only first option is used
return schemaToFields(schema.anyOf[0]).map(field => ({
...field,
optional: field.optional || schema[Optional] === 'Optional',
value: field.type === 'array' ? undefined : field.value ?? schema.default,
}));
} else {
return [];
}
}
const typeMap: Record<string, FieldBasic<any>['type']> = {
String: 'input',
Number: 'number',
Uint: 'number',
Boolean: 'checkbox',
} as const;
return [
{
name: '',
type: typeMap[schema[Kind]] ?? 'input',
value: schema.default,
optional: schema[Optional] === 'Optional',
},
];
};
// Get method state
export const getMethodState = (methodConfig?: Partial<MethodState>) => {
if (!methodConfig) return initialState;
// clone object
const state = {
...JSON.parse(JSON.stringify(methodConfig)),
// ...method,
} as MethodState;
// set default values
state.fields = state.fields.map(f => setAffectedValues(state, prepareBundle(f)));
console.log('state', state);
// set method params
return updateParams(state);
};
// Get method state from TypeBox schema
export const getMethodStateFromSchema = (method: keyof typeof TrezorConnect, schema: TSchema) => {
return getMethodState({
name: method,
fields: schemaToFields(schema),
submitButton: 'Submit',
});
};

View File

@@ -5,192 +5,57 @@ import {
REMOVE_BATCH,
RESPONSE,
SET_METHOD,
SET_SCHEMA,
} from '../actions/methodActions';
import type { Action, Field, FieldWithBundle } from '../types';
import type { Action, Field } from '../types';
import {
MethodState,
initialState,
prepareBundle,
setAffectedValues,
updateParams,
} from './methodCommon';
import { getMethodState, getMethodStateFromSchema } from './methodInit';
export interface MethodState {
name?: string;
submitButton: any;
fields: (Field<any> | FieldWithBundle<any>)[];
params: any;
javascriptCode?: string;
response?: any;
addressValidation?: boolean;
}
const initialState: MethodState = {
name: undefined,
submitButton: null,
fields: [],
params: {},
javascriptCode: undefined,
response: undefined,
addressValidation: false,
};
const getParam = (field: Field<any>, $params: Record<string, any> = {}) => {
const params = $params;
if (field.omit) {
return params;
}
if (field.optional && ((!field.value && field.value !== 0) || field.value === '')) {
return params;
}
if ('defaultValue' in field) {
if (field.defaultValue !== field.value) {
params[field.name] = field.value;
}
} else if (field.type === 'json') {
try {
if (typeof field.value === 'string') {
if (field.value.length > 0) {
params[field.name] = JSON.parse(field.value);
} else {
params[field.name] = undefined;
}
} else {
params[field.name] = field.value;
}
} catch (error) {
params[field.name] = `Invalid json, ${error.toString()}`;
}
} else if (field.type === 'function') {
try {
if (typeof field.value !== 'function') {
throw new Error('Invalid function');
}
params[field.name] = field.value;
} catch (error) {
params[field.name] = `Invalid function, ${error.toString()}`;
}
} else if (field.type === 'number') {
if (!Number.isNaN(Number.parseInt(field.value, 10))) {
params[field.name] = Number.parseInt(field.value, 10);
}
} else {
params[field.name] = field.value;
}
return params;
};
const updateJavascript = (state: MethodState) => {
const code =
Object.keys(state.params).length > 0
? JSON.stringify(
state.params,
(_, value) => {
if (Object.prototype.toString.call(value) === '[object ArrayBuffer]') {
return 'ArrayBuffer';
}
return value;
},
2,
)
: '';
return {
...state,
javascriptCode: `TrezorConnect.${state.name}(${code});`,
};
};
const updateParams = (state: MethodState) => {
const params: Record<string, any> = {};
state.fields.forEach(field => {
if (field.type === 'array') {
const arr: Record<string, any>[] = [];
field.items?.forEach(batch => {
const batchParams = {};
batch.forEach(batchField => {
getParam(batchField, batchParams);
});
arr.push(batchParams);
});
params[field.name] = arr;
} else {
getParam(field, params);
}
});
return updateJavascript({
...state,
params,
});
};
const setAffectedValues = (state: MethodState, field: any) => {
if (!field.affect) return field;
const data = field.data?.find(d => d.value === field.value);
if (data && data.affectedValue) {
const affectedFieldNames = !Array.isArray(field.affect) ? [field.affect] : field.affect;
const values = !Array.isArray(data.affectedValue)
? [data.affectedValue]
: data.affectedValue;
let root;
if (typeof field.key === 'string') {
const key = field.key.split('-');
const bundle = state.fields.find(f => f.name === key[0]);
if (bundle) {
root = bundle.items?.find((_batch, index) => index === Number.parseInt(key[1], 10));
}
} else {
root = state.fields;
}
affectedFieldNames.forEach((af, index) => {
const affectedField = root.find(f => f.name === af);
if (affectedField) {
affectedField.value = values[index];
if (state.name === 'composeTransaction') {
affectedField.value = values;
}
}
});
} else if (field.affect && typeof field.affect === 'string' && field.value) {
const affectedField = state.fields.find(f => f.name === field.affect);
if (affectedField) {
// @ts-expect-error todo: what is this?
affectedField.value = field.value;
}
}
return field;
};
const prepareBundle = (field: any) => {
if (field.type === 'array') {
field.items!.forEach((batch, index) => {
batch.forEach(batchField => {
batchField.key = `${field.name}-${index}`;
});
});
}
return field;
};
const findField = (state: MethodState, field: any) => {
// Recursively find a field in the schema (inner function)
const findFieldsNested = (
schema: Field<any>[],
field: Field<any>,
currentDepth = 0,
): Field<any> | undefined => {
if (typeof field.key === 'string') {
const key = field.key.split('-');
const bundle = state.fields.find(f => f.name === key[0]);
const batch = bundle?.items?.find((_batch, index) => index === Number.parseInt(key[1], 10));
const bundle = schema.find(f => f.name === key[currentDepth * 2] && f.type === 'array');
if (bundle?.type !== 'array') {
return;
}
const batch = bundle.items.find(
(_batch, index) => index === Number.parseInt(key[currentDepth * 2 + 1], 10),
);
if (!batch) return;
if (key.length > currentDepth * 2 + 2) {
return findFieldsNested(batch, field, currentDepth + 1);
}
return batch.find(f => f.name === field.name);
}
return state.fields.find(f => f.name === field.name);
return schema.find(f => f.name === field.name);
};
const onFieldChange = (state: MethodState, _field: any, value: any) => {
// Find a field in the schema
const findField = (state: MethodState, field: Field<any>) => {
return findFieldsNested(state.fields, field);
};
// Update field value
const onFieldChange = (state: MethodState, _field: Field<any>, value: any) => {
const newState = {
...JSON.parse(JSON.stringify(state)),
...state,
};
const field = findField(newState, _field);
if (!field || field.type === 'array') return state;
field.value = value;
if (field.affect) {
setAffectedValues(newState, field);
@@ -199,45 +64,37 @@ const onFieldChange = (state: MethodState, _field: any, value: any) => {
return updateParams(newState);
};
const onFieldDataChange = (state: MethodState, _field: any, data: any) => {
// Update field data
const onFieldDataChange = (state: MethodState, _field: Field<any>, data: any) => {
const newState = state;
const field = findField(newState, _field);
if (!field || field.type === 'array') return state;
field.data = data;
return updateParams(newState);
};
// initialization
const getMethodState = (methodConfig?: any) => {
if (!methodConfig) return initialState;
// clone object
const state = {
...JSON.parse(JSON.stringify(methodConfig)),
// ...method,
};
// set default values
state.fields = state.fields.map(f => setAffectedValues(state, prepareBundle(f)));
// set method params
return updateParams(state);
};
// Add new batch
const onAddBatch = (state: MethodState, _field: Field<any>, item: any) => {
const newState = JSON.parse(JSON.stringify(state));
const field = newState.fields.find(f => f.name === _field.name);
const field = findField(newState, _field);
if (!field || field.type !== 'array') return state;
field.items = [...field.items, item];
prepareBundle(field);
return updateParams(newState);
};
const onRemoveBatch = (state: MethodState, _field: any, _batch: any) => {
const field = state.fields.find(f => f.name === _field.name);
// Remove batch
const onRemoveBatch = (state: MethodState, _field: Field<any>, _batch: any) => {
const field = findField(state, _field);
if (!field || field.type !== 'array') return state;
const items = field?.items?.filter(batch => batch !== _batch);
const newState = JSON.parse(JSON.stringify(state));
const newField = newState.fields.find(f => f.name === field?.name);
const newField = findField(newState, field);
if (!newField || newField.type !== 'array') return state;
newField.items = items;
prepareBundle(newField);
@@ -249,6 +106,9 @@ export default function method(state: MethodState = initialState, action: Action
case SET_METHOD:
return getMethodState(action.methodConfig);
case SET_SCHEMA:
return getMethodStateFromSchema(action.method, action.schema);
case FIELD_CHANGE:
return onFieldChange(state, action.field, action.value);

View File

@@ -62,6 +62,7 @@ const onOptionChange = <T>(state: ConnectState, field: Field<T>, value: T): Conn
},
};
}
// @ts-expect-error field name must be key of options
newState.options[field.name] = value;
return newState;

View File

@@ -23,15 +23,14 @@ export interface FieldData {
affectedValue: string;
}
export interface Field<Value> {
export interface FieldCommon {
key?: string;
name: string;
batch?: any[];
items?: any[];
data?: FieldData[];
omit?: boolean;
optional?: boolean;
key: string;
affect?: string;
omit?: boolean;
}
export interface FieldBasic<Value> extends FieldCommon {
type:
| 'input'
| 'input-long'
@@ -45,6 +44,8 @@ export interface Field<Value> {
| 'file';
value: Value;
defaultValue?: Value;
affect?: string;
data?: FieldData[];
}
interface Batch<Value> {
@@ -52,9 +53,11 @@ interface Batch<Value> {
fields: Field<Value>[];
}
export interface FieldWithBundle<Value> {
name: 'bundle';
export interface FieldWithBundle<Value> extends FieldCommon {
type: 'array';
items: Field<Value>[][];
batch: Batch<Value>[];
items: Field<Value>[][];
affect?: undefined;
}
export type Field<Value> = FieldBasic<Value> | FieldWithBundle<Value>;

View File

@@ -5,7 +5,6 @@
"lib": ["dom", "dom.iterable", "esnext"],
"emitDeclarationOnly": false,
"noEmit": true,
"noImplicitAny": false,
"plugins": [
{ "name": "typescript-styled-plugin" }
]
@@ -21,6 +20,7 @@
{ "path": "../connect-web" },
{ "path": "../connect-webextension" },
{ "path": "../protobuf" },
{ "path": "../schema-utils" },
{ "path": "../utils" }
]
}

View File

@@ -1,4 +1,13 @@
import { JavaScriptTypeBuilder, TUnion, Hint, SchemaOptions, TLiteral } from '@sinclair/typebox';
import {
JavaScriptTypeBuilder,
TUnion,
Hint,
SchemaOptions,
TLiteral,
TEnum,
TEnumKey,
TEnumValue,
} from '@sinclair/typebox';
// UnionToIntersection<A | B> = A & B
type UnionToIntersection<U> = (U extends unknown ? (arg: U) => 0 : never) extends (
@@ -38,4 +47,15 @@ export class KeyofEnumBuilder extends JavaScriptTypeBuilder {
return this.Union(keys, { ...options, [Hint]: 'KeyOfEnum' }) as TKeyOfEnum<T>;
}
Enum<V extends TEnumValue, T extends Record<TEnumKey, V>>(
schema: T,
options?: SchemaOptions,
): TEnum<T> {
const anyOf = Object.entries(schema)
.filter(([key, _value]) => typeof key === 'string' || !isNaN(key))
.map(([key, value]) => this.Literal(value, { $id: key }));
return this.Union(anyOf, { ...options, [Hint]: 'Enum' }) as TEnum<T>;
}
}

View File

@@ -37,4 +37,23 @@ describe('enum', () => {
const x: T2 = 'B';
expect(x).toEqual('B');
});
it('should work with normal enum', () => {
enum E {
A = 'a',
B = 'b',
}
const schema = Type.Enum(E);
type T = Static<typeof schema>;
const x: T = E.A;
expect(x).toEqual('a');
// @ts-expect-error
const y: T = 'C';
expect(y).toEqual('C');
expect(Validate(schema, 'a')).toBe(true);
expect(Validate(schema, 'b')).toBe(true);
});
});

View File

@@ -10259,6 +10259,7 @@ __metadata:
"@trezor/connect-web": "workspace:^"
"@trezor/connect-webextension": "workspace:^"
"@trezor/protobuf": "workspace:^"
"@trezor/schema-utils": "workspace:^"
"@trezor/utils": "workspace:^"
"@types/redux-logger": "npm:^3.0.11"
babel-plugin-styled-components: "npm:^2.1.4"
@@ -10735,7 +10736,7 @@ __metadata:
languageName: unknown
linkType: soft
"@trezor/schema-utils@workspace:*, @trezor/schema-utils@workspace:packages/schema-utils":
"@trezor/schema-utils@workspace:*, @trezor/schema-utils@workspace:^, @trezor/schema-utils@workspace:packages/schema-utils":
version: 0.0.0-use.local
resolution: "@trezor/schema-utils@workspace:packages/schema-utils"
dependencies: