diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index 3f2354c4..ff020199 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -864,6 +864,10 @@ collections: label: Does not allow regeneration widget: uuid allow_regenerate: false + - name: with_prefix + label: With Prefix + widget: uuid + prefix: 'book/' - name: settings label: Settings delete: false diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index c95ae763..72a19e33 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -696,6 +696,7 @@ export interface StringOrTextField extends BaseField { export interface UUIDField extends BaseField { widget: 'uuid'; allow_regenerate?: boolean; + prefix?: string; } export interface UnknownField extends BaseField { diff --git a/packages/core/src/widgets/uuid/UUIDControl.tsx b/packages/core/src/widgets/uuid/UUIDControl.tsx index a109d1be..8ef88163 100644 --- a/packages/core/src/widgets/uuid/UUIDControl.tsx +++ b/packages/core/src/widgets/uuid/UUIDControl.tsx @@ -5,7 +5,7 @@ import { v4 as uuid, validate } from 'uuid'; import IconButton from '@staticcms/core/components/common/button/IconButton'; import Field from '@staticcms/core/components/common/field/Field'; import TextField from '@staticcms/core/components/common/text-field/TextField'; -import { isEmpty } from '@staticcms/core/lib/util/string.util'; +import { isEmpty, isNotEmpty } from '@staticcms/core/lib/util/string.util'; import type { UUIDField, WidgetControlProps } from '@staticcms/core/interface'; import type { FC } from 'react'; @@ -28,6 +28,12 @@ const UUIDControl: FC> = ({ ); const ref = useRef(null); + const prefix = useMemo(() => field.prefix ?? '', [field.prefix]); + const internalValueWithoutPrefix = + isNotEmpty(prefix) && internalValue.startsWith(prefix) + ? internalValue.replace(prefix, '') + : internalValue; + const handleChange = useCallback( (newUUID: string) => { setInternalValue(newUUID); @@ -37,14 +43,24 @@ const UUIDControl: FC> = ({ ); const generateUUID = useCallback(() => { - handleChange(uuid()); - }, [handleChange]); + handleChange(`${prefix}${uuid()}`); + }, [handleChange, prefix]); useEffect(() => { - if (isEmpty(internalValue) || !validate(internalValue)) { - generateUUID(); + let alive = true; + + if (isEmpty(internalValueWithoutPrefix) || !validate(internalValueWithoutPrefix)) { + setTimeout(() => { + if (alive) { + generateUUID(); + } + }, 100); } - }, [generateUUID, internalValue]); + + return () => { + alive = false; + }; + }, [generateUUID, internalValueWithoutPrefix]); const allowRegenerate = useMemo(() => field.allow_regenerate ?? true, [field.allow_regenerate]); diff --git a/packages/core/src/widgets/uuid/__tests__/UUIDControl.spec.ts b/packages/core/src/widgets/uuid/__tests__/UUIDControl.spec.ts index ea038061..6904b2f7 100644 --- a/packages/core/src/widgets/uuid/__tests__/UUIDControl.spec.ts +++ b/packages/core/src/widgets/uuid/__tests__/UUIDControl.spec.ts @@ -2,7 +2,7 @@ * @jest-environment jsdom */ import '@testing-library/jest-dom'; -import { act } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { v4, validate } from 'uuid'; @@ -91,10 +91,13 @@ describe(UUIDControl.name, () => { props: { onChange }, } = renderControl({ value: 'I_AM_AN_INVALID_UUID' }); - const inputWrapper = getByTestId('text-input'); - const input = inputWrapper.getElementsByTagName('input')[0]; - expect(input).toHaveValue('I_AM_A_NEW_UUID'); - expect(onChange).toHaveBeenLastCalledWith('I_AM_A_NEW_UUID'); + await waitFor(() => { + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toHaveValue('I_AM_A_NEW_UUID'); + expect(onChange).toHaveBeenLastCalledWith('I_AM_A_NEW_UUID'); + expect(mockValidate).toHaveBeenCalledWith('I_AM_AN_INVALID_UUID'); + }); }); it('should generate a new UUID if none is provided', async () => { @@ -103,11 +106,13 @@ describe(UUIDControl.name, () => { props: { onChange }, } = renderControl(); - const inputWrapper = getByTestId('text-input'); - const input = inputWrapper.getElementsByTagName('input')[0]; - expect(input).toHaveValue('I_AM_A_NEW_UUID'); + await waitFor(() => { + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toHaveValue('I_AM_A_NEW_UUID'); - expect(onChange).toHaveBeenLastCalledWith('I_AM_A_NEW_UUID'); + expect(onChange).toHaveBeenLastCalledWith('I_AM_A_NEW_UUID'); + }); }); it('shows generate new uuid button by default', async () => { @@ -135,8 +140,11 @@ describe(UUIDControl.name, () => { const inputWrapper = getByTestId('text-input'); const input = inputWrapper.getElementsByTagName('input')[0]; - expect(input).toHaveValue('I_AM_A_NEW_UUID'); - expect(onChange).toHaveBeenLastCalledWith('I_AM_A_NEW_UUID'); + + await waitFor(() => { + expect(input).toHaveValue('I_AM_A_NEW_UUID'); + expect(onChange).toHaveBeenLastCalledWith('I_AM_A_NEW_UUID'); + }); mockUUID.mockReturnValue('I_AM_ANOTHER_NEW_UUID'); const generateNewUUIDButton = getByTestId('generate-new-uuid'); @@ -189,4 +197,45 @@ describe(UUIDControl.name, () => { const input = inputWrapper.getElementsByTagName('input')[0]; expect(input).toBeDisabled(); }); + + it('should add prefix to start of UUID onChange', async () => { + const { + getByTestId, + props: { onChange }, + } = renderControl({ + field: { + ...mockUUIDField, + prefix: 'book/', + }, + }); + + await waitFor(() => { + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toHaveValue('book/I_AM_A_NEW_UUID'); + + expect(onChange).toHaveBeenLastCalledWith('book/I_AM_A_NEW_UUID'); + }); + }); + + it('should consider UUID with prefix to be valid', async () => { + mockValidate.mockReturnValue(true); + + const { + getByTestId, + props: { onChange }, + } = renderControl({ + value: 'book/I_AM_A_VALID_UUID', + field: { + ...mockUUIDField, + prefix: 'book/', + }, + }); + + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toHaveValue('book/I_AM_A_VALID_UUID'); + expect(onChange).not.toHaveBeenCalled(); + expect(mockValidate).toHaveBeenCalledWith('I_AM_A_VALID_UUID'); + }); }); diff --git a/packages/core/src/widgets/uuid/schema.ts b/packages/core/src/widgets/uuid/schema.ts index 0220d6d1..0756edc8 100644 --- a/packages/core/src/widgets/uuid/schema.ts +++ b/packages/core/src/widgets/uuid/schema.ts @@ -1,5 +1,6 @@ export default { properties: { allow_regenerate: { type: 'boolean' }, + prefix: { type: 'string' }, }, }; diff --git a/packages/demo/public/config.yml b/packages/demo/public/config.yml index 444b2ec9..67aa0a1b 100644 --- a/packages/demo/public/config.yml +++ b/packages/demo/public/config.yml @@ -864,6 +864,10 @@ collections: label: Does not allow regeneration widget: uuid allow_regenerate: false + - name: with_prefix + label: With Prefix + widget: uuid + prefix: 'book/' - name: settings label: Settings delete: false