Merge pull request #1719 from payloadcms/feat/1695-nullable-localized-array-and-blocks

Feat: allow null for non-default locales on arrays and blocks
This commit is contained in:
Jarrod Flesch
2023-01-10 11:23:46 -05:00
committed by GitHub
44 changed files with 448 additions and 139 deletions

View File

@@ -6,7 +6,7 @@ import './index.scss';
const baseClass = 'banner';
const Banner: React.FC<Props> = ({
export const Banner: React.FC<Props> = ({
children,
className,
to,

View File

@@ -12,4 +12,4 @@
&::after {
border-top-color: var(--theme-error-500);
}
}
}

View File

@@ -105,11 +105,16 @@ export const addFieldStatePromise = async ({
await Promise.all(promises);
// Add values to field state
fieldState.value = arrayValue.length;
fieldState.initialValue = arrayValue.length;
if (valueWithDefault === null) {
fieldState.value = null;
fieldState.initialValue = null;
} else {
fieldState.value = arrayValue.length;
fieldState.initialValue = arrayValue.length;
if (arrayValue.length > 0) {
fieldState.disableFormData = true;
if (arrayValue.length > 0) {
fieldState.disableFormData = true;
}
}
// Add field to state
@@ -161,13 +166,17 @@ export const addFieldStatePromise = async ({
}
});
await Promise.all(promises);
// Add values to field state
fieldState.value = blocksValue.length;
fieldState.initialValue = blocksValue.length;
if (valueWithDefault === null) {
fieldState.value = null;
fieldState.initialValue = null;
} else {
fieldState.value = blocksValue.length;
fieldState.initialValue = blocksValue.length;
if (blocksValue.length > 0) {
fieldState.disableFormData = true;
if (blocksValue.length > 0) {
fieldState.disableFormData = true;
}
}
// Add field to state

View File

@@ -0,0 +1,59 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { CheckboxInput } from '../field-types/Checkbox/Input';
import { Banner } from '../../elements/Banner';
import { useLocale } from '../../utilities/Locale';
import { useConfig } from '../../utilities/Config';
import { useForm } from '../Form/context';
type NullifyFieldProps = {
path: string
fieldValue?: null | [] | number
}
export const NullifyField: React.FC<NullifyFieldProps> = ({ path, fieldValue }) => {
const { dispatchFields, setModified } = useForm();
const currentLocale = useLocale();
const { localization } = useConfig();
const [checked, setChecked] = React.useState<boolean>(typeof fieldValue !== 'number');
const defaultLocale = (localization && localization.defaultLocale) ? localization.defaultLocale : 'en';
const { t } = useTranslation('general');
const onChange = () => {
const useFallback = !checked;
dispatchFields({
type: 'UPDATE',
path,
value: useFallback ? null : (fieldValue || 0),
});
setModified(true);
setChecked(useFallback);
};
if (currentLocale === defaultLocale) {
// hide when editing default locale
return null;
}
if (fieldValue) {
let hideCheckbox = false;
if (typeof fieldValue === 'number' && fieldValue > 0) hideCheckbox = true;
if (Array.isArray(fieldValue) && fieldValue.length > 0) hideCheckbox = true;
if (hideCheckbox) {
if (checked) setChecked(false); // uncheck when field has value
return null;
}
}
return (
<Banner>
<CheckboxInput
id={`field-${path.replace(/\./gi, '__')}`}
onToggle={onChange}
label={t('fallbackToDefaultLocale')}
checked={checked}
/>
</Banner>
);
};

View File

@@ -25,6 +25,8 @@ import HiddenInput from '../HiddenInput';
import { RowLabel } from '../../RowLabel';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import { useConfig } from '../../../utilities/Config';
import { NullifyField } from '../../NullifyField';
import './index.scss';
@@ -69,6 +71,15 @@ const ArrayFieldType: React.FC<Props> = (props) => {
const locale = useLocale();
const operation = useOperation();
const { t, i18n } = useTranslation('fields');
const { localization } = useConfig();
const checkSkipValidation = useCallback((value) => {
const defaultLocale = (localization && localization.defaultLocale) ? localization.defaultLocale : 'en';
const isEditingDefaultLocale = locale === defaultLocale;
if (value === null && !isEditingDefaultLocale) return true;
return false;
}, [locale, localization]);
// Handle labeling for Arrays, Global Arrays, and Blocks
const getLabels = (p: Props) => {
@@ -82,14 +93,15 @@ const ArrayFieldType: React.FC<Props> = (props) => {
const { dispatchFields, setModified } = formContext;
const memoizedValidate = useCallback((value, options) => {
if (checkSkipValidation(value)) return true;
return validate(value, { ...options, minRows, maxRows, required });
}, [maxRows, minRows, required, validate]);
}, [maxRows, minRows, required, validate, checkSkipValidation]);
const {
showError,
errorMessage,
value,
} = useField({
} = useField<number>({
path,
validate: memoizedValidate,
condition,
@@ -252,6 +264,12 @@ const ArrayFieldType: React.FC<Props> = (props) => {
description={description}
/>
</header>
<NullifyField
path={path}
fieldValue={value}
/>
<Droppable droppableId="array-drop">
{(provided) => (
<div
@@ -322,23 +340,28 @@ const ArrayFieldType: React.FC<Props> = (props) => {
</Draggable>
);
})}
{(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows ? labels.plural : labels.singular, i18n) || t(minRows > 1 ? 'general:row' : 'general:rows'),
})}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
{!checkSkipValidation(value) && (
<React.Fragment>
{(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows ? labels.plural : labels.singular, i18n) || t(minRows > 1 ? 'general:row' : 'general:rows'),
})}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
)}
</React.Fragment>
)}
{provided.placeholder}
</div>
)}
</Droppable>
{(!readOnly && !hasMaxRows) && (
<div className={`${baseClass}__add-button-wrap`}>
<Button
@@ -352,6 +375,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
</Button>
</div>
)}
</div>
</DragDropContext>
);

View File

@@ -28,6 +28,8 @@ import Pill from '../../../elements/Pill';
import { scrollToID } from '../../../../utilities/scrollToID';
import HiddenInput from '../HiddenInput';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { NullifyField } from '../../NullifyField';
import { useConfig } from '../../../utilities/Config';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import './index.scss';
@@ -74,11 +76,20 @@ const BlocksField: React.FC<Props> = (props) => {
const operation = useOperation();
const { dispatchFields, setModified } = formContext;
const [selectorIndexOpen, setSelectorIndexOpen] = useState<number>();
const { localization } = useConfig();
const checkSkipValidation = useCallback((value) => {
const defaultLocale = (localization && localization.defaultLocale) ? localization.defaultLocale : 'en';
const isEditingDefaultLocale = locale === defaultLocale;
if (value === null && !isEditingDefaultLocale) return true;
return false;
}, [locale, localization]);
const memoizedValidate = useCallback((value, options) => {
if (checkSkipValidation(value)) return true;
return validate(value, { ...options, minRows, maxRows, required });
}, [maxRows, minRows, required, validate]);
}, [maxRows, minRows, required, validate, checkSkipValidation]);
const {
showError,
@@ -252,6 +263,11 @@ const BlocksField: React.FC<Props> = (props) => {
/>
</header>
<NullifyField
path={path}
fieldValue={value}
/>
<Droppable
droppableId="blocks-drop"
isDropDisabled={readOnly}
@@ -359,18 +375,23 @@ const BlocksField: React.FC<Props> = (props) => {
return null;
})}
{(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural, i18n),
})}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
{!checkSkipValidation(value) && (
<React.Fragment>
{(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural, i18n),
})}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
)}
</React.Fragment>
)}
{provided.placeholder}
</div>

View File

@@ -0,0 +1,62 @@
import React from 'react';
import Check from '../../../icons/Check';
import Label from '../../Label';
import './index.scss';
const baseClass = 'custom-checkbox';
type CheckboxInputProps = {
onToggle: React.MouseEventHandler<HTMLButtonElement>
inputRef?: React.MutableRefObject<HTMLInputElement>
readOnly?: boolean
checked?: boolean
name?: string
id?: string
label?: string
required?: boolean
}
export const CheckboxInput: React.FC<CheckboxInputProps> = (props) => {
const {
onToggle,
checked,
inputRef,
name,
id,
label,
readOnly,
required,
} = props;
return (
<span
className={[
baseClass,
checked && `${baseClass}--checked`,
readOnly && `${baseClass}--read-only`,
].filter(Boolean).join(' ')}
>
<input
ref={inputRef}
id={id}
type="checkbox"
name={name}
checked={checked}
readOnly
/>
<button
type="button"
onClick={onToggle}
>
<span className={`${baseClass}__input`}>
<Check />
</span>
<Label
htmlFor={id}
label={label}
required={required}
/>
</button>
</span>
);
};

View File

@@ -14,6 +14,46 @@
&__error-wrap {
position: relative;
}
}
.custom-checkbox {
label {
padding-bottom: 0;
}
input {
// hidden HTML checkbox
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
&__input {
// visible checkbox
@include formInput;
padding: 0;
line-height: 0;
position: relative;
width: $baseline;
height: $baseline;
margin-right: base(.5);
svg {
opacity: 0;
}
}
&--read-only {
.custom-checkbox__input {
background-color: var(--theme-elevation-100);
}
label {
color: var(--theme-elevation-400);
}
}
button {
@extend %btn-reset;
@@ -33,45 +73,13 @@
}
}
&__input {
@include formInput;
padding: 0;
line-height: 0;
position: relative;
width: $baseline;
height: $baseline;
margin-right: base(.5);
svg {
opacity: 0;
}
}
input {
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
&--checked {
button {
.checkbox__input {
.custom-checkbox__input {
svg {
opacity: 1;
}
}
}
}
&--read-only {
.checkbox__label {
color: var(--theme-elevation-400);
}
.checkbox__input {
background-color: var(--theme-elevation-100);
}
}
}

View File

@@ -4,10 +4,10 @@ import useField from '../../useField';
import withCondition from '../../withCondition';
import Error from '../../Error';
import { checkbox } from '../../../../../fields/validations';
import Check from '../../../icons/Check';
import FieldDescription from '../../FieldDescription';
import { Props } from './types';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { CheckboxInput } from './Input';
import './index.scss';
@@ -52,6 +52,15 @@ const Checkbox: React.FC<Props> = (props) => {
condition,
});
const onToggle = useCallback(() => {
if (!readOnly) {
setValue(!value);
if (typeof onChange === 'function') onChange(!value);
}
}, [onChange, readOnly, setValue, value]);
const fieldID = `field-${path.replace(/\./gi, '__')}`;
return (
<div
className={[
@@ -73,27 +82,13 @@ const Checkbox: React.FC<Props> = (props) => {
message={errorMessage}
/>
</div>
<input
id={`field-${path.replace(/\./gi, '__')}`}
type="checkbox"
<CheckboxInput
onToggle={onToggle}
id={fieldID}
label={getTranslation(label || name, i18n)}
name={path}
checked={Boolean(value)}
readOnly
/>
<button
type="button"
onClick={readOnly ? undefined : () => {
setValue(!value);
if (typeof onChange === 'function') onChange(!value);
}}
>
<span className={`${baseClass}__input`}>
<Check />
</span>
<span className={`${baseClass}__label`}>
{getTranslation(label || name, i18n)}
</span>
</button>
<FieldDescription
value={value}
description={description}