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:
@@ -6,7 +6,7 @@ import './index.scss';
|
||||
|
||||
const baseClass = 'banner';
|
||||
|
||||
const Banner: React.FC<Props> = ({
|
||||
export const Banner: React.FC<Props> = ({
|
||||
children,
|
||||
className,
|
||||
to,
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
&::after {
|
||||
border-top-color: var(--theme-error-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
59
src/admin/components/forms/NullifyField/index.tsx
Normal file
59
src/admin/components/forms/NullifyField/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
62
src/admin/components/forms/field-types/Checkbox/Input.tsx
Normal file
62
src/admin/components/forms/field-types/Checkbox/Input.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user