From b65b54a335bd59e8035b516bfd92b764b380f9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20de=20Alba?= Date: Thu, 29 Feb 2024 22:53:29 +0100 Subject: [PATCH] Slots in core (#5775) Co-authored-by: Steve Piercy Co-authored-by: David Glick --- docs/source/conf.py | 5 +- docs/source/configuration/index.md | 1 + docs/source/configuration/slots.md | 412 ++++++++++ .../src/components/Slots/SlotTest.tsx | 10 + packages/coresandbox/src/index.ts | 10 + packages/registry/news/5775.feature | 1 + packages/registry/package.json | 2 +- packages/registry/src/index.ts | 216 ++++- packages/registry/src/registry.test.jsx | 112 --- packages/registry/src/registry.test.tsx | 736 ++++++++++++++++++ packages/types/news/5775.feature | 1 + packages/types/src/config/Slots.d.ts | 25 + packages/types/src/config/index.d.ts | 4 +- packages/types/src/content/common.d.ts | 2 + .../volto/cypress/tests/coresandbox/slots.js | 43 + packages/volto/news/5775.feature | 1 + packages/volto/package.json | 5 +- .../volto/src/components/manage/Form/Form.jsx | 258 +++--- .../src/components/manage/Form/Form.test.jsx | 7 + .../Preferences/ChangePassword.test.jsx | 7 + .../Preferences/PersonalInformation.test.jsx | 14 + .../Preferences/PersonalPreferences.test.jsx | 7 + .../theme/Comments/Comments.test.jsx | 7 + .../PasswordReset/PasswordReset.test.jsx | 7 + .../RequestPasswordReset.test.jsx | 7 + .../theme/Register/Register.test.jsx | 7 + .../theme/SlotRenderer/SlotRenderer.test.jsx | 56 ++ .../theme/SlotRenderer/SlotRenderer.tsx | 54 ++ .../volto/src/components/theme/View/View.jsx | 3 + .../src/components/theme/View/View.test.jsx | 3 + .../View/__snapshots__/View.test.jsx.snap | 18 + packages/volto/src/config/index.js | 4 +- packages/volto/src/helpers/Slots/index.tsx | 12 + packages/volto/src/helpers/index.js | 1 + packages/volto/test-setup-config.js | 1 + pnpm-lock.yaml | 81 +- 36 files changed, 1872 insertions(+), 268 deletions(-) create mode 100644 docs/source/configuration/slots.md create mode 100644 packages/coresandbox/src/components/Slots/SlotTest.tsx create mode 100644 packages/registry/news/5775.feature delete mode 100644 packages/registry/src/registry.test.jsx create mode 100644 packages/registry/src/registry.test.tsx create mode 100644 packages/types/news/5775.feature create mode 100644 packages/types/src/config/Slots.d.ts create mode 100644 packages/volto/cypress/tests/coresandbox/slots.js create mode 100644 packages/volto/news/5775.feature create mode 100644 packages/volto/src/components/theme/SlotRenderer/SlotRenderer.test.jsx create mode 100644 packages/volto/src/components/theme/SlotRenderer/SlotRenderer.tsx create mode 100644 packages/volto/src/helpers/Slots/index.tsx diff --git a/docs/source/conf.py b/docs/source/conf.py index b60d6eabac..6360e35dae 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -91,10 +91,11 @@ # Ignore github.com pages with anchors r"https://github.com/.*#.*", # Ignore other specific anchors - r"https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors#Identifying_the_issue", - r"https://docs.cypress.io/guides/references/migration-guide#Migrating-to-Cypress-version-10-0", r"https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi", # TODO retest with latest Sphinx when upgrading theme. chromewebstore recently changed its URL and has "too many redirects". r"https://chromewebstore.google.com/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd", # TODO retest with latest Sphinx when upgrading theme. chromewebstore recently changed its URL and has "too many redirects". + r"https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors#Identifying_the_issue", + r"https://docs.cypress.io/guides/references/migration-guide#Migrating-to-Cypress-version-10-0", + r"https://stackoverflow.com", # volto and documentation # TODO retest with latest Sphinx. ] linkcheck_anchors = True linkcheck_timeout = 10 diff --git a/docs/source/configuration/index.md b/docs/source/configuration/index.md index fde6916ff3..3375c41f9c 100644 --- a/docs/source/configuration/index.md +++ b/docs/source/configuration/index.md @@ -26,4 +26,5 @@ workingcopy environmentvariables expanders locking +slots ``` diff --git a/docs/source/configuration/slots.md b/docs/source/configuration/slots.md new file mode 100644 index 0000000000..6c2c9701e5 --- /dev/null +++ b/docs/source/configuration/slots.md @@ -0,0 +1,412 @@ +--- +myst: + html_meta: + "description": "Slots are insertion points in the Volto rendering tree structure." + "property=og:description": "Slots are insertion points in the Volto rendering tree structure." + "property=og:title": "Slots" + "keywords": "Volto, Plone, frontend, React, configuration, slots, viewlets" +--- + +# Slots + +Slots provide a way for Volto add-ons to insert their own components at predefined locations in the rendered page. + +```{note} +This concept is inspired by the Plone Classic UI {doc}`plone:classic-ui/viewlets`. +``` + + +## Anatomy + +Slots have a name, and they contain a list of named slot components. + +Volto renders slots using the `SlotRenderer` component. +You can add slot insertion points in your code, as shown in the following example. + +```ts + +``` + +Slot components are registered in the {ref}`configuration registry using a specific API for slots `. + +The rendering of a slot component is controlled by the presence or absence of a list of conditions called {term}`predicates`. + +You can register multiple slot components with the same name under the same slot, as long as they have different predicates or components. + +To illustrate how slots are structured and work, let's register a slot component, where the component is `PageHeader`, and the predicate matches a route that begins with `/de/about`. + +```ts +config.registerSlotComponent({ + slot: 'aboveContent', + name: 'header', + component: PageHeader, + predicates: [RouteCondition('/de/about')], +}); +``` + +The following tree structure diagram illustrates the resultant registration. + +```text +Slot (`name`=`aboveContent`) +└── SlotComponent + ├── `slot`=`aboveContent` + ├── `name`=`header` + ├── `component`=PageHeader + └── predicate of "only appear under `/de/about`" +``` + +Next, let's register another slot component in the same slot, with the same name and component, but with a different predicate where the content type matches either `Document` or `News Item`. + +```ts +config.registerSlotComponent({ + slot: 'aboveContent', + name: 'header', + component: PageHeader, + predicates: [ContentTypeCondition(['Document', 'News Item'])], +}); +``` + +The following tree structure diagram illustrates the result of the second registration. + +```text +Slot (`name`=`aboveContent`) +├── SlotComponent +│ ├── `slot`=`aboveContent` +│ ├── `name`=`header` +│ ├── `component`=PageHeader +│ └── predicate of "only appear under `/de/about`" +└── SlotComponent + ├── `slot`=`aboveContent` + ├── `name`=`header` + ├── `component`=PageHeader + └── predicate of "only appear when the content type is either a Document or News Item" +``` + +Finally, let's register another slot component in the same slot, with the same name, but with a different component and without a predicate. + +```ts +config.registerSlotComponent({ + slot: 'aboveContent', + name: 'header', + component: 'DefaultHeader', +}); +``` + +The following tree structure diagram illustrates the result of the third registration. + +```text +Slot (`name`=`aboveContent`) +├── SlotComponent +│ ├── `slot`=`aboveContent` +│ ├── `name`=`header` +│ ├── `component`=PageHeader +│ └── predicate of "only appear under `/de/about`" +├── SlotComponent +│ ├── `slot`=`aboveContent` +│ ├── `name`=`header` +│ ├── `component`=PageHeader +│ └── predicate of "only appear when the content type is either a Document or News Item" +└── SlotComponent + ├── `slot`=`aboveContent` + ├── `name`=`header` + └── `component`=`DefaultHeader` +``` + +The rendering of slot components follows an algorithm: + +- The last registered slot component is evaluated first. +- The first evaluated slot component's predicates to return `true` has its component rendered in the slot. +- A slot component without predicates becomes the fallback for other slot components with predicates. + +Working through the above diagram from bottom to top, let's assume a visitor goes to the route `/de/about` and views an Event content type. + +1. The algorithm looks for the third slot component's predicates. + Because it has no predicates to be evaluated, and therefore cannot return `true`, its component is a fallback to other slot components. + +2. Moving upward, the second slot component's predicates are evaluated. + If they are `true`, then its component is rendered in the slot, and evaluation stops. + But in this case, the content type is an Event, thus it returns `false`, and evaluation continues upward. + +3. The first slot component's predicates are evaluated. + In this case, they are true because the visitor is on the route `/de/about`. + Evaluation stops, and its component is rendered in the slot. + +Within a slot, slot components are grouped by their name. +The order in which the grouped slot components are evaluated is governed by the order in which they are registered. + +Extending our previous example, let's register another slot component with a different name. + +```ts +config.registerSlotComponent({ + slot: 'aboveContent', + name: 'subheader', + component: PageSubHeader, + predicates: [ContentTypeCondition(['Document', 'News Item'])], +}); +``` + +Thus the order of evaluation of the named slot components would be `header`, `subheader`. +As each group of slot components is evaluated, their predicates will determine what is rendered in their position. + +You can change the order of the named slot components for a different slot using the {ref}`slots-reorderSlotComponent-label` API. +In our example, you can reorder the `subheading` before the `heading`, although it would probably look strange. + +```ts +config.reorderSlotComponent({ + slot: 'aboveContent', + name: 'subheader', + action: 'before', + target: 'header', +}); +``` + +You can even delete the rendering of a registered slot component using the {ref}`slots-unregisterSlotComponent-label` API. + + +## Default slots + +Volto comes with the following default slots. + +- `aboveContent` +- `belowContent` + + +(configuration-registry-for-slot-components)= + +## Configuration registry for slot components + +You can manage slot components using the configuration registry for slot components and its API. + + +### `registerSlotComponent` + +`registerSlotComponent` registers a slot component as shown. + +```ts +config.registerSlotComponent({ + slot: 'aboveContent', + name: 'header', + component: PageHeader, + predicates: [ + RouteCondition('/de/about'), + ContentTypeCondition(['Document', 'News Item']) + ], +}); +``` + +A slot component must have the following parameters. + +`slot` +: The name of the slot, where the slot components are stored. + +`name` +: The name of the slot component that we are registering. + +`component` +: The component that we want to render in the slot. + +`predicates` +: A list of functions that return a function with this signature. + + ```ts + export type SlotPredicate = (args: any) => boolean; + ``` + + +#### Predicate helpers + +There are two predicate helpers available in the Volto helpers. +You can also create custom predicate helpers. + + +##### `RouteCondition` + +```ts +export function RouteCondition(path: string, exact?: boolean) { + return ({ pathname }: { pathname: string }) => + Boolean(matchPath(pathname, { path, exact })); +} +``` + +The `RouteCondition` predicate helper renders a slot if the specified route matches. +It accepts the following parameters. + +`path` +: String. + Required. + The route. + +`exact` +: Boolean. + Optional. + If `true`, then the match will be exact, else matches "begins with", for the given string from `path`. + + +##### `ContentTypeCondition` + +```ts +export function ContentTypeCondition(contentType: string[]) { + return ({ content }: { content: Content }) => + contentType.includes(content['@type']); +} +``` + +The `ContentTypeCondition` helper predicate allows you to render a slot when the given content type matches the current content type. +It accepts a list of possible content types. + + +##### Custom predicates + +You can create your own predicate helpers to determine whether your slot component should render. +The `SlotRenderer` will pass down the current `content` and the `pathname` into your custom predicate helper. +You can also tailor your own `SlotRenderer`s, or shadow the original `SlotRenderer`, to satisfy your requirements. + + +(slots-getSlot-label)= + +### `getSlot` + +`getSlot` returns the components to be rendered for the given named slot. +You should use this method while building you own slot renderer or customizing the existing `SlotRenderer`. +You can use the implementation of `SlotRenderer` as a template. +This is the signature: + +```ts +config.getSlot(name: string, args: GetSlotArgs): GetSlotReturn +``` + +It has the following parameters. + +`name` +: String. + Required. + The name of the slot we want to render. + +`args` +: Object. + Required. + An object containing the arguments to pass to the predicates. + + +(slots-getSlotComponents-label)= + +### `getSlotComponents` + +`getSlotComponents` returns the list of named slot components registered in a given slot. +This is useful to debug what is registered and in what order, informing you whether you need to change their order. +This is the signature: + +```ts +config.getSlotComponents(slot: string): string[] +``` + +`slot` +: String. + Required. + The name of the slot where the slot components are stored. + + +(slots-getSlotComponent-label)= + +### `getSlotComponent` + +`getSlotComponent` returns the registered named component's data for the given slot component name. +This is the signature: + +```ts +config.getSlotComponent(slot: string, name: string): SlotComponent[] +``` + +`slot` +: String. + Required. + The name of the slot where the slot components are stored. + +`name` +: String. + Required. + The name of the slot component to retrieve. + + +(slots-reorderSlotComponent-label)= + +### `reorderSlotComponent` + +`reorderSlotComponent` reorders the list of named slot components registered per slot. + +Given a `slot` and the `name` of a slot component, you must either specify the desired `position` or perform an `action` to reposition the slot component in the given slot, but not both. + +The available actions are `"after"`, `"before"`, `"first"`, and `"last"`. +`"first"` and `"last"` do not accept a `target`. + +This is the signature: + +```ts +config.reorderSlotComponent({ slot, name, position, action, target }: { + slot: string; + name: string; + position?: number | undefined; + action?: "after" | "before" | "first" | "last" | undefined; + target?: string | undefined; +}): void +``` + +`slot` +: String. + Required. + The name of the slot where the slot components are stored. + +`name` +: String. + Required. + The name of the slot component to reposition in the list of slot components. + +`position` +: Number. + Exactly one of `position` or `action` is required. + The destination position in the registered list of slot components. + The position is zero-indexed. + +`action` +: Enum: `"after"` | `"before"` | `"first"` | `"last"` | undefined. + Exactly one of `position` or `action` is required. + The action to perform on `name`. + + When using either the `"after"` or `"before"` values, a `target` is required. + The slot component will be repositioned relative to the `target`. + + When using either the `"first"` and `"last"` values, a `target` must not be used. + The slot component will be repositioned to either the first or last position. + +`target` +: String. + Required when `action` is either `"after"` or `"before"`, else must not be provided. + The name of the slot component targeted for the given `action`. + + +(slots-unregisterSlotComponent-label)= + +### `unregisterSlotComponent` + +`unregisterSlotComponent` removes a registration for a named slot component, given its registration position. +This is the signature: + +```ts +config.unRegisterSlotComponent(slot: string, name: string, position: number): void +``` + +`slot` +: String. + Required. + The name of the slot that contains the slot component to unregister. + +`name` +: String. + Required. + The name of the slot component to unregister inside the component. + +`position` +: Number. + Required. + The component position to remove in the slot component registration. + Use {ref}`slots-getSlotComponent-label` to find the zero-indexed position of the registered component to remove. diff --git a/packages/coresandbox/src/components/Slots/SlotTest.tsx b/packages/coresandbox/src/components/Slots/SlotTest.tsx new file mode 100644 index 0000000000..66f095c552 --- /dev/null +++ b/packages/coresandbox/src/components/Slots/SlotTest.tsx @@ -0,0 +1,10 @@ +const SlotComponentTest = () => { + return ( +
+

This is a test slot component

+

It should appear above the Content

+
+ ); +}; + +export default SlotComponentTest; diff --git a/packages/coresandbox/src/index.ts b/packages/coresandbox/src/index.ts index 1f4527a27c..b5c04d775b 100644 --- a/packages/coresandbox/src/index.ts +++ b/packages/coresandbox/src/index.ts @@ -9,6 +9,9 @@ import { conditionalVariationsSchemaEnhancer } from './components/Blocks/schemaE import codeSVG from '@plone/volto/icons/code.svg'; import type { BlockConfigBase } from '@plone/types'; import type { ConfigType } from '@plone/registry'; +import SlotComponentTest from './components/Slots/SlotTest'; +import { ContentTypeCondition } from '@plone/volto/helpers'; +import { RouteCondition } from '@plone/volto/helpers/Slots'; const testBlock: BlockConfigBase = { id: 'testBlock', @@ -171,6 +174,13 @@ const applyConfig = (config: ConfigType) => { config.blocks.blocksConfig.listing = listing(config); config.views.contentTypesViews.Folder = NewsAndEvents; + config.registerSlotComponent({ + slot: 'aboveContent', + name: 'testSlotComponent', + component: SlotComponentTest, + predicates: [ContentTypeCondition(['Document']), RouteCondition('/hello')], + }); + return config; }; diff --git a/packages/registry/news/5775.feature b/packages/registry/news/5775.feature new file mode 100644 index 0000000000..d3b2b6f4be --- /dev/null +++ b/packages/registry/news/5775.feature @@ -0,0 +1 @@ +Support for slots @tiberiuichim @sneridagh diff --git a/packages/registry/package.json b/packages/registry/package.json index c8a2fa1c1f..83351c88c2 100644 --- a/packages/registry/package.json +++ b/packages/registry/package.json @@ -50,7 +50,7 @@ }, "scripts": { "watch": "parcel watch", - "build": "parcel build", + "build": "parcel build --no-cache", "test": "vitest", "dry-release": "release-it --dry-run", "release": "release-it", diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts index dc81917a15..56740160ed 100644 --- a/packages/registry/src/index.ts +++ b/packages/registry/src/index.ts @@ -5,6 +5,10 @@ import type { ComponentsConfig, ExperimentalConfig, SettingsConfig, + GetSlotArgs, + GetSlotReturn, + SlotComponent, + SlotPredicate, SlotsConfig, ViewsConfig, WidgetsConfig, @@ -125,7 +129,7 @@ class Config { } getComponent( - options: { name: string; dependencies: string[] | string } | string, + options: { name: string; dependencies?: string[] | string } | string, ): GetComponentResult { if (typeof options === 'object') { const { name, dependencies = '' } = options; @@ -147,7 +151,7 @@ class Config { registerComponent(options: { name: string; - dependencies: string[] | string; + dependencies?: string[] | string; component: React.ComponentType; }) { const { name, component, dependencies = '' } = options; @@ -181,6 +185,214 @@ class Config { } } } + + getSlot(name: string, args: GetSlotArgs): GetSlotReturn { + if (!this._data.slots[name]) { + return; + } + const { slots, data } = this._data.slots[name]; + const slotComponents = []; + // For all enabled slots + for (const slotName of slots) { + // For all registered components for that slot, inversed, since the last one registered wins + // TODO: Cover ZCA use case, where if more predicates, more specificity wins if all true. + // Let's keep it simple here and stick to the registered order. + let noPredicateComponent: SlotComponent | undefined; + const reversedSlotComponents = data[slotName].slice().reverse(); // Immutable reversed copy + for (const slotComponent of reversedSlotComponents) { + let isPredicateTrueFound: boolean = false; + if (slotComponent.predicates) { + isPredicateTrueFound = slotComponent.predicates.every((predicate) => + predicate(args), + ); + } else { + // We mark the one with no predicates + noPredicateComponent = slotComponent; + } + + // If all the predicates are truthy + if (isPredicateTrueFound) { + slotComponents.push({ + component: slotComponent.component, + name: slotName, + }); + // We "reset" the marker, we already found a candidate + noPredicateComponent = undefined; + break; + } + } + + if (noPredicateComponent) { + slotComponents.push({ + component: noPredicateComponent.component, + name: slotName, + }); + } + } + + return slotComponents; + } + + registerSlotComponent(options: { + slot: string; + name: string; + predicates?: SlotPredicate[]; + component: SlotComponent['component']; + }): void { + const { name, component, predicates, slot } = options; + + if (!component) { + throw new Error('No component provided'); + } + if (!predicates) { + // Test if there's already one registered, we only support one + const hasRegisteredNoPredicatesComponent = this._data.slots?.[ + slot + ]?.data?.[name]?.find(({ predicates }) => !predicates); + if (hasRegisteredNoPredicatesComponent) { + throw new Error( + `There is already registered a component ${name} for the slot ${slot}. You can only register one slot component with no predicates per slot.`, + ); + } + } + + let currentSlot = this._data.slots[slot]; + if (!currentSlot) { + this._data.slots[slot] = { + slots: [], + data: {}, + }; + currentSlot = this._data.slots[slot]; + } + if (!currentSlot.data[name]) { + currentSlot.data[name] = []; + } + + const currentSlotComponents = currentSlot.data[name]; + if (!currentSlot.slots.includes(name)) { + currentSlot.slots.push(name); + } + const slotComponentData = { + component, + predicates, + }; + + // Try to set a displayName (useful for React dev tools) for the registered component + // Only if it's a function and it's not set previously + try { + const displayName = slotComponentData.component.displayName; + + if (!displayName && typeof slotComponentData?.component === 'function') { + slotComponentData.component.displayName = name; + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + `Not setting the slot component displayName because ${error}`, + ); + } + + currentSlotComponents.push(slotComponentData); + } + + getSlotComponent(slot: string, name: string) { + const currentSlot = this._data.slots[slot]; + if (!slot || !currentSlot) { + throw new Error(`No slot ${slot} found`); + } + const currentSlotComponents = currentSlot.data[name]; + if (!currentSlotComponents) { + throw new Error(`No slot component ${name} in slot ${slot} found`); + } + return currentSlotComponents; + } + + getSlotComponents(slot: string) { + const currentSlot = this._data.slots[slot]; + if (!slot || !currentSlot) { + throw new Error(`No slot ${slot} found`); + } + return currentSlot.slots; + } + + reorderSlotComponent({ + slot, + name, + position, + action, + target, + }: { + slot: string; + name: string; + position?: number; + action?: 'after' | 'before' | 'first' | 'last'; + target?: string; + }) { + if (!position && !action) { + throw new Error(`At least a position or action is required as argument`); + } + if (position && action) { + throw new Error( + `You should provide only one of position or action as arguments`, + ); + } + if ((action == 'after' || action == 'before') && !target) { + throw new Error( + `No action target set. You should provide the name of a slot component as target when action is 'after' or 'before'.`, + ); + } + + const currentSlot = this._data.slots[slot]; + if (!slot || !currentSlot) { + throw new Error(`No slot ${slot} found`); + } + const origin = currentSlot.slots.indexOf(name); + const result = Array.from(currentSlot.slots); + const [removed] = result.splice(origin, 1); + + if (action) { + let targetIdx = 0; + if (target) { + targetIdx = currentSlot.slots.indexOf(target); + } + switch (action) { + case 'after': + result.splice(targetIdx, 0, removed); + break; + case 'before': + result.splice(targetIdx - 1, 0, removed); + break; + case 'last': + result.push(removed); + break; + case 'first': + result.unshift(removed); + break; + + default: + break; + } + } + + if (position) { + result.splice(position, 0, removed); + } + + currentSlot.slots = result; + } + + unRegisterSlotComponent(slot: string, name: string, position: number) { + const currentSlot = this._data.slots[slot]; + if (!slot || !currentSlot) { + throw new Error(`No slot ${slot} found`); + } + const currentSlotComponents = currentSlot.data[name]; + if (!currentSlotComponents) { + throw new Error(`No slot component ${name} in slot ${slot} found`); + } + const result = currentSlotComponents.slice(); + currentSlot.data[name] = result.splice(position, 1); + } } const instance = new Config(); diff --git a/packages/registry/src/registry.test.jsx b/packages/registry/src/registry.test.jsx deleted file mode 100644 index 861d26701f..0000000000 --- a/packages/registry/src/registry.test.jsx +++ /dev/null @@ -1,112 +0,0 @@ -import config from './index'; -import { describe, expect, it } from 'vitest'; - -config.set('components', { - Toolbar: { component: 'this is the Toolbar component' }, - 'Toolbar.Types': { component: 'this is the Types component' }, - 'Teaser|News Item': { component: 'This is the News Item Teaser component' }, -}); - -describe('registry', () => { - it('get components', () => { - expect(config.getComponent('Toolbar').component).toEqual( - 'this is the Toolbar component', - ); - }); - it('get components with context', () => { - expect( - config.getComponent({ name: 'Teaser', dependencies: 'News Item' }) - .component, - ).toEqual('This is the News Item Teaser component'); - }); - it('get components with dots (as an object)', () => { - expect(config.getComponent({ name: 'Toolbar.Types' }).component).toEqual( - 'this is the Types component', - ); - }); - it('get components with | and spaces (as a string)', () => { - expect(config.getComponent('Teaser|News Item').component).toEqual( - 'This is the News Item Teaser component', - ); - }); - it('resolves unexistent component (as a string)', () => { - expect(config.getComponent('Toolbar.Doh').component).toEqual(undefined); - expect(config.getComponent('Toolbar.Doh')).toEqual({}); - }); - it('registers and gets a component by name (as string)', () => { - config.registerComponent({ - name: 'Toolbar.Bar', - component: 'this is a Bar component', - }); - expect(config.getComponent('Toolbar.Bar').component).toEqual( - 'this is a Bar component', - ); - }); - it('registers and gets a component by name (as an object)', () => { - config.registerComponent({ - name: 'Toolbar.Bar', - component: 'this is a Bar component', - }); - expect(config.getComponent({ name: 'Toolbar.Bar' }).component).toEqual( - 'this is a Bar component', - ); - }); - it('registers and gets a component by name (as an object) - check displayName', () => { - config.registerComponent({ - name: 'Toolbar.Bar', - component: () =>
Hello
, - }); - expect(config.getComponent('Toolbar.Bar').component.displayName).toEqual( - 'Toolbar.Bar', - ); - }); - it('registers and gets a component by name (as an object) - check displayName if it has already one, it does not overwrite it', () => { - const TestComponent = () =>
Hello
; - TestComponent.displayName = 'DisplayNameAlreadySet'; - config.registerComponent({ - name: 'Toolbar.Bar', - component: TestComponent, - }); - - expect(config.getComponent('Toolbar.Bar').component.displayName).toEqual( - 'DisplayNameAlreadySet', - ); - }); - it('registers and gets a component by name (as an object) - check displayName - do not break if it is a normal function', () => { - function myFunction() { - return 'true'; - } - - config.registerComponent({ - name: 'Toolbar.Bar', - component: myFunction, - }); - expect(config.getComponent('Toolbar.Bar').component.displayName).toEqual( - 'Toolbar.Bar', - ); - }); - it('registers a component by name with dependencies', () => { - config.registerComponent({ - name: 'Toolbar.Bar', - component: 'this is a Bar component', - dependencies: 'News Item', - }); - expect( - config.getComponent({ name: 'Toolbar.Bar', dependencies: 'News Item' }) - .component, - ).toEqual('this is a Bar component'); - }); - it('registers a component by name with dependencies array', () => { - config.registerComponent({ - name: 'Toolbar.Bar', - component: 'this is a Bar component', - dependencies: ['News Item', 'StringFieldWidget'], - }); - expect( - config.getComponent({ - name: 'Toolbar.Bar', - dependencies: ['News Item', 'StringFieldWidget'], - }).component, - ).toEqual('this is a Bar component'); - }); -}); diff --git a/packages/registry/src/registry.test.tsx b/packages/registry/src/registry.test.tsx new file mode 100644 index 0000000000..f5879bebe6 --- /dev/null +++ b/packages/registry/src/registry.test.tsx @@ -0,0 +1,736 @@ +import config from './index'; +import { describe, expect, it, afterEach } from 'vitest'; + +config.set('components', { + Toolbar: { component: 'this is the Toolbar component' }, + 'Toolbar.Types': { component: 'this is the Types component' }, + 'Teaser|News Item': { component: 'This is the News Item Teaser component' }, +}); + +config.set('slots', {}); + +describe('Component registry', () => { + it('get components', () => { + expect(config.getComponent('Toolbar').component).toEqual( + 'this is the Toolbar component', + ); + }); + it('get components with context', () => { + expect( + config.getComponent({ name: 'Teaser', dependencies: 'News Item' }) + .component, + ).toEqual('This is the News Item Teaser component'); + }); + it('get components with dots (as an object)', () => { + expect(config.getComponent({ name: 'Toolbar.Types' }).component).toEqual( + 'this is the Types component', + ); + }); + it('get components with | and spaces (as a string)', () => { + expect(config.getComponent('Teaser|News Item').component).toEqual( + 'This is the News Item Teaser component', + ); + }); + it('resolves unexistent component (as a string)', () => { + expect(config.getComponent('Toolbar.Doh').component).toEqual(undefined); + expect(config.getComponent('Toolbar.Doh')).toEqual({}); + }); + it('registers and gets a component by name (as string)', () => { + config.registerComponent({ + name: 'Toolbar.Bar', + component: 'this is a Bar component', + }); + expect(config.getComponent('Toolbar.Bar').component).toEqual( + 'this is a Bar component', + ); + }); + it('registers and gets a component by name (as an object)', () => { + config.registerComponent({ + name: 'Toolbar.Bar', + component: 'this is a Bar component', + }); + expect(config.getComponent({ name: 'Toolbar.Bar' }).component).toEqual( + 'this is a Bar component', + ); + }); + it('registers and gets a component by name (as an object) - check displayName', () => { + config.registerComponent({ + name: 'Toolbar.Bar', + component: () =>
Hello
, + }); + expect(config.getComponent('Toolbar.Bar').component.displayName).toEqual( + 'Toolbar.Bar', + ); + }); + it('registers and gets a component by name (as an object) - check displayName if it has already one, it does not overwrite it', () => { + const TestComponent = () =>
Hello
; + TestComponent.displayName = 'DisplayNameAlreadySet'; + config.registerComponent({ + name: 'Toolbar.Bar', + component: TestComponent, + }); + + expect(config.getComponent('Toolbar.Bar').component.displayName).toEqual( + 'DisplayNameAlreadySet', + ); + }); + it('registers and gets a component by name (as an object) - check displayName - do not break if it is a normal function', () => { + function myFunction() { + return 'true'; + } + + config.registerComponent({ + name: 'Toolbar.Bar', + component: myFunction, + }); + expect(config.getComponent('Toolbar.Bar').component.displayName).toEqual( + 'Toolbar.Bar', + ); + }); + it('registers a component by name with dependencies', () => { + config.registerComponent({ + name: 'Toolbar.Bar', + component: 'this is a Bar component', + dependencies: 'News Item', + }); + expect( + config.getComponent({ name: 'Toolbar.Bar', dependencies: 'News Item' }) + .component, + ).toEqual('this is a Bar component'); + }); + it('registers a component by name with dependencies array', () => { + config.registerComponent({ + name: 'Toolbar.Bar', + component: 'this is a Bar component', + dependencies: ['News Item', 'StringFieldWidget'], + }); + expect( + config.getComponent({ + name: 'Toolbar.Bar', + dependencies: ['News Item', 'StringFieldWidget'], + }).component, + ).toEqual('this is a Bar component'); + }); +}); + +describe('Slots registry', () => { + afterEach(() => { + config.set('slots', {}); + }); + + // type Predicate = (predicateValues: unknown) = (predicateValues, args) => boolean + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const RouteConditionTrue = (route: string) => () => true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const RouteConditionFalse = (route: string) => () => false; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const ContentTypeConditionTrue = (contentType: string[]) => () => true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const ContentTypeConditionFalse = (contentType: string[]) => () => false; + + it('registers a single slot component with no predicate', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with no predicate', + }); + + expect(config.getSlot('toolbar', {})![0].component).toEqual( + 'this is a toolbar component with no predicate', + ); + }); + + it('registers two slot components with predicates - registered components order is respected', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: + 'this is a toolbar component with only a truth-ish route condition', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a Bar component with a false predicate and one true', + predicates: [ + RouteConditionFalse('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + expect(config.getSlot('toolbar', {})![0].component).toEqual( + 'this is a toolbar component with only a truth-ish route condition', + ); + }); + + it('registers two slot components with predicates - All registered components predicates are truthy, the last one registered wins', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: + 'this is a toolbar component with only a truth-ish route condition', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two truth-ish predicates', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + expect(config.getSlot('toolbar', {})![0].component).toEqual( + 'this is a toolbar component with two truth-ish predicates', + ); + }); + + it('registers two slot components with predicates - No registered component have a truthy predicate', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two false predicate', + predicates: [ + RouteConditionFalse('/folder/path'), + ContentTypeConditionFalse(['News Item']), + ], + }); + + expect(config.getSlot('toolbar', {})).toEqual([]); + }); + + it('registers two slot components one without predicates - registered component with predicates are truthy, the last one registered wins', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with no predicate', + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two truth-ish predicates', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + expect(config.getSlot('toolbar', {})![0].component).toEqual( + 'this is a toolbar component with two truth-ish predicates', + ); + }); + + it('registers two slot components one without predicates - registered components predicates are falsy, the one with no predicates wins', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with no predicate', + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two truth-ish predicates', + predicates: [ + RouteConditionFalse('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + expect(config.getSlot('toolbar', {})![0].component).toEqual( + 'this is a toolbar component with no predicate', + ); + }); + + it('registers two slot components one without predicates - registered components predicates are truthy, the one with predicates wins', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two truth-ish predicates', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with no predicate', + }); + + expect(config.getSlot('toolbar', {})![0].component).toEqual( + 'this is a toolbar component with two truth-ish predicates', + ); + }); + + it('registers 2 + 2 slot components with predicates - No registered component have a truthy predicate', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two false predicate', + predicates: [ + RouteConditionFalse('/folder/path'), + ContentTypeConditionFalse(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with two false predicate', + predicates: [ + RouteConditionFalse('/folder/path'), + ContentTypeConditionFalse(['News Item']), + ], + }); + expect(config.getSlot('toolbar', {})).toEqual([]); + }); + + it('registers 2 + 2 slot components with predicates - One truthy predicate per set', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar save component with a true predicate', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two false predicate', + predicates: [ + RouteConditionFalse('/folder/path'), + ContentTypeConditionFalse(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + expect(config.getSlot('toolbar', {})![0].component).toEqual( + 'this is a toolbar save component with a true predicate', + ); + expect(config.getSlot('toolbar', {})![1].component).toEqual( + 'this is a toolbar edit component with true predicate', + ); + }); + + it('getSlotComponents - registers 2 + 2 slot components with predicates', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar save component with a true predicate', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two false predicate', + predicates: [ + RouteConditionFalse('/folder/path'), + ContentTypeConditionFalse(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + expect(config.getSlotComponents('toolbar').length).toEqual(2); + expect(config.getSlotComponents('toolbar')).toEqual(['save', 'edit']); + }); + + it('getSlotComponent - registers 2 + 2 slot components with predicates', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar save component with a true predicate', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two false predicate', + predicates: [ + RouteConditionFalse('/folder/path'), + ContentTypeConditionFalse(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + expect(config.getSlotComponent('toolbar', 'save').length).toEqual(2); + expect(config.getSlotComponent('toolbar', 'save')[0].component).toEqual( + 'this is a toolbar save component with a true predicate', + ); + }); + + it('reorderSlotComponent - position', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar save component with a true predicate', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two false predicate', + predicates: [ + RouteConditionFalse('/folder/path'), + ContentTypeConditionFalse(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + expect(config.getSlotComponent('toolbar', 'save').length).toEqual(2); + expect(config.getSlotComponent('toolbar', 'save')[0].component).toEqual( + 'this is a toolbar save component with a true predicate', + ); + config.reorderSlotComponent({ slot: 'toolbar', name: 'save', position: 1 }); + expect(config.getSlotComponents('toolbar')).toEqual(['edit', 'save']); + }); + + it('reorderSlotComponent - after', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar save component with a true predicate', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'cancel', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'bold', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + expect(config.getSlotComponents('toolbar')).toEqual([ + 'save', + 'edit', + 'cancel', + 'bold', + ]); + config.reorderSlotComponent({ + slot: 'toolbar', + name: 'save', + action: 'after', + target: 'cancel', + }); + expect(config.getSlotComponents('toolbar')).toEqual([ + 'edit', + 'cancel', + 'save', + 'bold', + ]); + }); + + it('reorderSlotComponent - before', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar save component with a true predicate', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'cancel', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'bold', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + expect(config.getSlotComponents('toolbar')).toEqual([ + 'save', + 'edit', + 'cancel', + 'bold', + ]); + config.reorderSlotComponent({ + slot: 'toolbar', + name: 'save', + action: 'before', + target: 'cancel', + }); + expect(config.getSlotComponents('toolbar')).toEqual([ + 'edit', + 'save', + 'cancel', + 'bold', + ]); + }); + + it('reorderSlotComponent - last', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar save component with a true predicate', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'cancel', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'bold', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + expect(config.getSlotComponents('toolbar')).toEqual([ + 'save', + 'edit', + 'cancel', + 'bold', + ]); + config.reorderSlotComponent({ + slot: 'toolbar', + name: 'save', + action: 'last', + }); + expect(config.getSlotComponents('toolbar')).toEqual([ + 'edit', + 'cancel', + 'bold', + 'save', + ]); + }); + + it('reorderSlotComponent - first', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar save component with a true predicate', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'cancel', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'bold', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + + expect(config.getSlotComponents('toolbar')).toEqual([ + 'save', + 'edit', + 'cancel', + 'bold', + ]); + config.reorderSlotComponent({ + slot: 'toolbar', + name: 'bold', + action: 'first', + }); + expect(config.getSlotComponents('toolbar')).toEqual([ + 'bold', + 'save', + 'edit', + 'cancel', + ]); + }); + + it('unRegisterSlotComponent - registers 2 + 2 slot components with predicates', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar save component with a true predicate', + predicates: [RouteConditionTrue('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: 'this is a toolbar component with two false predicate', + predicates: [ + RouteConditionFalse('/folder/path'), + ContentTypeConditionFalse(['News Item']), + ], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar component with a false predicate', + predicates: [RouteConditionFalse('/de')], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: 'this is a toolbar edit component with true predicate', + predicates: [ + RouteConditionTrue('/folder/path'), + ContentTypeConditionTrue(['News Item']), + ], + }); + expect(config.getSlotComponent('toolbar', 'save').length).toEqual(2); + expect(config.getSlotComponent('toolbar', 'save')[0].component).toEqual( + 'this is a toolbar save component with a true predicate', + ); + config.unRegisterSlotComponent('toolbar', 'save', 1); + expect(config.getSlotComponent('toolbar', 'save').length).toEqual(1); + }); +}); diff --git a/packages/types/news/5775.feature b/packages/types/news/5775.feature new file mode 100644 index 0000000000..58fafd2629 --- /dev/null +++ b/packages/types/news/5775.feature @@ -0,0 +1 @@ +Support for slots @sneridagh diff --git a/packages/types/src/config/Slots.d.ts b/packages/types/src/config/Slots.d.ts new file mode 100644 index 0000000000..87952c3f7d --- /dev/null +++ b/packages/types/src/config/Slots.d.ts @@ -0,0 +1,25 @@ +import type { Content } from '../content'; + +export type SlotPredicate = (args: any) => boolean; + +export type GetSlotArgs = { + content: Content; + pathname: string; + navRoot?: Content; +}; + +export type GetSlotReturn = + | { component: SlotComponent['component']; name: string }[] + | undefined; + +export type SlotComponent = { + component: React.ComponentType; + predicates?: SlotPredicate[]; +}; + +export type SlotManager = { + slots: string[]; + data: Record; +}; + +export type SlotsConfig = Record; diff --git a/packages/types/src/config/index.d.ts b/packages/types/src/config/index.d.ts index 02df192e63..a62e18c96d 100644 --- a/packages/types/src/config/index.d.ts +++ b/packages/types/src/config/index.d.ts @@ -2,6 +2,7 @@ import type { SettingsConfig } from './Settings'; import type { BlocksConfig } from './Blocks'; import type { ViewsConfig } from './Views'; import type { WidgetsConfig } from './Widgets'; +import type { SlotsConfig } from './Slots'; export type AddonReducersConfig = Record; @@ -11,8 +12,6 @@ export type AddonRoutesConfig = { component: React.ComponentType; }[]; -export type SlotsConfig = Record; - export type ComponentsConfig = Record< string, { component: React.ComponentType } @@ -34,3 +33,4 @@ export type ConfigData = { export { SettingsConfig, BlocksConfig, ViewsConfig, WidgetsConfig }; export * from './Blocks'; +export * from './Slots'; diff --git a/packages/types/src/content/common.d.ts b/packages/types/src/content/common.d.ts index b1b91edf84..de8922c49f 100644 --- a/packages/types/src/content/common.d.ts +++ b/packages/types/src/content/common.d.ts @@ -2,6 +2,7 @@ import type { BreadcrumbsResponse } from '../services/breadcrumbs'; import type { NavigationResponse } from '../services/navigation'; import type { ActionsResponse } from '../services/actions'; import type { GetTypesResponse } from '../services/types'; +import type { GetNavrootResponse } from '../services/navroot'; import type { GetAliasesResponse } from '../services/aliases'; import type { ContextNavigationResponse } from '../services/contextnavigation'; import type { WorkflowResponse } from '../services/workflow'; @@ -13,6 +14,7 @@ export interface Expanders { breadcrumbs: BreadcrumbsResponse; contextnavigation: ContextNavigationResponse; navigation: NavigationResponse; + navroot: GetNavrootResponse; types: GetTypesResponse; workflow: WorkflowResponse; } diff --git a/packages/volto/cypress/tests/coresandbox/slots.js b/packages/volto/cypress/tests/coresandbox/slots.js new file mode 100644 index 0000000000..4d8abe6e48 --- /dev/null +++ b/packages/volto/cypress/tests/coresandbox/slots.js @@ -0,0 +1,43 @@ +context('Slots', () => { + describe('Block Default View / Edit', () => { + beforeEach(() => { + cy.intercept('GET', `/**/*?expand*`).as('content'); + cy.intercept('GET', '/**/Document').as('schema'); + // given a logged in editor and a page in edit mode + cy.autologin(); + cy.createContent({ + contentType: 'Document', + contentId: 'document', + contentTitle: 'Test document', + }); + cy.createContent({ + contentType: 'Document', + contentId: 'hello', + contentTitle: 'Test document Hello', + }); + + cy.visit('/'); + cy.wait('@content'); + }); + + it('[ContentTypeCondition(["Document"]), RouteCondition("/hello")] only renders when the predicates are true', function () { + cy.get('body').should( + 'not.include.text', + 'This is a test slot component', + ); + + cy.navigate('/document'); + cy.wait('@content'); + + cy.get('body').should( + 'not.include.text', + 'This is a test slot component', + ); + + cy.navigate('/hello'); + cy.wait('@content'); + + cy.get('body').should('include.text', 'This is a test slot component'); + }); + }); +}); diff --git a/packages/volto/news/5775.feature b/packages/volto/news/5775.feature new file mode 100644 index 0000000000..58fafd2629 --- /dev/null +++ b/packages/volto/news/5775.feature @@ -0,0 +1 @@ +Support for slots @sneridagh diff --git a/packages/volto/package.json b/packages/volto/package.json index c202aaf0c6..3765fe3dda 100644 --- a/packages/volto/package.json +++ b/packages/volto/package.json @@ -189,6 +189,8 @@ "@plone/registry": "workspace:*", "@plone/scripts": "workspace:*", "@plone/volto-slate": "workspace:*", + "@types/react": "^18.2.57", + "@types/react-dom": "^18.2.19", "autoprefixer": "10.4.8", "axe-core": "4.4.2", "babel-plugin-add-module-exports": "0.2.1", @@ -365,8 +367,7 @@ "@testing-library/react-hooks": "8.0.1", "@types/jest": "^29.5.8", "@types/lodash": "^4.14.201", - "@types/react": "^17.0.52", - "@types/react-dom": "^17", + "@types/react-router-dom": "^5.3.3", "@types/react-test-renderer": "18.0.1", "@types/uuid": "^9.0.2", "@typescript-eslint/eslint-plugin": "6.7.0", diff --git a/packages/volto/src/components/manage/Form/Form.jsx b/packages/volto/src/components/manage/Form/Form.jsx index 2e7c4ff4e6..6ffc4020b0 100644 --- a/packages/volto/src/components/manage/Form/Form.jsx +++ b/packages/volto/src/components/manage/Form/Form.jsx @@ -53,6 +53,7 @@ import { } from '@plone/volto/actions'; import { compose } from 'redux'; import config from '@plone/volto/registry'; +import SlotRenderer from '../../theme/SlotRenderer/SlotRenderer'; /** * Form container class. @@ -641,131 +642,147 @@ class Form extends Component { // Removing this from SSR is important, since react-beautiful-dnd supports SSR, // but draftJS don't like it much and the hydration gets messed up this.state.isClient && ( - - { - const newFormData = { - ...formData, - ...newBlockData, - }; - this.setState({ - formData: newFormData, - }); - if (this.props.global) { - this.props.setFormData(newFormData); - } - }} - onSetSelectedBlocks={(blockIds) => - this.setState({ multiSelected: blockIds }) - } - onSelectBlock={this.onSelectBlock} - /> - { - if (this.props.global) { - this.props.setFormData(state.formData); - } - return this.setState(state); - }} - /> - { - const newFormData = { - ...formData, - ...newData, - }; - this.setState({ - formData: newFormData, - }); - if (this.props.global) { - this.props.setFormData(newFormData); - } - }} - onChangeField={this.onChangeField} - onSelectBlock={this.onSelectBlock} - properties={formData} + <> + - {this.state.isClient && this.props.editable && ( - - 0} + + + { + const newFormData = { + ...formData, + ...newBlockData, + }; + this.setState({ + formData: newFormData, + }); + if (this.props.global) { + this.props.setFormData(newFormData); + } + }} + onSetSelectedBlocks={(blockIds) => + this.setState({ multiSelected: blockIds }) + } + onSelectBlock={this.onSelectBlock} + /> + { + if (this.props.global) { + this.props.setFormData(state.formData); + } + return this.setState(state); + }} + /> + { + const newFormData = { + ...formData, + ...newData, + }; + this.setState({ + formData: newFormData, + }); + if (this.props.global) { + this.props.setFormData(newFormData); + } + }} + onChangeField={this.onChangeField} + onSelectBlock={this.onSelectBlock} + properties={formData} + navRoot={navRoot} + type={type} + pathname={this.props.pathname} + selectedBlock={this.state.selected} + multiSelected={this.state.multiSelected} + manage={this.props.isAdminForm} + allowedBlocks={this.props.allowedBlocks} + showRestricted={this.props.showRestricted} + editable={this.props.editable} + isMainForm={this.props.editable} + /> + {this.state.isClient && this.props.editable && ( + - {schema && - map(schema.fieldsets, (fieldset) => ( - -
0} + > + {schema && + map(schema.fieldsets, (fieldset) => ( + - - {fieldset.title} - {metadataFieldsets.includes(fieldset.id) ? ( - - ) : ( - - )} - - - - {map(fieldset.fields, (field, index) => ( - - ))} - - -
-
- ))} -
-
- )} -
+ + {fieldset.title} + {metadataFieldsets.includes(fieldset.id) ? ( + + ) : ( + + )} + + + + {map(fieldset.fields, (field, index) => ( + + ))} + + + + + ))} + + + )} + + + + ) ) : ( @@ -931,6 +948,7 @@ const FormIntl = injectIntl(Form, { forwardRef: true }); export default compose( connect( (state, props) => ({ + content: state.content.data, globalData: state.form?.global, metadataFieldsets: state.sidebar?.metadataFieldsets, metadataFieldFocus: state.sidebar?.metadataFieldFocus, diff --git a/packages/volto/src/components/manage/Form/Form.test.jsx b/packages/volto/src/components/manage/Form/Form.test.jsx index 16e2362b65..1ac45f0e4d 100644 --- a/packages/volto/src/components/manage/Form/Form.test.jsx +++ b/packages/volto/src/components/manage/Form/Form.test.jsx @@ -18,6 +18,13 @@ describe('Form', () => { locale: 'en', messages: {}, }, + content: { + data: {}, + create: { + loading: false, + loaded: true, + }, + }, }); const component = renderer.create( diff --git a/packages/volto/src/components/manage/Preferences/ChangePassword.test.jsx b/packages/volto/src/components/manage/Preferences/ChangePassword.test.jsx index 0df20b57de..48f71c5aa4 100644 --- a/packages/volto/src/components/manage/Preferences/ChangePassword.test.jsx +++ b/packages/volto/src/components/manage/Preferences/ChangePassword.test.jsx @@ -28,6 +28,13 @@ describe('ChangePassword', () => { loading: false, }, }, + content: { + data: {}, + create: { + loading: false, + loaded: true, + }, + }, }); const component = renderer.create( diff --git a/packages/volto/src/components/manage/Preferences/PersonalInformation.test.jsx b/packages/volto/src/components/manage/Preferences/PersonalInformation.test.jsx index d08924a6d6..52eac28cee 100644 --- a/packages/volto/src/components/manage/Preferences/PersonalInformation.test.jsx +++ b/packages/volto/src/components/manage/Preferences/PersonalInformation.test.jsx @@ -42,6 +42,13 @@ describe('PersonalInformation', () => { locale: 'en', messages: {}, }, + content: { + data: {}, + create: { + loading: false, + loaded: true, + }, + }, }); const component = renderer.create( @@ -76,6 +83,13 @@ describe('PersonalInformation', () => { locale: 'en', messages: {}, }, + content: { + data: {}, + create: { + loading: false, + loaded: true, + }, + }, }); const component = renderer.create( diff --git a/packages/volto/src/components/manage/Preferences/PersonalPreferences.test.jsx b/packages/volto/src/components/manage/Preferences/PersonalPreferences.test.jsx index 6d30297e6f..97ed07e951 100644 --- a/packages/volto/src/components/manage/Preferences/PersonalPreferences.test.jsx +++ b/packages/volto/src/components/manage/Preferences/PersonalPreferences.test.jsx @@ -31,6 +31,13 @@ describe('PersonalPreferences', () => { itemsTotal: 1, }, }, + content: { + data: {}, + create: { + loading: false, + loaded: true, + }, + }, }); const { container } = render( diff --git a/packages/volto/src/components/theme/Comments/Comments.test.jsx b/packages/volto/src/components/theme/Comments/Comments.test.jsx index 3c05f87ada..1ea8663e7d 100644 --- a/packages/volto/src/components/theme/Comments/Comments.test.jsx +++ b/packages/volto/src/components/theme/Comments/Comments.test.jsx @@ -57,6 +57,13 @@ describe('Comments', () => { locale: 'en', messages: {}, }, + content: { + data: {}, + create: { + loading: false, + loaded: true, + }, + }, }); const component = renderer.create( diff --git a/packages/volto/src/components/theme/PasswordReset/PasswordReset.test.jsx b/packages/volto/src/components/theme/PasswordReset/PasswordReset.test.jsx index 31401e2388..e5432c97b9 100644 --- a/packages/volto/src/components/theme/PasswordReset/PasswordReset.test.jsx +++ b/packages/volto/src/components/theme/PasswordReset/PasswordReset.test.jsx @@ -22,6 +22,13 @@ describe('PasswordReset', () => { locale: 'en', messages: {}, }, + content: { + data: {}, + create: { + loading: false, + loaded: true, + }, + }, }); const component = renderer.create( diff --git a/packages/volto/src/components/theme/PasswordReset/RequestPasswordReset.test.jsx b/packages/volto/src/components/theme/PasswordReset/RequestPasswordReset.test.jsx index b1b085c371..47a0345881 100644 --- a/packages/volto/src/components/theme/PasswordReset/RequestPasswordReset.test.jsx +++ b/packages/volto/src/components/theme/PasswordReset/RequestPasswordReset.test.jsx @@ -22,6 +22,13 @@ describe('RequestPasswordReset', () => { locale: 'en', messages: {}, }, + content: { + data: {}, + create: { + loading: false, + loaded: true, + }, + }, }); const component = renderer.create( diff --git a/packages/volto/src/components/theme/Register/Register.test.jsx b/packages/volto/src/components/theme/Register/Register.test.jsx index 6b288fc81d..f78c37ecbf 100644 --- a/packages/volto/src/components/theme/Register/Register.test.jsx +++ b/packages/volto/src/components/theme/Register/Register.test.jsx @@ -22,6 +22,13 @@ describe('Register', () => { locale: 'en', messages: {}, }, + content: { + data: {}, + create: { + loading: false, + loaded: true, + }, + }, }); const component = renderer.create( diff --git a/packages/volto/src/components/theme/SlotRenderer/SlotRenderer.test.jsx b/packages/volto/src/components/theme/SlotRenderer/SlotRenderer.test.jsx new file mode 100644 index 0000000000..0f09535e5a --- /dev/null +++ b/packages/volto/src/components/theme/SlotRenderer/SlotRenderer.test.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import SlotRenderer from './SlotRenderer'; +import config from '@plone/volto/registry'; + +describe('SlotRenderer Component', () => { + const RouteConditionTrue = () => () => true; + const RouteConditionFalse = () => () => false; + const ContentTypeConditionTrue = () => () => true; + const ContentTypeConditionFalse = () => () => false; + + test('renders a SlotRenderer component for the aboveContentTitle with two slots in the root', () => { + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: (props) =>
, + predicates: [RouteConditionTrue()], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'save', + component: (props) =>
, + predicates: [RouteConditionFalse(), ContentTypeConditionFalse()], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: (props) =>
, + predicates: [RouteConditionFalse()], + }); + + config.registerSlotComponent({ + slot: 'toolbar', + name: 'edit', + component: (props) => ( +