UNPKG

decap-cms-widget-relation

Version:

Widget for linking related entries in Decap CMS.

740 lines (654 loc) 21.3 kB
import React from 'react'; import { fromJS } from 'immutable'; import { render, fireEvent, waitFor, act } from '@testing-library/react'; import { DecapCmsWidgetRelation } from '../'; jest.mock('react-window', () => { function FixedSizeList(props) { return props.itemData.options; } return { FixedSizeList, }; }); jest.mock('../RelationCache', () => { return { __esModule: true, default: { getOptions: jest.fn((collection, searchFields, term, file, queryFn) => { return queryFn(); }), clear: jest.fn(), invalidateCollection: jest.fn(), }, }; }); beforeEach(() => { jest.clearAllMocks(); }); const RelationControl = DecapCmsWidgetRelation.controlComponent; const fieldConfig = { name: 'post', collection: 'posts', display_fields: ['title', 'slug'], search_fields: ['title', 'body'], value_field: 'title', }; const customizedOptionsLengthConfig = { name: 'post', collection: 'posts', display_fields: ['title', 'slug'], search_fields: ['title', 'body'], value_field: 'title', options_length: 10, }; const deeplyNestedFieldConfig = { name: 'post', collection: 'posts', display_fields: ['title', 'slug', 'deeply.nested.post.field'], search_fields: ['deeply.nested.post.field'], value_field: 'title', }; const nestedFieldConfig = { name: 'post', collection: 'posts', display_fields: ['title', 'slug', 'nested.field_1'], search_fields: ['nested.field_1', 'nested.field_2'], 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 filterIntegerFieldConfig = { name: 'post', collection: 'posts', display_fields: ['title', 'slug'], search_fields: ['title', 'body'], value_field: 'title', filters: [ { field: 'num', values: [1, 5, 9], }, ], }; 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: [], }, ], }; const nestedFilterFieldConfig = { name: 'post', collection: 'posts', display_fields: ['title', 'slug'], search_fields: ['title', 'body'], value_field: 'title', filters: [ { field: 'deeply.nested.post.field', values: ['Deeply nested field'], }, ], }; 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 num = idx + 1; const path = `posts/${slug}.md`; return { collection: 'posts', data: { title, slug, draft, num }, slug, path }; }); return [ ...hits, { collection: 'posts', data: { title: 'Deeply nested post', slug: 'post-deeply-nested', deeply: { nested: { post: { field: 'Deeply nested field', }, }, }, }, }, { collection: 'posts', data: { title: 'Nested post', slug: 'post-nested', nested: { field_1: 'Nested field 1', field_2: 'Nested field 2', }, }, }, { collection: 'posts', data: { title: 'YAML post', slug: 'post-yaml', body: 'Body yaml' }, }, { collection: 'posts', data: { title: 'JSON post', slug: 'post-json', body: 'Body json' }, }, ]; } const simpleFileCollectionHits = [{ data: { categories: ['category 1', 'category 2'] } }]; const nestedFileCollectionHits = [ { data: { nested: { categories: [ { name: 'category 1', id: 'cat1', }, { name: 'category 2', id: 'cat2', }, ], }, }, }, ]; const numberFieldsHits = [ { collection: 'posts', data: { title: 'post # 1', slug: 'post-1', index: 1, }, }, { collection: 'posts', data: { title: 'post # 2', slug: 'post-2', index: 2, }, }, ]; class RelationController extends React.Component { state = { value: this.props.value, queryHits: [], }; mounted = false; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } handleOnChange = jest.fn(value => { act(() => { this.setState({ ...this.state, value }); }); }); setQueryHits = jest.fn(queryHits => { if (this.mounted) { act(() => { this.setState({ ...this.state, queryHits }); }); } }); query = jest.fn((...args) => { const queryHits = generateHits(25); const [, collection, , term, file, optionsLength] = args; let hits = queryHits; if (collection === 'numbers_collection') { hits = numberFieldsHits; } else if (file === 'nested_file') { hits = nestedFileCollectionHits; } else if (file === 'simple_file') { hits = simpleFileCollectionHits; } else if (term === 'JSON post') { hits = [queryHits[queryHits.length - 1]]; } else if (term === 'YAML' || term === 'YAML post') { hits = [queryHits[queryHits.length - 2]]; } else if (term === 'Nested') { hits = [queryHits[queryHits.length - 3]]; } else if (term === 'Deeply nested') { hits = [queryHits[queryHits.length - 4]]; } hits = hits.slice(0, optionsLength); this.setQueryHits(hits); return Promise.resolve({ payload: { hits } }); }); render() { return this.props.children({ value: this.state.value, handleOnChange: this.handleOnChange, query: this.query, queryHits: this.state.queryHits, setQueryHits: this.setQueryHits, }); } } function setup({ field, value }) { let renderArgs; const setActiveSpy = jest.fn(); const setInactiveSpy = jest.fn(); const helpers = render( <RelationController value={value}> {({ handleOnChange, value, query, queryHits, setQueryHits }) => { renderArgs = { value, onChangeSpy: handleOnChange, setQueryHitsSpy: setQueryHits }; return ( <RelationControl field={field} value={value} query={query} queryHits={queryHits} onChange={handleOnChange} forID="relation-field" classNameWrapper="" setActiveStyle={setActiveSpy} setInactiveStyle={setInactiveSpy} /> ); }} </RelationController>, ); const input = helpers.container.querySelector('input'); return { ...helpers, ...renderArgs, setActiveSpy, setInactiveSpy, input, }; } describe('Relation widget', () => { it('should list the first 20 option hits on initial load', async () => { const field = fromJS(fieldConfig); const { getAllByText, input } = setup({ field }); fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { expect(getAllByText(/^Post # (\d{1,2}) post-number-\1$/)).toHaveLength(20); }); }); it('should list the first 10 option hits on initial load', async () => { const field = fromJS(customizedOptionsLengthConfig); const { getAllByText, input } = setup({ field }); fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { expect(getAllByText(/^Post # (\d{1,2}) post-number-\1$/)).toHaveLength(10); }); }); it('should update option list based on search term', async () => { const field = fromJS(fieldConfig); const { getAllByText, input } = setup({ field }); fireEvent.change(input, { target: { value: 'YAML' } }); await waitFor(() => { expect(getAllByText('YAML post post-yaml')).toHaveLength(1); }); }); it('should call onChange with correct selectedItem value and metadata', async () => { const field = fromJS(fieldConfig); const { getByText, input, onChangeSpy } = setup({ field }); const value = 'Post # 1'; const label = 'Post # 1 post-number-1'; const metadata = { post: { posts: { 'Post # 1': { title: 'Post # 1', draft: true, num: 1, slug: 'post-number-1' } }, }, }; fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { fireEvent.click(getByText(label)); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenCalledWith(value, metadata); }); }); it('should update metadata for initial preview 1', async () => { const field = fromJS(fieldConfig); const value = 'Post # 1'; const { getByText, onChangeSpy } = setup({ field, value }); const label = 'Post # 1 post-number-1'; const metadata = { post: { posts: { 'Post # 1': { title: 'Post # 1', draft: true, num: 1, slug: 'post-number-1' } }, }, }; // The component will automatically trigger a query for initial load // Wait for it to process and call onChange await waitFor( () => { expect(getByText(label)).toBeInTheDocument(); }, { timeout: 3000 }, ); await waitFor( () => { expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenCalledWith(value, metadata); }, { timeout: 3000 }, ); }); it('should update option list based on nested search term', async () => { const field = fromJS(nestedFieldConfig); const { getAllByText, input } = setup({ field }); fireEvent.change(input, { target: { value: 'Nested' } }); await waitFor(() => { expect(getAllByText('Nested post post-nested Nested field 1')).toHaveLength(1); }); }); it('should update option list based on deeply nested search term', async () => { const field = fromJS(deeplyNestedFieldConfig); const { getAllByText, input } = setup({ field }); fireEvent.change(input, { target: { value: 'Deeply nested' } }); await waitFor(() => { expect( getAllByText('Deeply nested post post-deeply-nested Deeply nested field'), ).toHaveLength(1); }); }); it('should handle string templates', async () => { const stringTemplateConfig = { name: 'post', collection: 'posts', display_fields: ['{{slug}}', '{{filename}}', '{{extension}}'], search_fields: ['slug'], value_field: '{{slug}}', }; const field = fromJS(stringTemplateConfig); const { getByText, input, onChangeSpy } = setup({ field }); const value = 'post-number-1'; const label = 'post-number-1 post-number-1 md'; const metadata = { post: { posts: { 'post-number-1': { title: 'Post # 1', draft: true, num: 1, slug: 'post-number-1' }, }, }, }; fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { fireEvent.click(getByText(label)); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenCalledWith(value, metadata); }); }); it('should default display_fields to value_field', async () => { const field = fromJS(fieldConfig).delete('display_fields'); const { getAllByText, input } = setup({ field }); fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { expect(getAllByText(/^Post # (\d{1,2})$/)).toHaveLength(20); }); }); it('should keep number type of referenced field', async () => { const fieldConfig = { name: 'numbers', collection: 'numbers_collection', value_field: 'index', search_fields: ['index'], display_fields: ['title'], }; const field = fromJS(fieldConfig); const { getByText, getAllByText, input, onChangeSpy } = setup({ field }); fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { expect(getAllByText(/^post # \d$/)).toHaveLength(2); }); fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.click(getByText('post # 1')); fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.click(getByText('post # 2')); expect(onChangeSpy).toHaveBeenCalledTimes(2); expect(onChangeSpy).toHaveBeenCalledWith(1, { numbers: { numbers_collection: { 1: { index: 1, slug: 'post-1', title: 'post # 1' } } }, }); expect(onChangeSpy).toHaveBeenCalledWith(2, { numbers: { numbers_collection: { 2: { index: 2, slug: 'post-2', title: 'post # 2' } } }, }); }); describe('with multiple', () => { it('should call onChange with correct selectedItem value and metadata', async () => { const field = fromJS({ ...fieldConfig, multiple: true }); const { getByText, input, onChangeSpy } = setup({ field }); const metadata1 = { post: { posts: { 'Post # 1': { title: 'Post # 1', draft: true, num: 1, slug: 'post-number-1' } }, }, }; const metadata2 = { post: { posts: { 'Post # 2': { title: 'Post # 2', draft: false, num: 2, slug: 'post-number-2' } }, }, }; fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { fireEvent.click(getByText('Post # 1 post-number-1')); }); fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { fireEvent.click(getByText('Post # 2 post-number-2')); }); expect(onChangeSpy).toHaveBeenCalledTimes(2); expect(onChangeSpy).toHaveBeenCalledWith(fromJS(['Post # 1']), metadata1); expect(onChangeSpy).toHaveBeenCalledWith(fromJS(['Post # 1', 'Post # 2']), metadata2); }); it('should update metadata for initial preview 2', async () => { const field = fromJS({ ...fieldConfig, multiple: true }); const value = fromJS(['YAML post', 'JSON post']); const { getByText, onChangeSpy } = setup({ field, value }); const metadata = { post: { posts: { 'YAML post': { title: 'YAML post', slug: 'post-yaml', body: 'Body yaml' }, 'JSON post': { title: 'JSON post', slug: 'post-json', body: 'Body json' }, }, }, }; // Wait for both labels to appear await waitFor( () => { expect(getByText('YAML post post-yaml')).toBeInTheDocument(); expect(getByText('JSON post post-json')).toBeInTheDocument(); }, { timeout: 3000 }, ); // Wait for onChange to be called await waitFor( () => { expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenCalledWith(value, metadata); }, { timeout: 3000 }, ); }); }); describe('with file collection', () => { const fileFieldConfig = { name: 'categories', collection: 'file', file: 'simple_file', value_field: 'categories.*', display_fields: ['categories.*'], }; it('should handle simple list', async () => { const field = fromJS(fileFieldConfig); const { getAllByText, input, getByText } = setup({ field }); fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { expect(getAllByText(/category/)).toHaveLength(2); expect(getByText('category 1')).toBeInTheDocument(); expect(getByText('category 2')).toBeInTheDocument(); }); }); it('should handle nested list', async () => { const field = fromJS({ ...fileFieldConfig, file: 'nested_file', value_field: 'nested.categories.*.id', display_fields: ['nested.categories.*.name'], }); const { getAllByText, input, getByText } = setup({ field }); fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { expect(getAllByText(/category/)).toHaveLength(2); expect(getByText('category 1')).toBeInTheDocument(); expect(getByText('category 2')).toBeInTheDocument(); }); }); }); 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 3 option hits on initial load using a filter on integer value', async () => { const field = fromJS(filterIntegerFieldConfig); const { getAllByText, input } = setup({ field }); const expectedOptions = [ 'Post # 1 post-number-1', 'Post # 5 post-number-5', 'Post # 9 post-number-9', ]; 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); }); }); it('should list 1 option hit on initial load on nested filter field', async () => { const field = fromJS(nestedFilterFieldConfig); const { getAllByText, input } = setup({ field }); fireEvent.keyDown(input, { key: 'ArrowDown' }); await waitFor(() => { expect(() => getAllByText(/^Post # (\d{1,2}) post-number-\1$/)).toThrow(Error); expect(getAllByText('Deeply nested post post-deeply-nested')).toHaveLength(1); }); }); }); });