Skip to content

Commit 69b273f

Browse files
feat: add external errors prop to TokenizedInput (#387)
1 parent d62c112 commit 69b273f

4 files changed

Lines changed: 138 additions & 2 deletions

File tree

src/components/TokenizedInput/context/TokenizedInputContext.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function TokenizedInputContextProvider<T extends TokenValueBase>({
3636
defaultTokens,
3737
transformTokens,
3838
validateToken,
39+
tokenErrors,
3940
formatToken,
4041
fields,
4142
placeholder,
@@ -56,6 +57,7 @@ export function TokenizedInputContextProvider<T extends TokenValueBase>({
5657
defaultTokens,
5758
transformTokens,
5859
validateToken,
60+
tokenErrors,
5961
formatToken,
6062
fields,
6163
placeholder,

src/components/TokenizedInput/hooks/__tests__/useTokenizedInputInfo.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,127 @@ describe('useTokenizedInputInfo', () => {
156156
expect(mockOnChange).toHaveBeenCalledWith([]);
157157
});
158158

159+
describe('tokenErrors', () => {
160+
it('should merge external errors with validateToken errors', () => {
161+
const externalTokens = [{key: 'User', value: 'Ivan'}];
162+
const validateToken = () => ({key: 'internal error'});
163+
const tokenErrors = [{value: 'external error'}];
164+
165+
const {result} = renderHook(() =>
166+
useTokenizedInputInfo({
167+
tokens: externalTokens,
168+
fields: mockFields,
169+
onChange: mockOnChange,
170+
validateToken,
171+
tokenErrors,
172+
}),
173+
);
174+
175+
expect(result.current.state.tokens[0].errors).toEqual({
176+
key: 'internal error',
177+
value: 'external error',
178+
});
179+
});
180+
181+
it('should give external errors priority over validateToken for the same field', () => {
182+
const externalTokens = [{key: 'User', value: 'Ivan'}];
183+
const validateToken = () => ({key: 'internal'});
184+
const tokenErrors = [{key: 'external'}];
185+
186+
const {result} = renderHook(() =>
187+
useTokenizedInputInfo({
188+
tokens: externalTokens,
189+
fields: mockFields,
190+
onChange: mockOnChange,
191+
validateToken,
192+
tokenErrors,
193+
}),
194+
);
195+
196+
expect(result.current.state.tokens[0].errors).toEqual({key: 'external'});
197+
});
198+
199+
it('should not apply external errors to new tokens', () => {
200+
const externalTokens = [{key: 'User', value: 'Ivan'}];
201+
const tokenErrors = [undefined, {key: 'error'}];
202+
203+
const {result} = renderHook(() =>
204+
useTokenizedInputInfo({
205+
tokens: externalTokens,
206+
fields: mockFields,
207+
onChange: mockOnChange,
208+
validateToken: false,
209+
tokenErrors,
210+
}),
211+
);
212+
213+
act(() => {
214+
result.current.callbacks.onChangeToken(1, {key: 'Status'});
215+
});
216+
217+
expect(result.current.state.tokens[1].kind).toBe('new');
218+
expect(result.current.state.tokens[1].errors).toBeUndefined();
219+
});
220+
221+
it('should skip undefined entries in tokenErrors', () => {
222+
const externalTokens = [
223+
{key: 'User', value: 'Ivan'},
224+
{key: 'Status', value: 'Active'},
225+
];
226+
const tokenErrors = [undefined, {key: 'error'}];
227+
228+
const {result} = renderHook(() =>
229+
useTokenizedInputInfo({
230+
tokens: externalTokens,
231+
fields: mockFields,
232+
onChange: mockOnChange,
233+
validateToken: false,
234+
tokenErrors,
235+
}),
236+
);
237+
238+
expect(result.current.state.tokens[0].errors).toBeUndefined();
239+
expect(result.current.state.tokens[1].errors).toEqual({key: 'error'});
240+
});
241+
242+
it('should update displayed errors when tokenErrors prop changes', () => {
243+
const externalTokens = [{key: 'User', value: 'Ivan'}];
244+
let tokenErrors: ({key?: string} | undefined)[] = [{key: 'old error'}];
245+
246+
const {result, rerender} = renderHook(() =>
247+
useTokenizedInputInfo({
248+
tokens: externalTokens,
249+
fields: mockFields,
250+
onChange: mockOnChange,
251+
validateToken: false,
252+
tokenErrors,
253+
}),
254+
);
255+
256+
expect(result.current.state.tokens[0].errors).toEqual({key: 'old error'});
257+
258+
tokenErrors = [{key: 'new error'}];
259+
rerender();
260+
261+
expect(result.current.state.tokens[0].errors).toEqual({key: 'new error'});
262+
});
263+
264+
it('should show no errors when tokenErrors is undefined', () => {
265+
const externalTokens = [{key: 'User', value: 'Ivan'}];
266+
267+
const {result} = renderHook(() =>
268+
useTokenizedInputInfo({
269+
tokens: externalTokens,
270+
fields: mockFields,
271+
onChange: mockOnChange,
272+
validateToken: false,
273+
}),
274+
);
275+
276+
expect(result.current.state.tokens[0].errors).toBeUndefined();
277+
});
278+
});
279+
159280
it('should handle undo and redo', () => {
160281
const externalTokens = [{key: 'User', value: 'Ivan'}];
161282

src/components/TokenizedInput/hooks/useTokenizedInputInfo.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const useTokenizedInputInfo = <T extends TokenValueBase>({
2626
validateToken = defaultValidateToken,
2727
formatToken,
2828
tokens: externalTokens,
29+
tokenErrors,
2930
fields,
3031
placeholder,
3132
className,
@@ -186,10 +187,20 @@ export const useTokenizedInputInfo = <T extends TokenValueBase>({
186187
[isClearable, externalTokens.length, defaultTokens.length, isEditable],
187188
);
188189

190+
const displayedTokens = React.useMemo(() => {
191+
if (!tokenErrors) return tokens;
192+
return tokens.map((token, i) => {
193+
if (token.kind === 'new') return token;
194+
const externalError = tokenErrors[i];
195+
if (!externalError) return token;
196+
return {...token, errors: {...token.errors, ...externalError}};
197+
});
198+
}, [tokens, tokenErrors]);
199+
189200
return React.useMemo(
190201
() => ({
191202
state: {
192-
tokens,
203+
tokens: displayedTokens,
193204
wrapperRef,
194205
defaultTokens,
195206
fields,
@@ -209,7 +220,6 @@ export const useTokenizedInputInfo = <T extends TokenValueBase>({
209220
},
210221
}),
211222
[
212-
tokens,
213223
defaultTokens,
214224
fields,
215225
isEditable,
@@ -223,6 +233,7 @@ export const useTokenizedInputInfo = <T extends TokenValueBase>({
223233
onClearInput,
224234
onUndo,
225235
onRedo,
236+
displayedTokens,
226237
],
227238
);
228239
};

src/components/TokenizedInput/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ export interface TokenizedInputData<T extends TokenValueBase> {
219219
transformTokens?: (tokens: T[]) => Token<T>[];
220220
/** Validates a token */
221221
validateToken?: ((token: T) => Partial<Record<keyof T, string>> | undefined) | false;
222+
/** External token errors — array parallel to tokens. Merged with validateToken errors per-field; on conflict the external error wins. */
223+
tokenErrors?: (Partial<Record<keyof T, string>> | undefined)[];
222224
/** Formats a token value */
223225
formatToken?: (token: T) => T;
224226
/** Field definitions; order matches display order */

0 commit comments

Comments
 (0)