feat: confine conditions inside list to the same local list item (#863)

This commit is contained in:
Daniel Lautzenheiser 2023-09-06 12:05:39 -04:00 committed by GitHub
parent 82a8e11ab3
commit 5602812774
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 319 additions and 60 deletions

View File

@ -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

View File

@ -1045,7 +1045,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
}
filterEntries(collection: { entries: Entry[] }, filterRule: FilterRule | FilterRule[]) {
return filterEntries(collection.entries, filterRule);
return filterEntries(collection.entries, filterRule, undefined);
}
}

View File

@ -54,6 +54,7 @@ const EditorControl = ({
t,
value,
forList = false,
listItemPath,
forSingleList = false,
changeDraftField,
i18n,
@ -94,7 +95,7 @@ const EditorControl = ({
() => 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;

View File

@ -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<EditorControlPaneProps>) => {
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}
/>
);
})}

View File

@ -311,6 +311,7 @@ export interface WidgetControlProps<T, F extends BaseField = UnknownField, EV =
fieldsErrors: FieldsErrors;
submitted: boolean;
forList: boolean;
listItemPath: string | undefined;
forSingleList: boolean;
disabled: boolean;
duplicate: boolean;

View File

@ -71,30 +71,86 @@ describe('filterEntries', () => {
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();
});
});
});
});

View File

@ -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]);
});
});
});

View File

@ -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]);
}

View File

@ -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);
});
}

View File

@ -207,6 +207,7 @@ const ListItem: FC<ListItemProps> = ({
locale={locale}
i18n={i18n}
forList={true}
listItemPath={`${path}.${objectField.name}`}
forSingleList={isSingleList}
/>
</ListItemWrapper>

View File

@ -69,8 +69,6 @@ const InsertLinkToolbarButton: FC<InsertLinkToolbarButtonProps> = ({
return;
}
console.log('editor.selection', editor.selection);
deleteText(editor, {
at: editor.selection as unknown as Location,
});

View File

@ -22,6 +22,7 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
errors,
disabled,
value = {},
listItemPath,
}) => {
const objectLabel = useMemo(() => {
const summary = field.summary;
@ -59,6 +60,7 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
locale={locale}
i18n={i18n}
forSingleList={forSingleList}
listItemPath={listItemPath}
/>
);
}) ?? null
@ -75,6 +77,7 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
locale,
i18n,
forSingleList,
listItemPath,
]);
if (fields.length) {

View File

@ -65,6 +65,7 @@ export const createMockWidgetControlProps = <
hasErrors,
submitted: false,
forList: false,
listItemPath: undefined,
forSingleList: false,
disabled: false,
locale: undefined,

View File

@ -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.
<CodeTabs>
```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.
<CodeTabs>
```yaml
@ -241,3 +241,96 @@ collections: [
```
</CodeTabs>
### 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`.
<CodeTabs>
```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"
]
}
]
}
]
}
]
}
]
}
],
```
</CodeTabs>