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,
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,

View File

@ -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,

View File

@ -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;
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({
<BaselineAlignment />
<Component
addRelation={value => {
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) ? (

View File

@ -67,7 +67,7 @@
}
},
{
"method": "GET",
"method": "POST",
"path": "/relations/:model/:targetField",
"handler": "relations.find",
"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';
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 = [];

View File

@ -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

View File

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