mirror of
https://github.com/strapi/strapi.git
synced 2025-09-16 12:02:41 +00:00
chore(content-releases): Add entries to content-releases' actions (#18918)
* chore(content-releases): add entries to content releases actions * chore(content-releases): add entries relation to findMany content-release's actions * chore(content-releases): improve findOne with actions code * chore(Content-releases): remi feedback * chore(content-releases): fix getReleaseActions response type * chore(content-releases): change findOne and findActions endpoints * chore(content-releases): fix error in release's tests * chore(content-releases): use queryBuilder from strapi.db * chore(content-releases): use queryBuilder from strapi.db
This commit is contained in:
parent
4f6722c6d4
commit
9b4c03b10b
@ -1,5 +1,33 @@
|
||||
import releaseActionController from '../release-action';
|
||||
|
||||
const mockSanitizedQueryRead = jest.fn().mockResolvedValue({});
|
||||
const mockFindActions = jest.fn().mockResolvedValue({ results: [], pagination: {} });
|
||||
const mockSanitizeOutput = jest.fn((entry: { id: number; name: string }) => ({ id: entry.id }));
|
||||
|
||||
jest.mock('../../utils', () => ({
|
||||
getService: jest.fn(() => ({
|
||||
create: jest.fn(),
|
||||
findActions: mockFindActions,
|
||||
findReleaseContentTypesMainFields: jest.fn(() => ({
|
||||
'api::contentTypeA.contentTypeA': {
|
||||
mainField: 'name',
|
||||
},
|
||||
'api::contentTypeB.contentTypeB': {
|
||||
mainField: 'name',
|
||||
},
|
||||
})),
|
||||
})),
|
||||
getAllowedContentTypes: jest
|
||||
.fn()
|
||||
.mockReturnValue(['api::contentTypeA.contentTypeA', 'api::contentTypeB.contentTypeB']),
|
||||
getPermissionsChecker: jest.fn(() => ({
|
||||
sanitizedQuery: {
|
||||
read: mockSanitizedQueryRead,
|
||||
},
|
||||
sanitizeOutput: mockSanitizeOutput,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Release Action controller', () => {
|
||||
describe('create', () => {
|
||||
beforeEach(() => {
|
||||
@ -66,4 +94,45 @@ describe('Release Action controller', () => {
|
||||
expect(() => releaseActionController.create(ctx)).rejects.toThrow('type is a required field');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMany', () => {
|
||||
const ctx = {
|
||||
state: {
|
||||
userAbility: {
|
||||
can: jest.fn(),
|
||||
cannot: jest.fn(),
|
||||
},
|
||||
},
|
||||
params: {
|
||||
releaseId: 1,
|
||||
},
|
||||
};
|
||||
|
||||
it('should call sanitizedQueryRead once for each contentType', async () => {
|
||||
// @ts-expect-error Ignore missing properties
|
||||
await releaseActionController.findMany(ctx);
|
||||
|
||||
expect(mockSanitizedQueryRead).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should call findActions with the right params', async () => {
|
||||
// @ts-expect-error Ignore missing properties
|
||||
await releaseActionController.findMany(ctx);
|
||||
|
||||
expect(mockFindActions).toHaveBeenCalledWith(
|
||||
1,
|
||||
['api::contentTypeA.contentTypeA', 'api::contentTypeB.contentTypeB'],
|
||||
{
|
||||
populate: {
|
||||
entry: {
|
||||
on: {
|
||||
'api::contentTypeA.contentTypeA': {},
|
||||
'api::contentTypeB.contentTypeB': {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,10 +1,25 @@
|
||||
import releaseController from '../release';
|
||||
|
||||
const mockFindPage = jest.fn();
|
||||
const mockFindMany = jest.fn();
|
||||
const mockCountActions = jest.fn();
|
||||
|
||||
jest.mock('../../utils', () => ({
|
||||
getService: jest.fn(() => ({
|
||||
findOne: jest.fn(() => ({ id: 1 })),
|
||||
findPage: mockFindPage,
|
||||
findMany: mockFindMany,
|
||||
countActions: mockCountActions,
|
||||
findReleaseContentTypesMainFields: jest.fn(),
|
||||
})),
|
||||
getAllowedContentTypes: jest.fn(() => ['contentTypeA', 'contentTypeB']),
|
||||
}));
|
||||
|
||||
describe('Release controller', () => {
|
||||
describe('findMany', () => {
|
||||
it('should call findPage', async () => {
|
||||
const findPage = jest.fn().mockResolvedValue({ results: [], pagination: {} });
|
||||
const findMany = jest.fn().mockResolvedValue([]);
|
||||
mockFindPage.mockResolvedValue({ results: [], pagination: {} });
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
const userAbility = {
|
||||
can: jest.fn(),
|
||||
};
|
||||
@ -30,28 +45,17 @@ describe('Release controller', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
// @ts-expect-error Ignore missing properties
|
||||
'content-releases': {
|
||||
services: {
|
||||
release: {
|
||||
findPage,
|
||||
findMany,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error partial context
|
||||
await releaseController.findMany(ctx);
|
||||
|
||||
expect(findPage).toHaveBeenCalled();
|
||||
expect(mockFindPage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call findMany', async () => {
|
||||
const findPage = jest.fn().mockResolvedValue({ results: [], pagination: {} });
|
||||
const findMany = jest.fn().mockResolvedValue([]);
|
||||
mockFindPage.mockResolvedValue({ results: [], pagination: {} });
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
const userAbility = {
|
||||
can: jest.fn(),
|
||||
};
|
||||
@ -74,23 +78,12 @@ describe('Release controller', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
// @ts-expect-error Ignore missing properties
|
||||
'content-releases': {
|
||||
services: {
|
||||
release: {
|
||||
findPage,
|
||||
findMany,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error partial context
|
||||
await releaseController.findMany(ctx);
|
||||
|
||||
expect(findMany).toHaveBeenCalled();
|
||||
expect(mockFindMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('create', () => {
|
||||
@ -154,4 +147,67 @@ describe('Release controller', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
global.strapi = {
|
||||
...global.strapi,
|
||||
plugins: {
|
||||
// @ts-expect-error incomplete plugin
|
||||
'content-manager': {
|
||||
services: {
|
||||
'content-types': {
|
||||
findConfiguration: () => ({
|
||||
settings: {
|
||||
mainField: 'name',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
state: {
|
||||
userAbility: {
|
||||
can: jest.fn(() => true),
|
||||
},
|
||||
},
|
||||
params: {
|
||||
id: 1,
|
||||
},
|
||||
user: {},
|
||||
body: {
|
||||
data: {
|
||||
actions: {
|
||||
meta: {
|
||||
total: 0,
|
||||
totalHidden: 0,
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('throws an error if the release does not exists', async () => {
|
||||
// @ts-expect-error partial context
|
||||
expect(() => releaseController.findOne(ctx).rejects.toThrow('Release not found for id: 1'));
|
||||
});
|
||||
|
||||
it('return the right meta object', async () => {
|
||||
// We mock the count all actions
|
||||
mockCountActions.mockResolvedValueOnce(2);
|
||||
|
||||
// We mock the count hidden actions
|
||||
mockCountActions.mockResolvedValueOnce(1);
|
||||
|
||||
// @ts-expect-error partial context
|
||||
await releaseController.findOne(ctx);
|
||||
expect(ctx.body.data.actions.meta).toEqual({
|
||||
total: 2,
|
||||
totalHidden: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,13 @@
|
||||
import type Koa from 'koa';
|
||||
import { UID } from '@strapi/types';
|
||||
import { mapAsync } from '@strapi/utils';
|
||||
import { validateReleaseAction } from './validation/release-action';
|
||||
import type { CreateReleaseAction } from '../../../shared/contracts/release-actions';
|
||||
import { getService } from '../utils';
|
||||
import type {
|
||||
CreateReleaseAction,
|
||||
GetReleaseActions,
|
||||
ReleaseAction,
|
||||
} from '../../../shared/contracts/release-actions';
|
||||
import { getAllowedContentTypes, getService, getPermissionsChecker } from '../utils';
|
||||
|
||||
const releaseActionController = {
|
||||
async create(ctx: Koa.Context) {
|
||||
@ -17,6 +23,80 @@ const releaseActionController = {
|
||||
data: releaseAction,
|
||||
};
|
||||
},
|
||||
async findMany(ctx: Koa.Context) {
|
||||
const releaseId: GetReleaseActions.Request['params']['releaseId'] = ctx.params.releaseId;
|
||||
const allowedContentTypes = getAllowedContentTypes({
|
||||
strapi,
|
||||
userAbility: ctx.state.userAbility,
|
||||
});
|
||||
|
||||
// We create an object with the permissionsChecker for each contentType, then we can reuse it for sanitization
|
||||
const permissionsChecker: Record<UID.ContentType, any> = {};
|
||||
// We create a populate object for polymorphic relations, so we considered custom conditions on permissions
|
||||
const morphSanitizedPopulate: Record<UID.ContentType, any> = {};
|
||||
|
||||
for (const contentTypeUid of allowedContentTypes) {
|
||||
const permissionChecker = await getPermissionsChecker({
|
||||
strapi,
|
||||
userAbility: ctx.state.userAbility,
|
||||
model: contentTypeUid,
|
||||
});
|
||||
permissionsChecker[contentTypeUid] = permissionChecker;
|
||||
morphSanitizedPopulate[contentTypeUid] = await permissionChecker.sanitizedQuery.read({});
|
||||
}
|
||||
|
||||
const releaseService = getService('release', { strapi });
|
||||
|
||||
const { results, pagination } = await releaseService.findActions(
|
||||
releaseId,
|
||||
allowedContentTypes,
|
||||
{
|
||||
populate: {
|
||||
entry: {
|
||||
on: morphSanitizedPopulate,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const contentTypesMainFields = await releaseService.findReleaseContentTypesMainFields(
|
||||
releaseId
|
||||
);
|
||||
// We loop over all the contentTypes mainfields to sanitize each mainField
|
||||
// By default, if user doesn't have permission to read the field, we return null as fallback
|
||||
for (const contentTypeUid of Object.keys(contentTypesMainFields)) {
|
||||
if (
|
||||
ctx.state.userAbility.cannot(
|
||||
'plugin::content-manager.explorer.read',
|
||||
contentTypeUid,
|
||||
contentTypesMainFields[contentTypeUid].mainField
|
||||
)
|
||||
) {
|
||||
contentTypesMainFields[contentTypeUid].mainField = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Because this is a morphTo relation, we need to sanitize each entry separately based on its contentType
|
||||
const sanitizedResults = await mapAsync(results, async (action: ReleaseAction) => {
|
||||
const mainField = contentTypesMainFields[action.contentType].mainField;
|
||||
|
||||
return {
|
||||
...action,
|
||||
entry: action.entry && {
|
||||
id: action.entry.id,
|
||||
mainField: mainField ? action.entry[mainField] : null,
|
||||
locale: action.entry.locale,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: sanitizedResults,
|
||||
meta: {
|
||||
pagination,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default releaseActionController;
|
||||
|
@ -9,7 +9,7 @@ import type {
|
||||
Release,
|
||||
} from '../../../shared/contracts/releases';
|
||||
import type { UserInfo } from '../../../shared/types';
|
||||
import { getService } from '../utils';
|
||||
import { getAllowedContentTypes, getService } from '../utils';
|
||||
|
||||
type ReleaseWithPopulatedActions = Release & { actions: { count: number } };
|
||||
|
||||
@ -56,22 +56,39 @@ const releaseController = {
|
||||
async findOne(ctx: Koa.Context) {
|
||||
const id: GetRelease.Request['params']['id'] = ctx.params.id;
|
||||
|
||||
const result = (await getService('release', { strapi }).findOne(
|
||||
Number(id)
|
||||
)) as ReleaseWithPopulatedActions | null;
|
||||
const releaseService = getService('release', { strapi });
|
||||
|
||||
if (!result) {
|
||||
const allowedContentTypes = getAllowedContentTypes({
|
||||
strapi,
|
||||
userAbility: ctx.state.userAbility,
|
||||
});
|
||||
|
||||
const release = await releaseService.findOne(id);
|
||||
const total = await releaseService.countActions({
|
||||
filters: {
|
||||
release: id,
|
||||
},
|
||||
});
|
||||
const totalHidden = await releaseService.countActions({
|
||||
filters: {
|
||||
release: id,
|
||||
contentType: {
|
||||
$notIn: allowedContentTypes,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!release) {
|
||||
throw new errors.NotFoundError(`Release not found for id: ${id}`);
|
||||
}
|
||||
|
||||
const { actions, ...release } = result;
|
||||
|
||||
// Format the data object
|
||||
const data = {
|
||||
...release,
|
||||
actions: {
|
||||
meta: {
|
||||
count: actions.count,
|
||||
total,
|
||||
totalHidden,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -17,5 +17,21 @@ export default {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/:releaseId/actions',
|
||||
handler: 'release-action.findMany',
|
||||
config: {
|
||||
policies: [
|
||||
'admin::isAuthenticatedAdmin',
|
||||
{
|
||||
name: 'admin::hasPermissions',
|
||||
config: {
|
||||
actions: ['plugin::content-releases.read']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
};
|
||||
|
@ -42,4 +42,20 @@ describe('release service', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActions', () => {
|
||||
it('throws an error if the release does not exist', () => {
|
||||
const strapiMock = {
|
||||
...baseStrapiMock,
|
||||
entityService: {
|
||||
findOne: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error Ignore missing properties
|
||||
const releaseService = createReleaseService({ strapi: strapiMock });
|
||||
|
||||
expect(() => releaseService.findActions(1, ['api::contentType.contentType'], {})).rejects.toThrow('No release found for id 1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,13 +1,17 @@
|
||||
import { setCreatorFields, errors } from '@strapi/utils';
|
||||
import type { LoadedStrapi } from '@strapi/types';
|
||||
import type { LoadedStrapi, Common, EntityService, UID } from '@strapi/types';
|
||||
import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants';
|
||||
import type {
|
||||
GetReleases,
|
||||
CreateRelease,
|
||||
UpdateRelease,
|
||||
GetRelease,
|
||||
Release,
|
||||
} from '../../../shared/contracts/releases';
|
||||
import type { CreateReleaseAction } from '../../../shared/contracts/release-actions';
|
||||
import type {
|
||||
CreateReleaseAction,
|
||||
GetReleaseActions,
|
||||
} from '../../../shared/contracts/release-actions';
|
||||
import type { UserInfo } from '../../../shared/types';
|
||||
import { getService } from '../utils';
|
||||
|
||||
@ -30,6 +34,9 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
|
||||
},
|
||||
});
|
||||
},
|
||||
findOne(id: GetRelease.Request['params']['id'], query = {}) {
|
||||
return strapi.entityService.findOne(RELEASE_MODEL_UID, id, query);
|
||||
},
|
||||
findMany(query?: GetReleases.Request['query']) {
|
||||
return strapi.entityService.findMany(RELEASE_MODEL_UID, {
|
||||
...query,
|
||||
@ -41,16 +48,6 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
|
||||
},
|
||||
});
|
||||
},
|
||||
findOne(id: GetRelease.Request['params']['id']) {
|
||||
return strapi.entityService.findOne(RELEASE_MODEL_UID, id, {
|
||||
populate: {
|
||||
actions: {
|
||||
// @ts-expect-error TS error on populate, is not considering count
|
||||
count: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
async update(
|
||||
id: number,
|
||||
releaseData: UpdateRelease.Request['body'],
|
||||
@ -102,6 +99,67 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
|
||||
populate: { release: { fields: ['id'] }, entry: { fields: ['id'] } },
|
||||
});
|
||||
},
|
||||
async findActions(
|
||||
releaseId: GetReleaseActions.Request['params']['releaseId'],
|
||||
contentTypes: Common.UID.ContentType[],
|
||||
query?: GetReleaseActions.Request['query']
|
||||
) {
|
||||
const result = await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId);
|
||||
|
||||
if (!result) {
|
||||
throw new errors.NotFoundError(`No release found for id ${releaseId}`);
|
||||
}
|
||||
|
||||
return strapi.entityService.findPage(RELEASE_ACTION_MODEL_UID, {
|
||||
...query,
|
||||
filters: {
|
||||
release: releaseId,
|
||||
contentType: {
|
||||
$in: contentTypes,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
async countActions(query: EntityService.Params.Pick<typeof RELEASE_ACTION_MODEL_UID, 'filters'>) {
|
||||
return strapi.entityService.count(RELEASE_ACTION_MODEL_UID, query);
|
||||
},
|
||||
async findReleaseContentTypesMainFields(releaseId: Release['id']) {
|
||||
const contentTypesFromReleaseActions: { contentType: UID.ContentType }[] = await strapi.db
|
||||
.queryBuilder(RELEASE_ACTION_MODEL_UID)
|
||||
.select('content_type')
|
||||
.where({
|
||||
$and: [
|
||||
{
|
||||
release: releaseId,
|
||||
},
|
||||
],
|
||||
})
|
||||
.groupBy('content_type')
|
||||
.execute();
|
||||
|
||||
const contentTypesUids = contentTypesFromReleaseActions.map(
|
||||
({ contentType: contentTypeUid }) => contentTypeUid
|
||||
);
|
||||
|
||||
const contentManagerContentTypeService = strapi
|
||||
.plugin('content-manager')
|
||||
.service('content-types');
|
||||
const contentTypesMeta: Record<UID.ContentType, { mainField: string }> = {};
|
||||
|
||||
for (const contentTypeUid of contentTypesUids) {
|
||||
const contentTypeConfig = await contentManagerContentTypeService.findConfiguration({
|
||||
uid: contentTypeUid,
|
||||
});
|
||||
|
||||
if (contentTypeConfig) {
|
||||
contentTypesMeta[contentTypeUid] = {
|
||||
mainField: contentTypeConfig.settings.mainField,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return contentTypesMeta;
|
||||
},
|
||||
});
|
||||
|
||||
export default createReleaseService;
|
||||
|
@ -1,6 +1,35 @@
|
||||
import type { LoadedStrapi, UID } from '@strapi/types';
|
||||
|
||||
export const getService = (
|
||||
name: 'release' | 'release-validation',
|
||||
{ strapi } = { strapi: global.strapi }
|
||||
) => {
|
||||
return strapi.plugin('content-releases').service(name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the content types that have draft and publish enabled and that the user can read
|
||||
*/
|
||||
export const getAllowedContentTypes = ({ strapi, userAbility }: { strapi: LoadedStrapi, userAbility: any }) => {
|
||||
const { contentTypes } = strapi;
|
||||
const contentTypesWithDraftAndPublish = (Object.keys(contentTypes) as UID.ContentType[]).filter(
|
||||
(contentTypeUid) => contentTypes[contentTypeUid].options?.draftAndPublish
|
||||
);
|
||||
const allowedContentTypes = contentTypesWithDraftAndPublish.filter(
|
||||
(contentTypeUid) => {
|
||||
return userAbility.can('plugin::content-manager.explorer.read', contentTypeUid);
|
||||
}
|
||||
);
|
||||
|
||||
return allowedContentTypes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the permissions checker for a given content type using the permission checker from content-manager
|
||||
*/
|
||||
export const getPermissionsChecker = ({ strapi, userAbility, model }: { strapi: LoadedStrapi, userAbility: any, model: UID.ContentType }) => {
|
||||
return strapi
|
||||
.plugin('content-manager')
|
||||
.service('permission-checker')
|
||||
.create({ userAbility, model });
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Attribute, Common } from '@strapi/types';
|
||||
import type { Release } from './releases';
|
||||
import type { Release, Pagination } from './releases';
|
||||
import type { Entity } from '../types';
|
||||
|
||||
import type { errors } from '@strapi/utils';
|
||||
@ -9,7 +9,7 @@ type ReleaseActionEntry = Entity & {
|
||||
[key: string]: Attribute.Any;
|
||||
};
|
||||
|
||||
export interface ReleaseAction {
|
||||
export interface ReleaseAction extends Entity {
|
||||
type: 'publish' | 'unpublish';
|
||||
entry: ReleaseActionEntry;
|
||||
contentType: Common.UID.ContentType;
|
||||
@ -38,3 +38,23 @@ export declare namespace CreateReleaseAction {
|
||||
error?: errors.ApplicationError | errors.ValidationError | errors.NotFoundError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /content-releases/:id/actions - Get all release actions
|
||||
*/
|
||||
export declare namespace GetReleaseActions {
|
||||
export interface Request {
|
||||
params: {
|
||||
releaseId: Release['id'];
|
||||
};
|
||||
query?: Partial<Pick<Pagination, 'page' | 'pageSize'>>;
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
data: ReleaseAction[];
|
||||
meta: {
|
||||
pagination: Pagination;
|
||||
};
|
||||
error?: errors.ApplicationError | errors.NotFoundError;
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import type { Entity } from '../types';
|
||||
import type { ReleaseAction } from './release-actions';
|
||||
import type { UserInfo } from '../types';
|
||||
import { errors } from '@strapi/utils';
|
||||
import { UID } from '@strapi/types';
|
||||
|
||||
export interface Release extends Entity {
|
||||
name: string;
|
||||
@ -9,7 +10,7 @@ export interface Release extends Entity {
|
||||
actions: ReleaseAction[];
|
||||
}
|
||||
|
||||
type Pagination = {
|
||||
export type Pagination = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
|
Loading…
x
Reference in New Issue
Block a user