diff --git a/packages/strapi-plugin-content-manager/admin/src/components/SelectMany/index.js b/packages/strapi-plugin-content-manager/admin/src/components/SelectMany/index.js index 09bdbc430f..32f6ce0ce1 100644 --- a/packages/strapi-plugin-content-manager/admin/src/components/SelectMany/index.js +++ b/packages/strapi-plugin-content-manager/admin/src/components/SelectMany/index.js @@ -18,6 +18,7 @@ function SelectMany({ move, onInputChange, onMenuClose, + onMenuOpen, onMenuScrollToBottom, onRemove, options, @@ -84,6 +85,7 @@ function SelectMany({ onChange={addRelation} onInputChange={onInputChange} onMenuClose={onMenuClose} + onMenuOpen={onMenuOpen} onMenuScrollToBottom={onMenuScrollToBottom} placeholder={placeholder} styles={styles} @@ -140,6 +142,7 @@ SelectMany.propTypes = { name: PropTypes.string.isRequired, onInputChange: PropTypes.func.isRequired, onMenuClose: PropTypes.func.isRequired, + onMenuOpen: PropTypes.func.isRequired, onMenuScrollToBottom: PropTypes.func.isRequired, onRemove: PropTypes.func.isRequired, options: PropTypes.array.isRequired, diff --git a/packages/strapi-plugin-content-manager/admin/src/components/SelectOne/index.js b/packages/strapi-plugin-content-manager/admin/src/components/SelectOne/index.js index 0a3600cae8..e38687ae2d 100644 --- a/packages/strapi-plugin-content-manager/admin/src/components/SelectOne/index.js +++ b/packages/strapi-plugin-content-manager/admin/src/components/SelectOne/index.js @@ -13,6 +13,7 @@ function SelectOne({ onChange, onInputChange, onMenuClose, + onMenuOpen, onMenuScrollToBottom, options, placeholder, @@ -31,6 +32,7 @@ function SelectOne({ onChange={onChange} onInputChange={onInputChange} onMenuClose={onMenuClose} + onMenuOpen={onMenuOpen} onMenuScrollToBottom={onMenuScrollToBottom} placeholder={placeholder} styles={styles} @@ -58,6 +60,7 @@ SelectOne.propTypes = { onChange: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired, onMenuClose: PropTypes.func.isRequired, + onMenuOpen: PropTypes.func.isRequired, onMenuScrollToBottom: PropTypes.func.isRequired, options: PropTypes.array.isRequired, placeholder: PropTypes.node.isRequired, diff --git a/packages/strapi-plugin-content-manager/admin/src/components/SelectWrapper/index.js b/packages/strapi-plugin-content-manager/admin/src/components/SelectWrapper/index.js index 69f555a6f3..02028fe4fd 100644 --- a/packages/strapi-plugin-content-manager/admin/src/components/SelectWrapper/index.js +++ b/packages/strapi-plugin-content-manager/admin/src/components/SelectWrapper/index.js @@ -17,6 +17,11 @@ import Option from './Option'; import { A, BaselineAlignment } from './components'; import { connect, select, styles } from './utils'; +const initialPaginationState = { + _contains: '', + _limit: 20, + _start: 0, +}; function SelectWrapper({ description, editable, @@ -34,17 +39,13 @@ function SelectWrapper({ // Disable the input in case of a polymorphic relation const isMorph = useMemo(() => relationType.toLowerCase().includes('morph'), [relationType]); const { addRelation, modifiedData, moveRelation, onChange, onRemoveRelation } = useDataManager(); - const { pathname } = useLocation(); const value = get(modifiedData, name, null); - const [state, setState] = useState({ - _contains: '', - _limit: 20, - _start: 0, - }); + const [state, setState] = useState(initialPaginationState); const [options, setOptions] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); const filteredOptions = useMemo(() => { return options.filter(option => { @@ -64,6 +65,22 @@ function SelectWrapper({ const { endPoint, containsKey, defaultParams, shouldDisplayRelationLink } = queryInfos; + const isSingle = ['oneWay', 'oneToOne', 'manyToOne', 'oneToManyMorph', 'oneToOneMorph'].includes( + relationType + ); + + const idsToOmit = useMemo(() => { + if (!value) { + return []; + } + + if (isSingle) { + return [value]; + } + + return value.map(val => val.id); + }, [isSingle, value]); + const getData = useCallback( async signal => { // Currently polymorphic relations are not handled @@ -79,14 +96,21 @@ function SelectWrapper({ return; } - const params = { _limit: state._limit, _start: state._start, ...defaultParams }; + setIsLoading(true); + + const params = { _limit: state._limit, ...defaultParams }; if (state._contains) { params[containsKey] = state._contains; } try { - const data = await request(endPoint, { method: 'GET', params, signal }); + const data = await request(endPoint, { + method: 'POST', + params, + signal, + body: { idsToOmit }, + }); const formattedData = data.map(obj => { return { value: obj, label: obj[mainField.name] }; @@ -108,17 +132,16 @@ function SelectWrapper({ // Silent } }, - [ - containsKey, - defaultParams, - endPoint, - isFieldAllowed, isMorph, - mainField.name, + isFieldAllowed, state._limit, - state._start, state._contains, + defaultParams, + containsKey, + endPoint, + idsToOmit, + mainField.name, ] ); @@ -126,12 +149,14 @@ function SelectWrapper({ const abortController = new AbortController(); const { signal } = abortController; - getData(signal); + if (isOpen) { + getData(signal); + } return () => abortController.abort(); - }, [getData]); + }, [getData, isOpen]); - const onInputChange = (inputValue, { action }) => { + const handleInputChange = (inputValue, { action }) => { if (action === 'input-change') { setState(prevState => { if (prevState._contains === inputValue) { @@ -145,13 +170,26 @@ function SelectWrapper({ return inputValue; }; - const onMenuScrollToBottom = () => { - setState(prevState => ({ ...prevState, _start: prevState._start + 20 })); + const handleMenuScrollToBottom = () => { + setState(prevState => ({ ...prevState, _limit: prevState._limit + 20 })); }; - const isSingle = ['oneWay', 'oneToOne', 'manyToOne', 'oneToManyMorph', 'oneToOneMorph'].includes( - relationType - ); + const handleMenuClose = () => { + setState(initialPaginationState); + setIsOpen(false); + }; + + const handleChange = value => { + onChange({ target: { name, value: value ? value.value : value } }); + }; + + const handleAddRelation = value => { + addRelation({ target: { name, value } }); + }; + + const handleMenuOpen = () => { + setIsOpen(true); + }; const to = `/plugins/${pluginId}/collectionType/${targetModel}/${value ? value.id : null}`; @@ -218,9 +256,7 @@ function SelectWrapper({ { - addRelation({ target: { name, value } }); - }} + addRelation={handleAddRelation} components={{ ClearIndicator, DropdownIndicator, IndicatorSeparator, Option }} displayNavigationLink={shouldDisplayRelationLink} id={name} @@ -231,14 +267,11 @@ function SelectWrapper({ move={moveRelation} name={name} options={filteredOptions} - onChange={value => { - onChange({ target: { name, value: value ? value.value : value } }); - }} - onInputChange={onInputChange} - onMenuClose={() => { - setState(prevState => ({ ...prevState, _contains: '' })); - }} - onMenuScrollToBottom={onMenuScrollToBottom} + onChange={handleChange} + onInputChange={handleInputChange} + onMenuClose={handleMenuClose} + onMenuOpen={handleMenuOpen} + onMenuScrollToBottom={handleMenuScrollToBottom} onRemove={onRemoveRelation} placeholder={ isEmpty(placeholder) ? ( diff --git a/packages/strapi-plugin-content-manager/config/routes.json b/packages/strapi-plugin-content-manager/config/routes.json index e4fa0ef15e..0bbad348f6 100644 --- a/packages/strapi-plugin-content-manager/config/routes.json +++ b/packages/strapi-plugin-content-manager/config/routes.json @@ -67,7 +67,7 @@ } }, { - "method": "GET", + "method": "POST", "path": "/relations/:model/:targetField", "handler": "relations.find", "config": { diff --git a/packages/strapi-plugin-content-manager/controllers/__tests__/relations.test.js b/packages/strapi-plugin-content-manager/controllers/__tests__/relations.test.js index 24b9e51292..5a52d858fd 100644 --- a/packages/strapi-plugin-content-manager/controllers/__tests__/relations.test.js +++ b/packages/strapi-plugin-content-manager/controllers/__tests__/relations.test.js @@ -158,5 +158,74 @@ describe('Relations', () => { }, ]); }); + + test('Omit somes ids', async () => { + const result = [ + { + id: 1, + title: 'title1', + secret: 'some secret', + }, + { + id: 2, + title: 'title2', + secret: 'some secret 2', + }, + ]; + const configuration = { + metadatas: { + target: { + edit: { + mainField: 'title', + }, + }, + }, + }; + const assocModel = { uid: 'application::test.test', primaryKey: 'id', attributes: {} }; + const notFound = jest.fn(); + const find = jest.fn(() => Promise.resolve(result)); + const findConfiguration = jest.fn(() => Promise.resolve(configuration)); + const getModelByAssoc = jest.fn(() => assocModel); + const getModel = jest.fn(() => ({ attributes: { target: { model: 'test' } } })); + + global.strapi = { + db: { + getModel, + getModelByAssoc, + }, + plugins: { + 'content-manager': { + services: { + 'content-types': { findConfiguration }, + 'entity-manager': { find }, + }, + }, + }, + }; + + const ctx = createContext( + { + params: { model: 'test', targetField: 'target' }, + body: { idsToOmit: [3, 4] }, + }, + { + notFound, + } + ); + + await relations.find(ctx); + + expect(find).toHaveBeenCalledWith({ _where: { id_nin: [3, 4] } }, assocModel.uid); + expect(ctx.body).toEqual([ + { + id: 1, + title: 'title1', + }, + { + id: 2, + title: 'title2', + }, + ]); + }); }); }); diff --git a/packages/strapi-plugin-content-manager/controllers/relations.js b/packages/strapi-plugin-content-manager/controllers/relations.js index 0b12acd06f..6161f58d8c 100644 --- a/packages/strapi-plugin-content-manager/controllers/relations.js +++ b/packages/strapi-plugin-content-manager/controllers/relations.js @@ -1,6 +1,6 @@ 'use strict'; -const { has, prop, pick } = require('lodash/fp'); +const { has, prop, pick, concat } = require('lodash/fp'); const { PUBLISHED_AT_ATTRIBUTE } = require('strapi-utils').contentTypes.constants; const { getService } = require('../utils'); @@ -9,6 +9,7 @@ module.exports = { async find(ctx) { const { model, targetField } = ctx.params; const { _component, ...query } = ctx.request.query; + const { idsToOmit } = ctx.request.body; if (!targetField) { return ctx.badRequest(); @@ -31,6 +32,11 @@ module.exports = { return ctx.notFound('target.notFound'); } + if (idsToOmit && Array.isArray(idsToOmit)) { + query._where = query._where || {}; + query._where.id_nin = concat(query._where.id_nin || [], idsToOmit); + } + const entityManager = getService('entity-manager'); let entities = []; diff --git a/packages/strapi-plugin-content-manager/oas.yml b/packages/strapi-plugin-content-manager/oas.yml index 337356b7aa..f4a4d453b7 100644 --- a/packages/strapi-plugin-content-manager/oas.yml +++ b/packages/strapi-plugin-content-manager/oas.yml @@ -210,7 +210,7 @@ paths: description: Suggestion if request value is not available # Relationships /content-manager/relations/{model}/{targetField}: - get: + post: tags: - Relational fields description: Fetch list of possible related content @@ -221,12 +221,22 @@ paths: schema: type: string required: true - description: name of the field in the model that holds the relation + description: Name of the field in the model that holds the relation - in: query name: _component schema: type: string description: Component uid if the targetField is in a component + requestBody: + content: + application/json: + schema: + type: object + properties: + idsToOmit: + type: array + items: + $ref: '#/components/schemas/id' responses: 200: description: Returns a list of sanitized entries based of the relational attribute info @@ -245,7 +255,7 @@ paths: - type: integer '[mainField]': type: string - description: value of the mainField of the entry + description: Value of the mainField of the entry published_at: type: date # Collection type diff --git a/packages/strapi-plugin-content-manager/tests/relation-list.test.e2e.js b/packages/strapi-plugin-content-manager/tests/relation-list.test.e2e.js index 450a823aec..532b780e5a 100644 --- a/packages/strapi-plugin-content-manager/tests/relation-list.test.e2e.js +++ b/packages/strapi-plugin-content-manager/tests/relation-list.test.e2e.js @@ -110,7 +110,7 @@ describe('Relation-list route', () => { test('Can get relation-list for products of a shop', async () => { const res = await rq({ - method: 'GET', + method: 'POST', url: '/content-manager/relations/application::shop.shop/products', }); @@ -119,6 +119,19 @@ describe('Relation-list route', () => { expect(res.body[index]).toStrictEqual(pick(['_id', 'id', 'name'], product)); }); }); + + test('Can get relation-list for products of a shop and omit some results', async () => { + const res = await rq({ + method: 'POST', + url: '/content-manager/relations/application::shop.shop/products', + body: { + idsToOmit: [data.products[0].id], + }, + }); + + expect(res.body).toHaveLength(1); + expect(res.body[0]).toStrictEqual(pick(['_id', 'id', 'name'], data.products[1])); + }); }); describe('with draftAndPublish', () => { @@ -145,7 +158,7 @@ describe('Relation-list route', () => { test('Can get relation-list for products of a shop', async () => { const res = await rq({ - method: 'GET', + method: 'POST', url: '/content-manager/relations/application::shop.shop/products', }); @@ -161,5 +174,18 @@ describe('Relation-list route', () => { published_at: null, }); }); + + test('Can get relation-list for products of a shop and omit some results', async () => { + const res = await rq({ + method: 'POST', + url: '/content-manager/relations/application::shop.shop/products', + body: { + idsToOmit: [data.products[1].id], + }, + }); + + expect(res.body).toHaveLength(1); + expect(res.body[0]).toMatchObject(pick(['_id', 'id', 'name'], data.products[0])); + }); }); });