fix: improve color widget, add tests (#712)

This commit is contained in:
Daniel Lautzenheiser 2023-04-19 10:57:34 -04:00 committed by GitHub
parent 23df691a0a
commit 28fd9caf4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 628 additions and 11 deletions

View File

@ -179,6 +179,10 @@ collections:
- name: required - name: required
label: Required Validation label: Required Validation
widget: color widget: color
- name: allow_input
label: Allow Input
widget: color
allow_input: true
- name: with_default - name: with_default
label: Required With Default label: Required With Default
widget: color widget: color
@ -186,7 +190,7 @@ collections:
- name: pattern - name: pattern
label: Pattern Validation label: Pattern Validation
widget: color widget: color
pattern: ['^#([0-9a-fA-F]{3})(?:[0-9a-fA-F]{3})?$', '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 allow_input: true
required: false required: false
- name: alpha - name: alpha

View File

@ -22,7 +22,7 @@ const ErrorMessage: FC<ErrorMessageProps> = ({ errors, className }) => {
text-xs text-xs
text-red-500 text-red-500
px-3 px-3
pt-1 pt-2
`, `,
className, className,
)} )}

View File

@ -93,8 +93,10 @@ const en: LocalePhrasesRoot = {
rangeCountExact: '%{fieldLabel} must have exactly %{count} item(s).', rangeCountExact: '%{fieldLabel} must have exactly %{count} item(s).',
rangeMin: '%{fieldLabel} must be at least %{minCount} item(s).', rangeMin: '%{fieldLabel} must be at least %{minCount} item(s).',
rangeMax: '%{fieldLabel} must be %{maxCount} or less item(s).', rangeMax: '%{fieldLabel} must be %{maxCount} or less item(s).',
invalidPath: `'%{path}' is not a valid path`, invalidPath: `'%{path}' is not a valid path.`,
pathExists: `Path '%{path}' already exists`, pathExists: `Path '%{path}' already exists.`,
invalidColor: `Color '%{color}' is invalid.`,
invalidHexCode: `Hex codes must start with a # sign.`,
}, },
i18n: { i18n: {
writingInLocale: 'Writing in %{locale}', writingInLocale: 'Writing in %{locale}',

View File

@ -9,7 +9,7 @@ import TextField from '@staticcms/core/components/common/text-field/TextField';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import type { ColorField, WidgetControlProps } from '@staticcms/core/interface'; import type { ColorField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC, MouseEvent } from 'react'; import type { ChangeEvent, FC, MouseEvent, MouseEventHandler } from 'react';
import type { ColorResult } from 'react-color'; import type { ColorResult } from 'react-color';
const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({ const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
@ -33,9 +33,10 @@ const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
); );
// show/hide color picker // show/hide color picker
const handleClick = useCallback(() => { const handleClick: MouseEventHandler = useCallback(event => {
setShowColorPicker(!showColorPicker); event.stopPropagation();
}, [showColorPicker]); setShowColorPicker(oldShowColorPicker => !oldShowColorPicker);
}, []);
const handleClear = useCallback( const handleClear = useCallback(
(event: MouseEvent) => { (event: MouseEvent) => {
@ -84,6 +85,7 @@ const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
forSingleList={forSingleList} forSingleList={forSingleList}
cursor={allowInput ? 'text' : 'pointer'} cursor={allowInput ? 'text' : 'pointer'}
disabled={disabled} disabled={disabled}
disableClick={showColorPicker}
> >
<div <div
className={classNames( className={classNames(
@ -100,6 +102,7 @@ const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
<div <div
ref={swatchRef} ref={swatchRef}
key="color-swatch" key="color-swatch"
data-testid="color-swatch"
onClick={!disabled ? handleClick : undefined} onClick={!disabled ? handleClick : undefined}
style={{ style={{
background: validateColor(internalValue) ? internalValue : '#fff', background: validateColor(internalValue) ? internalValue : '#fff',
@ -157,7 +160,8 @@ const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
onChange={handleInputChange} onChange={handleInputChange}
// make readonly and open color picker on click if set to allow_input: false // make readonly and open color picker on click if set to allow_input: false
onClick={!allowInput && !disabled ? handleClick : undefined} onClick={!allowInput && !disabled ? handleClick : undefined}
disabled={!allowInput || disabled} disabled={disabled}
readonly={!allowInput}
cursor={allowInput ? 'text' : 'pointer'} cursor={allowInput ? 'text' : 'pointer'}
/> />
{showClearButton ? ( {showClearButton ? (

View File

@ -0,0 +1,236 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import { act } from '@testing-library/react';
import { mockColorField } from '@staticcms/test/data/fields.mock';
import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness';
import ColorControl from '../ColorControl';
describe(ColorControl.name, () => {
const renderControl = createWidgetControlHarness(ColorControl, { field: mockColorField });
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();
// Color Widget input should be read only by default
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');
// Color Widget uses pointer cursor
expect(label).toHaveClass('cursor-pointer');
expect(field).toHaveClass('cursor-pointer');
// Color 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: '#ffffff' });
const swatch = getByTestId('color-swatch');
const inputWrapper = getByTestId('text-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('#ffffff');
expect(swatch).toHaveStyle({
background: '#ffffff',
color: 'rgba(255, 255, 255, 0)',
});
rerender({ value: '#000000' });
expect(input).toHaveValue('#ffffff');
expect(swatch).toHaveStyle({
background: '#ffffff',
color: 'rgba(255, 255, 255, 0)',
});
});
it('should use prop value exclusively if field is i18n duplicate', async () => {
const { rerender, getByTestId } = renderControl({
field: { ...mockColorField, i18n: 'duplicate' },
duplicate: true,
value: '#ffffff',
});
const swatch = getByTestId('color-swatch');
const inputWrapper = getByTestId('text-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toHaveValue('#ffffff');
expect(swatch).toHaveStyle({
background: '#ffffff',
color: 'rgba(255, 255, 255, 0)',
});
rerender({ value: '#000000' });
expect(input).toHaveValue('#000000');
expect(swatch).toHaveStyle({
background: '#000000',
color: 'rgba(255, 255, 255, 0)',
});
});
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 disable input if disabled', async () => {
const { getByTestId } = renderControl({ disabled: true });
const inputWrapper = getByTestId('text-input');
const input = inputWrapper.getElementsByTagName('input')[0];
expect(input).toBeDisabled();
});
describe('allow input', () => {
it('should call onChange when text input changes (hex code)', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
field: { ...mockColorField, allow_input: true },
});
const inputWrapper = getByTestId('text-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(input, '#000000');
});
expect(onChange).toHaveBeenLastCalledWith('#000000');
const swatch = getByTestId('color-swatch');
expect(swatch).toHaveStyle({
background: '#000000',
color: 'rgba(255, 255, 255, 0)',
});
});
it('should call onChange when text input changes (rgb code)', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
field: { ...mockColorField, allow_input: true },
});
const inputWrapper = getByTestId('text-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(input, 'rgb(25, 50, 5)');
});
expect(onChange).toHaveBeenLastCalledWith('rgb(25, 50, 5)');
const swatch = getByTestId('color-swatch');
expect(swatch).toHaveStyle({
background: 'rgb(25, 50, 5)',
color: 'rgba(255, 255, 255, 0)',
});
});
it('should call onChange when text input changes (rgba code)', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
field: { ...mockColorField, allow_input: true },
});
const inputWrapper = getByTestId('text-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(input, 'rgba(25, 50, 5, 0.5)');
});
expect(onChange).toHaveBeenLastCalledWith('rgba(25, 50, 5, 0.5)');
const swatch = getByTestId('color-swatch');
expect(swatch).toHaveStyle({
background: 'rgba(25, 50, 5, 0.5)',
color: 'rgba(255, 255, 255, 0)',
});
});
it('should make question mark visible if bad color is put in input', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
field: { ...mockColorField, allow_input: true },
});
const inputWrapper = getByTestId('text-input');
const input = inputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(input, '#0muz14');
});
expect(onChange).toHaveBeenLastCalledWith('#0muz14');
const swatch = getByTestId('color-swatch');
expect(swatch).toHaveStyle({
background: '#fff',
color: 'rgb(150, 150, 150)',
});
});
it('should focus input on field click', async () => {
const { getByTestId } = renderControl({
field: { ...mockColorField, allow_input: true },
});
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();
});
});
});

View File

@ -0,0 +1,294 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import ValidationErrorTypes from '@staticcms/core/constants/validationErrorTypes';
import { mockColorField } from '@staticcms/test/data/fields.mock';
import validator from '../validator';
describe('validator relation', () => {
const t = jest.fn();
beforeEach(() => {
t.mockReset();
t.mockImplementation((key: string) => key);
});
describe('hex code', () => {
it('should accept 6 digit hex code', () => {
expect(
validator({
field: mockColorField,
value: '#ffffff',
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should accept 4 digit hex code', () => {
expect(
validator({
field: mockColorField,
value: '#1b30',
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should accept 3 digit hex code', () => {
expect(
validator({
field: mockColorField,
value: '#1b3',
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should return error on hex code with invalid characters', () => {
expect(
validator({
field: mockColorField,
value: '#fzffOm',
t,
}),
).toEqual({
type: ValidationErrorTypes.CUSTOM,
message: 'editor.editorControlPane.widget.invalidColor',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.invalidColor', {
color: '#fzffOm',
});
});
it('should return error on hex code that is not 3, 4 or 6 characters', () => {
expect(
validator({
field: mockColorField,
value: '#ff',
t,
}),
).toEqual({
type: ValidationErrorTypes.CUSTOM,
message: 'editor.editorControlPane.widget.invalidColor',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.invalidColor', {
color: '#ff',
});
});
it('should return error on hex code without pound sign', () => {
expect(
validator({
field: mockColorField,
value: 'ffffff',
t,
}),
).toEqual({
type: ValidationErrorTypes.CUSTOM,
message: 'editor.editorControlPane.widget.invalidHexCode',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.invalidHexCode');
});
});
describe('rgb', () => {
it('should accept rgb string with commas', () => {
expect(
validator({
field: mockColorField,
value: 'rgb(25, 50, 5)',
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should accept rgb string with just red and green', () => {
expect(
validator({
field: mockColorField,
value: 'rgb(25, 50)',
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should accept rgb string without commas', () => {
expect(
validator({
field: mockColorField,
value: 'rgb(25 50 5)',
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should return error with no parentheses', () => {
expect(
validator({
field: mockColorField,
value: 'rgb 25 50 5',
t,
}),
).toEqual({
type: ValidationErrorTypes.CUSTOM,
message: 'editor.editorControlPane.widget.invalidColor',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.invalidColor', {
color: 'rgb 25 50 5',
});
});
it('should return error with invalid characters', () => {
expect(
validator({
field: mockColorField,
value: 'rgb(25f, 50, B)',
t,
}),
).toEqual({
type: ValidationErrorTypes.CUSTOM,
message: 'editor.editorControlPane.widget.invalidColor',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.invalidColor', {
color: 'rgb(25f, 50, B)',
});
});
it('should return error with just red', () => {
expect(
validator({
field: mockColorField,
value: 'rgb(25)',
t,
}),
).toEqual({
type: ValidationErrorTypes.CUSTOM,
message: 'editor.editorControlPane.widget.invalidColor',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.invalidColor', {
color: 'rgb(25)',
});
});
});
describe('rgba', () => {
it('should accept rgba string with commas', () => {
expect(
validator({
field: mockColorField,
value: 'rgba(25, 50, 5, 0.5)',
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should accept rgba string without alpha', () => {
expect(
validator({
field: mockColorField,
value: 'rgba(25, 50, 5)',
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should accept rgba string with just red and green', () => {
expect(
validator({
field: mockColorField,
value: 'rgba(25, 50)',
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should accept rgba string without commas', () => {
expect(
validator({
field: mockColorField,
value: 'rgba(25 50 5 0.5)',
t,
}),
).toBeFalsy();
expect(t).not.toHaveBeenCalled();
});
it('should return error with no parentheses', () => {
expect(
validator({
field: mockColorField,
value: 'rgba 25 50 5 0.5',
t,
}),
).toEqual({
type: ValidationErrorTypes.CUSTOM,
message: 'editor.editorControlPane.widget.invalidColor',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.invalidColor', {
color: 'rgba 25 50 5 0.5',
});
});
it('should return error with invalid characters', () => {
expect(
validator({
field: mockColorField,
value: 'rgba(25f, 50, B, 0.z)',
t,
}),
).toEqual({
type: ValidationErrorTypes.CUSTOM,
message: 'editor.editorControlPane.widget.invalidColor',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.invalidColor', {
color: 'rgba(25f, 50, B, 0.z)',
});
});
it('should return error with just red', () => {
expect(
validator({
field: mockColorField,
value: 'rgba(25)',
t,
}),
).toEqual({
type: ValidationErrorTypes.CUSTOM,
message: 'editor.editorControlPane.widget.invalidColor',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.invalidColor', {
color: 'rgba(25)',
});
});
});
});

View File

@ -1,6 +1,7 @@
import controlComponent from './ColorControl'; import controlComponent from './ColorControl';
import previewComponent from './ColorPreview'; import previewComponent from './ColorPreview';
import schema from './schema'; import schema from './schema';
import validator from './validator';
import type { ColorField, WidgetParam } from '@staticcms/core/interface'; import type { ColorField, WidgetParam } from '@staticcms/core/interface';
@ -11,6 +12,7 @@ const ColorWidget = (): WidgetParam<string, ColorField> => {
previewComponent, previewComponent,
options: { options: {
schema, schema,
validator,
}, },
}; };
}; };
@ -18,7 +20,8 @@ const ColorWidget = (): WidgetParam<string, ColorField> => {
export { export {
controlComponent as ColorControl, controlComponent as ColorControl,
previewComponent as ColorPreview, previewComponent as ColorPreview,
schema as ColorSchema, schema as colorSchema,
validator as colorValidator,
}; };
export default ColorWidget; export default ColorWidget;

View File

@ -0,0 +1,31 @@
import validateColor from 'validate-color';
import ValidationErrorTypes from '@staticcms/core/constants/validationErrorTypes';
import type { ColorField, FieldValidationMethod } from '@staticcms/core/interface';
const validator: FieldValidationMethod<string, ColorField> = ({ value, t }) => {
if (typeof value !== 'string') {
return false;
}
if (validateColor(value)) {
return false;
}
if (/^[a-fA-F0-9]{3}$|^[a-fA-F0-9]{4}$|^[a-fA-F0-9]{6}$/g.test(value)) {
return {
type: ValidationErrorTypes.CUSTOM,
message: t(`editor.editorControlPane.widget.invalidHexCode`),
};
}
return {
type: ValidationErrorTypes.CUSTOM,
message: t(`editor.editorControlPane.widget.invalidColor`, {
color: value,
}),
};
};
export default validator;

View File

@ -21,6 +21,38 @@ describe(SelectControl.name, () => {
expect(input).toBeDisabled(); expect(input).toBeDisabled();
}); });
it('should open and close options on field click', async () => {
const { getByTestId, queryByTestId } = renderControl();
const option1 = 'select-option-Option 1';
const option2 = 'select-option-Option 2';
await waitFor(() => {
expect(queryByTestId(option1)).not.toBeInTheDocument();
expect(queryByTestId(option2)).not.toBeInTheDocument();
});
await act(async () => {
const field = getByTestId('field');
await userEvent.click(field);
});
await waitFor(() => {
expect(queryByTestId(option1)).toBeInTheDocument();
expect(queryByTestId(option2)).toBeInTheDocument();
});
await act(async () => {
const field = getByTestId('field');
await userEvent.click(field);
});
await waitFor(() => {
expect(queryByTestId(option1)).not.toBeInTheDocument();
expect(queryByTestId(option2)).not.toBeInTheDocument();
});
});
describe('simple string select', () => { describe('simple string select', () => {
it('should render', () => { it('should render', () => {
const { getByTestId } = renderControl({ label: 'I am a label' }); const { getByTestId } = renderControl({ label: 'I am a label' });

View File

@ -1,5 +1,6 @@
import type { import type {
BooleanField, BooleanField,
ColorField,
DateTimeField, DateTimeField,
FileOrImageField, FileOrImageField,
MarkdownField, MarkdownField,
@ -15,6 +16,12 @@ export const mockBooleanField: BooleanField = {
widget: 'boolean', widget: 'boolean',
}; };
export const mockColorField: ColorField = {
label: 'Color',
name: 'mock_color',
widget: 'color',
};
export const mockDateTimeField: DateTimeField = { export const mockDateTimeField: DateTimeField = {
label: 'DateTime', label: 'DateTime',
name: 'mock_datetime', name: 'mock_datetime',

View File

@ -179,6 +179,10 @@ collections:
- name: required - name: required
label: Required Validation label: Required Validation
widget: color widget: color
- name: allow_input
label: Allow Input
widget: color
allow_input: true
- name: with_default - name: with_default
label: Required With Default label: Required With Default
widget: color widget: color
@ -186,7 +190,7 @@ collections:
- name: pattern - name: pattern
label: Pattern Validation label: Pattern Validation
widget: color widget: color
pattern: ['^#([0-9a-fA-F]{3})(?:[0-9a-fA-F]{3})?$', '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 allow_input: true
required: false required: false
- name: alpha - name: alpha