Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: filtering for relation widget (#2405) #7161

Merged
merged 3 commits into from
Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev-test/config.yml
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading