diff --git a/examples/getstarted/config/features.js b/examples/getstarted/config/features.js index b2bbf1f976..70f38bedb3 100644 --- a/examples/getstarted/config/features.js +++ b/examples/getstarted/config/features.js @@ -1,3 +1,5 @@ module.exports = ({ env }) => ({ - future: {}, + future: { + unstableWidgetsApi: true, + }, }); diff --git a/packages/core/admin/admin/src/StrapiApp.tsx b/packages/core/admin/admin/src/StrapiApp.tsx index d398d14402..5a1571ea6d 100644 --- a/packages/core/admin/admin/src/StrapiApp.tsx +++ b/packages/core/admin/admin/src/StrapiApp.tsx @@ -16,6 +16,7 @@ import { CustomFields } from './core/apis/CustomFields'; import { Plugin, PluginConfig } from './core/apis/Plugin'; import { RBAC, RBACMiddleware } from './core/apis/rbac'; import { Router, StrapiAppSetting, UnloadedSettingsLink } from './core/apis/router'; +import { Widgets } from './core/apis/Widgets'; import { RootState, Store, configureStore } from './core/store/configure'; import { getBasename } from './core/utils/basename'; import { Handler, createHook } from './core/utils/createHook'; @@ -121,6 +122,7 @@ class StrapiApp { reducers: ReducersMapObject = {}; store: Store | null = null; customFields = new CustomFields(); + widgets = new Widgets(); constructor({ config, appPlugins }: StrapiAppConstructorArgs = {}) { this.appPlugins = appPlugins || {}; diff --git a/packages/core/admin/admin/src/core/apis/CustomFields.ts b/packages/core/admin/admin/src/core/apis/CustomFields.ts index c18870a5eb..3affe42586 100644 --- a/packages/core/admin/admin/src/core/apis/CustomFields.ts +++ b/packages/core/admin/admin/src/core/apis/CustomFields.ts @@ -1,12 +1,17 @@ /* eslint-disable check-file/filename-naming-convention */ import { ComponentType } from 'react'; +import { Internal, Utils } from '@strapi/types'; import invariant from 'invariant'; import type { MessageDescriptor, PrimitiveType } from 'react-intl'; import type { AnySchema } from 'yup'; -type CustomFieldUID = `plugin::${string}.${string}` | `global::${string}`; +type CustomFieldUID = Utils.String.Suffix< + | Internal.Namespace.WithSeparator + | Internal.Namespace.WithSeparator, + string +>; type CustomFieldOptionInput = | 'text' diff --git a/packages/core/admin/admin/src/core/apis/Widgets.ts b/packages/core/admin/admin/src/core/apis/Widgets.ts new file mode 100644 index 0000000000..a9e23be65c --- /dev/null +++ b/packages/core/admin/admin/src/core/apis/Widgets.ts @@ -0,0 +1,64 @@ +/* eslint-disable check-file/filename-naming-convention */ + +import invariant from 'invariant'; +import { To } from 'react-router-dom'; + +import { Permission } from '../../../../shared/contracts/shared'; + +import type { Internal, Utils } from '@strapi/types'; +import type { MessageDescriptor } from 'react-intl'; + +type WidgetUID = Utils.String.Suffix< + | Internal.Namespace.WithSeparator + | Internal.Namespace.WithSeparator, + string +>; + +type WidgetArgs = { + icon: React.ComponentType; + title: MessageDescriptor; + link?: { + label: MessageDescriptor; + href: To; + }; + component: () => Promise<{ default: React.ComponentType }>; + pluginId?: string; + id: string; + permissions?: Permission[]; +}; + +type Widget = Omit & { uid: WidgetUID }; + +class Widgets { + widgets: Record; + + constructor() { + this.widgets = {}; + } + + register = (widget: WidgetArgs | WidgetArgs[]) => { + if (Array.isArray(widget)) { + widget.forEach((newWidget) => { + this.register(newWidget); + }); + } else { + invariant(widget.id, 'An id must be provided'); + invariant(widget.component, 'A component must be provided'); + invariant(widget.title, 'A title must be provided'); + invariant(widget.icon, 'An icon must be provided'); + + // Replace id and pluginId with computed uid + const { id, pluginId, ...widgetToStore } = widget; + const uid: WidgetUID = pluginId ? `plugin::${pluginId}.${id}` : `global::${id}`; + + this.widgets[uid] = { ...widgetToStore, uid }; + } + }; + + getAll = () => { + return Object.values(this.widgets); + }; +} + +export { Widgets }; +export type { WidgetArgs }; diff --git a/packages/core/admin/admin/src/core/apis/tests/widgets.test.ts b/packages/core/admin/admin/src/core/apis/tests/widgets.test.ts new file mode 100644 index 0000000000..9d75af982d --- /dev/null +++ b/packages/core/admin/admin/src/core/apis/tests/widgets.test.ts @@ -0,0 +1,102 @@ +import { Widgets, type WidgetArgs } from '../Widgets'; + +describe('Widgets', () => { + let widgets: Widgets; + + beforeEach(() => { + widgets = new Widgets(); + }); + + const mockWidget: WidgetArgs = { + id: 'test-widget', + component: () => Promise.resolve({ default: () => null }), + title: { id: 'test.title', defaultMessage: 'Test Title' }, + icon: () => null, + }; + + describe('register', () => { + test('registers a single widget correctly', () => { + widgets.register(mockWidget); + const registeredWidgets = widgets.getAll(); + + expect(registeredWidgets).toHaveLength(1); + expect(registeredWidgets[0]).toEqual({ + component: mockWidget.component, + title: mockWidget.title, + icon: mockWidget.icon, + uid: `global::${mockWidget.id}`, + }); + }); + + test('registers a plugin widget correctly', () => { + const pluginWidget = { ...mockWidget, pluginId: 'test-plugin' }; + widgets.register(pluginWidget); + const registeredWidgets = widgets.getAll(); + + expect(registeredWidgets).toHaveLength(1); + expect(registeredWidgets[0]).toEqual({ + component: pluginWidget.component, + title: pluginWidget.title, + icon: pluginWidget.icon, + uid: `plugin::${pluginWidget.pluginId}.${pluginWidget.id}`, + }); + }); + + test('registers multiple widgets', () => { + const secondWidget = { + ...mockWidget, + id: 'second-widget', + }; + widgets.register([mockWidget, secondWidget]); + const registeredWidgets = widgets.getAll(); + + expect(registeredWidgets).toHaveLength(2); + expect(registeredWidgets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ uid: `global::${mockWidget.id}` }), + expect.objectContaining({ uid: `global::${secondWidget.id}` }), + ]) + ); + }); + + test('throws when id is missing', () => { + const invalidWidget = { ...mockWidget, id: undefined }; + expect(() => widgets.register(invalidWidget as any)).toThrow('An id must be provided'); + }); + + test('throws when component is missing', () => { + const invalidWidget = { ...mockWidget, component: undefined }; + expect(() => widgets.register(invalidWidget as any)).toThrow('A component must be provided'); + }); + + test('throws when title is missing', () => { + const invalidWidget = { ...mockWidget, title: undefined }; + expect(() => widgets.register(invalidWidget as any)).toThrow('A title must be provided'); + }); + + test('throws when icon is missing', () => { + const invalidWidget = { ...mockWidget, icon: undefined }; + expect(() => widgets.register(invalidWidget as any)).toThrow('An icon must be provided'); + }); + }); + + describe('getAll', () => { + test('returns empty array when no widgets registered', () => { + expect(widgets.getAll()).toEqual([]); + }); + + test('returns all registered widgets as an array', () => { + widgets.register(mockWidget); + const registeredWidgets = widgets.getAll(); + + expect(Array.isArray(registeredWidgets)).toBe(true); + expect(registeredWidgets).toHaveLength(1); + expect(registeredWidgets[0]).toEqual({ + component: mockWidget.component, + title: mockWidget.title, + icon: mockWidget.icon, + uid: `global::${mockWidget.id}`, + }); + }); + }); +}); diff --git a/packages/core/types/src/modules/features.ts b/packages/core/types/src/modules/features.ts index c2e1a20b2b..d74da8f50d 100644 --- a/packages/core/types/src/modules/features.ts +++ b/packages/core/types/src/modules/features.ts @@ -1,5 +1,7 @@ export interface FeaturesConfig { - future?: object; + future?: { + unstableWidgetsApi?: boolean; + }; } export interface FeaturesService {