mirror of
https://github.com/strapi/strapi.git
synced 2025-09-04 14:23:03 +00:00
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:
parent
7d19888fdd
commit
387ded4a99
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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) ? (
|
||||||
|
@ -67,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "POST",
|
||||||
"path": "/relations/:model/:targetField",
|
"path": "/relations/:model/:targetField",
|
||||||
"handler": "relations.find",
|
"handler": "relations.find",
|
||||||
"config": {
|
"config": {
|
||||||
|
@ -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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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 = [];
|
||||||
|
@ -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
|
||||||
|
@ -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]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user