From 654263dd6f7ad11aa9a837a4764fad5a3de9f062 Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Tue, 13 Jun 2023 15:13:18 -0400 Subject: [PATCH] feat: dependent fields (#839) --- BREAKING_CHANGES.md | 2 + packages/core/dev-test/config.yml | 23 +++- .../mobile/MobileCollectionControlsDrawer.tsx | 14 +-- .../editor-control-pane/EditorControl.tsx | 40 +++++-- .../src/components/navbar/Breadcrumbs.tsx | 2 + .../core/src/components/navbar/Navbar.tsx | 2 +- .../components/navbar/NavigationDrawer.tsx | 14 +-- packages/core/src/interface.ts | 4 +- .../src/lib/util/__tests__/field.util.spec.ts | 100 ++++++++++++++++++ .../lib/util/__tests__/filter.util.spec.ts | 4 +- packages/core/src/lib/util/field.util.ts | 22 ++++ packages/core/src/lib/util/filter.util.ts | 2 +- .../core/src/widgets/list/ListControl.tsx | 5 - .../src/widgets/list/components/ListItem.tsx | 3 - .../core/src/widgets/object/ObjectControl.tsx | 3 - packages/core/test/data/widgets.mock.ts | 1 - packages/demo/public/config.yml | 21 ++++ .../docs/content/docs/collection-overview.mdx | 2 +- .../docs/content/docs/collection-types.mdx | 2 +- packages/docs/content/docs/widgets.mdx | 35 ++++-- 20 files changed, 236 insertions(+), 65 deletions(-) create mode 100644 BREAKING_CHANGES.md create mode 100644 packages/core/src/lib/util/__tests__/field.util.spec.ts diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md new file mode 100644 index 00000000..9d8cf4aa --- /dev/null +++ b/BREAKING_CHANGES.md @@ -0,0 +1,2 @@ +- gitea API config has changed. +- hidden removed as widget parameter \ No newline at end of file diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index 072dfd6e..9caef869 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -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 diff --git a/packages/core/src/components/collections/mobile/MobileCollectionControlsDrawer.tsx b/packages/core/src/components/collections/mobile/MobileCollectionControlsDrawer.tsx index d43500cf..4fe257ec 100644 --- a/packages/core/src/components/collections/mobile/MobileCollectionControlsDrawer.tsx +++ b/packages/core/src/components/collections/mobile/MobileCollectionControlsDrawer.tsx @@ -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 ( - ) : null} - + ); }; diff --git a/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx b/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx index 4ad818bd..40f595b1 100644 --- a/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx +++ b/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx @@ -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 ( -
+
{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; diff --git a/packages/core/src/components/navbar/Breadcrumbs.tsx b/packages/core/src/components/navbar/Breadcrumbs.tsx index b6b05818..71f79b87 100644 --- a/packages/core/src/components/navbar/Breadcrumbs.tsx +++ b/packages/core/src/components/navbar/Breadcrumbs.tsx @@ -56,6 +56,7 @@ const Breadcrumbs: FC = ({ breadcrumbs, inEditor = false }) => flex items-center gap-1 + pl-1 " > {breadcrumbs.map((breadcrumb, index) => @@ -73,6 +74,7 @@ const Breadcrumbs: FC = ({ breadcrumbs, inEditor = false }) => whitespace-nowrap focus:outline-none focus:ring-4 + rounded-md focus:ring-gray-200 dark:focus:ring-slate-700 `, diff --git a/packages/core/src/components/navbar/Navbar.tsx b/packages/core/src/components/navbar/Navbar.tsx index 2885b8d2..df6d75d3 100644 --- a/packages/core/src/components/navbar/Navbar.tsx +++ b/packages/core/src/components/navbar/Navbar.tsx @@ -71,7 +71,7 @@ const Navbar = ({ inEditor && 'pl-3 md:pl-0', )} > -
+
{ - const iOS = useMemo( - () => typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent), - [], - ); - const container = useMemo( () => (typeof window !== 'undefined' ? window.document.body : undefined), [], ); return ( -
- + ); }; diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index 10112a18..f6130d9b 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -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 { + 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(); + }); + }); +}); diff --git a/packages/core/src/lib/util/__tests__/filter.util.spec.ts b/packages/core/src/lib/util/__tests__/filter.util.spec.ts index 516ff0e1..88329caa 100644 --- a/packages/core/src/lib/util/__tests__/filter.util.spec.ts +++ b/packages/core/src/lib/util/__tests__/filter.util.spec.ts @@ -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, ]); diff --git a/packages/core/src/lib/util/field.util.ts b/packages/core/src/lib/util/field.util.ts index d4c947c4..5cfcb8e4 100644 --- a/packages/core/src/lib/util/field.util.ts +++ b/packages/core/src/lib/util/field.util.ts @@ -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]); +} diff --git a/packages/core/src/lib/util/filter.util.ts b/packages/core/src/lib/util/filter.util.ts index 8b981346..dd5a60ed 100644 --- a/packages/core/src/lib/util/filter.util.ts +++ b/packages/core/src/lib/util/filter.util.ts @@ -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)) { diff --git a/packages/core/src/widgets/list/ListControl.tsx b/packages/core/src/widgets/list/ListControl.tsx index 32c9db05..49204e25 100644 --- a/packages/core/src/widgets/list/ListControl.tsx +++ b/packages/core/src/widgets/list/ListControl.tsx @@ -49,7 +49,6 @@ interface SortableItemProps { submitted: boolean; disabled: boolean; duplicate: boolean; - hidden: boolean; locale: string | undefined; path: string; value: Record; @@ -68,7 +67,6 @@ const SortableItem: FC = ({ submitted, disabled, duplicate, - hidden, locale, path, i18n, @@ -112,7 +110,6 @@ const SortableItem: FC = ({ submitted={submitted} disabled={disabled} duplicate={duplicate} - hidden={hidden} locale={locale} path={path} value={item} @@ -185,7 +182,6 @@ const ListControl: FC> = pro submitted, disabled, duplicate, - hidden, locale, path, value, @@ -346,7 +342,6 @@ const ListControl: FC> = pro submitted={submitted} disabled={disabled} duplicate={duplicate} - hidden={hidden} locale={locale} path={path} value={item as Record} diff --git a/packages/core/src/widgets/list/components/ListItem.tsx b/packages/core/src/widgets/list/components/ListItem.tsx index 2c6d8174..3ab789e7 100644 --- a/packages/core/src/widgets/list/components/ListItem.tsx +++ b/packages/core/src/widgets/list/components/ListItem.tsx @@ -69,7 +69,6 @@ interface ListItemProps | 'submitted' | 'disabled' | 'duplicate' - | 'hidden' | 'locale' | 'path' | 'value' @@ -91,7 +90,6 @@ const ListItem: FC = ({ submitted, disabled, duplicate, - hidden, locale, path, valueType, @@ -206,7 +204,6 @@ const ListItem: FC = ({ parentPath={path} disabled={disabled || duplicate} parentDuplicate={duplicate} - parentHidden={hidden} locale={locale} i18n={i18n} forList={true} diff --git a/packages/core/src/widgets/object/ObjectControl.tsx b/packages/core/src/widgets/object/ObjectControl.tsx index 0e0ec50b..741e5b1f 100644 --- a/packages/core/src/widgets/object/ObjectControl.tsx +++ b/packages/core/src/widgets/object/ObjectControl.tsx @@ -16,7 +16,6 @@ const ObjectControl: FC> = ({ forList, forSingleList, duplicate, - hidden, locale, path, i18n, @@ -57,7 +56,6 @@ const ObjectControl: FC> = ({ parentPath={parentPath} disabled={disabled || duplicate} parentDuplicate={duplicate} - parentHidden={hidden} locale={locale} i18n={i18n} forSingleList={forSingleList} @@ -74,7 +72,6 @@ const ObjectControl: FC> = ({ submitted, disabled, duplicate, - hidden, locale, i18n, forSingleList, diff --git a/packages/core/test/data/widgets.mock.ts b/packages/core/test/data/widgets.mock.ts index ce73f6b6..5aea2090 100644 --- a/packages/core/test/data/widgets.mock.ts +++ b/packages/core/test/data/widgets.mock.ts @@ -71,7 +71,6 @@ export const createMockWidgetControlProps = < locale: undefined, i18n: undefined, duplicate: false, - hidden: false, controlled: false, theme: 'light', onChange: jest.fn(), diff --git a/packages/demo/public/config.yml b/packages/demo/public/config.yml index 95b9f182..fd462016 100644 --- a/packages/demo/public/config.yml +++ b/packages/demo/public/config.yml @@ -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 diff --git a/packages/docs/content/docs/collection-overview.mdx b/packages/docs/content/docs/collection-overview.mdx index 406c430c..7c09e0d9 100644 --- a/packages/docs/content/docs/collection-overview.mdx +++ b/packages/docs/content/docs/collection-overview.mdx @@ -15,7 +15,7 @@ weight: 9 | icon | string | | _Optional_. Unique name of icon to use in main menu. See [custom icons](/docs/custom-icons) | | description | string | | _Optional_. Text displayed below the label when viewing a collection | | files or folder | [Collection Files](/docs/collection-types#file-collections)
\| [Collection Folder](/docs/collection-types#folder-collections) | | **Requires one of these**: Specifies the collection type and location; details in [collection types](/docs/collection-types) | -| filter | FilterRule
\| List of FilterRules | | _Optional_. Field and file filter for [Folder Collections](/docs/collection-types#folder-collections). See [filtered folder collections](/docs/collection-types#filtered-folder-collections) | +| filter | FilterRule
\| List of FilterRules | | _Optional_. Field and file filter for [Folder Collections](/docs/collection-types#folder-collections). See [filtered folder collections](/docs/collection-types#filtered-folder-collections) | | create | boolean | `false` | _Optional_. **For [Folder Collections](/docs/collection-types#folder-collections) only**
`true` - Allows users to create new items in the collection | | hide | boolean | `false` | _Optional_. `true` hides a collection in the Static CMS UI. Useful when using the relation widget to hide referenced collections | | delete | boolean | `true` | _Optional_. `false` prevents users from deleting items in a collection | diff --git a/packages/docs/content/docs/collection-types.mdx b/packages/docs/content/docs/collection-types.mdx index 5ff57d8d..a3649328 100644 --- a/packages/docs/content/docs/collection-types.mdx +++ b/packages/docs/content/docs/collection-types.mdx @@ -103,7 +103,7 @@ collections: [ -### 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. diff --git a/packages/docs/content/docs/widgets.mdx b/packages/docs/content/docs/widgets.mdx index 4736644b..8eb259e6 100644 --- a/packages/docs/content/docs/widgets.mdx +++ b/packages/docs/content/docs/widgets.mdx @@ -30,22 +30,35 @@ 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 | -| [UUID](/docs/widget-uuid) | The uuid widget generates a unique id (uuid) and saves it as a string | +| [UUID](/docs/widget-uuid) | 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 | -| required | boolean | `true` | _Optional_. Specify as `false` to make a field optional | -| 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
\|'translate'
\|'duplicate'
\|'none' | | _Optional_.
  • `translate` - Allows translation of the field
  • `duplicate` - Duplicates the value from the default locale
  • `true` - Accept parent values as default
  • `none` or `false` - Exclude field from translations
| -| comment | string | | _Optional_. Adds comment before the field (only supported for yaml) | +| 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 | +| required | boolean | `true` | _Optional_. Specify as `false` to make a field optional | +| 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
\| 'translate'
\| 'duplicate'
\| 'none' | | _Optional_.
  • `translate` - Allows translation of the field
  • `duplicate` - Duplicates the value from the default locale
  • `true` - Accept parent values as default
  • `none` or `false` - Exclude field from translations
| +| condition | FilterRule
\| 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
\| 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_
  • `true` - The field's values must include or match all of the filter rule's values
  • `false` - The field's value must include or match only one of the filter rule's values
| ## Example