From 9d96a27952f098bad69382c866a5a936892969bf Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Thu, 20 Apr 2023 12:55:49 -0400 Subject: [PATCH] feat: add uuid widget (#719) --- packages/core/dev-test/config.yml | 15 +- packages/core/src/__mocks__/uuid.ts | 3 + .../src/components/common/field/Field.tsx | 14 ++ .../common/text-field/TextField.tsx | 8 +- packages/core/src/extensions.ts | 2 + packages/core/src/interface.ts | 7 +- packages/core/src/widgets/index.ts | 2 + .../list/__tests__/ListControl.spec.tsx | 2 + .../core/src/widgets/uuid/UUIDControl.tsx | 79 +++++++ .../core/src/widgets/uuid/UUIDPreview.tsx | 10 + .../uuid/__tests__/UUIDControl.spec.ts | 192 ++++++++++++++++++ packages/core/src/widgets/uuid/index.ts | 20 ++ packages/core/src/widgets/uuid/schema.ts | 5 + packages/core/test/data/fields.mock.ts | 7 + packages/demo/public/config.yml | 15 +- packages/docs/content/docs/widget-uuid.mdx | 36 ++++ packages/docs/content/docs/widgets.mdx | 19 +- 17 files changed, 423 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/__mocks__/uuid.ts create mode 100644 packages/core/src/widgets/uuid/UUIDControl.tsx create mode 100644 packages/core/src/widgets/uuid/UUIDPreview.tsx create mode 100644 packages/core/src/widgets/uuid/__tests__/UUIDControl.spec.ts create mode 100644 packages/core/src/widgets/uuid/index.ts create mode 100644 packages/core/src/widgets/uuid/schema.ts create mode 100644 packages/docs/content/docs/widget-uuid.mdx diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index d0acc1b9..3f2354c4 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -190,7 +190,8 @@ collections: - name: pattern label: Pattern Validation widget: color - pattern: ['^#[a-fA-F0-9]{3}$|^[a-fA-F0-9]{4}$|^[a-fA-F0-9]{6}$', 'Must be a valid hex code'] + pattern: + ['^#[a-fA-F0-9]{3}$|^[a-fA-F0-9]{4}$|^[a-fA-F0-9]{6}$', 'Must be a valid hex code'] allow_input: true required: false - name: alpha @@ -851,6 +852,18 @@ collections: widget: text pattern: ['.{12,}', 'Must have at least 12 characters'] required: false + - name: uuid + label: UUID + file: _widgets/uuid.json + description: UUID widget + fields: + - name: uuid + label: UUID + widget: uuid + - name: no_regenerate + label: Does not allow regeneration + widget: uuid + allow_regenerate: false - name: settings label: Settings delete: false diff --git a/packages/core/src/__mocks__/uuid.ts b/packages/core/src/__mocks__/uuid.ts new file mode 100644 index 00000000..24699844 --- /dev/null +++ b/packages/core/src/__mocks__/uuid.ts @@ -0,0 +1,3 @@ +export const v4 = jest.fn().mockReturnValue('I_AM_A_UUID'); + +export const validate = jest.fn(); diff --git a/packages/core/src/components/common/field/Field.tsx b/packages/core/src/components/common/field/Field.tsx index f0b97744..7026b964 100644 --- a/packages/core/src/components/common/field/Field.tsx +++ b/packages/core/src/components/common/field/Field.tsx @@ -22,6 +22,7 @@ export interface FieldProps { noHightlight?: boolean; disabled: boolean; disableClick?: boolean; + endAdornment?: ReactNode; } const Field: FC = ({ @@ -37,6 +38,7 @@ const Field: FC = ({ noHightlight = false, disabled, disableClick = false, + endAdornment, }) => { const finalCursor = useCursor(cursor, disabled); @@ -95,6 +97,8 @@ const Field: FC = ({ ` relative flex + items-center + gap-2 border-b border-slate-400 focus-within:border-blue-800 @@ -153,6 +157,16 @@ const Field: FC = ({ {renderedHint} {renderedErrorMessage} +
+ {endAdornment} +
); }; diff --git a/packages/core/src/components/common/text-field/TextField.tsx b/packages/core/src/components/common/text-field/TextField.tsx index 9048ed35..ffc846db 100644 --- a/packages/core/src/components/common/text-field/TextField.tsx +++ b/packages/core/src/components/common/text-field/TextField.tsx @@ -4,7 +4,7 @@ import React from 'react'; import useCursor from '@staticcms/core/lib/hooks/useCursor'; import classNames from '@staticcms/core/lib/util/classNames.util'; -import type { ChangeEventHandler, FC, MouseEventHandler, Ref } from 'react'; +import type { ChangeEventHandler, FC, MouseEventHandler, ReactNode, Ref } from 'react'; export interface BaseTextFieldProps { id?: string; @@ -17,6 +17,8 @@ export interface BaseTextFieldProps { variant?: 'borderless' | 'contained'; inputRef?: Ref; placeholder?: string; + endAdornment?: ReactNode; + startAdornment?: ReactNode; } export interface NumberTextFieldProps extends BaseTextFieldProps { @@ -45,6 +47,8 @@ const TextField: FC = ({ disabled = false, onChange, onClick, + startAdornment, + endAdornment, ...otherProps }) => { const finalCursor = useCursor(cursor, disabled); @@ -58,6 +62,8 @@ const TextField: FC = ({ data-testid={dataTestId ?? `${type}-input`} readOnly={readonly} disabled={disabled} + startAdornment={startAdornment} + endAdornment={endAdornment} slotProps={{ root: { className: ` diff --git a/packages/core/src/extensions.ts b/packages/core/src/extensions.ts index 7fee2484..0b5c3d82 100644 --- a/packages/core/src/extensions.ts +++ b/packages/core/src/extensions.ts @@ -25,6 +25,7 @@ import { SelectWidget, StringWidget, TextWidget, + UUIDWidget, } from './widgets'; export default function addExtensions() { @@ -53,6 +54,7 @@ export default function addExtensions() { SelectWidget(), StringWidget(), TextWidget(), + UUIDWidget(), ]); Object.keys(locales).forEach(locale => { diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index 430a635b..312830c2 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -689,11 +689,15 @@ export interface HiddenField extends BaseField { } export interface StringOrTextField extends BaseField { - // This is the default widget, so declaring its type is optional. widget: 'string' | 'text'; default?: string; } +export interface UUIDField extends BaseField { + widget: 'uuid'; + allow_regenerate?: boolean; +} + export interface UnknownField extends BaseField { widget: 'unknown'; } @@ -713,6 +717,7 @@ export type Field = | SelectField | HiddenField | StringOrTextField + | UUIDField | EF; export interface ViewFilter { diff --git a/packages/core/src/widgets/index.ts b/packages/core/src/widgets/index.ts index 43b11808..277541cc 100644 --- a/packages/core/src/widgets/index.ts +++ b/packages/core/src/widgets/index.ts @@ -28,3 +28,5 @@ export * from './string'; export { default as StringWidget } from './string'; export * from './text'; export { default as TextWidget } from './text'; +export * from './uuid'; +export { default as UUIDWidget } from './uuid'; diff --git a/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx b/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx index 40126291..68b72243 100644 --- a/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx +++ b/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx @@ -13,6 +13,8 @@ import ListControl from '../ListControl'; import type { DragEndEvent } from '@dnd-kit/core'; import type { ListField, ValueOrNestedValue } from '@staticcms/core/interface'; +jest.unmock('uuid'); + const singletonListField: ListField = { widget: 'list', name: 'singleton', diff --git a/packages/core/src/widgets/uuid/UUIDControl.tsx b/packages/core/src/widgets/uuid/UUIDControl.tsx new file mode 100644 index 00000000..a109d1be --- /dev/null +++ b/packages/core/src/widgets/uuid/UUIDControl.tsx @@ -0,0 +1,79 @@ +import { Refresh as RefreshIcon } from '@styled-icons/material/Refresh'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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 type { UUIDField, WidgetControlProps } from '@staticcms/core/interface'; +import type { FC } from 'react'; + +const UUIDControl: FC> = ({ + value, + label, + errors, + disabled, + field, + forSingleList, + duplicate, + controlled, + onChange, +}) => { + const [internalRawValue, setInternalValue] = useState(value ?? ''); + const internalValue = useMemo( + () => (controlled || duplicate ? value ?? '' : internalRawValue), + [controlled, duplicate, value, internalRawValue], + ); + const ref = useRef(null); + + const handleChange = useCallback( + (newUUID: string) => { + setInternalValue(newUUID); + onChange(newUUID); + }, + [onChange], + ); + + const generateUUID = useCallback(() => { + handleChange(uuid()); + }, [handleChange]); + + useEffect(() => { + if (isEmpty(internalValue) || !validate(internalValue)) { + generateUUID(); + } + }, [generateUUID, internalValue]); + + const allowRegenerate = useMemo(() => field.allow_regenerate ?? true, [field.allow_regenerate]); + + return ( + + + + ) : null + } + > + + + ); +}; + +export default UUIDControl; diff --git a/packages/core/src/widgets/uuid/UUIDPreview.tsx b/packages/core/src/widgets/uuid/UUIDPreview.tsx new file mode 100644 index 00000000..a2dae592 --- /dev/null +++ b/packages/core/src/widgets/uuid/UUIDPreview.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +import type { UUIDField, WidgetPreviewProps } from '@staticcms/core/interface'; +import type { FC } from 'react'; + +const StringPreview: FC> = ({ value = '' }) => { + return
{value}
; +}; + +export default StringPreview; diff --git a/packages/core/src/widgets/uuid/__tests__/UUIDControl.spec.ts b/packages/core/src/widgets/uuid/__tests__/UUIDControl.spec.ts new file mode 100644 index 00000000..ea038061 --- /dev/null +++ b/packages/core/src/widgets/uuid/__tests__/UUIDControl.spec.ts @@ -0,0 +1,192 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { v4, validate } from 'uuid'; + +import { mockUUIDField } from '@staticcms/test/data/fields.mock'; +import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness'; +import UUIDControl from '../UUIDControl'; + +describe(UUIDControl.name, () => { + const renderControl = createWidgetControlHarness(UUIDControl, { field: mockUUIDField }); + + const mockValidate = validate as jest.Mock; + const mockUUID = v4 as jest.Mock; + + beforeEach(() => { + mockValidate.mockReturnValue(true); + mockUUID.mockReturnValue('I_AM_A_NEW_UUID'); + }); + + it('should render', () => { + const { getByTestId } = renderControl({ label: 'I am a label' }); + + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('readonly'); + + const label = getByTestId('label'); + expect(label.textContent).toBe('I am a label'); + expect(label).toHaveClass('text-slate-500'); + + const field = getByTestId('field'); + expect(field).toHaveClass('group/active'); + + const fieldWrapper = getByTestId('field-wrapper'); + expect(fieldWrapper).not.toHaveClass('mr-14'); + + // String Widget uses text cursor + expect(label).toHaveClass('cursor-text'); + expect(field).toHaveClass('cursor-text'); + + // String Widget uses default label layout, with bottom padding on field + expect(label).toHaveClass('px-3', 'pt-3'); + expect(field).toHaveClass('pb-3'); + }); + + it('should render as single list item', () => { + const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true }); + + expect(getByTestId('text-input')).toBeInTheDocument(); + + const fieldWrapper = getByTestId('field-wrapper'); + expect(fieldWrapper).toHaveClass('mr-14'); + }); + + it('should only use prop value as initial value', async () => { + const { rerender, getByTestId } = renderControl({ value: 'I_AM_A_VALID_UUID' }); + + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toHaveValue('I_AM_A_VALID_UUID'); + + rerender({ value: 'i am a new value' }); + expect(input).toHaveValue('I_AM_A_VALID_UUID'); + }); + + it('should use prop value exclusively if field is i18n duplicate', async () => { + const { rerender, getByTestId } = renderControl({ + field: { ...mockUUIDField, i18n: 'duplicate' }, + duplicate: true, + value: 'I_AM_A_VALID_UUID', + }); + + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toHaveValue('I_AM_A_VALID_UUID'); + + rerender({ value: 'I_AM_ANOTHER_VALID_UUID' }); + expect(input).toHaveValue('I_AM_ANOTHER_VALID_UUID'); + }); + + it('should generate a new UUID if provided on is invalid', async () => { + mockValidate.mockReturnValue(false); + + const { + getByTestId, + 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'); + }); + + it('should generate a new UUID if none is provided', async () => { + const { + getByTestId, + props: { onChange }, + } = renderControl(); + + 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'); + }); + + it('shows generate new uuid button by default', async () => { + const { queryByTestId } = renderControl(); + + expect(queryByTestId('generate-new-uuid')).toBeInTheDocument(); + }); + + it('hides generate new uuid button if allow_regenerate is false', async () => { + const { queryByTestId } = renderControl({ + field: { + ...mockUUIDField, + allow_regenerate: false, + }, + }); + + expect(queryByTestId('generate-new-uuid')).not.toBeInTheDocument(); + }); + + it('should generate a new UUID when generate new uuid button is clicked', async () => { + const { + getByTestId, + props: { onChange }, + } = renderControl(); + + 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'); + + mockUUID.mockReturnValue('I_AM_ANOTHER_NEW_UUID'); + const generateNewUUIDButton = getByTestId('generate-new-uuid'); + + // No change yet + expect(input).toHaveValue('I_AM_A_NEW_UUID'); + + await act(async () => { + await userEvent.click(generateNewUUIDButton); + }); + + expect(input).toHaveValue('I_AM_ANOTHER_NEW_UUID'); + expect(onChange).toHaveBeenLastCalledWith('I_AM_ANOTHER_NEW_UUID'); + }); + + it('should show error', async () => { + const { getByTestId } = renderControl({ + errors: [{ type: 'error-type', message: 'i am an error' }], + }); + + const error = getByTestId('error'); + expect(error.textContent).toBe('i am an error'); + + const field = getByTestId('field'); + expect(field).not.toHaveClass('group/active'); + + const label = getByTestId('label'); + expect(label).toHaveClass('text-red-500'); + }); + + it('should focus input on field click', async () => { + const { getByTestId } = renderControl(); + + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).not.toHaveFocus(); + + await act(async () => { + const field = getByTestId('field'); + await userEvent.click(field); + }); + + expect(input).toHaveFocus(); + }); + + it('should disable input if disabled', async () => { + const { getByTestId } = renderControl({ disabled: true }); + + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toBeDisabled(); + }); +}); diff --git a/packages/core/src/widgets/uuid/index.ts b/packages/core/src/widgets/uuid/index.ts new file mode 100644 index 00000000..e328e149 --- /dev/null +++ b/packages/core/src/widgets/uuid/index.ts @@ -0,0 +1,20 @@ +import schema from './schema'; +import controlComponent from './UUIDControl'; +import previewComponent from './UUIDPreview'; + +import type { UUIDField, WidgetParam } from '@staticcms/core/interface'; + +const UUIDWidget = (): WidgetParam => { + return { + name: 'uuid', + controlComponent, + previewComponent, + options: { + schema, + }, + }; +}; + +export { controlComponent as UUIDControl, previewComponent as UUIDPreview, schema as uuidSchema }; + +export default UUIDWidget; diff --git a/packages/core/src/widgets/uuid/schema.ts b/packages/core/src/widgets/uuid/schema.ts new file mode 100644 index 00000000..0220d6d1 --- /dev/null +++ b/packages/core/src/widgets/uuid/schema.ts @@ -0,0 +1,5 @@ +export default { + properties: { + allow_regenerate: { type: 'boolean' }, + }, +}; diff --git a/packages/core/test/data/fields.mock.ts b/packages/core/test/data/fields.mock.ts index e5d7666a..e8c1722f 100644 --- a/packages/core/test/data/fields.mock.ts +++ b/packages/core/test/data/fields.mock.ts @@ -8,6 +8,7 @@ import type { RelationField, SelectField, StringOrTextField, + UUIDField, } from '@staticcms/core'; export const mockBooleanField: BooleanField = { @@ -89,3 +90,9 @@ export const mockTextField: StringOrTextField = { name: 'mock_text', widget: 'text', }; + +export const mockUUIDField: UUIDField = { + label: 'UUID', + name: 'mock_uuid', + widget: 'uuid', +}; diff --git a/packages/demo/public/config.yml b/packages/demo/public/config.yml index 080d3e1b..444b2ec9 100644 --- a/packages/demo/public/config.yml +++ b/packages/demo/public/config.yml @@ -190,7 +190,8 @@ collections: - name: pattern label: Pattern Validation widget: color - pattern: ['^#[a-fA-F0-9]{3}$|^[a-fA-F0-9]{4}$|^[a-fA-F0-9]{6}$', 'Must be a valid hex code'] + pattern: + ['^#[a-fA-F0-9]{3}$|^[a-fA-F0-9]{4}$|^[a-fA-F0-9]{6}$', 'Must be a valid hex code'] allow_input: true required: false - name: alpha @@ -851,6 +852,18 @@ collections: widget: text pattern: ['.{12,}', 'Must have at least 12 characters'] required: false + - name: uuid + label: UUID + file: _widgets/uuid.json + description: UUID widget + fields: + - name: uuid + label: UUID + widget: uuid + - name: no_regenerate + label: Does not allow regeneration + widget: uuid + allow_regenerate: false - name: settings label: Settings delete: false diff --git a/packages/docs/content/docs/widget-uuid.mdx b/packages/docs/content/docs/widget-uuid.mdx new file mode 100644 index 00000000..b0fab511 --- /dev/null +++ b/packages/docs/content/docs/widget-uuid.mdx @@ -0,0 +1,36 @@ +--- +group: Widgets +title: Text +weight: 25 +--- + +- **Name:** `uuid` +- **UI:** Text input +- **Data type:** `string` + +The uuid widget generates a unique id (uuid) and saves it as a string. + +## Widget Options + +For common options, see [Common widget options](/docs/widgets#common-widget-options). + +| Name | Type | Default | Description | +| ---------------- | ------- | ------- | -------------------------------------------------------------------------------------- | +| allow_regenerate | boolean | `true` | _Optional_. If set to `false` the `Generate new UUID` button does not appear in the UI | + +## Example + + +```yaml +name: id +label: Id +widget: uuid +``` + +```js +name: 'id', +label: 'Id', +widget: 'uuid', +``` + + diff --git a/packages/docs/content/docs/widgets.mdx b/packages/docs/content/docs/widgets.mdx index 200e0bf2..4c349f2d 100644 --- a/packages/docs/content/docs/widgets.mdx +++ b/packages/docs/content/docs/widgets.mdx @@ -30,21 +30,22 @@ To see working examples of all of the built-in widgets, try making a 'Kitchen Si | [Select](/docs/widget-select) | The select widget allows you to pick a string value from a dropdown menu | | [String](/docs/widget-string) | The string widget translates a basic text input to a string value | | [Text](/docs/widget-text) | The text widget takes a multiline text field and saves it as a string | +| [Text](/docs/widget-text) | The uuid widget generates a unique id (uuid) and saves it as a string | ## Common widget options The following options are available on all fields: -| Name | Type | Default | Description | -| -------- | ----------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| name | string | | The name of the field | -| widget | string | `'string'` | _Optional_. The type of widget to render for the field | -| label | string | `name` | _Optional_. The display name of the field | -| required | boolean | `true` | _Optional_. Specify as `false` to make a field optional | -| hint | string | | _Optional_. Adds helper text directly below a widget. Useful for including instructions. Accepts markdown for bold, italic, strikethrough, and links. | -| pattern | list of strings | | _Optional_. Adds field validation by specifying a list with a [regex pattern](https://regexr.com/) and an error message; more extensive validation can be achieved with [custom widgets](/docs/custom-widgets/#advanced-field-validation) | +| Name | Type | Default | Description | +| -------- | ----------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| name | string | | The name of the field | +| widget | string | `'string'` | _Optional_. The type of widget to render for the field | +| label | string | `name` | _Optional_. The display name of the field | +| required | boolean | `true` | _Optional_. Specify as `false` to make a field optional | +| hint | string | | _Optional_. Adds helper text directly below a widget. Useful for including instructions. Accepts markdown for bold, italic, strikethrough, and links. | +| pattern | list of strings | | _Optional_. Adds field validation by specifying a list with a [regex pattern](https://regexr.com/) and an error message; more extensive validation can be achieved with [custom widgets](/docs/custom-widgets/#advanced-field-validation) | | i18n | boolean
\|'translate'
\|'duplicate'
\|'none' | | _Optional_.
  • `translate` - Allows translation of the field
  • `duplicate` - Duplicates the value from the default locale
  • `true` - Accept parent values as default
  • `none` or `false` - Exclude field from translations
| -| comment | string | | _Optional_. Adds comment before the field (only supported for yaml) | +| comment | string | | _Optional_. Adds comment before the field (only supported for yaml) | ## Example