Skip to content

Commit 942d83f

Browse files
authored
fix(APP-989): Allow empty strings in action ABI string inputs (#719)
1 parent 9c106d8 commit 942d83f

8 files changed

Lines changed: 101 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@aragon/gov-ui-kit": patch
3+
---
4+
5+
Allow empty strings on `ProposalActionsDecoder` string parameters (empty strings are valid Solidity values, including elements inside `string[]` inputs), skip validation on hidden tuple registration fields to avoid invisible submit-blocking errors, and keep the array index label and remove button aligned to the input when a validation alert is displayed.

src/modules/components/proposal/proposalActions/proposalActionsDecoder/proposalActionsDecoder.stories.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,31 @@ export const ArrayType: Story = {
214214
},
215215
};
216216

217+
/**
218+
* Usage example of the Decoded view with a string array type, empty strings are valid values for string parameters.
219+
*/
220+
export const StringArrayType: Story = {
221+
render: defaultRender,
222+
args: {
223+
view: ProposalActionsDecoderView.DECODED,
224+
mode: ProposalActionsDecoderMode.EDIT,
225+
action: generateProposalAction({
226+
inputData: {
227+
function: 'schedule',
228+
contract: 'Timelock',
229+
parameters: [
230+
{
231+
name: 'functionSignatures',
232+
type: 'string[]',
233+
value: ['transfer(address,uint256)', ''],
234+
notice: 'Function signatures - optional: if empty string, datas[i] is already starting with the function selector.',
235+
},
236+
],
237+
},
238+
}),
239+
},
240+
};
241+
217242
/**
218243
* Usage example of the Decoded view from the ProposalActionsItem component with an array type inside a tuple.
219244
*/

src/modules/components/proposal/proposalActions/proposalActionsDecoder/proposalActionsDecoderField/proposalActionsDecoderField.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ describe('<ProposalActionsDecoderField /> component', () => {
9292
// eslint-disable-next-line testing-library/no-node-access
9393
expect(screen.getByText('[0]:').parentElement).toContainElement(textInputs[0]);
9494
// eslint-disable-next-line testing-library/no-node-access
95-
expect(screen.getByText('[0]:').parentElement).toHaveClass('items-center');
95+
expect(screen.getByText('[0]:').parentElement).toHaveClass('items-start');
96+
expect(screen.getByText('[0]:')).toHaveClass('items-center');
9697
textInputs.forEach((input, index) => expect(input).toHaveDisplayValue(parameter.value[index]));
9798
});
9899

src/modules/components/proposal/proposalActions/proposalActionsDecoder/proposalActionsDecoderField/proposalActionsDecoderField.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -131,17 +131,18 @@ export const ProposalActionsDecoderField: React.FC<IProposalActionsDecoderFieldP
131131
const isNestedParameter =
132132
proposalActionsDecoderUtils.isTupleType(parameter.type) ||
133133
proposalActionsDecoderUtils.isArrayType(parameter.type);
134+
const isBooleanEditParameter =
135+
parameter.type === 'bool' && mode === ProposalActionsDecoderMode.EDIT;
136+
const alignToInput = !isNestedParameter && !isBooleanEditParameter;
134137

135138
return (
136-
<div
137-
className={classNames('flex flex-row gap-2', {
138-
'items-start': isNestedParameter,
139-
'items-center': !isNestedParameter,
140-
})}
141-
key={nestedFieldPath}
142-
>
139+
<div className="flex flex-row items-start gap-2" key={nestedFieldPath}>
143140
{isArray && (
144-
<p className={classNames(nestedParameterHeaderClassName, 'shrink-0')}>
141+
<p
142+
className={classNames(nestedParameterHeaderClassName, 'shrink-0', {
143+
'flex h-12 items-center': alignToInput,
144+
})}
145+
>
145146
[{index.toString()}]:
146147
</p>
147148
)}
@@ -154,7 +155,7 @@ export const ProposalActionsDecoderField: React.FC<IProposalActionsDecoderFieldP
154155
parameter={parameter}
155156
/>
156157
</div>
157-
{renderDeleteButton(removeArrayItem, 'sm')}
158+
{renderDeleteButton(removeArrayItem, 'sm', alignToInput ? 'mt-2' : undefined)}
158159
</div>
159160
);
160161
})}

src/modules/components/proposal/proposalActions/proposalActionsDecoder/proposalActionsDecoderTextField/proposalActionsDecoderTextFieldEdit.test.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { render, screen } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import * as ReactHookForm from 'react-hook-form';
4+
import { modulesCopy } from '../../../../../assets';
45
import * as useFormContext from '../../../../../hooks';
56
import {
67
type IProposalActionsDecoderTextFieldEditProps,
@@ -90,8 +91,8 @@ describe('<ProposalActionsDecoderTextFieldEdit /> component', () => {
9091
});
9192
});
9293

93-
it('does not set validation rules for array types', () => {
94-
const parameter = { name: 'tupleArray', type: 'tuple[]', value: undefined };
94+
it.each(['tuple[]', 'tuple', 'string'])('does not set validation rules for %s types', (type) => {
95+
const parameter = { name: 'testParam', type, value: undefined };
9596
const fieldName = 'test';
9697
render(createTestComponent({ fieldName, parameter }));
9798
expect(useControllerSpy).toHaveBeenCalledWith({ name: fieldName, rules: { validate: undefined } });
@@ -163,6 +164,45 @@ describe('<ProposalActionsDecoderTextFieldEdit /> component', () => {
163164
);
164165
});
165166

167+
it('rejects empty values for uint types', () => {
168+
const parameter = { name: 'uintParam', type: 'uint256', value: undefined };
169+
render(createTestComponent({ parameter }));
170+
const { validate } = useControllerSpy.mock.calls[0][0]!.rules!;
171+
expect((validate as (value: unknown) => unknown)('')).toEqual(
172+
modulesCopy.proposalActionsDecoder.validation.required(parameter.name),
173+
);
174+
});
175+
176+
it('initialises string types to empty strings when value is null', () => {
177+
const onChange = jest.fn();
178+
const parameter = { name: 'stringParam', type: 'string', value: undefined };
179+
const controllerReturn = {
180+
fieldState: { error: undefined },
181+
field: { value: parameter.value, onChange },
182+
} as unknown as ReactHookForm.UseControllerReturn;
183+
useControllerSpy.mockReturnValue(controllerReturn);
184+
useFormContextSpy.mockReturnValue({
185+
watch: () => controllerReturn.field.value as unknown,
186+
} as useFormContext.UseFormContextReturn);
187+
render(createTestComponent({ parameter }));
188+
expect(onChange).toHaveBeenCalledWith('');
189+
});
190+
191+
it('does not change string values when parameter value is defined', () => {
192+
const onChange = jest.fn();
193+
const parameter = { name: 'stringParam', type: 'string', value: 'test-value' };
194+
const controllerReturn = {
195+
fieldState: { error: undefined },
196+
field: { value: parameter.value, onChange },
197+
} as unknown as ReactHookForm.UseControllerReturn;
198+
useControllerSpy.mockReturnValue(controllerReturn);
199+
useFormContextSpy.mockReturnValue({
200+
watch: () => controllerReturn.field.value as unknown,
201+
} as useFormContext.UseFormContextReturn);
202+
render(createTestComponent({ parameter }));
203+
expect(onChange).not.toHaveBeenCalled();
204+
});
205+
166206
it('initialises arrays to empty-arrays when value is null', () => {
167207
const onChange = jest.fn();
168208
const parameter = { name: 'uintArray', type: 'uint[]', value: undefined };

src/modules/components/proposal/proposalActions/proposalActionsDecoder/proposalActionsDecoderTextField/proposalActionsDecoderTextFieldEdit.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const ProposalActionsDecoderTextFieldEdit: React.FC<IProposalActionsDecod
1313

1414
const { name, type } = parameter;
1515
const isArrayType = proposalActionsDecoderUtils.isArrayType(type);
16+
const isStringType = proposalActionsDecoderUtils.isStringType(type);
1617

1718
const { copy } = useGukModulesContext();
1819
const { watch } = useFormContext<Record<string, ProposalActionsFieldValue>>(true);
@@ -21,24 +22,30 @@ export const ProposalActionsDecoderTextFieldEdit: React.FC<IProposalActionsDecod
2122
const validateFunction = (value: ProposalActionsFieldValue) =>
2223
proposalActionsDecoderUtils.validateValue(value, { label: name, type, required: true, errorMessages });
2324

25+
// Skip validation for string types (empty strings are valid Solidity values) and for array / tuple types, which
26+
// are hidden registration fields whose nested fields validate themselves.
27+
const skipValidation = isArrayType || isStringType || proposalActionsDecoderUtils.isTupleType(type);
28+
2429
// Watch value for changes as useControlled does not return updated value for array types
2530
// Note: using watch instead of useWatch because of form values being out of sync otherwise
2631
const fieldValue = watch(fieldName);
2732

2833
const { fieldState, field } = useController<Record<string, ProposalActionsFieldValue>>({
2934
name: fieldName,
30-
rules: { validate: isArrayType ? undefined : validateFunction },
35+
rules: { validate: skipValidation ? undefined : validateFunction },
3136
});
3237

3338
const { error } = fieldState;
3439
const { value, onChange, ...fieldProps } = field;
3540

3641
useEffect(() => {
37-
// Initialise array types as empty arrays to properly decode transaction data
42+
// Initialise array types as empty arrays and string types as empty strings to properly encode transaction data
3843
if (isArrayType && fieldValue == null) {
3944
onChange([]);
45+
} else if (isStringType && fieldValue == null) {
46+
onChange('');
4047
}
41-
}, [fieldValue, isArrayType, onChange]);
48+
}, [fieldValue, isArrayType, isStringType, onChange]);
4249

4350
const handleFieldChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
4451
if (type === 'bool') {

src/modules/components/proposal/proposalActions/proposalActionsDecoder/proposalActionsDecoderUtils.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ describe('ProposalActionsDecoder utils', () => {
6565
expect(proposalActionsDecoderUtils.validateValue('value', params)).toBeUndefined();
6666
});
6767

68+
it('returns undefined when value is an empty string and not required', () => {
69+
const params = buildValidateValueParams({ type: 'string', required: false });
70+
expect(proposalActionsDecoderUtils.validateValue('', params)).toBeUndefined();
71+
});
72+
6873
it('returns bool error message when value has bool type and is not valid', () => {
6974
validateBooleanSpy.mockReturnValue(false);
7075
const params = buildValidateValueParams({ type: 'bool' });

src/modules/components/proposal/proposalActions/proposalActionsDecoder/proposalActionsDecoderUtils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ class ProposalActionsDecoderUtils {
7979

8080
isTupleType = (type: string) => type === 'tuple';
8181

82+
isStringType = (type: string) => type === 'string';
83+
8284
isNumberType = (type: string) => this.isUnsignedNumberType(type) || this.isSignedNumberType(type);
8385

8486
isUnsignedNumberType = (type: string) => type.startsWith('uint');

0 commit comments

Comments
 (0)