Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions runtime/slots/widget/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -84,6 +89,10 @@ export interface WidgetRelationshipProps {
relatedId: string,
}

export interface WidgetWrapperProps {
wrapper: (props: { component: ReactNode, idx: number, pluginProps?: Record<string, unknown> }) => ReactNode,
}

// Concrete UI Widget Operations

export type WidgetAppendOperation = BaseWidgetOperation & WidgetIdentityProps & WidgetRendererProps & {
Expand Down Expand Up @@ -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<string, unknown> }) => 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
Expand Down
37 changes: 34 additions & 3 deletions runtime/slots/widget/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -122,6 +126,7 @@ function createIdentifiedWidget(operation: WidgetRendererOperation, componentPro
{widget}
</WidgetProvider>
),
wrappers: [],
};
}

Expand Down Expand Up @@ -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<string, unknown>) {
const identifiedWidgets: IdentifiedWidget[] = [];

Expand All @@ -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;
});
}
8 changes: 8 additions & 0 deletions shell/dev/slotShowcase/SlotShowcasePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export default function SlotShowcasePage() {
<h3>Slot with widget with options.</h3>
<p>Both widgets accept options. The first shows the default title, the second shows it set to &quot;Bar&quot;</p>
<Slot id="org.openedx.frontend.slot.dev.slotShowcaseWidgetOptions" />

<h3>Slot with wrapped widgets</h3>
<p>This slot demonstrates the wrap operation. Each widget is wrapped with different wrapper components.</p>
<Slot id="org.openedx.frontend.slot.dev.slotShowcaseWrapping" />

<h3>Slot with multiply wrapped widget</h3>
<p>This slot shows a widget with multiple wrappers applied (<code>WidgetOperationTypes.WRAP</code>). Wrappers are nested in the order they were declared.</p>
<Slot id="org.openedx.frontend.slot.dev.slotShowcaseMultipleWraps" />
</div>
);
}
80 changes: 80 additions & 0 deletions shell/dev/slotShowcase/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,40 @@ function TakesPropsViaContext() {
);
}

// Wrapper function for demonstrating wrap operation
function SimpleWrapper({ component, idx }: { component: any, idx: number }) {
return (
<div
style={{
border: '2px dashed #007bff',
padding: '8px',
margin: '4px',
borderRadius: '4px',
backgroundColor: '#f8f9fa'
}}
>
<p style={{ margin: '0 0 8px 0', fontSize: '12px', color: '#6c757d' }}>
Wrapper {idx + 1} - This widget has been wrapped!
</p>
{component}
</div>
);
}

// Toggle wrapper that could hide/show widgets
function ToggleWrapper({ component, idx }: { component: any, idx: number }) {
return (
<details open>
<summary style={{ cursor: 'pointer', padding: '4px', backgroundColor: '#e9ecef' }}>
Toggle Widget {idx + 1} (Click to show/hide)
</summary>
<div style={{ padding: '8px', border: '1px solid #dee2e6' }}>
{component}
</div>
</details>
);
}

const app: App = {
appId: 'org.openedx.frontend.app.slotShowcase',
routes: [{
Expand Down Expand Up @@ -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: (<Child title="Child One" />)
},
{
slotId: 'org.openedx.frontend.slot.dev.slotShowcaseWrapping',
id: 'org.openedx.frontend.widget.slotShowcase.wrappingChild2',
op: WidgetOperationTypes.APPEND,
element: (<Child title="Child Two" />)
},
{
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: (<Child title="Multiply Wrapped Child" />)
},
{
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',
Expand Down
Loading