feat: dependent fields (#839)

This commit is contained in:
Daniel Lautzenheiser 2023-06-13 15:13:18 -04:00 committed by GitHub
parent 0dc7576bb4
commit 654263dd6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 236 additions and 65 deletions

2
BREAKING_CHANGES.md Normal file
View File

@ -0,0 +1,2 @@
- gitea API config has changed.
- hidden removed as widget parameter

View File

@ -87,14 +87,35 @@ collections:
- label: Question
name: title
widget: string
- label: Type
name: type
widget: select
default: internal
options:
- label: Internal
value: internal
- label: External
value: external
- label: URL
name: url
widget: string
condition:
field: type
value: external
- label: Answer
name: body
widget: markdown
condition:
field: type
value: internal
- name: posts
label: Posts
label_singular: Post
widget: list
summary: "{{fields.post | split('|', '$1')}}"
condition:
field: type
value: internal
fields:
- label: Related Post
name: post
@ -870,7 +891,7 @@ collections:
- value: 2
label: Another fancy label
- value: c
label: And one more fancy label test test test test test test test
label: And one more fancy label
- label: Value and Label With Default
name: value_and_label_with_default
widget: select

View File

@ -1,4 +1,4 @@
import SwipeableDrawer from '@mui/material/SwipeableDrawer';
import Drawer from '@mui/material/Drawer';
import React, { useMemo } from 'react';
import FilterControl from '../FilterControl';
@ -38,24 +38,16 @@ const MobileCollectionControlsDrawer = ({
fields,
onSortClick,
}: MobileCollectionControlsDrawerProps) => {
const iOS = useMemo(
() => typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent),
[],
);
const container = useMemo(
() => (typeof window !== 'undefined' ? window.document.body : undefined),
[],
);
return (
<SwipeableDrawer
disableBackdropTransition={!iOS}
disableDiscovery={iOS}
<Drawer
container={container}
variant="temporary"
open={mobileOpen}
onOpen={onMobileOpenToggle}
onClose={onMobileOpenToggle}
anchor="right"
ModalProps={{
@ -110,7 +102,7 @@ const MobileCollectionControlsDrawer = ({
/>
) : null}
</div>
</SwipeableDrawer>
</Drawer>
);
};

View File

@ -17,8 +17,9 @@ import useMemoCompare from '@staticcms/core/lib/hooks/useMemoCompare';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { isFieldDuplicate, isFieldHidden } from '@staticcms/core/lib/i18n';
import { resolveWidget } from '@staticcms/core/lib/registry';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { fileForEntry } from '@staticcms/core/lib/util/collection.util';
import { getFieldLabel } from '@staticcms/core/lib/util/field.util';
import { getFieldLabel, useHidden } from '@staticcms/core/lib/util/field.util';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { validate } from '@staticcms/core/lib/util/validation.util';
import { selectFieldErrors } from '@staticcms/core/reducers/selectors/entryDraft';
@ -49,7 +50,6 @@ const EditorControl = ({
submitted,
disabled = false,
parentDuplicate = false,
parentHidden = false,
locale,
mediaPaths,
openMediaLibrary,
@ -95,13 +95,19 @@ const EditorControl = ({
() => parentDuplicate || isFieldDuplicate(field, locale, i18n?.defaultLocale),
[field, i18n?.defaultLocale, parentDuplicate, locale],
);
const hidden = useMemo(
() => parentHidden || isFieldHidden(field, locale, i18n?.defaultLocale),
[field, i18n?.defaultLocale, parentHidden, locale],
const i18nDisabled = useMemo(
() => isFieldHidden(field, locale, i18n?.defaultLocale),
[field, i18n?.defaultLocale, locale],
);
const hidden = useHidden(field, entry);
useEffect(() => {
if ((!dirty && !submitted) || hidden || disabled) {
if (hidden) {
dispatch(changeDraftFieldValidation(path, [], i18n, isMeta));
return;
}
if ((!dirty && !submitted) || disabled || i18nDisabled) {
return;
}
@ -111,7 +117,21 @@ const EditorControl = ({
};
validateValue();
}, [dirty, dispatch, field, i18n, hidden, path, submitted, t, value, widget, disabled, isMeta]);
}, [
dirty,
dispatch,
field,
i18n,
hidden,
path,
submitted,
t,
value,
widget,
disabled,
isMeta,
i18nDisabled,
]);
const handleChangeDraftField = useCallback(
(value: ValueOrNestedValue) => {
@ -157,7 +177,7 @@ const EditorControl = ({
}
return (
<div>
<div className={classNames(hidden && 'hidden')}>
{createElement(widget.control, {
key: `${id}-${version}`,
collection,
@ -167,9 +187,8 @@ const EditorControl = ({
field: field as UnknownField,
fieldsErrors,
submitted,
disabled: disabled || duplicate || hidden,
disabled: disabled || duplicate || hidden || i18nDisabled,
duplicate,
hidden,
label: getFieldLabel(field, t),
locale,
mediaPaths,
@ -226,7 +245,6 @@ interface EditorControlOwnProps {
submitted: boolean;
disabled?: boolean;
parentDuplicate?: boolean;
parentHidden?: boolean;
locale?: string;
parentPath: string;
value: ValueOrNestedValue;

View File

@ -56,6 +56,7 @@ const Breadcrumbs: FC<BreadcrumbsProps> = ({ breadcrumbs, inEditor = false }) =>
flex
items-center
gap-1
pl-1
"
>
{breadcrumbs.map((breadcrumb, index) =>
@ -73,6 +74,7 @@ const Breadcrumbs: FC<BreadcrumbsProps> = ({ breadcrumbs, inEditor = false }) =>
whitespace-nowrap
focus:outline-none
focus:ring-4
rounded-md
focus:ring-gray-200
dark:focus:ring-slate-700
`,

View File

@ -71,7 +71,7 @@ const Navbar = ({
inEditor && 'pl-3 md:pl-0',
)}
>
<div className="flex flex-1 h-full items-stretch justify-start gap-2 md:gap-4 truncate">
<div className="flex flex-1 h-full items-stretch justify-start gap-2 md:gap-3 truncate">
<div
className={classNames(
`

View File

@ -1,4 +1,4 @@
import SwipeableDrawer from '@mui/material/SwipeableDrawer';
import Drawer from '@mui/material/Drawer';
import React, { useMemo } from 'react';
import SidebarContent from './SidebarContent';
@ -11,24 +11,16 @@ interface NavigationDrawerProps {
}
const NavigationDrawer = ({ mobileOpen, onMobileOpenToggle }: NavigationDrawerProps) => {
const iOS = useMemo(
() => typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent),
[],
);
const container = useMemo(
() => (typeof window !== 'undefined' ? window.document.body : undefined),
[],
);
return (
<SwipeableDrawer
disableBackdropTransition={!iOS}
disableDiscovery={iOS}
<Drawer
container={container}
variant="temporary"
open={mobileOpen}
onOpen={onMobileOpenToggle}
onClose={onMobileOpenToggle}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
@ -49,7 +41,7 @@ const NavigationDrawer = ({ mobileOpen, onMobileOpenToggle }: NavigationDrawerPr
<div onClick={onMobileOpenToggle} className="w-full h-full">
<SidebarContent />
</div>
</SwipeableDrawer>
</Drawer>
);
};

View File

@ -179,7 +179,7 @@ export interface FieldPatternFilterRule extends BaseFieldFilterRule {
}
export interface FieldValueFilterRule extends BaseFieldFilterRule {
value: string | string[];
value: string | number | boolean | (string | number | boolean)[];
matchAll?: boolean;
}
@ -305,7 +305,6 @@ export interface WidgetControlProps<T, F extends BaseField = UnknownField, EV =
forSingleList: boolean;
disabled: boolean;
duplicate: boolean;
hidden: boolean;
label: string;
locale: string | undefined;
// @deprecated Use useMediaInsert instead
@ -603,6 +602,7 @@ export interface BaseField {
i18n?: boolean | 'translate' | 'duplicate' | 'none';
comment?: string;
widget: string;
condition?: FieldFilterRule | FieldFilterRule[];
}
export interface MediaField extends BaseField {

View File

@ -0,0 +1,100 @@
import { createMockEntry } from '@staticcms/test/data/entry.mock';
import { isHidden } from '../field.util';
import { I18N_FIELD_NONE } from '../../i18n';
import type { StringOrTextField } from '@staticcms/core/interface';
describe('filterEntries', () => {
const mockTitleField: StringOrTextField = {
label: 'Title',
name: 'title',
widget: 'string',
};
const mockUrlField: StringOrTextField = {
label: 'URL',
name: 'url',
widget: 'string',
i18n: I18N_FIELD_NONE,
condition: {
field: 'type',
value: 'external',
},
};
const mockBodyField: StringOrTextField = {
label: 'Body',
name: 'body',
widget: 'text',
condition: [
{
field: 'type',
value: 'internal',
},
{
field: 'hasSummary',
value: true,
},
],
};
const mockExternalEntry = createMockEntry({
path: 'path/to/file-1.md',
data: {
title: 'I am a title',
type: 'external',
url: 'http://example.com',
hasSummary: false,
},
});
const mockInternalEntry = createMockEntry({
path: 'path/to/file-1.md',
data: {
title: 'I am a title',
type: 'internal',
body: 'I am the body of your post',
hasSummary: false,
},
});
const mockHasSummaryEntry = createMockEntry({
path: 'path/to/file-1.md',
data: {
title: 'I am a title',
type: 'external',
url: 'http://example.com',
body: 'I am the body of your post',
hasSummary: true,
},
});
describe('isHidden', () => {
it('should show field by default', () => {
expect(isHidden(mockTitleField, mockExternalEntry)).toBeFalsy();
});
it('should hide field if single condition is not met', () => {
expect(isHidden(mockUrlField, mockInternalEntry)).toBeTruthy();
});
it('should show field if single condition is met', () => {
expect(isHidden(mockUrlField, mockExternalEntry)).toBeFalsy();
});
it('should hide field if all multiple conditions are not met', () => {
expect(isHidden(mockBodyField, mockExternalEntry)).toBeTruthy();
});
it('should show field if single condition is met', () => {
expect(isHidden(mockBodyField, mockHasSummaryEntry)).toBeFalsy();
expect(isHidden(mockBodyField, mockInternalEntry)).toBeFalsy();
});
it('should show field if entry is undefined', () => {
expect(isHidden(mockTitleField, undefined)).toBeFalsy();
expect(isHidden(mockUrlField, undefined)).toBeFalsy();
expect(isHidden(mockBodyField, undefined)).toBeFalsy();
});
});
});

View File

@ -88,7 +88,7 @@ describe('filterEntries', () => {
mockTags1and4Entry,
]);
expect(filterEntries(entries, { field: 'draft', value: 'true' })).toEqual([
expect(filterEntries(entries, { field: 'draft', value: true })).toEqual([
mockEnglishEntry,
mockTags1and4Entry,
]);
@ -119,7 +119,7 @@ describe('filterEntries', () => {
mockTags1and4Entry,
]);
expect(filterEntries(entries, { field: 'numbers', value: '8' })).toEqual([
expect(filterEntries(entries, { field: 'numbers', value: 8 })).toEqual([
mockRandomFileNameEntry,
mockTags1and4Entry,
]);

View File

@ -1,7 +1,9 @@
import get from 'lodash/get';
import { useMemo } from 'react';
import { getLocaleDataPath } from '../i18n';
import { keyToPathArray } from '../widgets/stringTemplate';
import { entryMatchesFieldRule } from './filter.util';
import type {
BaseField,
@ -88,3 +90,23 @@ export function getFieldValue(
return entry.data?.[field.name];
}
export function isHidden(field: Field, entry: Entry | undefined): boolean {
if (field.condition) {
if (!entry) {
return false;
}
if (Array.isArray(field.condition)) {
return !field.condition.find(r => entryMatchesFieldRule(entry, r));
}
return !entryMatchesFieldRule(entry, field.condition);
}
return false;
}
export function useHidden(field: Field, entry: Entry | undefined): boolean {
return useMemo(() => isHidden(field, entry), [entry, field]);
}

View File

@ -2,7 +2,7 @@ import { parse } from 'path';
import type { Entry, FieldFilterRule, FilterRule } from '@staticcms/core/interface';
function entryMatchesFieldRule(entry: Entry, filterRule: FieldFilterRule): boolean {
export function entryMatchesFieldRule(entry: Entry, filterRule: FieldFilterRule): boolean {
const fieldValue = entry.data?.[filterRule.field];
if ('pattern' in filterRule) {
if (Array.isArray(fieldValue)) {

View File

@ -49,7 +49,6 @@ interface SortableItemProps {
submitted: boolean;
disabled: boolean;
duplicate: boolean;
hidden: boolean;
locale: string | undefined;
path: string;
value: Record<string, ObjectValue>;
@ -68,7 +67,6 @@ const SortableItem: FC<SortableItemProps> = ({
submitted,
disabled,
duplicate,
hidden,
locale,
path,
i18n,
@ -112,7 +110,6 @@ const SortableItem: FC<SortableItemProps> = ({
submitted={submitted}
disabled={disabled}
duplicate={duplicate}
hidden={hidden}
locale={locale}
path={path}
value={item}
@ -185,7 +182,6 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = pro
submitted,
disabled,
duplicate,
hidden,
locale,
path,
value,
@ -346,7 +342,6 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = pro
submitted={submitted}
disabled={disabled}
duplicate={duplicate}
hidden={hidden}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}

View File

@ -69,7 +69,6 @@ interface ListItemProps
| 'submitted'
| 'disabled'
| 'duplicate'
| 'hidden'
| 'locale'
| 'path'
| 'value'
@ -91,7 +90,6 @@ const ListItem: FC<ListItemProps> = ({
submitted,
disabled,
duplicate,
hidden,
locale,
path,
valueType,
@ -206,7 +204,6 @@ const ListItem: FC<ListItemProps> = ({
parentPath={path}
disabled={disabled || duplicate}
parentDuplicate={duplicate}
parentHidden={hidden}
locale={locale}
i18n={i18n}
forList={true}

View File

@ -16,7 +16,6 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
forList,
forSingleList,
duplicate,
hidden,
locale,
path,
i18n,
@ -57,7 +56,6 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
parentPath={parentPath}
disabled={disabled || duplicate}
parentDuplicate={duplicate}
parentHidden={hidden}
locale={locale}
i18n={i18n}
forSingleList={forSingleList}
@ -74,7 +72,6 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
submitted,
disabled,
duplicate,
hidden,
locale,
i18n,
forSingleList,

View File

@ -71,7 +71,6 @@ export const createMockWidgetControlProps = <
locale: undefined,
i18n: undefined,
duplicate: false,
hidden: false,
controlled: false,
theme: 'light',
onChange: jest.fn(),

View File

@ -87,14 +87,35 @@ collections:
- label: Question
name: title
widget: string
- label: Type
name: type
widget: select
default: internal
options:
- label: Internal
value: internal
- label: External
value: external
- label: URL
name: url
widget: string
condition:
field: type
value: external
- label: Answer
name: body
widget: markdown
condition:
field: type
value: internal
- name: posts
label: Posts
label_singular: Post
widget: list
summary: "{{fields.post | split('|', '$1')}}"
condition:
field: type
value: internal
fields:
- label: Related Post
name: post

View File

@ -103,7 +103,7 @@ collections: [
</CodeTabs>
### Filtered folder collections
### Filtered Folder Collections
The entries for any folder collection can be filtered based on the values of the fields or on file names. By filtering a folder into different collections, you can manage files with different fields, options, extensions, etc. in the same folder.

View File

@ -37,7 +37,7 @@ To see working examples of all of the built-in widgets, try making a 'Kitchen Si
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 |
@ -45,7 +45,20 @@ The following options are available on all fields:
| 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<br />\| 'translate'<br />\| 'duplicate'<br />\| 'none' | | _Optional_. <BetaImage /><ul><li>`translate` - Allows translation of the field</li><li>`duplicate` - Duplicates the value from the default locale</li><li>`true` - Accept parent values as default</li><li>`none` or `false` - Exclude field from translations</li></ul> |
| comment | string | | _Optional_. Adds comment before the field (only supported for yaml) |
| condition | FilterRule<br />\| List of FilterRules | | _Optional_. See [Field Conditions](#field-conditions) |
## Field Conditions
The fields can be shown conditionally based on the values of the other fields.
The `condition` option can take a single filter rule or a list of filter rules.
| Name | Type | Default | Description |
| -------- | ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| field | string | | The name of one of the field. |
| value | string<br />\| list of strings | | _Optional_. The desired value or values to match. Required if no `pattern` provided. Ignored if `pattern` is provided |
| pattern | regular expression | | _Optional_. A regex pattern to match against the field's value |
| matchAll | boolean | `false` | _Optional_. _Ignored if value is not a list of strings_<br /><ul><li>`true` - The field's values must include or match all of the filter rule's values</li><li>`false` - The field's value must include or match only one of the filter rule's values</li></ul> |
## Example