feat: expanded folder collection filter support (#820)

This commit is contained in:
Daniel Lautzenheiser 2023-05-24 14:45:22 -04:00 committed by GitHub
parent 85db6b4f8d
commit 061febfd02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 524 additions and 48 deletions

View File

@ -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<EF extends BaseField = UnknownField, BC extends BackendClas
return file.fields.map(f => 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);
}
}

View File

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

View File

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

View File

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

View File

@ -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)<br />\| [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**<br />`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'<br />\| 'yml'<br />\| 'json'<br />\| 'frontmatter'<br />\| 'json-frontmatter'<br />\| 'yaml-frontmatter' | | _Optional_. See [format](#extension-and-format) below |
| frontmatter_delimiter | string<br />\| [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)<br />\| [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<br />\| 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**<br />`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'<br />\| 'yml'<br />\| 'json'<br />\| 'frontmatter'<br />\| 'json-frontmatter'<br />\| 'yaml-frontmatter' | | _Optional_. See [extension and format](#extension-and-format) below |
| frontmatter_delimiter | string<br />\| [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

View File

@ -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<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> |
##### 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',
},
],
},
],
```
</CodeTabs>
##### 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.
<CodeTabs>
```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',
},
],
},
],
```
</CodeTabs>
#### 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.
<CodeTabs>
```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',
},
],
},
],