Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: releases settings #20354

Merged
merged 7 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import release from './release';
import releaseAction from './release-action';
import settings from './settings';

export const controllers = { release, 'release-action': releaseAction };
export const controllers = {
release,
'release-action': releaseAction,
settings,
};
35 changes: 35 additions & 0 deletions packages/core/content-releases/server/src/controllers/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Used to store user configurations related to releases.
* E.g the default timezone for the release schedule.
*/
import type Koa from 'koa';

import type { GetSettings, UpdateSettings, Settings } from '../../../shared/contracts/settings';
import { getService } from '../utils';
import { validateSettings } from './validation/settings';

const settingsController = {
async find(ctx: Koa.Context) {
// Get settings
const settingsService = getService('settings', { strapi });
const settings = await settingsService.find();

// Response
ctx.body = { data: settings } satisfies GetSettings.Response;
},

async update(ctx: Koa.Context) {
// Data validation
const settingsBody = ctx.request.body;
const settings = (await validateSettings(settingsBody)) as Settings;

// Update
const settingsService = getService('settings', { strapi });
const updatedSettings = await settingsService.update({ settings });

// Response
ctx.body = { data: updatedSettings } satisfies UpdateSettings.Response;
},
};

export default settingsController;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { validateYupSchema } from '@strapi/utils';
import { SETTINGS_SCHEMA } from '../../../../shared/validation-schemas';

export const validateSettings = validateYupSchema(SETTINGS_SCHEMA);
2 changes: 2 additions & 0 deletions packages/core/content-releases/server/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import release from './release';
import releaseAction from './release-action';
import settings from './settings';

export const routes = {
settings,
release,
'release-action': releaseAction,
};
38 changes: 38 additions & 0 deletions packages/core/content-releases/server/src/routes/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export default {
type: 'admin',
routes: [
{
method: 'GET',
path: '/settings',
handler: 'settings.find',
config: {
policies: [
'admin::isAuthenticatedAdmin',
// {
// name: 'admin::hasPermissions',
// config: {
// actions: ['plugin::content-releases.settings-read'],
// },
// },
remidej marked this conversation as resolved.
Show resolved Hide resolved
],
},
},

{
method: 'PUT',
path: '/settings',
handler: 'settings.update',
config: {
policies: [
'admin::isAuthenticatedAdmin',
// {
// name: 'admin::hasPermissions',
// config: {
// actions: ['plugin::content-releases.settings-update'],
// },
// },
],
},
},
],
};
2 changes: 2 additions & 0 deletions packages/core/content-releases/server/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import release from './release';
import releaseValidation from './validation';
import scheduling from './scheduling';
import settings from './settings';

export const services = {
release,
'release-validation': releaseValidation,
scheduling,
settings,
};
32 changes: 32 additions & 0 deletions packages/core/content-releases/server/src/services/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Core } from '@strapi/types';

import type { Settings } from '../../../shared/contracts/settings';

const DEFAULT_SETTINGS = {
defaultTimezone: null,
} satisfies Settings;

const createSettingsService = ({ strapi }: { strapi: Core.Strapi }) => {
const getStore = async () => strapi.store({ type: 'core', name: 'content-releases' });

return {
async update({ settings }: { settings: Settings }): Promise<Settings> {
const store = await getStore();
store.set({ key: 'settings', value: settings });
return settings;
},
async find(): Promise<Settings> {
const store = await getStore();
const settings = (await store.get({ key: 'settings' })) as Settings | undefined;

return {
...DEFAULT_SETTINGS,
...(settings || {}),
};
},
};
};

export type SettingsService = ReturnType<typeof createSettingsService>;

export default createSettingsService;
16 changes: 13 additions & 3 deletions packages/core/content-releases/server/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import type { UID, Data, Core } from '@strapi/types';
import type { SettingsService } from '../services/settings';

export const getService = (
name: 'release' | 'release-validation' | 'scheduling' | 'release-action' | 'event-manager',
type Services = {
release: any;
'release-validation': any;
scheduling: any;
'release-action': any;
'event-manager': any;
settings: SettingsService;
};
Comment on lines +4 to +11
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Feranchz I updated the getService method to be typed, but we might as well want to add the rest of services type. Do you prefer to fill them in another PR?


export const getService = <TName extends keyof Services>(
name: TName,
{ strapi }: { strapi: Core.Strapi }
) => {
): Services[TName] => {
return strapi.plugin('content-releases').service(name);
};

Expand Down
43 changes: 43 additions & 0 deletions packages/core/content-releases/shared/contracts/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Used to store user configurations related to releases.
* E.g the default timezone for the release schedule.
*/

import { errors } from '@strapi/utils';
import { Utils } from '@strapi/types';

export interface Settings {
defaultTimezone: string | null;
}

/**
* GET /content-releases/settings
*
* Return the stored settings. If not set,
* it will return an object with null values
*/
export declare namespace GetSettings {
export interface Request {
query?: {};
}

export interface Response {
data: Settings;
}
}

/**
* PUT /content-releases/settings
*
* Update the stored settings
*/
export declare namespace UpdateSettings {
export interface Request {
body: Settings;
}

export type Response = Utils.OneOf<
{ data: Settings },
{ error?: errors.ApplicationError | errors.ValidationError }
>;
}
8 changes: 8 additions & 0 deletions packages/core/content-releases/shared/validation-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,11 @@ export const RELEASE_SCHEMA = yup
})
.required()
.noUnknown();

export const SETTINGS_SCHEMA = yup
.object()
.shape({
defaultTimezone: yup.string().nullable().default(null),
})
.required()
.noUnknown();
23 changes: 23 additions & 0 deletions packages/core/types/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,26 @@ export type Cast<TValue, TType> = TValue extends TType ? TValue : never;
* This type can be useful when working with partial data and object methods that contain a pseudo `this` context.
*/
export type PartialWithThis<T> = Partial<T> & ThisType<T>;

/**
* Enforce mutually exclusive properties.
*
* The `OneOf<T, U>` type ensures that either properties of type {@link T} or properties of type {@link U} are present,
* but never both at the same time. It is useful for defining states where you want to
* have exactly one of two possible sets of properties.
*
* @template T - The first set of properties.
* @template U - The second set of properties.
*
* @example
* // Define a type where you either have data or an error, but not both:
* type Response = OneOf<
* { data: Data },
* { error: ApplicationError | ValidationError }
* >;
*
* // Is equivalent to:
* type Response = { data: Data, error: never } | { data: never, error: ApplicationError | ValidationError };
*
*/
export type OneOf<T, U> = (T & { [K in keyof U]?: never }) | (U & { [K in keyof T]?: never });
106 changes: 106 additions & 0 deletions tests/api/core/content-releases/settings.test.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Core } from '@strapi/types';

// Helpers
import { createTestBuilder } from 'api-tests/builder';
import { createStrapiInstance } from 'api-tests/strapi';
import { createAuthRequest } from 'api-tests/request';

import type { Settings } from '../../../../packages/core/content-releases/shared/contracts/settings';

const builder = createTestBuilder();
let strapi: Core.Strapi;
let rq: any;
let rqEditor: any;

const getSettings = async (request = rq) => {
return request({
url: '/content-releases/settings',
method: 'GET',
}).then((res: any) => ({
settings: res.body?.data,
status: res.statusCode,
}));
};

const updateSettings = async (settings: Settings, request = rq) => {
return request({
url: '/content-releases/settings',
method: 'PUT',
body: settings,
}).then((res: any) => ({
settings: res.body?.data,
status: res.statusCode,
error: res.error,
}));
};

describe('Content releases settings', () => {
beforeAll(async () => {
await builder.build();
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
});

beforeEach(async () => {
// Reset settings
const store = await strapi.store({ type: 'core', name: 'content-releases' });
await store.set({ key: 'settings', value: null });
});

afterAll(async () => {
await strapi.destroy();
await builder.cleanup();
});

describe('Find settings', () => {
test('Find settings when there is none set', async () => {
const { status, settings } = await getSettings();

// Settings should be the default ones
expect(status).toBe(200);
expect(settings).toEqual({ defaultTimezone: null });
});

test('Find settings', async () => {
// Set settings
await updateSettings({ defaultTimezone: 'Europe/Paris' });

const { status, settings } = await getSettings();

// Settings should be the default ones
expect(status).toBe(200);
expect(settings).toEqual({ defaultTimezone: 'Europe/Paris' });
});
});

describe('Update settings', () => {
test('Can update settings', async () => {
// Set settings
const { status, settings } = await updateSettings({ defaultTimezone: 'Europe/Paris' });

// Returned settings should be the updated ones
expect(status).toBe(200);
expect(settings).toEqual({ defaultTimezone: 'Europe/Paris' });
});

test('Can update timezone to null', async () => {
// Set settings
await updateSettings({ defaultTimezone: 'Europe/Paris' });
const { status, settings } = await updateSettings({ defaultTimezone: null });

// Returned settings should be the updated ones
expect(status).toBe(200);
expect(settings).toEqual({ defaultTimezone: null });
});

test('Update settings with invalid data should throw', async () => {
const { status } = await updateSettings({
// @ts-expect-error - Invalid data
other: 'value',
});

// Should throw a validation error
expect(status).toBe(400);
});
});
});
Loading