feat: add uuid widget (#719)

This commit is contained in:
Daniel Lautzenheiser 2023-04-20 12:55:49 -04:00 committed by GitHub
parent 79877fcd1f
commit 9d96a27952
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 423 additions and 13 deletions

View File

@ -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

View File

@ -0,0 +1,3 @@
export const v4 = jest.fn().mockReturnValue('I_AM_A_UUID');
export const validate = jest.fn();

View File

@ -22,6 +22,7 @@ export interface FieldProps {
noHightlight?: boolean;
disabled: boolean;
disableClick?: boolean;
endAdornment?: ReactNode;
}
const Field: FC<FieldProps> = ({
@ -37,6 +38,7 @@ const Field: FC<FieldProps> = ({
noHightlight = false,
disabled,
disableClick = false,
endAdornment,
}) => {
const finalCursor = useCursor(cursor, disabled);
@ -95,6 +97,8 @@ const Field: FC<FieldProps> = ({
`
relative
flex
items-center
gap-2
border-b
border-slate-400
focus-within:border-blue-800
@ -153,6 +157,16 @@ const Field: FC<FieldProps> = ({
{renderedHint}
{renderedErrorMessage}
</div>
<div
className={classNames(
`
pr-2
`,
!noPadding && '-mb-3',
)}
>
{endAdornment}
</div>
</div>
);
};

View File

@ -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<HTMLInputElement>;
placeholder?: string;
endAdornment?: ReactNode;
startAdornment?: ReactNode;
}
export interface NumberTextFieldProps extends BaseTextFieldProps {
@ -45,6 +47,8 @@ const TextField: FC<TextFieldProps> = ({
disabled = false,
onChange,
onClick,
startAdornment,
endAdornment,
...otherProps
}) => {
const finalCursor = useCursor(cursor, disabled);
@ -58,6 +62,8 @@ const TextField: FC<TextFieldProps> = ({
data-testid={dataTestId ?? `${type}-input`}
readOnly={readonly}
disabled={disabled}
startAdornment={startAdornment}
endAdornment={endAdornment}
slotProps={{
root: {
className: `

View File

@ -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 => {

View File

@ -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<EF extends BaseField = UnknownField> =
| SelectField
| HiddenField
| StringOrTextField
| UUIDField
| EF;
export interface ViewFilter {

View File

@ -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';

View File

@ -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',

View File

@ -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<WidgetControlProps<string, UUIDField>> = ({
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<HTMLInputElement | null>(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 (
<Field
inputRef={ref}
label={label}
errors={errors}
hint={field.hint}
forSingleList={forSingleList}
cursor="text"
disabled={disabled}
endAdornment={
allowRegenerate ? (
<IconButton
data-testid="generate-new-uuid"
title="Generate new UUID"
aria-label="generate new uuid"
onClick={generateUUID}
variant="text"
>
<RefreshIcon className="w-5 h-5" />
</IconButton>
) : null
}
>
<TextField type="text" inputRef={ref} value={internalValue} disabled={disabled} readonly />
</Field>
);
};
export default UUIDControl;

View File

@ -0,0 +1,10 @@
import React from 'react';
import type { UUIDField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const StringPreview: FC<WidgetPreviewProps<string, UUIDField>> = ({ value = '' }) => {
return <div>{value}</div>;
};
export default StringPreview;

View File

@ -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();
});
});

View File

@ -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<string, UUIDField> => {
return {
name: 'uuid',
controlComponent,
previewComponent,
options: {
schema,
},
};
};
export { controlComponent as UUIDControl, previewComponent as UUIDPreview, schema as uuidSchema };
export default UUIDWidget;

View File

@ -0,0 +1,5 @@
export default {
properties: {
allow_regenerate: { type: 'boolean' },
},
};

View File

@ -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',
};

View File

@ -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

View File

@ -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
<CodeTabs>
```yaml
name: id
label: Id
widget: uuid
```
```js
name: 'id',
label: 'Id',
widget: 'uuid',
```
</CodeTabs>

View File

@ -30,13 +30,14 @@ 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 |