diff --git a/docusaurus/docs/cms/admin-panel-customization/homepage.md b/docusaurus/docs/cms/admin-panel-customization/homepage.md new file mode 100644 index 0000000000..bc847fa252 --- /dev/null +++ b/docusaurus/docs/cms/admin-panel-customization/homepage.md @@ -0,0 +1,774 @@ +--- +title: Homepage customization +description: Learn about the Strapi admin panel Homepage and how to customize it with widgets. +toc_max_heading_level: 6 +tags: +- admin panel +- homepage +- widgets +- features +--- + +# Homepage customization + + +The Homepage is the landing page of the Strapi admin panel. By default, it provides an overview of your content with 2 default widgets: + +- _Last edited entries_: Displays recently modified content entries, including their content type, status, and when they were updated. +- _Last published entries_: Shows recently published content entries, allowing you to quickly access and manage your published content. + + + +These default widgets cannot currently be removed, but you can customize the Homepage by creating your own widgets. + +:::note +If you recently created a Strapi project, the Homepage may also display a quick tour above widgets if you haven't skipped it yet. +::: + +## Adding custom widgets + +To add a custom widget, you can: + +- install a plugin from the [Marketplace](/cms/plugins/installing-plugins-via-marketplace) +- or create and register your own widgets + +The present page will describe how to create and register your widgets. + +### Registering custom widgets + +To register a widget, use `app.widgets.register()`: + +- in the plugin’s [`register` lifecycle method](/cms/plugins-development/server-api#register) of the `index` file if you're building a plugin (recommended way), +- or in the [application's global `register()` lifecycle method](/cms/configurations/functions#register) if you're adding the widget to just one Strapi application without a plugin. + +:::info +The examples on the present page will cover registering a widget through a plugin. Most of the code should be reusable if you register the widget in the application's global `register()` lifecycle method, except you should not pass the `pluginId` property. +::: + + + + + +```jsx title="src/plugins/my-plugin/admin/src/index.js" +import pluginId from './pluginId'; +import MyWidgetIcon from './components/MyWidgetIcon'; + +export default { + register(app) { + // Register the plugin itself + app.registerPlugin({ + id: pluginId, + name: 'My Plugin', + }); + + // Register a widget for the Homepage + app.widgets.register({ + icon: MyWidgetIcon, + title: { + id: `${pluginId}.widget.title`, + defaultMessage: 'My Widget', + }, + component: async () => { + const component = await import('./components/MyWidget'); + return component.default; + }, + /** + * Use this instead if you used a named export for your component + */ + // component: async () => { + // const { Component } = await import('./components/MyWidget'); + // return Component; + // }, + id: 'my-custom-widget', + pluginId: pluginId, + }); + }, + + bootstrap() {}, + // ... +}; +``` + + + + + +```tsx title="src/plugins/my-plugin/admin/src/index.ts" +import pluginId from './pluginId'; +import MyWidgetIcon from './components/MyWidgetIcon'; +import type { StrapiApp } from '@strapi/admin/strapi-admin'; + +export default { + register(app: StrapiApp) { + // Register the plugin itself + app.registerPlugin({ + id: pluginId, + name: 'My Plugin', + }); + + // Register a widget for the Homepage + app.widgets.register({ + icon: MyWidgetIcon, + title: { + id: `${pluginId}.widget.title`, + defaultMessage: 'My Widget', + }, + component: async () => { + const component = await import('./components/MyWidget'); + return component.default; + }, + /** + * Use this instead if you used a named export for your component + */ + // component: async () => { + // const { Component } = await import('./components/MyWidget'); + // return Component; + // }, + id: 'my-custom-widget', + pluginId: pluginId, + }); + }, + + bootstrap() {}, + // ... +}; +``` + + + + +:::note The API requires Strapi 5.13+ +The `app.widgets.register` API only works with Strapi 5.13 and above. Trying to call the API with older versions of Strapi will crash the admin panel. +Plugin developers who want to register widgets should either: + +- set `^5.13.0` as their `@strapi/strapi` peerDependency in their plugin `package.json`. This peer dependency powers the Marketplace's compatibility check. +- or check if the API exists before calling it: + + ```js + if ('widgets' in app) { + // proceed with the registration + } + ``` + +The peerDependency approach is recommended if the whole purpose of the plugin is to register widgets. The second approach makes more sense if a plugin wants to add a widget but most of its functionality is elsewhere. +::: + +#### Widget API reference + +The `app.widgets.register()` method can take either a single widget configuration object or an array of configuration objects. Each widget configuration object can accept the following properties: + +| Property | Type | Description | Required | +|-------------|------------------------|-------------------------------------------------------|----------| +| `icon` | `React.ComponentType` | Icon component to display beside the widget title | Yes | +| `title` | `MessageDescriptor` | Title for the widget with translation support | Yes | +| `component` | `() => Promise` | Async function that returns the widget component | Yes | +| `id` | `string` | Unique identifier for the widget | Yes | +| `link` | `Object` | Optional link to add to the widget (see link object properties)| No | +| `pluginId` | `string` | ID of the plugin registering the widget | No | +| `permissions` | `Permission[]` | Permissions required to view the widget | No | + +**Link object properties:** + +If you want to add a link to your widget (e.g., to navigate to a detailed view), you can provide a `link` object with the following properties: + +| Property | Type | Description | Required | +|----------|---------------------|------------------------------------------------|----------| +| `label` | `MessageDescriptor` | The text to display for the link | Yes | +| `href` | `string` | The URL where the link should navigate to | Yes | + +### Creating a widget component + +Widget components should be designed to display content in a compact and informative way. + +Here's how to implement a basic widget component: + + + + +```jsx title="src/plugins/my-plugin/admin/src/components/MyWidget/index.js" +import React, { useState, useEffect } from 'react'; +import { Widget } from '@strapi/admin/strapi-admin'; + +const MyWidget = () => { + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + // Fetch your data here + const fetchData = async () => { + try { + // Replace with your actual API call + const response = await fetch('/my-plugin/data'); + const result = await response.json(); + + setData(result); + setLoading(false); + } catch (err) { + setError(err); + setLoading(false); + } + }; + + fetchData(); + }, []); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + if (!data || data.length === 0) { + return ; + } + + return ( +
+ {/* Your widget content here */} +
    + {data.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ); +}; + +export default MyWidget; +``` + +
+ + + +```tsx title="src/plugins/my-plugin/admin/src/components/MyWidget/index.tsx" +import React, { useState, useEffect } from 'react'; +import { Widget } from '@strapi/admin/strapi-admin'; + +interface DataItem { + id: number; + name: string; +} + +const MyWidget: React.FC = () => { + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + // Fetch your data here + const fetchData = async () => { + try { + // Replace with your actual API call + const response = await fetch('/my-plugin/data'); + const result = await response.json(); + + setData(result); + setLoading(false); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + setLoading(false); + } + }; + + fetchData(); + }, []); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + if (!data || data.length === 0) { + return ; + } + + return ( +
+ {/* Your widget content here */} +
    + {data.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ); +}; + +export default MyWidget; +``` + +
+
+ +:::tip +For simplicity, the example below uses data fetching directly inside a useEffect hook. While this works for demonstration purposes, it may not reflect best practices in production. + +For more robust solutions, consider alternative approaches recommended in the [React documentation](https://react.dev/learn/build-a-react-app-from-scratch#data-fetching). If you're looking to integrate a data fetching library, we recommend using [TanStackQuery](https://tanstack.com/query/v3/). +::: + +**Data management**: + +![Rendering and Data management](/img/assets/homepage-customization/rendering-data-management.png) + +The green box above represents the area where the user’s React component (from `widget.component` in the [API](#widget-api-reference)) is rendered. You can render whatever you like inside of this box. Everything outside that box is, however, rendered by Strapi. This ensures overall design consistency within the admin panel. The `icon`, `title`, and `link` (optional) properties provided in the API are used to display the widget. + +#### Widget helper components reference + +Strapi provides several helper components to maintain a consistent user experience across widgets: + +| Component | Description | Usage | +|------------------|-----------------------------------------------------|--------------------------------------| +| `Widget.Loading` | Displays a loading spinner and message | When data is being fetched | +| `Widget.Error` | Displays an error state | When an error occurs | +| `Widget.NoData` | Displays when no data is available | When the widget has no data to show | +| `Widget.NoPermissions` | Displays when user lacks required permissions | When the user cannot access the widget | + +These components help maintain a consistent look and feel across different widgets. +You could render these components without children to get the default wording: `` +or you could pass children to override the default copy and specify your own wording: `Your custom error message`. + +## Example: Adding a content metrics widget + +The following is a complete example of how to create a content metrics widget that displays the number of entries for each content type in your Strapi application. + +The end result will look like the following in your admin panel's Homepage: + + + +The widget shows counts for example content-types automatically generated by Strapi when you provide the `--example` flag on installation (see [CLI installation options](/cms/installation/cli#cli-installation-options) for details). + +This widget can be added to Strapi by: + +1. creating a "content-metrics" plugin (see [plugin creation](/cms/plugins-development/create-a-plugin) documentation for details) +2. re-using the code examples provided below. + +:::tip +If you prefer a hands-on approach, you can reuse the following . +::: + + + + +The following file registers the plugin and the widget: + +```jsx title="src/plugins/content-metrics/admin/src/index.js" {28-42} +import { PLUGIN_ID } from './pluginId'; +import { Initializer } from './components/Initializer'; +import { PluginIcon } from './components/PluginIcon'; +import { Stethoscope } from '@strapi/icons' + +export default { + register(app) { + app.addMenuLink({ + to: `plugins/${PLUGIN_ID}`, + icon: PluginIcon, + intlLabel: { + id: `${PLUGIN_ID}.plugin.name`, + defaultMessage: PLUGIN_ID, + }, + Component: async () => { + const { App } = await import('./pages/App'); + return App; + }, + }); + + app.registerPlugin({ + id: PLUGIN_ID, + initializer: Initializer, + isReady: false, + name: PLUGIN_ID, + }); + + // Registers the widget + app.widgets.register({ + icon: Stethoscope, + title: { + id: `${PLUGIN_ID}.widget.metrics.title`, + defaultMessage: 'Content Metrics', + }, + component: async () => { + const component = await import('./components/MetricsWidget'); + return component.default; + }, + id: 'content-metrics', + pluginId: PLUGIN_ID, + }); + }, + + async registerTrads({ locales }) { + return Promise.all( + locales.map(async (locale) => { + try { + const { default: data } = await import(`./translations/${locale}.json`); + return { data, locale }; + } catch { + return { data: {}, locale }; + } + }) + ); + }, + + bootstrap() {}, +}; +``` + +The following file defines the widget's component and its logic. It's tapping into a specific controller and route that we'll create for the plugin: + +```jsx title="src/plugins/content-metrics/admin/src/components/MetricsWidget/index.js" +import React, { useState, useEffect } from 'react'; +import { Table, Tbody, Tr, Td, Typography, Box } from '@strapi/design-system'; +import { Widget } from '@strapi/admin/strapi-admin' + +const MetricsWidget = () => { + const [loading, setLoading] = useState(true); + const [metrics, setMetrics] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchMetrics = async () => { + try { + const response = await fetch('/api/content-metrics/count'); + const data = await response.json(); + + console.log("data:", data); + + const formattedData = {}; + + if (data && typeof data === 'object') { + Object.keys(data).forEach(key => { + const value = data[key]; + formattedData[key] = typeof value === 'number' ? value : String(value); + }); + } + + setMetrics(formattedData); + setLoading(false); + } catch (err) { + console.error(err); + setError(err.message || 'An error occurred'); + setLoading(false); + } + }; + + fetchMetrics(); + }, []); + + if (loading) { + return ( + + ); + } + + if (error) { + return ( + + ); + } + + if (!metrics || Object.keys(metrics).length === 0) { + return No content types found; + } + + return ( + + + {Object.entries(metrics).map(([contentType, count], index) => ( + + + + + ))} + +
+ {String(contentType)} + + {String(count)} +
+ ); +}; + +export default MetricsWidget; +``` + +The following file defines a custom controller that counts all content-types: + +```js title="src/plugins/content-metrics/server/src/controllers/metrics.js" +'use strict'; +module.exports = ({ strapi }) => ({ + async getContentCounts(ctx) { + try { + // Get all content types + const contentTypes = Object.keys(strapi.contentTypes) + .filter(uid => uid.startsWith('api::')) + .reduce((acc, uid) => { + const contentType = strapi.contentTypes[uid]; + acc[contentType.info.displayName || uid] = 0; + return acc; + }, {}); + + // Count entities for each content type + for (const [name, _] of Object.entries(contentTypes)) { + const uid = Object.keys(strapi.contentTypes) + .find(key => + strapi.contentTypes[key].info.displayName === name || key === name + ); + + if (uid) { + // Using the count() method from the Document Service API + const count = await strapi.documents(uid).count(); + contentTypes[name] = count; + } + } + + ctx.body = contentTypes; + } catch (err) { + ctx.throw(500, err); + } + } +}); +``` + +The following file ensures that the metrics controller is reachable at a custom `/count` route: + +```js title="src/plugins/content-metrics/server/src/routes/index.js" +export default { + 'content-api': { + type: 'content-api', + routes: [ + { + method: 'GET', + path: '/count', + handler: 'metrics.getContentCounts', + config: { + policies: [], + }, + }, + ], + }, +}; +``` + +
+ + + +The following file registers the plugin and the widget: + +```tsx title="src/plugins/content-metrics/admin/src/index.ts" {28-42} +import { PLUGIN_ID } from './pluginId'; +import { Initializer } from './components/Initializer'; +import { PluginIcon } from './components/PluginIcon'; +import { Stethoscope } from '@strapi/icons' + +export default { + register(app) { + app.addMenuLink({ + to: `plugins/${PLUGIN_ID}`, + icon: PluginIcon, + intlLabel: { + id: `${PLUGIN_ID}.plugin.name`, + defaultMessage: PLUGIN_ID, + }, + Component: async () => { + const { App } = await import('./pages/App'); + return App; + }, + }); + + app.registerPlugin({ + id: PLUGIN_ID, + initializer: Initializer, + isReady: false, + name: PLUGIN_ID, + }); + + // Registers the widget + app.widgets.register({ + icon: Stethoscope, + title: { + id: `${PLUGIN_ID}.widget.metrics.title`, + defaultMessage: 'Content Metrics', + }, + component: async () => { + const component = await import('./components/MetricsWidget'); + return component.default; + }, + id: 'content-metrics', + pluginId: PLUGIN_ID, + }); + }, + + async registerTrads({ locales }) { + return Promise.all( + locales.map(async (locale) => { + try { + const { default: data } = await import(`./translations/${locale}.json`); + return { data, locale }; + } catch { + return { data: {}, locale }; + } + }) + ); + }, + + bootstrap() {}, +}; +``` + +The following file defines the widget's component and its logic. It's tapping into a specific controller and route that we'll create for the plugin: + +```tsx title="src/plugins/content-metrics/admin/src/components/MetricsWidget/index.ts" +import React, { useState, useEffect } from 'react'; +import { Table, Tbody, Tr, Td, Typography, Box } from '@strapi/design-system'; +import { Widget } from '@strapi/admin/strapi-admin' + +const MetricsWidget = () => { + const [loading, setLoading] = useState(true); + const [metrics, setMetrics] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchMetrics = async () => { + try { + const response = await fetch('/api/content-metrics/count'); + const data = await response.json(); + + console.log("data:", data); + + const formattedData = {}; + + if (data && typeof data === 'object') { + Object.keys(data).forEach(key => { + const value = data[key]; + formattedData[key] = typeof value === 'number' ? value : String(value); + }); + } + + setMetrics(formattedData); + setLoading(false); + } catch (err) { + console.error(err); + setError(err.message || 'An error occurred'); + setLoading(false); + } + }; + + fetchMetrics(); + }, []); + + if (loading) { + return ( + + ); + } + + if (error) { + return ( + + ); + } + + if (!metrics || Object.keys(metrics).length === 0) { + return No content types found; + } + + return ( + + + {Object.entries(metrics).map(([contentType, count], index) => ( + + + + + ))} + +
+ {String(contentType)} + + {String(count)} +
+ ); +}; + +export default MetricsWidget; +``` + +The following file defines a custom controller that counts all content-types: + +```js title="src/plugins/content-metrics/server/src/controllers/metrics.js" +'use strict'; +module.exports = ({ strapi }) => ({ + async getContentCounts(ctx) { + try { + // Get all content types + const contentTypes = Object.keys(strapi.contentTypes) + .filter(uid => uid.startsWith('api::')) + .reduce((acc, uid) => { + const contentType = strapi.contentTypes[uid]; + acc[contentType.info.displayName || uid] = 0; + return acc; + }, {}); + + // Count entities for each content type using Document Service + for (const [name, _] of Object.entries(contentTypes)) { + const uid = Object.keys(strapi.contentTypes) + .find(key => + strapi.contentTypes[key].info.displayName === name || key === name + ); + + if (uid) { + // Using the count() method from Document Service instead of strapi.db.query + const count = await strapi.documents(uid).count(); + contentTypes[name] = count; + } + } + + ctx.body = contentTypes; + } catch (err) { + ctx.throw(500, err); + } + } +}); +``` + +The following file ensures that the metrics controller is reachable at a custom `/count` route: + +```js title="src/plugins/content-metrics/server/src/routes/index.js" +export default { + 'content-api': { + type: 'content-api', + routes: [ + { + method: 'GET', + path: '/count', + handler: 'metrics.getContentCounts', + config: { + policies: [], + }, + }, + ], + }, +}; +``` + +
+
diff --git a/docusaurus/docs/cms/configurations/features.md b/docusaurus/docs/cms/configurations/features.md index 7863ec8bc0..1713108a7e 100644 --- a/docusaurus/docs/cms/configurations/features.md +++ b/docusaurus/docs/cms/configurations/features.md @@ -16,7 +16,7 @@ The `config/features.js|ts` file is used to enable feature flags. Currently this Some incoming Strapi features are not yet ready to be shipped to all users, but Strapi still offers community users the opportunity to provide early feedback on these new features or changes. With these experimental features, developers have the flexibility to choose and integrate new features and changes into their Strapi applications as they become available in the current major version as well as assist us in shaping these new features. -Such experimental features are indicated by a badge throughout the documentation and enabling these features requires enabling the corresponding future flags. Future flags differ from features that are in alpha in that future flags are disabled by default. +Such experimental features are indicated by a badge throughout the documentation, where the name of the feature flag to use is included in the badge (e.g., ). Enabling these features requires enabling the corresponding future flags. Future flags differ from features that are in alpha in that future flags are disabled by default. :::danger Enable future flags at your own risk. Experimental features may be subject to change or removal, may contain breaking changes, may be unstable or not fully ready for use, and some parts may still be under development or using mock data. diff --git a/docusaurus/docs/cms/features/admin-panel.md b/docusaurus/docs/cms/features/admin-panel.md index ce220a0b12..490f9cce36 100644 --- a/docusaurus/docs/cms/features/admin-panel.md +++ b/docusaurus/docs/cms/features/admin-panel.md @@ -16,11 +16,15 @@ The admin panel is the back office of your Strapi application. From the admin pa +:::tip +You can [create your own widgets](/cms/admin-panel-customization/homepage) to customize the admin panel's homepage. +::: + ## Overview :::prerequisites diff --git a/docusaurus/sidebars.js b/docusaurus/sidebars.js index 3fc0936edb..036d5a65e2 100644 --- a/docusaurus/sidebars.js +++ b/docusaurus/sidebars.js @@ -75,6 +75,7 @@ const sidebars = { id: 'cms/features/draft-and-publish' }, 'cms/features/email', + { type: 'doc', label: 'Internationalization (i18n)', @@ -293,6 +294,14 @@ const sidebars = { 'cms/admin-panel-customization/wysiwyg-editor', ] }, + { + type: 'doc', + label: 'Homepage customization', + id: 'cms/admin-panel-customization/homepage', + customProps: { + new: true, + } + }, 'cms/cli', { type: 'doc', diff --git a/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage-with-tour.png b/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage-with-tour.png new file mode 100644 index 0000000000..966422b455 Binary files /dev/null and b/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage-with-tour.png differ diff --git a/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage-with-tour_DARK.png b/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage-with-tour_DARK.png new file mode 100644 index 0000000000..966134a428 Binary files /dev/null and b/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage-with-tour_DARK.png differ diff --git a/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage.png b/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage.png new file mode 100644 index 0000000000..35d3615c45 Binary files /dev/null and b/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage.png differ diff --git a/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage_DARK.png b/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage_DARK.png new file mode 100644 index 0000000000..2d62775c8c Binary files /dev/null and b/docusaurus/static/img/assets/admin-homepage/admin-panel-homepage_DARK.png differ diff --git a/docusaurus/static/img/assets/homepage-customization/content-metrics-widget.png b/docusaurus/static/img/assets/homepage-customization/content-metrics-widget.png new file mode 100644 index 0000000000..4691a709d5 Binary files /dev/null and b/docusaurus/static/img/assets/homepage-customization/content-metrics-widget.png differ diff --git a/docusaurus/static/img/assets/homepage-customization/content-metrics-widget_DARK.png b/docusaurus/static/img/assets/homepage-customization/content-metrics-widget_DARK.png new file mode 100644 index 0000000000..50eb18785c Binary files /dev/null and b/docusaurus/static/img/assets/homepage-customization/content-metrics-widget_DARK.png differ diff --git a/docusaurus/static/img/assets/homepage-customization/rendering-data-management.png b/docusaurus/static/img/assets/homepage-customization/rendering-data-management.png new file mode 100644 index 0000000000..25891916f9 Binary files /dev/null and b/docusaurus/static/img/assets/homepage-customization/rendering-data-management.png differ