From 895e3a43a3339f68712cbfe9778b4f40786f6a10 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Tue, 17 Mar 2026 09:52:27 -0600 Subject: [PATCH] feat: wrap slop content operation supported --- runtime/slots/widget/types.ts | 23 +++++- runtime/slots/widget/utils.tsx | 37 +++++++++- shell/dev/slotShowcase/SlotShowcasePage.tsx | 8 +++ shell/dev/slotShowcase/app.tsx | 80 +++++++++++++++++++++ 4 files changed, 143 insertions(+), 5 deletions(-) diff --git a/runtime/slots/widget/types.ts b/runtime/slots/widget/types.ts index f6bc5554..737b566e 100644 --- a/runtime/slots/widget/types.ts +++ b/runtime/slots/widget/types.ts @@ -46,11 +46,16 @@ export enum WidgetOperationTypes { * Provides options to the specified widget ID. Multiple "options" operations on the same widget ID will merge with and override any duplicate properties in the options object - last one in wins. */ OPTIONS = 'widgetOptions', + + /** + * Wraps the specified widget ID with a React component. Multiple "wrap" operations on the same widget ID occur in the order they were declared, creating nested wrappers. + */ + WRAP = 'widgetWrap', } export type AbsoluteWidgetOperationTypes = WidgetOperationTypes.APPEND | WidgetOperationTypes.PREPEND; -export type RelativeWidgetOperationTypes = WidgetOperationTypes.INSERT_AFTER | WidgetOperationTypes.INSERT_BEFORE | WidgetOperationTypes.REPLACE | WidgetOperationTypes.OPTIONS; +export type RelativeWidgetOperationTypes = WidgetOperationTypes.INSERT_AFTER | WidgetOperationTypes.INSERT_BEFORE | WidgetOperationTypes.REPLACE | WidgetOperationTypes.OPTIONS | WidgetOperationTypes.WRAP; export interface BaseWidgetOperation extends BaseSlotOperation { op: WidgetOperationTypes, @@ -84,6 +89,10 @@ export interface WidgetRelationshipProps { relatedId: string, } +export interface WidgetWrapperProps { + wrapper: (props: { component: ReactNode, idx: number, pluginProps?: Record }) => ReactNode, +} + // Concrete UI Widget Operations export type WidgetAppendOperation = BaseWidgetOperation & WidgetIdentityProps & WidgetRendererProps & { @@ -113,16 +122,26 @@ export type WidgetOptionsOperation = BaseWidgetOperation & WidgetRelationshipPro export type WidgetReplaceOperation = BaseWidgetOperation & WidgetIdentityProps & WidgetRendererProps & WidgetRelationshipProps & { op: WidgetOperationTypes.REPLACE }; +export type WidgetWrapOperation = BaseWidgetOperation & WidgetRelationshipProps & WidgetWrapperProps & { + op: WidgetOperationTypes.WRAP, +}; + export type WidgetAbsoluteOperation = WidgetAppendOperation | WidgetPrependOperation; export type WidgetRelativeRendererOperation = WidgetInsertAfterOperation | WidgetInsertBeforeOperation | WidgetReplaceOperation; -export type WidgetRelativeOperation = WidgetRelativeRendererOperation | WidgetRemoveOperation | WidgetOptionsOperation; +export type WidgetRelativeOperation = WidgetRelativeRendererOperation | WidgetRemoveOperation | WidgetOptionsOperation | WidgetWrapOperation; export type WidgetRendererOperation = WidgetAbsoluteOperation | WidgetRelativeRendererOperation; export type WidgetOperation = WidgetAbsoluteOperation | WidgetRelativeOperation; +export interface IdentifiedWidget { + id: string, + node: ReactNode, + wrappers?: ((props: { component: ReactNode, idx: number, pluginProps?: Record }) => ReactNode)[], +} + /** * An identified widget is a simple data structure to associate an ID with a ReactNode so we can * apply widget operations to our list of widgets. It helps us find a ReactNode with a particular diff --git a/runtime/slots/widget/utils.tsx b/runtime/slots/widget/utils.tsx index e4d34758..5be4bfab 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, WidgetOperation, WidgetOperationTypes, WidgetOptionsOperation, WidgetPrependOperation, WidgetRemoveOperation, WidgetRendererOperation, WidgetRendererProps, WidgetReplaceOperation, WidgetWrapOperation } from './types'; import WidgetProvider from './WidgetProvider'; export function isWidgetOperation(operation: SlotOperation): operation is WidgetOperation { @@ -45,6 +45,10 @@ export function isWidgetOptionsOperation(operation: SlotOperation): operation is return isWidgetOperation(operation) && operation.op === WidgetOperationTypes.OPTIONS; } +export function isWidgetWrapOperation(operation: SlotOperation): operation is WidgetWrapOperation { + return isWidgetOperation(operation) && operation.op === WidgetOperationTypes.WRAP; +} + export function isWidgetRendererOperation(operation: SlotOperation): operation is WidgetRendererOperation { return isWidgetOperation(operation) && hasWidgetRendererProps(operation); } @@ -122,6 +126,7 @@ function createIdentifiedWidget(operation: WidgetRendererOperation, componentPro {widget} ), + wrappers: [], }; } @@ -175,6 +180,15 @@ function removeWidget(operation: WidgetRemoveOperation, widgets: IdentifiedWidge } } +function wrapWidget(operation: WidgetWrapOperation, widgets: IdentifiedWidget[]) { + const relatedIndex = findRelatedWidgetIndex(operation.relatedId, widgets); + if (relatedIndex !== null) { + const widget = widgets[relatedIndex]; + widget.wrappers ??= []; + widget.wrappers.push(operation.wrapper); + } +} + export function createWidgets(operations: WidgetOperation[], componentProps?: Record) { const identifiedWidgets: IdentifiedWidget[] = []; @@ -192,10 +206,27 @@ export function createWidgets(operations: WidgetOperation[], componentProps?: Re replaceWidget(operation, identifiedWidgets, componentProps); } else if (isWidgetRemoveOperation(operation)) { removeWidget(operation, identifiedWidgets); + } else if (isWidgetWrapOperation(operation)) { + wrapWidget(operation, identifiedWidgets); } } } - // Remove the 'id' metadata and return just the nodes. - return identifiedWidgets.map(widget => widget.node); + // Apply wrappers and return just the nodes. + return identifiedWidgets.map((widget) => { + let finalNode = widget.node; + + // Apply wrappers if any exist + if (widget.wrappers && widget.wrappers.length > 0) { + widget.wrappers.forEach((wrapper, wrapperIndex) => { + finalNode = wrapper({ + component: finalNode, + idx: wrapperIndex, + pluginProps: componentProps, + }); + }); + } + + return finalNode; + }); } diff --git a/shell/dev/slotShowcase/SlotShowcasePage.tsx b/shell/dev/slotShowcase/SlotShowcasePage.tsx index ffd7721e..124f0c6d 100644 --- a/shell/dev/slotShowcase/SlotShowcasePage.tsx +++ b/shell/dev/slotShowcase/SlotShowcasePage.tsx @@ -61,6 +61,14 @@ export default function SlotShowcasePage() {

Slot with widget with options.

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

+ +

Slot with wrapped widgets

+

This slot demonstrates the wrap operation. Each widget is wrapped with different wrapper components.

+ + +

Slot with multiply wrapped widget

+

This slot shows a widget with multiple wrappers applied (WidgetOperationTypes.WRAP). Wrappers are nested in the order they were declared.

+ ); } diff --git a/shell/dev/slotShowcase/app.tsx b/shell/dev/slotShowcase/app.tsx index 83bf3d7a..c782b272 100644 --- a/shell/dev/slotShowcase/app.tsx +++ b/shell/dev/slotShowcase/app.tsx @@ -42,6 +42,40 @@ function TakesPropsViaContext() { ); } +// Wrapper function for demonstrating wrap operation +function SimpleWrapper({ component, idx }: { component: any, idx: number }) { + return ( +
+

+ Wrapper {idx + 1} - This widget has been wrapped! +

+ {component} +
+ ); +} + +// Toggle wrapper that could hide/show widgets +function ToggleWrapper({ component, idx }: { component: any, idx: number }) { + return ( +
+ + Toggle Widget {idx + 1} (Click to show/hide) + +
+ {component} +
+
+ ); +} + const app: App = { appId: 'org.openedx.frontend.app.slotShowcase', routes: [{ @@ -305,6 +339,52 @@ const app: App = { } }, + // Widget Wrapping + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseWrapping', + id: 'org.openedx.frontend.widget.slotShowcase.wrappingChild1', + op: WidgetOperationTypes.APPEND, + element: () + }, + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseWrapping', + id: 'org.openedx.frontend.widget.slotShowcase.wrappingChild2', + op: WidgetOperationTypes.APPEND, + element: () + }, + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseWrapping', + op: WidgetOperationTypes.WRAP, + relatedId: 'org.openedx.frontend.widget.slotShowcase.wrappingChild1', + wrapper: SimpleWrapper, + }, + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseWrapping', + op: WidgetOperationTypes.WRAP, + relatedId: 'org.openedx.frontend.widget.slotShowcase.wrappingChild2', + wrapper: ToggleWrapper, + }, + + // Multiple Wraps (Nested) + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseMultipleWraps', + id: 'org.openedx.frontend.widget.slotShowcase.multiWrapChild', + op: WidgetOperationTypes.APPEND, + element: () + }, + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseMultipleWraps', + op: WidgetOperationTypes.WRAP, + relatedId: 'org.openedx.frontend.widget.slotShowcase.multiWrapChild', + wrapper: SimpleWrapper, + }, + { + slotId: 'org.openedx.frontend.slot.dev.slotShowcaseMultipleWraps', + op: WidgetOperationTypes.WRAP, + relatedId: 'org.openedx.frontend.widget.slotShowcase.multiWrapChild', + wrapper: ToggleWrapper, + }, + // Header { slotId: 'org.openedx.frontend.slot.header.primaryLinks.v1',