diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index 928135fc..813ea934 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -39,6 +39,7 @@ import { selectInferredField, selectMediaFolders, } from './lib/util/collection.util'; +import filterEntries from './lib/util/filter.util'; import { DRAFT_MEDIA_FILES, selectMediaFilePath, @@ -1020,15 +1021,8 @@ export class Backend f.name); } - filterEntries(collection: { entries: Entry[] }, filterRule: FilterRule) { - return collection.entries.filter(entry => { - const fieldValue = entry.data?.[filterRule.field]; - // TODO Investigate when the value could be a string array - // if (Array.isArray(fieldValue)) { - // return fieldValue.includes(filterRule.value); - // } - return fieldValue === filterRule.value; - }); + filterEntries(collection: { entries: Entry[] }, filterRule: FilterRule | FilterRule[]) { + return filterEntries(collection.entries, filterRule); } } diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index 88587601..dff305df 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -170,11 +170,27 @@ export interface EntryDraft { fieldsErrors: FieldsErrors; } -export interface FilterRule { - value: string; +export interface BaseFieldFilterRule { field: string; } +export interface FieldPatternFilterRule extends BaseFieldFilterRule { + pattern: string; +} + +export interface FieldValueFilterRule extends BaseFieldFilterRule { + value: string | string[]; + matchAll?: boolean; +} + +export type FieldFilterRule = FieldPatternFilterRule | FieldValueFilterRule; + +export interface FileNameFilterRule { + pattern: string; +} + +export type FilterRule = FieldFilterRule | FileNameFilterRule; + export interface EditorConfig { preview?: boolean; frame?: boolean; @@ -224,7 +240,7 @@ export interface BaseCollection { isFetching?: boolean; summary?: string; summary_fields?: string[]; - filter?: FilterRule; + filter?: FilterRule | FilterRule[]; label_singular?: string; label: string; sortable_fields?: SortableFields; diff --git a/packages/core/src/lib/util/__tests__/filter.util.spec.ts b/packages/core/src/lib/util/__tests__/filter.util.spec.ts new file mode 100644 index 00000000..92895e2c --- /dev/null +++ b/packages/core/src/lib/util/__tests__/filter.util.spec.ts @@ -0,0 +1,200 @@ +import { createMockEntry } from '@staticcms/test/data/entry.mock'; +import filterEntries from '../filter.util'; + +import type { Entry } from '@staticcms/core/interface'; + +describe('filterEntries', () => { + const mockEnglishEntry = createMockEntry({ + path: 'path/to/file-1.md', + data: { + language: 'en', + tags: ['tag-1', 'tag-2', 'fish-catfish'], + }, + }); + + const mockFrenchEntry = createMockEntry({ + path: 'path/to/file-2.md', + data: { + language: 'fr', + tags: ['tag-1', 'tag-4'], + }, + }); + + const mockIndexEntry = createMockEntry({ + path: 'path/to/index.md', + data: { + language: 'gr', + tags: ['tag-1', 'tag-4'], + }, + }); + + const mockUnderscoreIndexEntry = createMockEntry({ + path: 'path/to/_index.md', + data: { + language: 'gr', + tags: ['tag-1', 'tag-3'], + }, + }); + + const mockRandomFileNameEntry = createMockEntry({ + path: 'path/to/someOtherFile.md', + data: { + language: 'gr', + tags: ['tag-3'], + }, + }); + + const mockTags1and4Entry = createMockEntry({ + path: 'path/to/thatOtherPost.md', + data: { + language: 'gr', + tags: ['tag-1', 'tag-4', 'fish-pike'], + }, + }); + + const entries: Entry[] = [ + mockEnglishEntry, + mockFrenchEntry, + mockIndexEntry, + mockUnderscoreIndexEntry, + mockRandomFileNameEntry, + mockTags1and4Entry, + ]; + + describe('field rules', () => { + it('should filter fields', () => { + expect(filterEntries(entries, { field: 'language', value: 'en' })).toEqual([ + mockEnglishEntry, + ]); + + expect(filterEntries(entries, { field: 'language', value: 'fr' })).toEqual([mockFrenchEntry]); + + expect(filterEntries(entries, { field: 'language', value: 'gr' })).toEqual([ + mockIndexEntry, + mockUnderscoreIndexEntry, + mockRandomFileNameEntry, + 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, + ]); + }); + + it('should filter fields based on pattern', () => { + // Languages with an r in their name + expect(filterEntries(entries, { field: 'language', pattern: 'r' })).toEqual([ + mockFrenchEntry, + mockIndexEntry, + mockUnderscoreIndexEntry, + mockRandomFileNameEntry, + mockTags1and4Entry, + ]); + }); + + it('should filter fields if field value is an array (must include value)', () => { + expect(filterEntries(entries, { field: 'tags', value: 'tag-4' })).toEqual([ + mockFrenchEntry, + mockIndexEntry, + 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([ + mockFrenchEntry, + mockIndexEntry, + mockUnderscoreIndexEntry, + 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([ + mockFrenchEntry, + mockIndexEntry, + mockUnderscoreIndexEntry, + mockRandomFileNameEntry, + mockTags1and4Entry, + ]); + }); + + 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 }), + ).toEqual([mockFrenchEntry, mockIndexEntry, mockTags1and4Entry]); + }); + + 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([ + mockEnglishEntry, + mockTags1and4Entry, + ]); + }); + + 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' }, + ]), + ).toEqual([ + mockIndexEntry, + mockUnderscoreIndexEntry, + mockRandomFileNameEntry, + mockTags1and4Entry, + ]); + }); + + 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' }, + ]), + ).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$' })).toEqual([ + mockUnderscoreIndexEntry, + ]); + + expect(filterEntries(entries, { pattern: 'index.md$' })).toEqual([ + mockIndexEntry, + mockUnderscoreIndexEntry, + ]); + + // File names containing the word file (case insensitive) + expect(filterEntries(entries, { pattern: '[fF][iI][lL][eE]' })).toEqual([ + mockEnglishEntry, + mockFrenchEntry, + mockRandomFileNameEntry, + ]); + }); + + 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' }]), + ).toEqual([mockRandomFileNameEntry]); + }); + }); + + 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' }]), + ).toEqual([mockUnderscoreIndexEntry]); + }); + }); +}); diff --git a/packages/core/src/lib/util/filter.util.ts b/packages/core/src/lib/util/filter.util.ts new file mode 100644 index 00000000..17babf1d --- /dev/null +++ b/packages/core/src/lib/util/filter.util.ts @@ -0,0 +1,54 @@ +import { parse } from 'path'; + +import type { Entry, FieldFilterRule, FilterRule } from '@staticcms/core/interface'; + +function entryMatchesFieldRule(entry: Entry, filterRule: FieldFilterRule): boolean { + const fieldValue = entry.data?.[filterRule.field]; + if ('pattern' in filterRule) { + if (Array.isArray(fieldValue)) { + return Boolean(fieldValue.find(v => new RegExp(filterRule.pattern).test(String(v)))); + } + + return new RegExp(filterRule.pattern).test(String(fieldValue)); + } + + if (Array.isArray(fieldValue)) { + if (Array.isArray(filterRule.value)) { + if (filterRule.matchAll) { + return Boolean(filterRule.value.every(ruleValue => fieldValue.includes(ruleValue))); + } + + return Boolean(fieldValue.find(v => filterRule.value.includes(String(v)))); + } + + return fieldValue.includes(filterRule.value); + } + + if (Array.isArray(filterRule.value)) { + if (filterRule.matchAll) { + return Boolean(filterRule.value.every(ruleValue => fieldValue === ruleValue)); + } + + return filterRule.value.includes(String(fieldValue)); + } + + return fieldValue === filterRule.value; +} + +function entryMatchesRule(entry: Entry, filterRule: FilterRule) { + if ('field' in filterRule) { + return entryMatchesFieldRule(entry, filterRule); + } + + return new RegExp(filterRule.pattern).test(parse(entry.path).base); +} + +export default function filterEntries(entries: Entry[], filterRule: FilterRule | FilterRule[]) { + return entries.filter(entry => { + if (Array.isArray(filterRule)) { + return filterRule.every(r => entryMatchesRule(entry, r)); + } + + return entryMatchesRule(entry, filterRule); + }); +} diff --git a/packages/docs/content/docs/collection-overview.mdx b/packages/docs/content/docs/collection-overview.mdx index 209888e8..406c430c 100644 --- a/packages/docs/content/docs/collection-overview.mdx +++ b/packages/docs/content/docs/collection-overview.mdx @@ -6,30 +6,30 @@ weight: 9 `collections` accepts a list of collection objects, each with the following options -| Name | Type | Default | Description | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| name | string | | Unique identifier for the collection, used as the key when referenced in other contexts (like the [relation widget](/docs/widgets/#relation)) | -| identifier_field | string | `'title'` | _Optional_. See [identifier_field](#identifier_field) below | -| label | string | `name` | _Optional_. Label for the collection in the editor UI | -| label_singular | string | `label` | _Optional_. Singular label for certain elements in the editor | -| 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 | | _Optional_. Filter for [Folder Collections](/docs/collection-types#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 | -| extension | string | | _Optional_. See [extension](#extension-and-format) below | -| format | 'yaml'
\| 'yml'
\| 'json'
\| 'frontmatter'
\| 'json-frontmatter'
\| 'yaml-frontmatter' | | _Optional_. See [format](#extension-and-format) below | -| frontmatter_delimiter | string
\| [string, string] | | _Optional_. See [frontmatter_delimiter](#frontmatter_delimiter) below | -| slug | string | | _Optional_. See [slug](#slug) below | -| fields (required) | Field | | _Optional_. See [fields](#fields) below. Ignored if [Files Collection](/docs/collection-types#file-collections) | -| editor | EditorConfig | | _Optional_. See [editor](#editor) below | -| summary | string | | _Optional_. See [summary](#summary) below | -| summary_fields | list of strings | ['summary'] | _Optional_. A list of fields to show in the table view | -| sortable_fields | SortableFields | | _Optional_. See [sortable_fields](#sortable_fields) below | -| view_filters | ViewFilter | | _Optional_. See [view_filters](#view_filters) below | -| view_groups | ViewGroup | | _Optional_. See [view_groups](#view_groups) below | +| Name | Type | Default | Description | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | string | | Unique identifier for the collection, used as the key when referenced in other contexts (like the [relation widget](/docs/widgets/#relation)) | +| identifier_field | string | `'title'` | _Optional_. See [identifier field](#identifier-field) below | +| label | string | `name` | _Optional_. Label for the collection in the editor UI | +| label_singular | string | `label` | _Optional_. Singular label for certain elements in the editor | +| 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) | +| 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 | +| extension | string | | _Optional_. See [extension and format](#extension-and-format) below | +| format | 'yaml'
\| 'yml'
\| 'json'
\| 'frontmatter'
\| 'json-frontmatter'
\| 'yaml-frontmatter' | | _Optional_. See [extension and format](#extension-and-format) below | +| frontmatter_delimiter | string
\| [string, string] | | _Optional_. See [frontmatter delimiter](#frontmatter-delimiter) below | +| slug | string | | _Optional_. See [slug](#slug) below | +| fields (required) | Field | | _Optional_. See [fields](#fields) below. Ignored if [Files Collection](/docs/collection-types#file-collections) | +| editor | EditorConfig | | _Optional_. See [editor](#editor) below | +| summary | string | | _Optional_. See [summary](#summary) below | +| summary_fields | list of strings | ['summary'] | _Optional_. A list of fields to show in the table view | +| sortable_fields | SortableFields | | _Optional_. See [sortable fields](#sortable-fields) below | +| view_filters | ViewFilter | | _Optional_. See [view filters](#view-filters) below | +| view_groups | ViewGroup | | _Optional_. See [view groups](#view-groups) below | ## Identifier Field diff --git a/packages/docs/content/docs/collection-types.mdx b/packages/docs/content/docs/collection-types.mdx index d615c530..5ff57d8d 100644 --- a/packages/docs/content/docs/collection-types.mdx +++ b/packages/docs/content/docs/collection-types.mdx @@ -105,12 +105,20 @@ collections: [ ### Filtered folder collections -The entries for any folder collection can be filtered based on the value of a single field. By filtering a folder into different collections, you can manage files with different fields, options, extensions, etc. in the same folder. +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. -The `filter` option requires two fields: +The `filter` option can take a single filter rule or a list of filter rules. There are two types of filter rules available: field and file. -- `field`: The name of the collection field to filter on. -- `value`: The desired field value. +#### Field Filter Rule + +| Name | Type | Default | Description | +| -------- | ----------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| field | string | | The name of one of the fields in the collection's `fields` | +| 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_
| + +##### Filtered by Language The example below creates two collections in the same folder, filtered by the `language` field. The first collection includes posts with `language: en`, and the second, with `language: es`. @@ -129,6 +137,7 @@ collections: label: Language widget: select options: ['en', 'es'] + default: 'en' - name: title label: Title widget: string @@ -147,6 +156,7 @@ collections: label: Lenguaje widget: select options: ['en', 'es'] + default: 'es' - name: title label: Titulo widget: string @@ -162,11 +172,28 @@ collections: [ label: 'Blog in English', folder: '_posts', create: true, - filter: { field: 'language', value: 'en' }, + filter: { + field: 'language', + value: 'en', + }, fields: [ - { name: 'language', label: 'Language', widget: 'select', options: ['en', 'es'] }, - { name: 'title', label: 'Title', widget: 'string' }, - { name: 'body', label: 'Content', widget: 'markdown' }, + { + name: 'language', + label: 'Language', + widget: 'select', + options: ['en', 'es'], + default: 'en', + }, + { + name: 'title', + label: 'Title', + widget: 'string', + }, + { + name: 'body', + label: 'Content', + widget: 'markdown', + }, ], }, { @@ -174,11 +201,196 @@ collections: [ label: 'Blog en EspaƱol', folder: '_posts', create: true, - filter: { field: 'language', value: 'es' }, + filter: { + field: 'language', + value: 'es', + }, fields: [ - { name: 'language', label: 'Lenguaje', widget: 'select', options: ['en', 'es'] }, - { name: 'title', label: 'Titulo', widget: 'string' }, - { name: 'body', label: 'Contenido', widget: 'markdown' }, + { + name: 'language', + label: 'Lenguaje', + widget: 'select', + options: ['en', 'es'], + default: 'es', + }, + { + name: 'title', + label: 'Titulo', + widget: 'string', + }, + { + name: 'body', + label: 'Contenido', + widget: 'markdown', + }, + ], + }, +], +``` + + + +##### Filtered by Tags + +The example below creates two collections in the same folder, filtered by the `tags` field. The first collection includes posts with the `news` or `article` tags, and the second, with the `blog` tag. + + +```yaml +collections: + - name: 'news' + label: 'News' + folder: '_posts' + create: true + filter: + field: tags + value: + - news + - article + fields: + - name: tags + label: Tags + widget: list + default: + - news + - name: title + label: Title + widget: string + - name: body + label: Content + widget: markdown + - name: blogs + label: 'Blogs' + folder: _posts + create: true + filter: + field: tags + value: blog + fields: + - name: tags + label: Tags + widget: list + default: + - blog + - name: title + label: Title + widget: string + - name: body + label: Content + widget: markdown +``` + +```js +collections: [ + { + name: 'news', + label: 'News', + folder: '_posts', + create: true, + filter: { + field: 'tags', + value: ['news', 'article'], + }, + fields: [ + { + name: 'tags', + label: 'Tags', + widget: 'list', + default: ['news'], + }, + { + name: 'title', + label: 'Title', + widget: 'string', + }, + { + name: 'body', + label: 'Content', + widget: 'markdown', + }, + ], + }, + { + name: 'blogs', + label: 'Blogs', + folder: '_posts', + create: true, + filter: { + field: 'tags', + value: 'blog', + }, + fields: [ + { + name: 'tags', + label: 'Tags', + widget: 'list', + default: ['blog'], + }, + { + name: 'title', + label: 'Title', + widget: 'string', + }, + { + name: 'body', + label: 'Content', + widget: 'markdown', + }, + ], + }, +], +``` + + + +#### File Name Filter Rule + +| Name | Type | Default | Description | +| ------- | ------------------ | ------- | ------------------------------------------------------ | +| pattern | regular expression | | A regex pattern to match against the entry's file name | + +##### Filtered by Tags + +The example below creates a collection containing only files named `index.md` exactly. + + +```yaml +collections: + - name: 'posts' + label: 'Posts' + folder: '_posts' + create: true + filter: + pattern: '^index.md$' + fields: + - name: title + label: Title + widget: string + - name: body + label: Content + widget: markdown +``` + +```js +collections: [ + { + name: 'posts', + label: 'Posts', + folder: '_posts', + create: true, + filter: { + pattern: '^index.md$', + }, + fields: [ + { + name: 'title', + label: 'Title', + widget: 'string', + }, + { + name: 'body', + label: 'Content', + widget: 'markdown', + }, ], }, ],