Skip to content

Commit

Permalink
feat: filtering for relation widget (#2405) (#7161)
Browse files Browse the repository at this point in the history
* feat: filtering for relation widget (#2405)

* feat: filter relation widget (#2405)

---------

Co-authored-by: Anze Demsar <anze.demsar@p-m.si>
  • Loading branch information
JimmyOei and demshy committed Mar 28, 2024
1 parent 3f96112 commit 85c92f0
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 25 deletions.
2 changes: 1 addition & 1 deletion dev-test/config.yml
Expand Up @@ -66,7 +66,7 @@ collections: # A list of collections the CMS should be able to edit
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
- { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' }
- { name: 'gallery', widget: 'image', choose_url: true, media_library: {config: {multiple: true, max_files: 999}}}
- { name: 'post', widget: relation, collection: posts, multiple: true, search_fields: [ "title" ], display_fields: [ "title" ], value_field: "{{slug}}"}
- { name: 'post', widget: relation, collection: posts, multiple: true, search_fields: [ "title" ], display_fields: [ "title" ], value_field: "{{slug}}", filters: [ {field: "draft", values: [false]} ] }
- name: authors
label: Authors
label_singular: 'Author'
Expand Down
51 changes: 33 additions & 18 deletions packages/decap-cms-widget-relation/src/RelationControl.js
Expand Up @@ -147,8 +147,12 @@ function uniqOptions(initial, current) {
return uniqBy(initial.concat(current), o => o.value);
}

function getSearchFieldArray(searchFields) {
return List.isList(searchFields) ? searchFields.toJS() : [searchFields];
function getFieldArray(field) {
if (!field) {
return [];
}

return List.isList(field) ? field.toJS() : [field];
}

function getSelectedValue({ value, options, isMultiple }) {
Expand Down Expand Up @@ -238,7 +242,7 @@ export default class RelationControl extends React.Component {
const initialSearchValues = value && (this.isMultiple() ? getSelectedOptions(value) : [value]);
if (initialSearchValues && initialSearchValues.length > 0) {
const metadata = {};
const searchFieldsArray = getSearchFieldArray(field.get('search_fields'));
const searchFieldsArray = getFieldArray(field.get('search_fields'));
const { payload } = await query(forID, collection, searchFieldsArray, '', file);
const hits = payload.hits || [];
const options = this.parseHitOptions(hits);
Expand All @@ -249,12 +253,13 @@ export default class RelationControl extends React.Component {
return selectedOption;
})
.filter(Boolean);
const filteredValue = initialOptions.map(option => option.value);

this.mounted && this.setState({ initialOptions });

//set metadata
this.mounted &&
onChange(value, {
onChange(filteredValue.length === 1 ? filteredValue[0] : fromJS(filteredValue), {
[field.get('name')]: {
[field.get('collection')]: metadata,
},
Expand Down Expand Up @@ -337,18 +342,28 @@ export default class RelationControl extends React.Component {
const { field } = this.props;
const valueField = field.get('value_field');
const displayField = field.get('display_fields') || List([field.get('value_field')]);
const filters = getFieldArray(field.get('filters'));

const options = hits.reduce((acc, hit) => {
const valuesPaths = stringTemplate.expandPath({ data: hit.data, path: valueField });
for (let i = 0; i < valuesPaths.length; i++) {
const label = displayField
.toJS()
.map(key => {
const displayPaths = stringTemplate.expandPath({ data: hit.data, path: key });
return this.parseNestedFields(hit, displayPaths[i] || displayPaths[0]);
})
.join(' ');
const value = this.parseNestedFields(hit, valuesPaths[i]);
acc.push({ data: hit.data, value, label });
if (
filters.every(
filter =>
Object.prototype.hasOwnProperty.call(hit.data, filter.field) &&
filter.values.includes(hit.data[filter.field]),
)
) {
const valuesPaths = stringTemplate.expandPath({ data: hit.data, path: valueField });
for (let i = 0; i < valuesPaths.length; i++) {
const label = displayField
.toJS()
.map(key => {
const displayPaths = stringTemplate.expandPath({ data: hit.data, path: key });
return this.parseNestedFields(hit, displayPaths[i] || displayPaths[0]);
})
.join(' ');
const value = this.parseNestedFields(hit, valuesPaths[i]);
acc.push({ data: hit.data, value, label });
}
}

return acc;
Expand All @@ -361,13 +376,13 @@ export default class RelationControl extends React.Component {
const { field, query, forID } = this.props;
const collection = field.get('collection');
const optionsLength = field.get('options_length') || 20;
const searchFieldsArray = getSearchFieldArray(field.get('search_fields'));
const searchFieldsArray = getFieldArray(field.get('search_fields'));
const file = field.get('file');

query(forID, collection, searchFieldsArray, term, file, optionsLength).then(({ payload }) => {
query(forID, collection, searchFieldsArray, term, file).then(({ payload }) => {
const hits = payload.hits || [];
const options = this.parseHitOptions(hits);
const uniq = uniqOptions(this.state.initialOptions, options);
const uniq = uniqOptions(this.state.initialOptions, options).slice(0, optionsLength);
callback(uniq);
});
}, 500);
Expand Down
158 changes: 152 additions & 6 deletions packages/decap-cms-widget-relation/src/__tests__/relation.spec.js
Expand Up @@ -49,12 +49,73 @@ const nestedFieldConfig = {
value_field: 'title',
};

const filterBooleanFieldConfig = {
name: 'post',
collection: 'posts',
display_fields: ['title', 'slug'],
search_fields: ['title', 'body'],
value_field: 'title',
filters: [
{
field: 'draft',
values: [false],
},
],
};

const filterStringFieldConfig = {
name: 'post',
collection: 'posts',
display_fields: ['title', 'slug'],
search_fields: ['title', 'body'],
value_field: 'title',
filters: [
{
field: 'title',
values: ['Post # 1', 'Post # 2', 'Post # 7', 'Post # 9', 'Post # 15'],
},
],
};

const multipleFiltersFieldConfig = {
name: 'post',
collection: 'posts',
display_fields: ['title', 'slug'],
search_fields: ['title', 'body'],
value_field: 'title',
filters: [
{
field: 'title',
values: ['Post # 1', 'Post # 2', 'Post # 7', 'Post # 9', 'Post # 15'],
},
{
field: 'draft',
values: [true],
},
],
};

const emptyFilterFieldConfig = {
name: 'post',
collection: 'posts',
display_fields: ['title', 'slug'],
search_fields: ['title', 'body'],
value_field: 'title',
filters: [
{
field: 'draft',
values: [],
},
],
};

function generateHits(length) {
const hits = Array.from({ length }, (val, idx) => {
const title = `Post # ${idx + 1}`;
const slug = `post-number-${idx + 1}`;
const draft = idx % 2 === 0;
const path = `posts/${slug}.md`;
return { collection: 'posts', data: { title, slug }, slug, path };
return { collection: 'posts', data: { title, slug, draft }, slug, path };
});

return [
Expand Down Expand Up @@ -277,7 +338,7 @@ describe('Relation widget', () => {
const value = 'Post # 1';
const label = 'Post # 1 post-number-1';
const metadata = {
post: { posts: { 'Post # 1': { title: 'Post # 1', slug: 'post-number-1' } } },
post: { posts: { 'Post # 1': { title: 'Post # 1', draft: true, slug: 'post-number-1' } } },
};

fireEvent.keyDown(input, { key: 'ArrowDown' });
Expand All @@ -295,7 +356,7 @@ describe('Relation widget', () => {
const { getByText, onChangeSpy, setQueryHitsSpy } = setup({ field, value });
const label = 'Post # 1 post-number-1';
const metadata = {
post: { posts: { 'Post # 1': { title: 'Post # 1', slug: 'post-number-1' } } },
post: { posts: { 'Post # 1': { title: 'Post # 1', draft: true, slug: 'post-number-1' } } },
};

setQueryHitsSpy(generateHits(1));
Expand Down Expand Up @@ -343,7 +404,9 @@ describe('Relation widget', () => {
const value = 'post-number-1';
const label = 'post-number-1 post-number-1 md';
const metadata = {
post: { posts: { 'post-number-1': { title: 'Post # 1', slug: 'post-number-1' } } },
post: {
posts: { 'post-number-1': { title: 'Post # 1', draft: true, slug: 'post-number-1' } },
},
};

fireEvent.keyDown(input, { key: 'ArrowDown' });
Expand Down Expand Up @@ -399,10 +462,10 @@ describe('Relation widget', () => {
const field = fromJS({ ...fieldConfig, multiple: true });
const { getByText, input, onChangeSpy } = setup({ field });
const metadata1 = {
post: { posts: { 'Post # 1': { title: 'Post # 1', slug: 'post-number-1' } } },
post: { posts: { 'Post # 1': { title: 'Post # 1', draft: true, slug: 'post-number-1' } } },
};
const metadata2 = {
post: { posts: { 'Post # 2': { title: 'Post # 2', slug: 'post-number-2' } } },
post: { posts: { 'Post # 2': { title: 'Post # 2', draft: false, slug: 'post-number-2' } } },
};

fireEvent.keyDown(input, { key: 'ArrowDown' });
Expand Down Expand Up @@ -481,4 +544,87 @@ describe('Relation widget', () => {
});
});
});

describe('with filter', () => {
it('should list the 10 option hits on initial load using a filter on boolean value', async () => {
const field = fromJS(filterBooleanFieldConfig);
const { getAllByText, input } = setup({ field });
const expectedOptions = [];
for (let i = 2; i <= 25; i += 2) {
expectedOptions.push(`Post # ${i} post-number-${i}`);
}
fireEvent.keyDown(input, { key: 'ArrowDown' });

await waitFor(() => {
const displayedOptions = getAllByText(/^Post # (\d{1,2}) post-number-\1$/);
expect(displayedOptions).toHaveLength(expectedOptions.length);
for (let i = 0; i < expectedOptions.length; i++) {
const expectedOption = expectedOptions[i];
const optionFound = displayedOptions.some(
option => option.textContent === expectedOption,
);
expect(optionFound).toBe(true);
}
});
});

it('should list the 5 option hits on initial load using a filter on string value', async () => {
const field = fromJS(filterStringFieldConfig);
const { getAllByText, input } = setup({ field });
const expectedOptions = [
'Post # 1 post-number-1',
'Post # 2 post-number-2',
'Post # 7 post-number-7',
'Post # 9 post-number-9',
'Post # 15 post-number-15',
];
fireEvent.keyDown(input, { key: 'ArrowDown' });

await waitFor(() => {
const displayedOptions = getAllByText(/^Post # (\d{1,2}) post-number-\1$/);
expect(displayedOptions).toHaveLength(expectedOptions.length);
for (let i = 0; i < expectedOptions.length; i++) {
const expectedOption = expectedOptions[i];
const optionFound = displayedOptions.some(
option => option.textContent === expectedOption,
);
expect(optionFound).toBe(true);
}
});
});

it('should list 4 option hits on initial load using multiple filters', async () => {
const field = fromJS(multipleFiltersFieldConfig);
const { getAllByText, input } = setup({ field });
const expectedOptions = [
'Post # 1 post-number-1',
'Post # 7 post-number-7',
'Post # 9 post-number-9',
'Post # 15 post-number-15',
];
fireEvent.keyDown(input, { key: 'ArrowDown' });

await waitFor(() => {
const displayedOptions = getAllByText(/^Post # (\d{1,2}) post-number-\1$/);
expect(displayedOptions).toHaveLength(expectedOptions.length);
for (let i = 0; i < expectedOptions.length; i++) {
const expectedOption = expectedOptions[i];
const optionFound = displayedOptions.some(
option => option.textContent === expectedOption,
);
expect(optionFound).toBe(true);
}
});
});

it('should list 0 option hits on initial load on empty filter values array', async () => {
const field = fromJS(emptyFilterFieldConfig);
const { getAllByText, input } = setup({ field });
fireEvent.keyDown(input, { key: 'ArrowDown' });

await waitFor(() => {
expect(() => getAllByText(/^Post # (\d{1,2}) post-number-\1$/)).toThrow(Error);
});
});
});
});
11 changes: 11 additions & 0 deletions packages/decap-cms-widget-relation/src/schema.js
Expand Up @@ -9,6 +9,17 @@ export default {
max: { type: 'integer' },
display_fields: { type: 'array', minItems: 1, items: { type: 'string' } },
options_length: { type: 'integer' },
filters: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
values: { type: 'array', minItems: 1, items: { type: ['string', 'boolean'] } },
},
required: ['field', 'values'],
},
},
},
oneOf: [
{
Expand Down

0 comments on commit 85c92f0

Please sign in to comment.