future(homepage): add widgets api (#23342)

This commit is contained in:
markkaylor 2025-04-11 10:04:19 +02:00 committed by GitHub
parent df1dc7b498
commit b0ac9d7f6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 180 additions and 3 deletions

View File

@ -1,3 +1,5 @@
module.exports = ({ env }) => ({
future: {},
future: {
unstableWidgetsApi: true,
},
});

View File

@ -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 || {};

View File

@ -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.Plugin>
| Internal.Namespace.WithSeparator<Internal.Namespace.Global>,
string
>;
type CustomFieldOptionInput =
| 'text'

View File

@ -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.Plugin>
| Internal.Namespace.WithSeparator<Internal.Namespace.Global>,
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<WidgetArgs, 'id' | 'pluginId'> & { uid: WidgetUID };
class Widgets {
widgets: Record<string, Widget>;
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 };

View File

@ -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}`,
});
});
});
});

View File

@ -1,5 +1,7 @@
export interface FeaturesConfig {
future?: object;
future?: {
unstableWidgetsApi?: boolean;
};
}
export interface FeaturesService {