mirror of
https://github.com/strapi/strapi.git
synced 2025-09-26 08:52:26 +00:00
Merge branch 'feature/relational-fields' into relational-fields/many-values-tooltip
This commit is contained in:
commit
86600562ec
@ -10,4 +10,5 @@ module.exports = {
|
|||||||
'<rootDir>/test/',
|
'<rootDir>/test/',
|
||||||
],
|
],
|
||||||
transform: {},
|
transform: {},
|
||||||
|
modulePathIgnorePatterns: ['.cache'],
|
||||||
};
|
};
|
||||||
|
@ -66,7 +66,6 @@
|
|||||||
"policies": []
|
"policies": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/relations/:model/:targetField",
|
"path": "/relations/:model/:targetField",
|
||||||
@ -85,7 +84,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/single-types/:model",
|
"path": "/single-types/:model",
|
||||||
@ -155,7 +153,18 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/collection-types/:model/:id/:targetField",
|
||||||
|
"handler": "collection-types.previewManyRelations",
|
||||||
|
"config": {
|
||||||
|
"policies": [
|
||||||
|
"routing",
|
||||||
|
"admin::isAuthenticatedAdmin",
|
||||||
|
["plugins::content-manager.hasPermissions", ["plugins::content-manager.explorer.read"]]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/collection-types/:model",
|
"path": "/collection-types/:model",
|
||||||
@ -245,7 +254,7 @@
|
|||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/collection-types/:model/actions/bulkDelete",
|
"path": "/collection-types/:model/actions/bulkDelete",
|
||||||
"handler": "collection-types.bulkdDelete",
|
"handler": "collection-types.bulkDelete",
|
||||||
"config": {
|
"config": {
|
||||||
"policies": [
|
"policies": [
|
||||||
"routing",
|
"routing",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { has, pipe } = require('lodash/fp');
|
const { has, pipe, prop, pick } = require('lodash/fp');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getService,
|
getService,
|
||||||
@ -8,7 +8,8 @@ const {
|
|||||||
setCreatorFields,
|
setCreatorFields,
|
||||||
pickWritableAttributes,
|
pickWritableAttributes,
|
||||||
} = require('../utils');
|
} = require('../utils');
|
||||||
const { validateBulkDeleteInput } = require('./validation');
|
const { MANY_RELATIONS } = require('../services/constants');
|
||||||
|
const { validateBulkDeleteInput, validatePagination } = require('./validation');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async find(ctx) {
|
async find(ctx) {
|
||||||
@ -198,7 +199,7 @@ module.exports = {
|
|||||||
ctx.body = permissionChecker.sanitizeOutput(result);
|
ctx.body = permissionChecker.sanitizeOutput(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
async bulkdDelete(ctx) {
|
async bulkDelete(ctx) {
|
||||||
const { userAbility } = ctx.state;
|
const { userAbility } = ctx.state;
|
||||||
const { model } = ctx.params;
|
const { model } = ctx.params;
|
||||||
const { query, body } = ctx.request;
|
const { query, body } = ctx.request;
|
||||||
@ -225,4 +226,59 @@ module.exports = {
|
|||||||
|
|
||||||
ctx.body = results.map(result => permissionChecker.sanitizeOutput(result));
|
ctx.body = results.map(result => permissionChecker.sanitizeOutput(result));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async previewManyRelations(ctx) {
|
||||||
|
const { userAbility } = ctx.state;
|
||||||
|
const { model, id, targetField } = ctx.params;
|
||||||
|
const { pageSize = 10, page = 1 } = ctx.request.query;
|
||||||
|
|
||||||
|
validatePagination({ page, pageSize });
|
||||||
|
|
||||||
|
const contentTypeService = getService('content-types');
|
||||||
|
const entityManager = getService('entity-manager');
|
||||||
|
const permissionChecker = getService('permission-checker').create({ userAbility, model });
|
||||||
|
|
||||||
|
if (permissionChecker.cannot.read()) {
|
||||||
|
return ctx.forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelDef = strapi.getModel(model);
|
||||||
|
const assoc = modelDef.associations.find(a => a.alias === targetField);
|
||||||
|
|
||||||
|
if (!assoc || !MANY_RELATIONS.includes(assoc.nature)) {
|
||||||
|
return ctx.badRequest('Invalid target field');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = await entityManager.findOneWithCreatorRoles(id, model);
|
||||||
|
|
||||||
|
if (!entity) {
|
||||||
|
return ctx.notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionChecker.cannot.read(entity, targetField)) {
|
||||||
|
return ctx.forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
let relationList;
|
||||||
|
if (assoc.nature === 'manyWay') {
|
||||||
|
const populatedEntity = await entityManager.findOne(id, model, [targetField]);
|
||||||
|
const relationsListIds = populatedEntity[targetField].map(prop('id'));
|
||||||
|
relationList = await entityManager.findPage(
|
||||||
|
{ page, pageSize, id_in: relationsListIds },
|
||||||
|
assoc.targetUid
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
relationList = await entityManager.findPage(
|
||||||
|
{ page, pageSize, [assoc.via]: entity.id },
|
||||||
|
assoc.targetUid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { settings } = await contentTypeService.findConfiguration({ uid: assoc.targetUid });
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
pagination: relationList.pagination,
|
||||||
|
results: relationList.results.map(pick(['id', modelDef.primaryKey, settings.mainField])),
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@ -88,6 +88,18 @@ const validateUIDField = (contentTypeUID, field) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validatePagination = ({ page, pageSize }) => {
|
||||||
|
const pageNumber = parseInt(page);
|
||||||
|
const pageSizeNumber = parseInt(pageSize);
|
||||||
|
|
||||||
|
if (isNaN(pageNumber) || pageNumber < 1) {
|
||||||
|
throw strapi.errors.badRequest('invalid pageNumber param');
|
||||||
|
}
|
||||||
|
if (isNaN(pageSizeNumber) || pageSizeNumber < 1) {
|
||||||
|
throw strapi.errors.badRequest('invalid pageSize param');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createModelConfigurationSchema,
|
createModelConfigurationSchema,
|
||||||
validateKind,
|
validateKind,
|
||||||
@ -95,4 +107,5 @@ module.exports = {
|
|||||||
validateGenerateUIDInput,
|
validateGenerateUIDInput,
|
||||||
validateCheckUIDAvailabilityInput,
|
validateCheckUIDAvailabilityInput,
|
||||||
validateUIDField,
|
validateUIDField,
|
||||||
|
validatePagination,
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const MANY_RELATIONS = ['oneToMany', 'manyToMany', 'manyWay'];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MANY_RELATIONS,
|
||||||
|
};
|
@ -44,32 +44,32 @@ module.exports = {
|
|||||||
return assoc(`${CREATED_BY_ATTRIBUTE}.roles`, roles, entity);
|
return assoc(`${CREATED_BY_ATTRIBUTE}.roles`, roles, entity);
|
||||||
},
|
},
|
||||||
|
|
||||||
find(params, model) {
|
find(params, model, populate) {
|
||||||
return strapi.entityService.find({ params }, { model });
|
return strapi.entityService.find({ params, populate }, { model });
|
||||||
},
|
},
|
||||||
|
|
||||||
findPage(params, model) {
|
findPage(params, model, populate) {
|
||||||
return strapi.entityService.findPage({ params }, { model });
|
return strapi.entityService.findPage({ params, populate }, { model });
|
||||||
},
|
},
|
||||||
|
|
||||||
search(params, model) {
|
search(params, model, populate) {
|
||||||
return strapi.entityService.search({ params }, { model });
|
return strapi.entityService.search({ params, populate }, { model });
|
||||||
},
|
},
|
||||||
|
|
||||||
searchPage(params, model) {
|
searchPage(params, model, populate) {
|
||||||
return strapi.entityService.searchPage({ params }, { model });
|
return strapi.entityService.searchPage({ params, populate }, { model });
|
||||||
},
|
},
|
||||||
|
|
||||||
count(params, model) {
|
count(params, model) {
|
||||||
return strapi.entityService.count({ params }, { model });
|
return strapi.entityService.count({ params }, { model });
|
||||||
},
|
},
|
||||||
|
|
||||||
async findOne(id, model) {
|
async findOne(id, model, populate) {
|
||||||
return strapi.entityService.findOne({ params: { id } }, { model });
|
return strapi.entityService.findOne({ params: { id }, populate }, { model });
|
||||||
},
|
},
|
||||||
|
|
||||||
async findOneWithCreatorRoles(id, model) {
|
async findOneWithCreatorRoles(id, model, populate) {
|
||||||
const entity = await this.findOne(id, model);
|
const entity = await this.findOne(id, model, populate);
|
||||||
|
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
return entity;
|
return entity;
|
||||||
|
@ -17,12 +17,12 @@ const createPermissionChecker = ({ userAbility, model }) => {
|
|||||||
|
|
||||||
const toSubject = entity => (entity ? permissionsManager.toSubject(entity, model) : model);
|
const toSubject = entity => (entity ? permissionsManager.toSubject(entity, model) : model);
|
||||||
|
|
||||||
const can = (action, entity) => {
|
const can = (action, entity, field) => {
|
||||||
return userAbility.can(action, toSubject(entity));
|
return userAbility.can(action, toSubject(entity), field);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cannot = (action, entity) => {
|
const cannot = (action, entity, field) => {
|
||||||
return userAbility.cannot(action, toSubject(entity));
|
return userAbility.cannot(action, toSubject(entity), field);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeOutput = (data, { action = ACTIONS.read } = {}) => {
|
const sanitizeOutput = (data, { action = ACTIONS.read } = {}) => {
|
||||||
|
@ -0,0 +1,218 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { prop, difference, map, uniq } = require('lodash/fp');
|
||||||
|
const { registerAndLogin } = require('../../../../test/helpers/auth');
|
||||||
|
const createModelsUtils = require('../../../../test/helpers/models');
|
||||||
|
const { createAuthRequest } = require('../../../../test/helpers/request');
|
||||||
|
|
||||||
|
const toIds = arr => uniq(map(prop('id'))(arr));
|
||||||
|
const getFrom = model => (start, end) => fixtures[model].map(prop('name')).slice(start, end);
|
||||||
|
|
||||||
|
let rq;
|
||||||
|
let modelsUtils;
|
||||||
|
const data = {
|
||||||
|
product: [],
|
||||||
|
category: [],
|
||||||
|
shop: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const productModel = {
|
||||||
|
attributes: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
nature: 'oneToMany',
|
||||||
|
private: false,
|
||||||
|
target: 'application::category.category',
|
||||||
|
targetAttribute: 'product',
|
||||||
|
},
|
||||||
|
shops: {
|
||||||
|
nature: 'manyWay',
|
||||||
|
target: 'application::shop.shop',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: 'product',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryModel = {
|
||||||
|
attributes: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: 'category',
|
||||||
|
};
|
||||||
|
|
||||||
|
const shopModel = {
|
||||||
|
attributes: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: 'shop',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixtures = {
|
||||||
|
shop: [
|
||||||
|
{ name: 'SH.A', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.B', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.C', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.D', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.E', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.F', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.G', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.H', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.I', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.J', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.K', metadata: 'foobar' },
|
||||||
|
{ name: 'SH.L', metadata: 'foobar' },
|
||||||
|
],
|
||||||
|
category: [
|
||||||
|
{ name: 'CT.A' },
|
||||||
|
{ name: 'CT.B' },
|
||||||
|
{ name: 'CT.C' },
|
||||||
|
{ name: 'CT.D' },
|
||||||
|
{ name: 'CT.E' },
|
||||||
|
{ name: 'CT.F' },
|
||||||
|
{ name: 'CT.G' },
|
||||||
|
{ name: 'CT.H' },
|
||||||
|
{ name: 'CT.I' },
|
||||||
|
{ name: 'CT.J' },
|
||||||
|
{ name: 'CT.K' },
|
||||||
|
{ name: 'CT.L' },
|
||||||
|
],
|
||||||
|
product: () => {
|
||||||
|
const { shop, category } = data;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ name: 'PD.A', categories: getFrom('category')(0, 5), shops: getFrom('shop')(0, 12) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return items.map(item => ({
|
||||||
|
...item,
|
||||||
|
categories: item.categories.map(catName => category.find(cat => cat.name === catName).id),
|
||||||
|
shops: item.shops.map(shopName => shop.find(sh => sh.name === shopName).id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUID = modelName => `application::${modelName}.${modelName}`;
|
||||||
|
const getCMPrefixUrl = modelName => `/content-manager/collection-types/${getUID(modelName)}`;
|
||||||
|
|
||||||
|
const createFixtures = async () => {
|
||||||
|
let url = getCMPrefixUrl(shopModel.name);
|
||||||
|
for (const shop of fixtures.shop) {
|
||||||
|
const res = await rq.post(url, { body: shop });
|
||||||
|
data.shop.push(res.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
url = getCMPrefixUrl(categoryModel.name);
|
||||||
|
for (const category of fixtures.category) {
|
||||||
|
const res = await rq.post(url, { body: category });
|
||||||
|
data.category.push(res.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
url = getCMPrefixUrl(productModel.name);
|
||||||
|
for (const product of fixtures.product()) {
|
||||||
|
const res = await rq.post(url, { body: product });
|
||||||
|
data.product.push(res.body);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('x-to-many RF Preview', () => {
|
||||||
|
const cmProductUrl = getCMPrefixUrl(productModel.name);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const token = await registerAndLogin();
|
||||||
|
rq = createAuthRequest(token);
|
||||||
|
|
||||||
|
modelsUtils = createModelsUtils({ rq });
|
||||||
|
await modelsUtils.createContentTypes([shopModel, categoryModel, productModel]);
|
||||||
|
await modelsUtils.cleanupContentTypes(['shop', 'category', 'product']);
|
||||||
|
|
||||||
|
await createFixtures();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await modelsUtils.cleanupContentTypes(['shop', 'category', 'product']);
|
||||||
|
await modelsUtils.deleteContentTypes(['shop', 'category', 'product']);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe('Entity Misc', () => {
|
||||||
|
test.each(['foobar', 'name'])(`Throws if the targeted field is invalid (%s)`, async field => {
|
||||||
|
const product = data.product[0];
|
||||||
|
const { body, statusCode } = await rq.get(`${cmProductUrl}/${product.id}/${field}`);
|
||||||
|
|
||||||
|
expect(statusCode).toBe(400);
|
||||||
|
expect(body.error).toBe('Bad Request');
|
||||||
|
expect(body.message).toBe('Invalid target field');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Throws if the entity does not exist', async () => {
|
||||||
|
const { body, statusCode } = await rq.get(`${cmProductUrl}/${data.shop[1].id}/categories`);
|
||||||
|
|
||||||
|
expect(statusCode).toBe(404);
|
||||||
|
expect(body.error).toBe('Not Found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Relation Nature', () => {
|
||||||
|
test(`Throws if the relation's nature is not a x-to-many`, async () => {
|
||||||
|
const url = getCMPrefixUrl(categoryModel.name);
|
||||||
|
const id = data.category[0].id;
|
||||||
|
|
||||||
|
const { body, statusCode } = await rq.get(`${url}/${id}/product`);
|
||||||
|
|
||||||
|
expect(statusCode).toBe(400);
|
||||||
|
expect(body.error).toBe('Bad Request');
|
||||||
|
expect(body.message).toBe('Invalid target field');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Default Behavior', () => {
|
||||||
|
test.each(['shops', 'categories'])('Should return a preview for the %s field', async field => {
|
||||||
|
const product = data.product[0];
|
||||||
|
|
||||||
|
const { body, statusCode } = await rq.get(`${cmProductUrl}/${product.id}/${field}`);
|
||||||
|
|
||||||
|
const expected = product[field].slice(0, 10);
|
||||||
|
|
||||||
|
expect(statusCode).toBe(200);
|
||||||
|
expect(body.results).toHaveLength(expected.length);
|
||||||
|
expect(difference(toIds(body.results), toIds(product[field]))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pagination', () => {
|
||||||
|
test.each([
|
||||||
|
[1, 10],
|
||||||
|
[2, 10],
|
||||||
|
[5, 1],
|
||||||
|
[4, 2],
|
||||||
|
[1, 100],
|
||||||
|
])('Custom pagination (%s, %s)', async (page, pageSize) => {
|
||||||
|
const product = data.product[0];
|
||||||
|
|
||||||
|
const { body, statusCode } = await rq.get(
|
||||||
|
`${cmProductUrl}/${product.id}/shops?page=${page}&pageSize=${pageSize}`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(statusCode).toBe(200);
|
||||||
|
|
||||||
|
const { pagination, results } = body;
|
||||||
|
|
||||||
|
expect(pagination.page).toBe(page);
|
||||||
|
expect(pagination.pageSize).toBe(pageSize);
|
||||||
|
expect(results).toHaveLength(
|
||||||
|
Math.min(pageSize, product.shops.length - pageSize * (page - 1))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user