Skip to content
Draft
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
90 changes: 90 additions & 0 deletions frontend/src/components/editor/renderers/__tests__/plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { describe, expect, it } from "vitest";
import type { CellId } from "@/core/cells/ids";
import type { CellData } from "@/core/cells/types";
import { deserializeLayout, getCellRendererPlugin } from "../plugins";

function makeCell(id: string): CellData {
return {
id: id as CellId,
name: id,
code: "",
edited: false,
lastCodeRun: null,
lastExecutionTime: null,
config: { hide_code: false, disabled: false, column: null },
serializedEditorState: null,
};
}

describe("getCellRendererPlugin", () => {
it("returns the matching plugin keyed by layout type", () => {
expect(getCellRendererPlugin("vertical").type).toBe("vertical");
expect(getCellRendererPlugin("grid").type).toBe("grid");
expect(getCellRendererPlugin("slides").type).toBe("slides");
});
});

describe("deserializeLayout", () => {
it("deserializes valid grid layout data", () => {
const layout = deserializeLayout({
type: "grid",
data: {
columns: 12,
rowHeight: 20,
cells: [{ position: [1, 2, 3, 4] }],
},
cells: [makeCell("a")],
});

expect(layout.columns).toBe(12);
expect(layout.cells).toEqual([{ i: "a", x: 1, y: 2, w: 3, h: 4 }]);
});

it("deserializes valid slides layout data", () => {
const layout = deserializeLayout({
type: "slides",
data: {
cells: [{ type: "fragment" }],
deck: { transition: "fade" },
},
cells: [makeCell("a")],
});

expect(layout.deck).toEqual({ transition: "fade" });
expect(layout.cells.get("a" as CellId)).toEqual({ type: "fragment" });
});

it("vertical layout is always null regardless of stored data", () => {
// Older save files may have arbitrary `data` for vertical; we must
// ignore it because `VerticalLayout = null`.
const layout = deserializeLayout({
type: "vertical",
data: { something: "unexpected" },
cells: [makeCell("a")],
});

expect(layout).toBeNull();
});

it("tolerates legacy `null` for optional grid fields", () => {
// Older marimo versions wrote unset optional fields as `null`
// (e.g. `"maxWidth": null` in `layout_grid_with_sidebar.grid.json`).
// Those files must keep loading.
const layout = deserializeLayout({
type: "grid",
data: {
columns: 24,
rowHeight: 20,
maxWidth: null,
bordered: true,
cells: [{ position: [0, 0, 5, 2] }, { position: null }],
},
cells: [makeCell("a"), makeCell("b")],
});

expect(layout.columns).toBe(24);
expect(layout.cells).toEqual([{ i: "a", x: 0, y: 0, w: 5, h: 2 }]);
});
});
69 changes: 34 additions & 35 deletions frontend/src/components/editor/renderers/cells-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,11 @@ import { memo, type PropsWithChildren } from "react";
import { flattenTopLevelNotebookCells, useNotebook } from "@/core/cells/cells";
import type { AppConfig } from "@/core/config/config-schema";
import { KnownQueryParams } from "@/core/constants";
import {
type LayoutData,
useLayoutActions,
useLayoutState,
} from "@/core/layout/layout";
import { useLayoutActions, useLayoutState } from "@/core/layout/layout";
import { type AppMode, kioskModeAtom } from "@/core/mode";
import { cellRendererPlugins } from "./plugins";
import {
type ICellRendererPlugin,
type LayoutType,
OVERRIDABLE_LAYOUT_TYPES,
} from "./types";
import { logNever } from "@/utils/assertNever";
import { getCellRendererPlugin, type LayoutDataByType } from "./plugins";
import { type LayoutType, OVERRIDABLE_LAYOUT_TYPES } from "./types";

interface Props {
appConfig: AppConfig;
Expand Down Expand Up @@ -46,20 +39,12 @@ export const CellsRenderer: React.FC<PropsWithChildren<Props>> = memo(
}
}

const plugin = cellRendererPlugins.find((p) => p.type === finalLayout);

// Just render children if there is no plugin
if (!plugin) {
return children;
}

return (
<PluginCellRenderer
appConfig={appConfig}
mode={mode}
plugin={plugin}
layoutData={layoutData}
finalLayout={finalLayout}
layoutData={layoutData}
/>
);
},
Expand All @@ -69,28 +54,42 @@ CellsRenderer.displayName = "CellsRenderer";
interface PluginCellRendererProps extends PropsWithChildren<Props> {
appConfig: AppConfig;
mode: AppMode;
// oxlint-disable-next-line typescript/no-explicit-any
plugin: ICellRendererPlugin<any, any>;
layoutData: Partial<Record<LayoutType, LayoutData>>;
layoutData: Partial<LayoutDataByType>;
finalLayout: LayoutType;
}

export const PluginCellRenderer = (props: PluginCellRendererProps) => {
const { appConfig, mode, plugin, layoutData, finalLayout } = props;
const { appConfig, mode, layoutData, finalLayout } = props;
const notebook = useNotebook();
const { setCurrentLayoutData } = useLayoutActions();
const cells = flattenTopLevelNotebookCells(notebook);

const Renderer = plugin.Component;
const body = (
<Renderer
appConfig={appConfig}
mode={mode}
cells={cells}
layout={layoutData[finalLayout] || plugin.getInitialLayout(cells)}
setLayout={setCurrentLayoutData}
/>
);
const renderFor = <K extends LayoutType>(
type: K,
data: LayoutDataByType[K] | undefined,
) => {
const plugin = getCellRendererPlugin(type);
const Renderer = plugin.Component;
return (
<Renderer
appConfig={appConfig}
mode={mode}
cells={cells}
layout={data ?? plugin.getInitialLayout(cells)}
setLayout={setCurrentLayoutData}
/>
);
};

return body;
switch (finalLayout) {
case "vertical":
return renderFor("vertical", layoutData.vertical);
case "grid":
return renderFor("grid", layoutData.grid);
case "slides":
return renderFor("slides", layoutData.slides);
default:
logNever(finalLayout);
return null;
}
};
58 changes: 44 additions & 14 deletions frontend/src/components/editor/renderers/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,60 @@

import type { CellData } from "@/core/cells/types";
import { GridLayoutPlugin } from "./grid-layout/plugin";
import type { GridLayout, SerializedGridLayout } from "./grid-layout/types";
import { SlidesLayoutPlugin } from "./slides-layout/plugin";
import type {
SerializedSlidesLayout,
SlidesLayout,
} from "./slides-layout/types";
import type { ICellRendererPlugin, LayoutType } from "./types";
import type {
SerializedVerticalLayout,
VerticalLayout,
} from "./vertical-layout/types.ts";
import { VerticalLayoutPlugin } from "./vertical-layout/vertical-layout";

export interface LayoutDataByType {
vertical: VerticalLayout;
grid: GridLayout;
slides: SlidesLayout;
}

interface SerializedLayoutDataByType {
vertical: SerializedVerticalLayout;
grid: SerializedGridLayout;
slides: SerializedSlidesLayout;
}

type CellRendererPluginByType = {
[K in LayoutType]: ICellRendererPlugin<
SerializedLayoutDataByType[K],
LayoutDataByType[K]
>;
};

// If more renderers are added, we may want to consider lazy loading them.
// oxlint-disable-next-line typescript/no-explicit-any
export const cellRendererPlugins: ICellRendererPlugin<any, any>[] = [
GridLayoutPlugin,
SlidesLayoutPlugin,
VerticalLayoutPlugin,
];
const cellRendererPluginMap: CellRendererPluginByType = {
vertical: VerticalLayoutPlugin,
grid: GridLayoutPlugin,
slides: SlidesLayoutPlugin,
};

export function getCellRendererPlugin<K extends LayoutType>(
type: K,
): CellRendererPluginByType[K] {
return cellRendererPluginMap[type];
}

export function deserializeLayout({
export function deserializeLayout<K extends LayoutType>({
type,
data,
cells,
}: {
type: LayoutType;
type: K;
data: unknown;
cells: CellData[];
}) {
const plugin = cellRendererPlugins.find((plugin) => plugin.type === type);
if (plugin === undefined) {
throw new Error(`Unknown layout type: ${type}`);
}
return plugin.deserializeLayout(data, cells);
}): LayoutDataByType[K] {
const plugin = getCellRendererPlugin(type);
return plugin.deserializeLayout(data as SerializedLayoutDataByType[K], cells);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* Copyright 2026 Marimo. All rights reserved. */

export type SerializedVerticalLayout = unknown;

export type VerticalLayout = null;
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,9 @@ import { Filenames } from "@/utils/filenames";
import { FloatingOutline } from "../../chrome/panels/outline/floating-outline";
import { cellDomProps } from "../../common";
import type { ICellRendererPlugin, ICellRendererProps } from "../types";
import type { SerializedVerticalLayout, VerticalLayout } from "./types.ts";
import { useDelayVisibility } from "./useDelayVisibility";
import { VerticalLayoutWrapper } from "./vertical-layout-wrapper";

type VerticalLayout = null;
type VerticalLayoutProps = ICellRendererProps<VerticalLayout>;

const VerticalLayoutRenderer: React.FC<VerticalLayoutProps> = ({
Expand Down Expand Up @@ -468,15 +467,15 @@ const VerticalCell = memo(
VerticalCell.displayName = "VerticalCell";

export const VerticalLayoutPlugin: ICellRendererPlugin<
VerticalLayout,
SerializedVerticalLayout,
VerticalLayout
> = {
type: "vertical",
name: "Vertical",
validator: z.any(),
validator: z.unknown(),
Component: VerticalLayoutRenderer,
deserializeLayout: (serialized) => serialized,
serializeLayout: (layout) => layout,
deserializeLayout: () => null,
serializeLayout: () => null,
getInitialLayout: () => null,
};

Expand Down
Loading
Loading