Fix relation search (#9156)

* add params to relation route to omit existing relations

* small refacto

* use omit approach

* Fix relation select (Front)

Signed-off-by: HichamELBSI <elabbassih@gmail.com>

* Fix relation select review

Signed-off-by: HichamELBSI <elabbassih@gmail.com>

* update doc aos + remove useless cast to string

Co-authored-by: HichamELBSI <elabbassih@gmail.com>
This commit is contained in:
Pierre Noël 2021-01-25 17:58:18 +01:00 committed by GitHub
parent 7d19888fdd
commit 387ded4a99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 192 additions and 42 deletions

View File

@ -18,6 +18,7 @@ function SelectMany({
move, move,
onInputChange, onInputChange,
onMenuClose, onMenuClose,
onMenuOpen,
onMenuScrollToBottom, onMenuScrollToBottom,
onRemove, onRemove,
options, options,
@ -84,6 +85,7 @@ function SelectMany({
onChange={addRelation} onChange={addRelation}
onInputChange={onInputChange} onInputChange={onInputChange}
onMenuClose={onMenuClose} onMenuClose={onMenuClose}
onMenuOpen={onMenuOpen}
onMenuScrollToBottom={onMenuScrollToBottom} onMenuScrollToBottom={onMenuScrollToBottom}
placeholder={placeholder} placeholder={placeholder}
styles={styles} styles={styles}
@ -140,6 +142,7 @@ SelectMany.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
onInputChange: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired,
onMenuClose: PropTypes.func.isRequired, onMenuClose: PropTypes.func.isRequired,
onMenuOpen: PropTypes.func.isRequired,
onMenuScrollToBottom: PropTypes.func.isRequired, onMenuScrollToBottom: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired, onRemove: PropTypes.func.isRequired,
options: PropTypes.array.isRequired, options: PropTypes.array.isRequired,

View File

@ -13,6 +13,7 @@ function SelectOne({
onChange, onChange,
onInputChange, onInputChange,
onMenuClose, onMenuClose,
onMenuOpen,
onMenuScrollToBottom, onMenuScrollToBottom,
options, options,
placeholder, placeholder,
@ -31,6 +32,7 @@ function SelectOne({
onChange={onChange} onChange={onChange}
onInputChange={onInputChange} onInputChange={onInputChange}
onMenuClose={onMenuClose} onMenuClose={onMenuClose}
onMenuOpen={onMenuOpen}
onMenuScrollToBottom={onMenuScrollToBottom} onMenuScrollToBottom={onMenuScrollToBottom}
placeholder={placeholder} placeholder={placeholder}
styles={styles} styles={styles}
@ -58,6 +60,7 @@ SelectOne.propTypes = {
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired,
onMenuClose: PropTypes.func.isRequired, onMenuClose: PropTypes.func.isRequired,
onMenuOpen: PropTypes.func.isRequired,
onMenuScrollToBottom: PropTypes.func.isRequired, onMenuScrollToBottom: PropTypes.func.isRequired,
options: PropTypes.array.isRequired, options: PropTypes.array.isRequired,
placeholder: PropTypes.node.isRequired, placeholder: PropTypes.node.isRequired,

View File

@ -17,6 +17,11 @@ import Option from './Option';
import { A, BaselineAlignment } from './components'; import { A, BaselineAlignment } from './components';
import { connect, select, styles } from './utils'; import { connect, select, styles } from './utils';
const initialPaginationState = {
_contains: '',
_limit: 20,
_start: 0,
};
function SelectWrapper({ function SelectWrapper({
description, description,
editable, editable,
@ -34,17 +39,13 @@ function SelectWrapper({
// Disable the input in case of a polymorphic relation // Disable the input in case of a polymorphic relation
const isMorph = useMemo(() => relationType.toLowerCase().includes('morph'), [relationType]); const isMorph = useMemo(() => relationType.toLowerCase().includes('morph'), [relationType]);
const { addRelation, modifiedData, moveRelation, onChange, onRemoveRelation } = useDataManager(); const { addRelation, modifiedData, moveRelation, onChange, onRemoveRelation } = useDataManager();
const { pathname } = useLocation(); const { pathname } = useLocation();
const value = get(modifiedData, name, null); const value = get(modifiedData, name, null);
const [state, setState] = useState({ const [state, setState] = useState(initialPaginationState);
_contains: '',
_limit: 20,
_start: 0,
});
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const filteredOptions = useMemo(() => { const filteredOptions = useMemo(() => {
return options.filter(option => { return options.filter(option => {
@ -64,6 +65,22 @@ function SelectWrapper({
const { endPoint, containsKey, defaultParams, shouldDisplayRelationLink } = queryInfos; 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( const getData = useCallback(
async signal => { async signal => {
// Currently polymorphic relations are not handled // Currently polymorphic relations are not handled
@ -79,14 +96,21 @@ function SelectWrapper({
return; return;
} }
const params = { _limit: state._limit, _start: state._start, ...defaultParams }; setIsLoading(true);
const params = { _limit: state._limit, ...defaultParams };
if (state._contains) { if (state._contains) {
params[containsKey] = state._contains; params[containsKey] = state._contains;
} }
try { 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 => { const formattedData = data.map(obj => {
return { value: obj, label: obj[mainField.name] }; return { value: obj, label: obj[mainField.name] };
@ -108,17 +132,16 @@ function SelectWrapper({
// Silent // Silent
} }
}, },
[ [
containsKey,
defaultParams,
endPoint,
isFieldAllowed,
isMorph, isMorph,
mainField.name, isFieldAllowed,
state._limit, state._limit,
state._start,
state._contains, state._contains,
defaultParams,
containsKey,
endPoint,
idsToOmit,
mainField.name,
] ]
); );
@ -126,12 +149,14 @@ function SelectWrapper({
const abortController = new AbortController(); const abortController = new AbortController();
const { signal } = abortController; const { signal } = abortController;
if (isOpen) {
getData(signal); getData(signal);
}
return () => abortController.abort(); return () => abortController.abort();
}, [getData]); }, [getData, isOpen]);
const onInputChange = (inputValue, { action }) => { const handleInputChange = (inputValue, { action }) => {
if (action === 'input-change') { if (action === 'input-change') {
setState(prevState => { setState(prevState => {
if (prevState._contains === inputValue) { if (prevState._contains === inputValue) {
@ -145,13 +170,26 @@ function SelectWrapper({
return inputValue; return inputValue;
}; };
const onMenuScrollToBottom = () => { const handleMenuScrollToBottom = () => {
setState(prevState => ({ ...prevState, _start: prevState._start + 20 })); setState(prevState => ({ ...prevState, _limit: prevState._limit + 20 }));
}; };
const isSingle = ['oneWay', 'oneToOne', 'manyToOne', 'oneToManyMorph', 'oneToOneMorph'].includes( const handleMenuClose = () => {
relationType 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}`; const to = `/plugins/${pluginId}/collectionType/${targetModel}/${value ? value.id : null}`;
@ -218,9 +256,7 @@ function SelectWrapper({
<BaselineAlignment /> <BaselineAlignment />
<Component <Component
addRelation={value => { addRelation={handleAddRelation}
addRelation({ target: { name, value } });
}}
components={{ ClearIndicator, DropdownIndicator, IndicatorSeparator, Option }} components={{ ClearIndicator, DropdownIndicator, IndicatorSeparator, Option }}
displayNavigationLink={shouldDisplayRelationLink} displayNavigationLink={shouldDisplayRelationLink}
id={name} id={name}
@ -231,14 +267,11 @@ function SelectWrapper({
move={moveRelation} move={moveRelation}
name={name} name={name}
options={filteredOptions} options={filteredOptions}
onChange={value => { onChange={handleChange}
onChange({ target: { name, value: value ? value.value : value } }); onInputChange={handleInputChange}
}} onMenuClose={handleMenuClose}
onInputChange={onInputChange} onMenuOpen={handleMenuOpen}
onMenuClose={() => { onMenuScrollToBottom={handleMenuScrollToBottom}
setState(prevState => ({ ...prevState, _contains: '' }));
}}
onMenuScrollToBottom={onMenuScrollToBottom}
onRemove={onRemoveRelation} onRemove={onRemoveRelation}
placeholder={ placeholder={
isEmpty(placeholder) ? ( isEmpty(placeholder) ? (

View File

@ -67,7 +67,7 @@
} }
}, },
{ {
"method": "GET", "method": "POST",
"path": "/relations/:model/:targetField", "path": "/relations/:model/:targetField",
"handler": "relations.find", "handler": "relations.find",
"config": { "config": {

View File

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

View File

@ -1,6 +1,6 @@
'use strict'; '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 { PUBLISHED_AT_ATTRIBUTE } = require('strapi-utils').contentTypes.constants;
const { getService } = require('../utils'); const { getService } = require('../utils');
@ -9,6 +9,7 @@ module.exports = {
async find(ctx) { async find(ctx) {
const { model, targetField } = ctx.params; const { model, targetField } = ctx.params;
const { _component, ...query } = ctx.request.query; const { _component, ...query } = ctx.request.query;
const { idsToOmit } = ctx.request.body;
if (!targetField) { if (!targetField) {
return ctx.badRequest(); return ctx.badRequest();
@ -31,6 +32,11 @@ module.exports = {
return ctx.notFound('target.notFound'); 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'); const entityManager = getService('entity-manager');
let entities = []; let entities = [];

View File

@ -210,7 +210,7 @@ paths:
description: Suggestion if request value is not available description: Suggestion if request value is not available
# Relationships # Relationships
/content-manager/relations/{model}/{targetField}: /content-manager/relations/{model}/{targetField}:
get: post:
tags: tags:
- Relational fields - Relational fields
description: Fetch list of possible related content description: Fetch list of possible related content
@ -221,12 +221,22 @@ paths:
schema: schema:
type: string type: string
required: true 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 - in: query
name: _component name: _component
schema: schema:
type: string type: string
description: Component uid if the targetField is in a component 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: responses:
200: 200:
description: Returns a list of sanitized entries based of the relational attribute info description: Returns a list of sanitized entries based of the relational attribute info
@ -245,7 +255,7 @@ paths:
- type: integer - type: integer
'[mainField]': '[mainField]':
type: string type: string
description: value of the mainField of the entry description: Value of the mainField of the entry
published_at: published_at:
type: date type: date
# Collection type # Collection type

View File

@ -110,7 +110,7 @@ describe('Relation-list route', () => {
test('Can get relation-list for products of a shop', async () => { test('Can get relation-list for products of a shop', async () => {
const res = await rq({ const res = await rq({
method: 'GET', method: 'POST',
url: '/content-manager/relations/application::shop.shop/products', 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)); 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', () => { describe('with draftAndPublish', () => {
@ -145,7 +158,7 @@ describe('Relation-list route', () => {
test('Can get relation-list for products of a shop', async () => { test('Can get relation-list for products of a shop', async () => {
const res = await rq({ const res = await rq({
method: 'GET', method: 'POST',
url: '/content-manager/relations/application::shop.shop/products', url: '/content-manager/relations/application::shop.shop/products',
}); });
@ -161,5 +174,18 @@ describe('Relation-list route', () => {
published_at: null, 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]));
});
}); });
}); });