diff --git a/docs/decisions/0011-no-slot-wrapping-operation.rst b/docs/decisions/0011-no-slot-wrapping-operation.rst new file mode 100644 index 00000000..a0f1ae15 --- /dev/null +++ b/docs/decisions/0011-no-slot-wrapping-operation.rst @@ -0,0 +1,180 @@ +############################################ +Slot layouts instead of a wrapping operation +############################################ + +Status +====== + +Accepted + + +Context +======= + +Legacy ``@openedx/frontend-plugin-framework`` provided a ``Wrap`` plugin +operation that allowed a plugin to wrap an existing widget's rendered output +with an arbitrary React component. The wrapper received the original content +as a ``component`` prop and could add markup around it, conditionally render +it, or replace it entirely. + +The canonical example of this was +`frontend-plugin-aspects `_, +the Open edX analytics plugin. It used ``Wrap`` on the authoring MFE's +sidebar slots to implement a toggle between the default sidebar and an +analytics sidebar: + +.. code-block:: typescript + + // frontend-plugin-aspects/src/components/SidebarToggleWrapper.tsx + export const SidebarToggleWrapper = ({ component }: { component: ReactNode }) => { + const { sidebarOpen } = useAspectsSidebarContext(); + return !sidebarOpen && component; + }; + + // frontend-plugin-aspects/src/plugin-slots.ts + { + op: PLUGIN_OPERATIONS.Wrap, + widgetId: 'default_contents', + wrapper: SidebarToggleWrapper, + } + +When the user opened the Aspects sidebar, the wrapper hid the default sidebar +content; when the Aspects sidebar was closed, the default content reappeared. +The plugin also used an ``Insert`` operation to add its own sidebar widget to +the slot. + +With the introduction of ``frontend-base`` as the successor to both +``frontend-plugin-framework`` and ``frontend-platform``, the slot and widget +architecture was redesigned. The question arose as to whether a ``Wrap`` +operation should be carried forward into the new architecture. + + +Decision +======== + +We will **not** implement a slot wrapping operation in ``frontend-base``. The +use cases previously served by wrapping are better addressed by improving the +existing layout system. + +``useWidgets()`` will be enriched with helper methods that let layouts filter +widgets by identity (ID or role). Internally, this metadata already exists +throughout the widget pipeline; it is only stripped at the very end before being +returned to layouts. The enriched return value remains a ``ReactNode[]`` that +renders identically when used as-is (preserving backwards compatibility), but +adds methods like ``byId()``, ``withoutId()``, ``byRole()``, and +``withoutRole()`` for selective rendering. + +For the sidebar toggle use case, a plugin can replace the slot's layout and +add its own widget, using only existing operations: + +.. code-block:: typescript + + // Replace the sidebar layout with a toggle-aware layout + { + slotId: 'org.openedx.frontend.slot.authoring.outlineSidebar.v1', + op: LayoutOperationTypes.REPLACE, + component: AspectsSidebarLayout, + }, + // Add the Aspects sidebar as a widget + { + slotId: 'org.openedx.frontend.slot.authoring.outlineSidebar.v1', + id: 'org.openedx.frontend.widget.aspects.outlineSidebar', + op: WidgetOperationTypes.APPEND, + component: CourseOutlineSidebar, + }, + +Where the custom layout controls the toggle: + +.. code-block:: typescript + + const aspectsWidget = 'org.openedx.frontend.widget.aspects.outlineSidebar'; + + function AspectsSidebarLayout() { + const widgets = useWidgets(); + const { sidebarOpen } = useAspectsSidebarContext(); + + if (sidebarOpen) { + return <>{widgets.byId(aspectsWidget)}; + } + return <>{widgets.withoutId(aspectsWidget)}; + } + +This approach is preferable for several reasons: + +Separation of concerns + The toggle is not a property of an individual widget; it is a rendering + strategy for the entire slot. A layout makes this explicit. With a wrapping + operation, the toggle logic is bolted onto a specific widget ID, creating a + hidden dependency between the wrapper and the slot's internal structure. + +Explicit widget identity through standard hooks + With ``useWidgets()`` exposing identity metadata, layouts can distinguish + widgets through a documented, type-safe API. The slot's children are + available as the ``defaultContent`` widget, and plugin-added widgets are + identified by their declared IDs. + +Composability + If two plugins both want to affect a slot's rendering strategy, two ``Wrap`` + operations on the same widget create nested wrappers with no coordination + mechanism. Layout replacements have a clear last-one-wins semantic, and a + layout can use ``useLayoutOptions()`` to accept configuration from multiple + plugins, enabling collaboration between them. + + +Consequences +============ + +Plugins that previously used ``Wrap`` to conditionally render or decorate slot +content will use layout replacements instead. This requires the plugin author +to write a layout component, which is slightly more code than a wrapper +function, but provides full control over rendering with access to all of the +slot's hooks and context. + +The array returned by ``useWidgets()`` will gain helper methods (``byId()``, +``withoutId()``, ``byRole()``, ``withoutRole()``) that let layouts selectively +render widgets. Because the array elements remain plain ``ReactNode`` values, +existing layouts that render ``useWidgets()`` directly will continue to work +without modification. + + +Rejected alternatives +===================== + +Widget wrapping operation +------------------------- + +Adding a ``WRAP`` widget operation that takes a target widget ID and a wrapper +component was considered and rejected. Beyond the design advantages of layouts +discussed above, a wrapping operation is fundamentally at odds with the +slot/layout/widget architecture in several ways: + +Breaks the widget rendering pipeline + Widgets are created, keyed, and enclosed in a ``WidgetProvider`` before they + are handed to the layout for rendering. A wrapping operation must either + intervene before this pipeline (losing access to the rendered node) or after + it (wrapping around the ``WidgetProvider``). In the latter case, the wrapper + sits outside the widget's context boundary, so it cannot use widget-level + hooks like ``useWidgetOptions()``. In both cases, the React ``key`` assigned + to the ``WidgetProvider`` ends up on an inner element rather than the + outermost one, breaking React's list reconciliation for any slot that renders + multiple widgets. + +Interacts poorly with other widget operations + Widget operations are sorted so that absolute operations (``APPEND``, + ``PREPEND``) execute before relative ones, guaranteeing that target widgets + exist when referenced. A wrapping operation adds another ordering + dependency: it must run after its target widget is created, but also + interacts with ``REPLACE`` (which discards and recreates the target) and + ``REMOVE`` (which deletes it). These interactions create edge cases that are + difficult to specify and surprising to plugin authors, especially when + operations originate from different plugins that are unaware of each other. + +Bypasses the layout's role + The layout is the architectural component responsible for deciding how a + slot's content is presented. A wrapping operation that modifies individual + widgets before they reach the layout undermines this responsibility, creating + a second, parallel mechanism for controlling rendering. When both are in + play, the resulting behavior depends on the interleaving of layout logic and + wrapper logic in ways that are hard to reason about. + + diff --git a/runtime/slots/widget/hooks.ts b/runtime/slots/widget/hooks.ts index b8e66b61..2c8b2465 100644 --- a/runtime/slots/widget/hooks.ts +++ b/runtime/slots/widget/hooks.ts @@ -1,17 +1,17 @@ import { useContext, useEffect, useState } from 'react'; import { useSlotContext, useSlotOperations } from '../hooks'; import { isSlotOperationConditionSatisfied } from '../utils'; -import { WidgetOperation } from './types'; +import { WidgetList, WidgetOperation } from './types'; import { createWidgets, isWidgetAbsoluteOperation, isWidgetOperation, isWidgetOptionsOperation, isWidgetRelativeOperation } from './utils'; import WidgetContext from './WidgetContext'; -export function useWidgets() { +export function useWidgets(): WidgetList { const { id, ...props } = useSlotContext(); delete props.children; return useWidgetsForId(id, props); } -export function useWidgetsForId(id: string, componentProps?: Record) { +export function useWidgetsForId(id: string, componentProps?: Record): WidgetList { const operations = useSortedWidgetOperations(id); return createWidgets(operations, componentProps); } diff --git a/runtime/slots/widget/types.ts b/runtime/slots/widget/types.ts index f6bc5554..9e496b38 100644 --- a/runtime/slots/widget/types.ts +++ b/runtime/slots/widget/types.ts @@ -130,5 +130,14 @@ export type WidgetOperation = WidgetAbsoluteOperation | WidgetRelativeOperation; */ export interface IdentifiedWidget { id: string, + role?: string, node: ReactNode, } + +export interface WidgetList extends Array { + identified: IdentifiedWidget[], + byId(id: string): ReactNode[], + withoutId(id: string): ReactNode[], + byRole(role: string): ReactNode[], + withoutRole(role: string): ReactNode[], +} diff --git a/runtime/slots/widget/utils.test.tsx b/runtime/slots/widget/utils.test.tsx new file mode 100644 index 00000000..be0de4dc --- /dev/null +++ b/runtime/slots/widget/utils.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { WidgetOperationTypes } from './types'; +import { createWidgets } from './utils'; + +// Mock WidgetProvider to just render children, avoiding context dependencies. +jest.mock('./WidgetProvider', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Mock condition checking to always pass. +jest.mock('../utils', () => ({ + isSlotOperationConditionSatisfied: () => true, +})); + +const slotId = 'test-slot'; + +function makeAppendOp(id: string, label: string, role?: string) { + return { + slotId, + id, + role, + op: WidgetOperationTypes.APPEND as const, + element:
{label}
, + }; +} + +describe('createWidgets', () => { + it('returns a renderable ReactNode array', () => { + const widgets = createWidgets([ + makeAppendOp('w1', 'One'), + makeAppendOp('w2', 'Two'), + ]); + + expect(widgets).toHaveLength(2); + expect(Array.isArray(widgets)).toBe(true); + }); + + describe('byId', () => { + it('returns only widgets matching the given ID', () => { + const widgets = createWidgets([ + makeAppendOp('w1', 'One'), + makeAppendOp('w2', 'Two'), + makeAppendOp('w3', 'Three'), + ]); + + const result = widgets.byId('w2'); + expect(result).toHaveLength(1); + }); + + it('returns empty array when no widgets match', () => { + const widgets = createWidgets([ + makeAppendOp('w1', 'One'), + ]); + + expect(widgets.byId('nonexistent')).toHaveLength(0); + }); + }); + + describe('withoutId', () => { + it('returns all widgets except the given ID', () => { + const widgets = createWidgets([ + makeAppendOp('w1', 'One'), + makeAppendOp('w2', 'Two'), + makeAppendOp('w3', 'Three'), + ]); + + const result = widgets.withoutId('w2'); + expect(result).toHaveLength(2); + }); + }); + + describe('byRole', () => { + it('returns only widgets matching the given role', () => { + const widgets = createWidgets([ + makeAppendOp('w1', 'One', 'sidebar'), + makeAppendOp('w2', 'Two', 'main'), + makeAppendOp('w3', 'Three', 'sidebar'), + ]); + + const result = widgets.byRole('sidebar'); + expect(result).toHaveLength(2); + }); + + it('does not include widgets without a role', () => { + const widgets = createWidgets([ + makeAppendOp('w1', 'One', 'sidebar'), + makeAppendOp('w2', 'Two'), + ]); + + expect(widgets.byRole('sidebar')).toHaveLength(1); + }); + }); + + describe('identified', () => { + it('exposes the underlying IdentifiedWidget array', () => { + const widgets = createWidgets([ + makeAppendOp('w1', 'One', 'sidebar'), + makeAppendOp('w2', 'Two', 'main'), + makeAppendOp('w3', 'Three'), + ]); + + expect(widgets.identified).toHaveLength(3); + expect(widgets.identified[0]).toEqual( + expect.objectContaining({ id: 'w1', role: 'sidebar' }) + ); + expect(widgets.identified[1]).toEqual( + expect.objectContaining({ id: 'w2', role: 'main' }) + ); + expect(widgets.identified[2]).toEqual( + expect.objectContaining({ id: 'w3', role: undefined }) + ); + }); + }); + + describe('withoutRole', () => { + it('returns all widgets except those with the given role', () => { + const widgets = createWidgets([ + makeAppendOp('w1', 'One', 'sidebar'), + makeAppendOp('w2', 'Two', 'main'), + makeAppendOp('w3', 'Three', 'sidebar'), + ]); + + const result = widgets.withoutRole('sidebar'); + expect(result).toHaveLength(1); + }); + + it('includes widgets without a role', () => { + const widgets = createWidgets([ + makeAppendOp('w1', 'One', 'sidebar'), + makeAppendOp('w2', 'Two'), + ]); + + expect(widgets.withoutRole('sidebar')).toHaveLength(1); + }); + }); +}); diff --git a/runtime/slots/widget/utils.tsx b/runtime/slots/widget/utils.tsx index e4d34758..eaaf13fc 100644 --- a/runtime/slots/widget/utils.tsx +++ b/runtime/slots/widget/utils.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react'; import { SlotOperation } from '../types'; import { isSlotOperationConditionSatisfied } from '../utils'; import { IFrameWidget } from './iframe'; -import { IdentifiedWidget, WidgetAbsoluteOperation, WidgetAppendOperation, WidgetComponentProps, WidgetElementProps, WidgetIdentityProps, WidgetIFrameProps, WidgetInsertAfterOperation, WidgetInsertBeforeOperation, WidgetOperation, WidgetOperationTypes, WidgetOptionsOperation, WidgetPrependOperation, WidgetRemoveOperation, WidgetRendererOperation, WidgetRendererProps, WidgetReplaceOperation } from './types'; +import { IdentifiedWidget, WidgetAbsoluteOperation, WidgetAppendOperation, WidgetComponentProps, WidgetElementProps, WidgetIdentityProps, WidgetIFrameProps, WidgetInsertAfterOperation, WidgetInsertBeforeOperation, WidgetList, WidgetOperation, WidgetOperationTypes, WidgetOptionsOperation, WidgetPrependOperation, WidgetRemoveOperation, WidgetRendererOperation, WidgetRendererProps, WidgetReplaceOperation } from './types'; import WidgetProvider from './WidgetProvider'; export function isWidgetOperation(operation: SlotOperation): operation is WidgetOperation { @@ -117,6 +117,7 @@ function createIdentifiedWidget(operation: WidgetRendererOperation, componentPro return { id, + role: operation.role, node: ( {widget} @@ -175,7 +176,32 @@ function removeWidget(operation: WidgetRemoveOperation, widgets: IdentifiedWidge } } -export function createWidgets(operations: WidgetOperation[], componentProps?: Record) { +function createWidgetList(identifiedWidgets: IdentifiedWidget[]): WidgetList { + const nodes = identifiedWidgets.map(widget => widget.node); + const widgetList = nodes as WidgetList; + + widgetList.identified = identifiedWidgets; + + widgetList.byId = (id: string): ReactNode[] => { + return identifiedWidgets.filter(w => w.id === id).map(w => w.node); + }; + + widgetList.withoutId = (id: string): ReactNode[] => { + return identifiedWidgets.filter(w => w.id !== id).map(w => w.node); + }; + + widgetList.byRole = (role: string): ReactNode[] => { + return identifiedWidgets.filter(w => w.role === role).map(w => w.node); + }; + + widgetList.withoutRole = (role: string): ReactNode[] => { + return identifiedWidgets.filter(w => w.role !== role).map(w => w.node); + }; + + return widgetList; +} + +export function createWidgets(operations: WidgetOperation[], componentProps?: Record): WidgetList { const identifiedWidgets: IdentifiedWidget[] = []; for (const operation of operations) { @@ -196,6 +222,5 @@ export function createWidgets(operations: WidgetOperation[], componentProps?: Re } } - // Remove the 'id' metadata and return just the nodes. - return identifiedWidgets.map(widget => widget.node); + return createWidgetList(identifiedWidgets); } diff --git a/shell/dev/devHome/HomePage.tsx b/shell/dev/devHome/HomePage.tsx index e4643b3f..541ddf58 100644 --- a/shell/dev/devHome/HomePage.tsx +++ b/shell/dev/devHome/HomePage.tsx @@ -4,9 +4,9 @@ import { getUrlByRouteRole } from '../../../runtime/routing'; import messages from './messages'; export default function HomePage() { - const coursewareUrl = getUrlByRouteRole('courseware'); - const dashboardUrl = getUrlByRouteRole('learnerDashboard'); - const slotShowcaseUrl = getUrlByRouteRole('slotShowcase'); + const coursewareUrl = getUrlByRouteRole('org.openedx.frontend.role.courseware'); + const dashboardUrl = getUrlByRouteRole('org.openedx.frontend.role.dashboard'); + const slotShowcaseUrl = getUrlByRouteRole('org.openedx.frontend.role.slotShowcase'); const intl = useIntl(); return ( diff --git a/shell/dev/slotShowcase/HorizontalSlotLayout.tsx b/shell/dev/slotShowcase/HorizontalSlotLayout.tsx index 19f1c15f..fc0af58a 100644 --- a/shell/dev/slotShowcase/HorizontalSlotLayout.tsx +++ b/shell/dev/slotShowcase/HorizontalSlotLayout.tsx @@ -1,11 +1,12 @@ +import { Stack } from '@openedx/paragon'; import { useWidgets } from '../../../runtime'; export default function HorizontalSlotLayout() { const widgets = useWidgets(); return ( -
+ {widgets} -
+ ); } diff --git a/shell/dev/slotShowcase/LayoutWithOptions.tsx b/shell/dev/slotShowcase/LayoutWithOptions.tsx index 3d1df01a..c447ea58 100644 --- a/shell/dev/slotShowcase/LayoutWithOptions.tsx +++ b/shell/dev/slotShowcase/LayoutWithOptions.tsx @@ -8,7 +8,7 @@ export default function LayoutWithOptions() { return ( <> -
Layout Title: {title}
+
Layout Title: {title}
{widgets}
diff --git a/shell/dev/slotShowcase/SlotShowcase.scss b/shell/dev/slotShowcase/SlotShowcase.scss new file mode 100644 index 00000000..b5df7c0c --- /dev/null +++ b/shell/dev/slotShowcase/SlotShowcase.scss @@ -0,0 +1,50 @@ +// Inline code +.showcase-page code { + color: var(--pgn-color-dark-400); +} + +// Page grid +.showcase-page { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.showcase-full-width { + grid-column: 1 / -1; +} + +.showcase-divider { + grid-column: 1 / -1; + border-bottom: 2px solid var(--pgn-color-primary-500); + padding-bottom: 0.25rem; +} + +// Slot container (dashed boundary via Card) +.showcase-slot { + border: 2px dashed var(--pgn-color-primary-300); + background-color: var(--pgn-color-primary-100); +} + +// Widget card +.showcase-widget { + background-color: var(--pgn-color-light-300); + border-left: 3px solid var(--pgn-color-primary-500); + border-radius: 0.25rem; + padding: 0.5rem; + margin: 0.5rem; +} + +// Highlighted widget variant +.showcase-widget-highlighted { + background-color: var(--pgn-color-warning-100); + border-left: 3px solid var(--pgn-color-warning-300); +} + +// Layout title +.showcase-layout-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--pgn-color-primary-500); + margin-bottom: 0.5rem; +} diff --git a/shell/dev/slotShowcase/SlotShowcasePage.tsx b/shell/dev/slotShowcase/SlotShowcasePage.tsx index ffd7721e..268acd96 100644 --- a/shell/dev/slotShowcase/SlotShowcasePage.tsx +++ b/shell/dev/slotShowcase/SlotShowcasePage.tsx @@ -1,66 +1,135 @@ +import { ReactNode } from 'react'; +import { Card, Container } from '@openedx/paragon'; import { Slot } from '../../../runtime'; import HorizontalSlotLayout from './HorizontalSlotLayout'; import LayoutWithOptions from './LayoutWithOptions'; +import ToggleByRoleLayout from './ToggleByRoleLayout'; +import './SlotShowcase.scss'; -export default function SlotShowcasePage() { +function SlotContainer({ children }: { children: ReactNode }) { return ( -
-

Slot Showcase

- -

As a best practice, widgets should pass additional props (...props) to their rendered HTMLElement. This accomplishes two things:

-
    -
  • It allows custom layouts to add className and style props as necessary for the layout.
  • -
  • It allows widgets to be effectively "wrapped" by a parent component to alter their behavior.
  • -
- -

Simple slot with default layout

-

This slot has no opinionated layout, it just renders its children.

- - -

Simple slot with default content and props

-

This slot has default content, and it exposes a slot prop to widgets.

- -
Look, I'm default content!
-
- -

UI Layout Operations

- -

Slot with custom layout

-

This slot uses a horizontal flexbox layout from a component.

- -

This slot uses a horizontal flexbox layout from a JSX element.

- } /> - -

Slot with override custom layout

-

This slot uses a horizontal flexbox layout, but it was added by a layout replace operation.

- - -

Slot with layout options

-

These slots use a custom layout that takes options. The first shows the default title, the second shows it set to "Bar"

- - - -

UI Widget Operations

- -

Slot with prepended element

-

This slot has a prepended element (and two appended elements).

- - -

Slot with inserted elements

-

This slot has elements inserted before and after the second element. Also note that the insert operations are declared before the related element is declared, but can still insert themselves relative to it.

- - -

Slot with replaced element

-

This slot has an element replacing element two.

- - -

Slot with removed element

-

This slot has removed element two (WidgetOperationTypes.REMOVE).

- - -

Slot with widget with options.

-

Both widgets accept options. The first shows the default title, the second shows it set to "Bar"

- + + + {children} + + + ); +} + +function Section({ title, children }: { title: string, children: ReactNode }) { + return ( +
+

{title}

+ {children}
); } + +export default function SlotShowcasePage() { + return ( + +
+

Slot Showcase

+

As a best practice, widgets should pass additional props (...props) to their rendered HTMLElement. This allows custom layouts to add className and style props as necessary for the layout.

+
+ +
+

This slot has no opinionated layout, it just renders its children.

+ + + +
+ +
+

This slot has default content, and it exposes a slot prop to widgets.

+ + +
Look, I'm default content!
+
+
+
+ +

UI Layout Operations

+ +
+

This slot uses a horizontal flexbox layout from a component.

+ + + +
+ +
+

This slot uses a horizontal flexbox layout from a JSX element.

+ + } /> + +
+ +
+

This slot uses a horizontal flexbox layout, but it was added by a layout replace operation.

+ + + +
+ +
+

This slot uses a custom layout that takes options. It shows the default title.

+ + + +
+ +
+

Same layout, but the title option is set to "Bar".

+ + + +
+ +
+

This slot has four widgets, two with a "highlighted" role. The layout uses widgets.byRole() to toggle between all and highlighted.

+ + + +
+ +

UI Widget Operations

+ +
+

This slot has a prepended element (and two appended elements).

+ + + +
+ +
+

This slot has elements inserted before and after the second element. The insert operations are declared before the related element, but can still insert themselves relative to it.

+ + + +
+ +
+

This slot has an element replacing element two.

+ + + +
+ +
+

This slot has removed element two (WidgetOperationTypes.REMOVE).

+ + + +
+ +
+

This widget accepts options. It shows the default title.

+ + + +
+ +
+ ); +} diff --git a/shell/dev/slotShowcase/ToggleByRoleLayout.tsx b/shell/dev/slotShowcase/ToggleByRoleLayout.tsx new file mode 100644 index 00000000..13aa77a0 --- /dev/null +++ b/shell/dev/slotShowcase/ToggleByRoleLayout.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react'; +import { Button } from '@openedx/paragon'; +import { useWidgets } from '../../../runtime'; + +const highlitedRole = 'org.openedx.frontend.role.slotShowcase.highlighted'; + +export default function ToggleByRoleLayout() { + const widgets = useWidgets(); + const [showHighlighted, setShowHighlighted] = useState(false); + + return ( +
+ +
+ {showHighlighted ? widgets.byRole(highlitedRole) : widgets} +
+
+ ); +} diff --git a/shell/dev/slotShowcase/WidgetWithOptions.tsx b/shell/dev/slotShowcase/WidgetWithOptions.tsx index 2194d2bf..21e5e7a9 100644 --- a/shell/dev/slotShowcase/WidgetWithOptions.tsx +++ b/shell/dev/slotShowcase/WidgetWithOptions.tsx @@ -6,6 +6,6 @@ export default function WidgetWithOptions() { const title = typeof options.title === 'string' ? options.title : 'Foo'; return ( -
{title}
+
{title}
); } diff --git a/shell/dev/slotShowcase/app.tsx b/shell/dev/slotShowcase/app.tsx index 83bf3d7a..67c1dde0 100644 --- a/shell/dev/slotShowcase/app.tsx +++ b/shell/dev/slotShowcase/app.tsx @@ -6,20 +6,9 @@ import HorizontalSlotLayout from './HorizontalSlotLayout'; import SlotShowcasePage from './SlotShowcasePage'; import WidgetWithOptions from './WidgetWithOptions'; -function Title({ title, op }: { title: string, op?: string }) { +function Widget({ title, op, className, ...props }: { title: string, op?: string, className?: string } & Record) { return ( - - {title} - {op && ( - <>{' '}({op}) - )} - - ); -} - -function Child({ title, op }: { title: string, op?: string }) { - return ( -
+
{title} {op && ( {' '}({op}) @@ -30,7 +19,7 @@ function Child({ title, op }: { title: string, op?: string }) { function TakesProps({ aSlotProp }: { aSlotProp: string }) { return ( -
And this is a slot prop that was passed down via props: {aSlotProp}
+
And this is a slot prop that was passed down via props: {aSlotProp}
); } @@ -38,7 +27,7 @@ function TakesPropsViaContext() { const slotContext = useSlotContext(); const aSlotProp = typeof slotContext.aSlotProp === 'string' ? slotContext.aSlotProp : 'foo'; return ( -
And this is the same prop, but accessed via slot context: {aSlotProp}
+
And this is the same prop, but accessed via slot context: {aSlotProp}
); } @@ -58,19 +47,19 @@ const app: App = { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseSimple', id: 'org.openedx.frontend.widget.slotShowcase.simpleChild1', op: WidgetOperationTypes.APPEND, - element: () + element: () }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseSimple', id: 'org.openedx.frontend.widget.slotShowcase.simpleChild2', op: WidgetOperationTypes.APPEND, - element: () + element: () }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseSimple', id: 'org.openedx.frontend.widget.slotShowcase.simpleChild3', op: WidgetOperationTypes.APPEND, - element: () + element: () }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseSimpleWithDefaultContent', @@ -90,19 +79,19 @@ const app: App = { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseCustom', id: 'org.openedx.frontend.widget.slotShowcase.customChild1', op: WidgetOperationTypes.APPEND, - element: () + element: () }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseCustom', id: 'org.openedx.frontend.widget.slotShowcase.customChild2', op: WidgetOperationTypes.APPEND, - element: () + element: () }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseCustom', id: 'org.openedx.frontend.widget.slotShowcase.customChild3', op: WidgetOperationTypes.APPEND, - element: () + element: () }, // Override custom layout @@ -110,19 +99,19 @@ const app: App = { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseCustomConfig', id: 'org.openedx.frontend.widget.slotShowcase.customConfigChild1', op: WidgetOperationTypes.APPEND, - element: () + element: () }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseCustomConfig', id: 'org.openedx.frontend.widget.slotShowcase.customConfigChild2', op: WidgetOperationTypes.APPEND, - element: () + element: () }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseCustomConfig', id: 'org.openedx.frontend.widget.slotShowcase.customConfigChild3', op: WidgetOperationTypes.APPEND, - element: () + element: () }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseCustomConfig', @@ -135,44 +124,44 @@ const app: App = { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseLayoutWithOptions', op: LayoutOperationTypes.OPTIONS, options: { - title: (), + title: 'Bar', } }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseLayoutWithOptions', id: 'org.openedx.frontend.widget.slotShowcase.layoutWithOptionsChild1', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child One" />) + element: (<Widget title="Widget One" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseLayoutWithOptions', id: 'org.openedx.frontend.widget.slotShowcase.layoutWithOptionsChild2', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Two" />) + element: (<Widget title="Widget Two" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseLayoutWithOptions', id: 'org.openedx.frontend.widget.slotShowcase.layoutWithOptionsChild3', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Three" />) + element: (<Widget title="Widget Three" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseLayoutWithOptionsDefault', id: 'org.openedx.frontend.widget.slotShowcase.layoutWithOptionsDefaultChild1', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child One" />) + element: (<Widget title="Widget One" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseLayoutWithOptionsDefault', id: 'org.openedx.frontend.widget.slotShowcase.layoutWithOptionsDefaultChild2', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Two" />) + element: (<Widget title="Widget Two" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseLayoutWithOptionsDefault', id: 'org.openedx.frontend.widget.slotShowcase.layoutWithOptionsDefaultChild3', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Three" />) + element: (<Widget title="Widget Three" />) }, // TODO: Override Layout @@ -182,19 +171,19 @@ const app: App = { slotId: 'org.openedx.frontend.slot.dev.slotShowcasePrepending', id: 'org.openedx.frontend.widget.slotShowcase.prependingChild1', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child One" op="WidgetOperationTypes.APPEND" />) + element: (<Widget title="Widget One" op="WidgetOperationTypes.APPEND" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcasePrepending', id: 'org.openedx.frontend.widget.slotShowcase.prependingChild2', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Two" op="WidgetOperationTypes.APPEND" />) + element: (<Widget title="Widget Two" op="WidgetOperationTypes.APPEND" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcasePrepending', id: 'org.openedx.frontend.widget.slotShowcase.prependingChild3', op: WidgetOperationTypes.PREPEND, - element: (<Child title="Child Three" op="WidgetOperationTypes.PREPEND" />) + element: (<Widget title="Widget Three" op="WidgetOperationTypes.PREPEND" />) }, // Inserting @@ -203,32 +192,32 @@ const app: App = { id: 'slot-showcase.inserting.child4', op: WidgetOperationTypes.INSERT_AFTER, relatedId: 'org.openedx.frontend.widget.slotShowcase.insertingChild2', - element: (<Child title="Child Four" op="WidgetOperationTypes.INSERT_AFTER" />) + element: (<Widget title="Widget Four" op="WidgetOperationTypes.INSERT_AFTER" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseInserting', id: 'slot-showcase.inserting.child5', op: WidgetOperationTypes.INSERT_BEFORE, relatedId: 'org.openedx.frontend.widget.slotShowcase.insertingChild2', - element: (<Child title="Child Five" op="WidgetOperationTypes.INSERT_BEFORE" />) + element: (<Widget title="Widget Five" op="WidgetOperationTypes.INSERT_BEFORE" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseInserting', id: 'org.openedx.frontend.widget.slotShowcase.insertingChild1', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child One" />) + element: (<Widget title="Widget One" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseInserting', id: 'org.openedx.frontend.widget.slotShowcase.insertingChild2', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Two" />) + element: (<Widget title="Widget Two" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseInserting', id: 'org.openedx.frontend.widget.slotShowcase.insertingChild3', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Three" />) + element: (<Widget title="Widget Three" />) }, // Replacing @@ -236,26 +225,26 @@ const app: App = { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseReplacing', id: 'org.openedx.frontend.widget.slotShowcase.replacingChild1', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child One" />) + element: (<Widget title="Widget One" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseReplacing', id: 'org.openedx.frontend.widget.slotShowcase.replacingChild2', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Two" />) + element: (<Widget title="Widget Two" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseReplacing', id: 'org.openedx.frontend.widget.slotShowcase.replacingChild3', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Three" />) + element: (<Widget title="Widget Three" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseReplacing', id: 'org.openedx.frontend.widget.slotShowcase.replacingChild4', op: WidgetOperationTypes.REPLACE, relatedId: 'org.openedx.frontend.widget.slotShowcase.replacingChild2', - element: (<Child title="Child Four" op="WidgetOperationTypes.REPLACE" />) + element: (<Widget title="Widget Four" op="WidgetOperationTypes.REPLACE" />) }, // Hiding @@ -263,19 +252,19 @@ const app: App = { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseRemoving', id: 'org.openedx.frontend.widget.slotShowcase.removingChild1', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child One" />) + element: (<Widget title="Widget One" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseRemoving', id: 'org.openedx.frontend.widget.slotShowcase.removingChild2', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Two" />) + element: (<Widget title="Widget Two" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseRemoving', id: 'org.openedx.frontend.widget.slotShowcase.removingChild3', op: WidgetOperationTypes.APPEND, - element: (<Child title="Child Three" />) + element: (<Widget title="Widget Three" />) }, { slotId: 'org.openedx.frontend.slot.dev.slotShowcaseRemoving', @@ -301,10 +290,38 @@ const app: App = { relatedId: 'org.openedx.frontend.widget.slotShowcase.widgetOptionsChild2', op: WidgetOperationTypes.OPTIONS, options: { - title: (<Title title="Bar" op="WidgetOperationTypes.OPTIONS" />), + title: 'Bar', } }, + // Widget filtering by role + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseFilterByRole', + id: 'org.openedx.frontend.widget.slotShowcase.filterChild1', + op: WidgetOperationTypes.APPEND, + element: (<Widget title="Widget One" />) + }, + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseFilterByRole', + id: 'org.openedx.frontend.widget.slotShowcase.filterChild2', + role: 'org.openedx.frontend.role.slotShowcase.highlighted', + op: WidgetOperationTypes.APPEND, + element: (<Widget title="Widget Two (highlighted)" className="showcase-widget-highlighted" />) + }, + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseFilterByRole', + id: 'org.openedx.frontend.widget.slotShowcase.filterChild3', + op: WidgetOperationTypes.APPEND, + element: (<Widget title="Widget Three" />) + }, + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseFilterByRole', + id: 'org.openedx.frontend.widget.slotShowcase.filterChild4', + role: 'org.openedx.frontend.role.slotShowcase.highlighted', + op: WidgetOperationTypes.APPEND, + element: (<Widget title="Widget Four (highlighted)" className="showcase-widget-highlighted" />) + }, + // Header { slotId: 'org.openedx.frontend.slot.header.primaryLinks.v1',