From 5602812774307fe8a9015a845f561f4d5b114173 Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Wed, 6 Sep 2023 12:05:39 -0400 Subject: [PATCH] feat: confine conditions inside list to the same local list item (#863) --- packages/core/dev-test/config.yml | 58 +++++++++++ packages/core/src/backend.ts | 2 +- .../editor-control-pane/EditorControl.tsx | 6 +- .../editor-control-pane/EditorControlPane.tsx | 4 + packages/core/src/interface.ts | 1 + .../src/lib/util/__tests__/field.util.spec.ts | 74 ++++++++++++-- .../lib/util/__tests__/filter.util.spec.ts | 87 ++++++++++------- packages/core/src/lib/util/field.util.ts | 18 +++- packages/core/src/lib/util/filter.util.ts | 25 +++-- .../src/widgets/list/components/ListItem.tsx | 1 + .../buttons/InsertLinkToolbarButton.tsx | 2 - .../core/src/widgets/object/ObjectControl.tsx | 3 + packages/core/test/data/widgets.mock.ts | 1 + packages/docs/content/docs/widgets.mdx | 97 ++++++++++++++++++- 14 files changed, 319 insertions(+), 60 deletions(-) diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index 4eefa7f6..5172e588 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -569,6 +569,64 @@ collections: - label: File name: file widget: file + - name: typed_list_with_condition + label: Typed List With Condition + widget: list + types: + - label: Type 1 Object + name: type_1_object + widget: object + fields: + - name: template + label: template + widget: select + options: + - column + - row + - banner + default: column + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + condition: + field: template + value: banner + - label: Text + name: text + widget: text + - label: Type 2 Object + name: type_2_object + widget: object + fields: + - label: Number + name: number + widget: number + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Datetime + name: datetime + widget: datetime + - label: Markdown + name: markdown + widget: markdown + - label: Type 3 Object + name: type_3_object + widget: object + fields: + - label: Image + name: image + widget: image + - label: File + name: file + widget: file - name: map label: Map file: _widgets/map.json diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index e4708542..89c9b323 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -1045,7 +1045,7 @@ export class Backend isFieldHidden(field, locale, i18n?.defaultLocale), [field, i18n?.defaultLocale, locale], ); - const hidden = useHidden(field, entry); + const hidden = useHidden(field, entry, listItemPath); useEffect(() => { if (hidden) { @@ -202,6 +203,7 @@ const EditorControl = ({ t, value: finalValue, forList, + listItemPath, forSingleList, i18n, hasErrors, @@ -231,6 +233,7 @@ const EditorControl = ({ query, finalValue, forList, + listItemPath, forSingleList, i18n, hasErrors, @@ -248,6 +251,7 @@ interface EditorControlOwnProps { parentPath: string; value: ValueOrNestedValue; forList?: boolean; + listItemPath?: string; forSingleList?: boolean; i18n: I18nSettings | undefined; fieldName?: string; diff --git a/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx b/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx index 8908abe8..6320e44a 100644 --- a/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx +++ b/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx @@ -32,6 +32,7 @@ export interface EditorControlPaneProps { onLocaleChange?: (locale: string) => void; allowDefaultLocale?: boolean; context?: 'default' | 'i18nSplit'; + listItemPath?: string; } const EditorControlPane = ({ @@ -47,6 +48,7 @@ const EditorControlPane = ({ onLocaleChange, allowDefaultLocale = false, context = 'default', + listItemPath, t, }: TranslatedProps) => { const pathField = useMemo( @@ -138,6 +140,7 @@ const EditorControlPane = ({ locale={locale} parentPath="" i18n={i18n} + listItemPath={listItemPath} controlled isMeta /> @@ -156,6 +159,7 @@ const EditorControlPane = ({ locale={locale} parentPath="" i18n={i18n} + listItemPath={listItemPath} /> ); })} diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index ec8ff042..ac10ae33 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -311,6 +311,7 @@ export interface WidgetControlProps { describe('isHidden', () => { it('should show field by default', () => { - expect(isHidden(mockTitleField, mockExternalEntry)).toBeFalsy(); + expect(isHidden(mockTitleField, mockExternalEntry, undefined)).toBeFalsy(); }); it('should hide field if single condition is not met', () => { - expect(isHidden(mockUrlField, mockInternalEntry)).toBeTruthy(); + expect(isHidden(mockUrlField, mockInternalEntry, undefined)).toBeTruthy(); }); it('should show field if single condition is met', () => { - expect(isHidden(mockUrlField, mockExternalEntry)).toBeFalsy(); + expect(isHidden(mockUrlField, mockExternalEntry, undefined)).toBeFalsy(); }); it('should hide field if all multiple conditions are not met', () => { - expect(isHidden(mockBodyField, mockExternalEntry)).toBeTruthy(); + expect(isHidden(mockBodyField, mockExternalEntry, undefined)).toBeTruthy(); }); it('should show field if single condition is met', () => { - expect(isHidden(mockBodyField, mockHasSummaryEntry)).toBeFalsy(); - expect(isHidden(mockBodyField, mockInternalEntry)).toBeFalsy(); + expect(isHidden(mockBodyField, mockHasSummaryEntry, undefined)).toBeFalsy(); + expect(isHidden(mockBodyField, mockInternalEntry, undefined)).toBeFalsy(); }); it('should show field if entry is undefined', () => { - expect(isHidden(mockTitleField, undefined)).toBeFalsy(); - expect(isHidden(mockUrlField, undefined)).toBeFalsy(); - expect(isHidden(mockBodyField, undefined)).toBeFalsy(); + expect(isHidden(mockTitleField, undefined, undefined)).toBeFalsy(); + expect(isHidden(mockUrlField, undefined, undefined)).toBeFalsy(); + expect(isHidden(mockBodyField, undefined, undefined)).toBeFalsy(); + }); + + describe('inside list', () => { + const mockInsideListEntry = createMockEntry({ + path: 'path/to/file-1.md', + data: { + list: [ + { + title: 'I am a title', + type: 'external', + url: 'http://example.com', + hasSummary: false, + }, + { + title: 'I am a title', + type: 'internal', + body: 'I am the body of your post', + hasSummary: false, + }, + { + title: 'I am a title', + type: 'external', + url: 'http://example.com', + body: 'I am the body of your post', + hasSummary: true, + }, + ], + }, + }); + + it('should show field by default', () => { + expect(isHidden(mockTitleField, mockInsideListEntry, 'list.0')).toBeFalsy(); + }); + + it('should hide field if single condition is not met', () => { + expect(isHidden(mockUrlField, mockInsideListEntry, 'list.1')).toBeTruthy(); + }); + + it('should show field if single condition is met', () => { + expect(isHidden(mockUrlField, mockInsideListEntry, 'list.0')).toBeFalsy(); + }); + + it('should hide field if all multiple conditions are not met', () => { + expect(isHidden(mockBodyField, mockInsideListEntry, 'list.0')).toBeTruthy(); + }); + + it('should show field if single condition is met', () => { + expect(isHidden(mockBodyField, mockInsideListEntry, 'list.2')).toBeFalsy(); + expect(isHidden(mockBodyField, mockInsideListEntry, 'list.1')).toBeFalsy(); + }); + + it('should show field if entry is undefined', () => { + expect(isHidden(mockTitleField, undefined, 'list.0')).toBeFalsy(); + expect(isHidden(mockUrlField, undefined, 'list.0')).toBeFalsy(); + expect(isHidden(mockBodyField, undefined, 'list.0')).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 a04bd5e2..2a5e91d7 100644 --- a/packages/core/src/lib/util/__tests__/filter.util.spec.ts +++ b/packages/core/src/lib/util/__tests__/filter.util.spec.ts @@ -87,35 +87,36 @@ describe('filterEntries', () => { describe('field rules', () => { it('should filter fields', () => { - expect(filterEntries(entries, { field: 'language', value: 'en' })).toEqual([ + expect(filterEntries(entries, { field: 'language', value: 'en' }, undefined)).toEqual([ mockEnglishEntry, ]); - expect(filterEntries(entries, { field: 'language', value: 'fr' })).toEqual([mockFrenchEntry]); + expect(filterEntries(entries, { field: 'language', value: 'fr' }, undefined)).toEqual([ + mockFrenchEntry, + ]); - expect(filterEntries(entries, { field: 'language', value: 'gr' })).toEqual([ + expect(filterEntries(entries, { field: 'language', value: 'gr' }, undefined)).toEqual([ mockIndexEntry, mockUnderscoreIndexEntry, mockRandomFileNameEntry, mockTags1and4Entry, ]); - expect(filterEntries(entries, { field: 'draft', value: true })).toEqual([ + expect(filterEntries(entries, { field: 'draft', value: true }, undefined)).toEqual([ mockEnglishEntry, mockTags1and4Entry, ]); }); it('should filter fields if multiple filter values are provided (must match only one)', () => { - expect(filterEntries(entries, { field: 'language', value: ['en', 'fr'] })).toEqual([ - mockEnglishEntry, - mockFrenchEntry, - ]); + expect(filterEntries(entries, { field: 'language', value: ['en', 'fr'] }, undefined)).toEqual( + [mockEnglishEntry, mockFrenchEntry], + ); }); it('should filter fields based on pattern', () => { // Languages with an r in their name - expect(filterEntries(entries, { field: 'language', pattern: 'r' })).toEqual([ + expect(filterEntries(entries, { field: 'language', pattern: 'r' }, undefined)).toEqual([ mockFrenchEntry, mockIndexEntry, mockUnderscoreIndexEntry, @@ -125,20 +126,22 @@ describe('filterEntries', () => { }); it('should filter fields if field value is an array (must include value)', () => { - expect(filterEntries(entries, { field: 'tags', value: 'tag-4' })).toEqual([ + expect(filterEntries(entries, { field: 'tags', value: 'tag-4' }, undefined)).toEqual([ mockFrenchEntry, mockIndexEntry, mockTags1and4Entry, ]); - expect(filterEntries(entries, { field: 'numbers', value: 8 })).toEqual([ + expect(filterEntries(entries, { field: 'numbers', value: 8 }, undefined)).toEqual([ mockRandomFileNameEntry, mockTags1and4Entry, ]); }); it('should filter fields if field value is an array and multiple filter values are provided (must include only one)', () => { - expect(filterEntries(entries, { field: 'tags', value: ['tag-3', 'tag-4'] })).toEqual([ + expect( + filterEntries(entries, { field: 'tags', value: ['tag-3', 'tag-4'] }, undefined), + ).toEqual([ mockFrenchEntry, mockIndexEntry, mockUnderscoreIndexEntry, @@ -146,7 +149,7 @@ describe('filterEntries', () => { mockTags1and4Entry, ]); - expect(filterEntries(entries, { field: 'numbers', value: ['5', '6'] })).toEqual([ + expect(filterEntries(entries, { field: 'numbers', value: ['5', '6'] }, undefined)).toEqual([ mockEnglishEntry, mockFrenchEntry, mockIndexEntry, @@ -155,17 +158,21 @@ describe('filterEntries', () => { it('should match all values if matchAll is one (value is an array, multiple filter values are provided)', () => { expect( - filterEntries(entries, { field: 'tags', value: ['tag-1', 'tag-4'], matchAll: true }), + filterEntries( + entries, + { field: 'tags', value: ['tag-1', 'tag-4'], matchAll: true }, + undefined, + ), ).toEqual([mockFrenchEntry, mockIndexEntry, mockTags1and4Entry]); expect( - filterEntries(entries, { field: 'numbers', value: ['5', '6'], matchAll: true }), + filterEntries(entries, { field: 'numbers', value: ['5', '6'], matchAll: true }, undefined), ).toEqual([mockFrenchEntry, mockIndexEntry]); }); it('should filter fields based on pattern when value is an array', () => { // Tags containing the word "fish" - expect(filterEntries(entries, { field: 'tags', pattern: 'fish' })).toEqual([ + expect(filterEntries(entries, { field: 'tags', pattern: 'fish' }, undefined)).toEqual([ mockEnglishEntry, mockTags1and4Entry, ]); @@ -173,10 +180,14 @@ describe('filterEntries', () => { it('should filter based on multiple rules (must match all rules)', () => { expect( - filterEntries(entries, [ - { field: 'tags', value: ['tag-3', 'tag-4'] }, - { field: 'language', value: 'gr' }, - ]), + filterEntries( + entries, + [ + { field: 'tags', value: ['tag-3', 'tag-4'] }, + { field: 'language', value: 'gr' }, + ], + undefined, + ), ).toEqual([ mockIndexEntry, mockUnderscoreIndexEntry, @@ -187,29 +198,35 @@ describe('filterEntries', () => { it('should filter based on multiple rules (must match all rules) (matchAll on)', () => { expect( - filterEntries(entries, [ - { field: 'tags', value: ['tag-1', 'tag-4'], matchAll: true }, - { field: 'language', value: 'gr' }, - ]), + filterEntries( + entries, + [ + { field: 'tags', value: ['tag-1', 'tag-4'], matchAll: true }, + { field: 'language', value: 'gr' }, + ], + undefined, + ), ).toEqual([mockIndexEntry, mockTags1and4Entry]); }); }); describe('file rule', () => { it('should filter based on file name', () => { - expect(filterEntries(entries, { pattern: '^index.md$' })).toEqual([mockIndexEntry]); + expect(filterEntries(entries, { pattern: '^index.md$' }, undefined)).toEqual([ + mockIndexEntry, + ]); - expect(filterEntries(entries, { pattern: '^_index.md$' })).toEqual([ + expect(filterEntries(entries, { pattern: '^_index.md$' }, undefined)).toEqual([ mockUnderscoreIndexEntry, ]); - expect(filterEntries(entries, { pattern: 'index.md$' })).toEqual([ + expect(filterEntries(entries, { pattern: 'index.md$' }, undefined)).toEqual([ mockIndexEntry, mockUnderscoreIndexEntry, ]); // File names containing the word file (case insensitive) - expect(filterEntries(entries, { pattern: '[fF][iI][lL][eE]' })).toEqual([ + expect(filterEntries(entries, { pattern: '[fF][iI][lL][eE]' }, undefined)).toEqual([ mockEnglishEntry, mockFrenchEntry, mockRandomFileNameEntry, @@ -219,7 +236,7 @@ describe('filterEntries', () => { it('should filter based on multiple rules (must match all rules)', () => { // File names containing the word file (case insensitive) expect( - filterEntries(entries, [{ pattern: '[fF][iI][lL][eE]' }, { pattern: 'some' }]), + filterEntries(entries, [{ pattern: '[fF][iI][lL][eE]' }, { pattern: 'some' }], undefined), ).toEqual([mockRandomFileNameEntry]); }); }); @@ -227,16 +244,20 @@ describe('filterEntries', () => { describe('combined field and file rule', () => { it('should filter based on multiple rules (must match all rules)', () => { expect( - filterEntries(entries, [{ pattern: 'index.md$' }, { field: 'tags', value: 'tag-3' }]), + filterEntries( + entries, + [{ pattern: 'index.md$' }, { field: 'tags', value: 'tag-3' }], + undefined, + ), ).toEqual([mockUnderscoreIndexEntry]); }); }); describe('nested fields', () => { it('should filter based on multiple rules (must match all rules)', () => { - expect(filterEntries(entries, [{ field: 'nested.object.field', value: 'yes' }])).toEqual([ - mockNestedEntry, - ]); + expect( + filterEntries(entries, [{ field: 'nested.object.field', value: 'yes' }], undefined), + ).toEqual([mockNestedEntry]); }); }); }); diff --git a/packages/core/src/lib/util/field.util.ts b/packages/core/src/lib/util/field.util.ts index 5cfcb8e4..f2bfc35a 100644 --- a/packages/core/src/lib/util/field.util.ts +++ b/packages/core/src/lib/util/field.util.ts @@ -91,22 +91,30 @@ export function getFieldValue( return entry.data?.[field.name]; } -export function isHidden(field: Field, entry: Entry | undefined): boolean { +export function isHidden( + field: Field, + entry: Entry | undefined, + listItemPath: string | undefined, +): boolean { if (field.condition) { if (!entry) { return false; } if (Array.isArray(field.condition)) { - return !field.condition.find(r => entryMatchesFieldRule(entry, r)); + return !field.condition.find(r => entryMatchesFieldRule(entry, r, listItemPath)); } - return !entryMatchesFieldRule(entry, field.condition); + return !entryMatchesFieldRule(entry, field.condition, listItemPath); } return false; } -export function useHidden(field: Field, entry: Entry | undefined): boolean { - return useMemo(() => isHidden(field, entry), [entry, field]); +export function useHidden( + field: Field, + entry: Entry | undefined, + listItemPath: string | undefined, +): boolean { + return useMemo(() => isHidden(field, entry, listItemPath), [entry, field, listItemPath]); } diff --git a/packages/core/src/lib/util/filter.util.ts b/packages/core/src/lib/util/filter.util.ts index 9ce65e74..4f76c6df 100644 --- a/packages/core/src/lib/util/filter.util.ts +++ b/packages/core/src/lib/util/filter.util.ts @@ -3,8 +3,15 @@ import get from 'lodash/get'; import type { Entry, FieldFilterRule, FilterRule } from '@staticcms/core/interface'; -export function entryMatchesFieldRule(entry: Entry, filterRule: FieldFilterRule): boolean { - const fieldValue = get(entry.data, filterRule.field); +export function entryMatchesFieldRule( + entry: Entry, + filterRule: FieldFilterRule, + listItemPath: string | undefined, +): boolean { + const fieldValue = get( + entry.data, + listItemPath ? `${listItemPath}.${filterRule.field}` : filterRule.field, + ); if ('pattern' in filterRule) { if (Array.isArray(fieldValue)) { return Boolean(fieldValue.find(v => new RegExp(filterRule.pattern).test(String(v)))); @@ -48,20 +55,24 @@ export function entryMatchesFieldRule(entry: Entry, filterRule: FieldFilterRule) return String(fieldValue) === String(filterRule.value); } -function entryMatchesRule(entry: Entry, filterRule: FilterRule) { +function entryMatchesRule(entry: Entry, filterRule: FilterRule, listItemPath: string | undefined) { if ('field' in filterRule) { - return entryMatchesFieldRule(entry, filterRule); + return entryMatchesFieldRule(entry, filterRule, listItemPath); } return new RegExp(filterRule.pattern).test(parse(entry.path).base); } -export default function filterEntries(entries: Entry[], filterRule: FilterRule | FilterRule[]) { +export default function filterEntries( + entries: Entry[], + filterRule: FilterRule | FilterRule[], + listItemPath: string | undefined, +) { return entries.filter(entry => { if (Array.isArray(filterRule)) { - return filterRule.every(r => entryMatchesRule(entry, r)); + return filterRule.every(r => entryMatchesRule(entry, r, listItemPath)); } - return entryMatchesRule(entry, filterRule); + return entryMatchesRule(entry, filterRule, listItemPath); }); } diff --git a/packages/core/src/widgets/list/components/ListItem.tsx b/packages/core/src/widgets/list/components/ListItem.tsx index 3ab789e7..8996a2b2 100644 --- a/packages/core/src/widgets/list/components/ListItem.tsx +++ b/packages/core/src/widgets/list/components/ListItem.tsx @@ -207,6 +207,7 @@ const ListItem: FC = ({ locale={locale} i18n={i18n} forList={true} + listItemPath={`${path}.${objectField.name}`} forSingleList={isSingleList} /> diff --git a/packages/core/src/widgets/markdown/plate/components/buttons/InsertLinkToolbarButton.tsx b/packages/core/src/widgets/markdown/plate/components/buttons/InsertLinkToolbarButton.tsx index 18fe569e..7f07ecef 100644 --- a/packages/core/src/widgets/markdown/plate/components/buttons/InsertLinkToolbarButton.tsx +++ b/packages/core/src/widgets/markdown/plate/components/buttons/InsertLinkToolbarButton.tsx @@ -69,8 +69,6 @@ const InsertLinkToolbarButton: FC = ({ return; } - console.log('editor.selection', editor.selection); - deleteText(editor, { at: editor.selection as unknown as Location, }); diff --git a/packages/core/src/widgets/object/ObjectControl.tsx b/packages/core/src/widgets/object/ObjectControl.tsx index 741e5b1f..c8f8e1be 100644 --- a/packages/core/src/widgets/object/ObjectControl.tsx +++ b/packages/core/src/widgets/object/ObjectControl.tsx @@ -22,6 +22,7 @@ const ObjectControl: FC> = ({ errors, disabled, value = {}, + listItemPath, }) => { const objectLabel = useMemo(() => { const summary = field.summary; @@ -59,6 +60,7 @@ const ObjectControl: FC> = ({ locale={locale} i18n={i18n} forSingleList={forSingleList} + listItemPath={listItemPath} /> ); }) ?? null @@ -75,6 +77,7 @@ const ObjectControl: FC> = ({ locale, i18n, forSingleList, + listItemPath, ]); if (fields.length) { diff --git a/packages/core/test/data/widgets.mock.ts b/packages/core/test/data/widgets.mock.ts index 4f73e17d..94424f8f 100644 --- a/packages/core/test/data/widgets.mock.ts +++ b/packages/core/test/data/widgets.mock.ts @@ -65,6 +65,7 @@ export const createMockWidgetControlProps = < hasErrors, submitted: false, forList: false, + listItemPath: undefined, forSingleList: false, disabled: false, locale: undefined, diff --git a/packages/docs/content/docs/widgets.mdx b/packages/docs/content/docs/widgets.mdx index 5a619b67..337200b1 100644 --- a/packages/docs/content/docs/widgets.mdx +++ b/packages/docs/content/docs/widgets.mdx @@ -81,7 +81,7 @@ The `condition` option can take a single filter rule or a list of filter rules. ### Example -The example below creates a collection based on a nested field's value. +The example below conditionally shows fields based on the values of other fields. ```yaml @@ -163,7 +163,7 @@ collections: [ ### Nested Field Example -The example below creates a collection based on a nested field's value. +The example below conditionally shows fields based on the values of other nested fields. ```yaml @@ -241,3 +241,96 @@ collections: [ ``` + +### List Field Example + +The example below conditionally shows fields inside a list based on the values of other fields in the same list item. This works with both `fields` or `types`. + + +```yaml +collections: + - name: list-field-filtered-collection + label: List Field Filtered Collection + folder: _list_field_condition + create: true + fields: + - name: list + label: List Field + widget: list + fields: + - name: value + label: Value 1 + widget: string + condition: + field: nested.object.field + value: yes + - name: nested + label: Nested + widget: object + fields: + - name: object + label: Object + widget: object + fields: + - name: field + label: Field + widget: select + options: + - yes + - no + +``` + +```js +collections: [ + { + name: "list-field-filtered-collection", + label: "List Field Filtered Collection", + folder: "_list_field_condition", + create: true, + fields: [ + { + name: "list", + label: "List Field", + widget: "list", + fields: [ + { + name: "value", + label: "Value 1", + widget: "string", + condition: { + field: "nested.object.field", + value: "yes" + } + }, + { + name: "nested", + label: "Nested", + widget: "object", + fields: [ + { + name: "object", + label: "Object", + widget: "object", + fields: [ + { + name: "field", + label: "Field", + widget: "select", + options: [ + "yes", + "no" + ] + } + ] + } + ] + } + ] + } + ] + } +], +``` + +