/* Copyright 2026 Marimo. All rights reserved. */ import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowUpDownIcon, BracketsIcon, ColumnsIcon, CombineIcon, CopySlashIcon, FileJsonIcon, FilterIcon, FunctionSquareIcon, GroupIcon, InfoIcon, MousePointerSquareDashedIcon, PencilIcon, PlusIcon, ShuffleIcon, SquareMousePointerIcon, Table2Icon, Trash2Icon, } from "lucide-react"; import React, { type PropsWithChildren, useEffect, useImperativeHandle, useMemo, } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import useEvent from "react-use-event-hook"; import type { z } from "zod"; import type { FieldTypesWithExternalType } from "@/components/data-table/types"; import { ColumnFetchValuesContext, ColumnInfoContext, } from "@/plugins/impl/data-frames/forms/context"; import { Strings } from "@/utils/strings"; import { ZodForm } from "../../../components/forms/form"; import { getDefaults, getUnionLiteral, } from "../../../components/forms/form-utils"; import { Button } from "../../../components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; import { Tooltip } from "../../../components/ui/tooltip"; import { cn } from "../../../utils/cn"; import { DATAFRAME_FORM_RENDERERS } from "./forms/renderers"; import { type Transformations, TransformationsSchema, type TransformType, TransformTypeSchema, } from "./schema"; import type { ColumnDataTypes } from "./types"; import { getEffectiveColumns } from "./utils/getEffectiveColumns"; export interface TransformPanelHandle { submit: () => void; } interface Props { columns: ColumnDataTypes; initialValue: Transformations; onChange: (value: Transformations) => void; onInvalidChange: (value: Transformations) => void; getColumnValues: (req: { column: string }) => Promise<{ values: unknown[]; too_many_values: boolean; }>; // Column types at each transform step (index 0 = original, index N = after N transforms) columnTypesPerStep?: FieldTypesWithExternalType[]; lazy: boolean; ref?: React.Ref; } export const TransformPanel: React.FC = ({ initialValue, columns, onChange, onInvalidChange, getColumnValues, columnTypesPerStep, lazy, ref, }) => { const form = useForm>({ resolver: zodResolver(TransformationsSchema), defaultValues: initialValue, mode: "onChange", reValidateMode: "onChange", }); const { handleSubmit, watch, control, formState } = form; const onSubmit = useEvent((values: z.infer) => { onChange(values); }); const onInvalidSubmit = useEvent( (values: z.infer) => { onInvalidChange(values); }, ); const handleApply = useEvent(() => { handleSubmit( (values) => { onSubmit(values); // Reset dirty state by setting current values as new default // Use keepValues to avoid re-initializing field arrays if (lazy) { form.reset(values, { keepValues: true }); } }, () => { onInvalidSubmit(form.getValues()); }, )(); }); useImperativeHandle(ref, () => { return { submit: handleApply, }; }, []); useEffect(() => { // If lazy, do not auto-submit on input changes if (lazy) { return; } const subscription = watch(() => { handleApply(); }); return () => subscription.unsubscribe(); }, [watch, handleApply, lazy]); const [selectedTransform, setSelectedTransform] = React.useState< number | undefined >(initialValue.transforms.length > 0 ? 0 : undefined); // TODO: This crashes in latest version of react-hook-form const transformsField = useFieldArray({ control: control, name: "transforms", }); const transforms = form.watch("transforms"); const selectedTransformType = selectedTransform === undefined ? undefined : transforms[selectedTransform]?.type; const selectedTransformSchema = TransformTypeSchema.options.find((option) => { return getUnionLiteral(option).value === selectedTransformType; }); const effectiveColumns = useMemo(() => { return getEffectiveColumns(columns, columnTypesPerStep, selectedTransform); }, [columns, transforms, selectedTransform]); const handleAddTransform = (transform: z.ZodType) => { const next: TransformType = getDefaults( transform as z.ZodType, ); const nextIdx = transformsField.fields.length; transformsField.append(next); setSelectedTransform(nextIdx); }; return (
e.preventDefault()} // When lazy, prevent Enter from submitting onKeyDown={ lazy ? (e) => e.key === "Enter" && e.preventDefault() : undefined } className="relative flex flex-row max-h-[400px] overflow-hidden bg-background" > { setSelectedTransform(index); }} onDelete={(index) => { transformsField.remove(index); const indexBefore = index - 1; setSelectedTransform(Math.max(indexBefore, 0)); }} onAdd={handleAddTransform} />
{selectedTransform !== undefined && selectedTransformSchema && ( )} {(selectedTransform === undefined || !selectedTransformSchema) && (
)}
{lazy && (

This dataframe is marked lazy to improve performance.{" "} Click{" "} Apply to apply the transforms.

Pass{" "} lazy=False {" "} to{" "} mo.ui.dataframe {" "} to automatically apply transformations.

} > )}
); }; interface SidebarProps { items: TransformType[]; selected: number | undefined; onSelect: (index: number) => void; onDelete: (index: number) => void; onAdd: (transform: z.ZodType) => void; } export const Sidebar: React.FC = ({ items, selected, onAdd, onSelect, onDelete, }) => { return (
{items.map((item, idx) => { return (
{ onSelect(idx); }} className={cn( "flex flex-row min-h-[40px] items-center px-2 cursor-pointer hover:bg-accent/50 text-sm overflow-hidden hover-actions-parent border border-muted border-l-2 border-l-transparent", { "border-l-primary bg-accent text-accent-foreground": selected === idx, }, )} >
{Strings.startCase(item.type)}
{ onDelete(idx); e.stopPropagation(); }} />
); })}
); }; const AddTransformDropdown: React.FC< PropsWithChildren<{ onAdd: (transform: z.ZodType) => void }> > = ({ onAdd, children }) => { return ( {children} Add Transform {Object.values(TransformTypeSchema.options).map((type) => { const literal = getUnionLiteral(type); const Icon = ICONS[literal.value as TransformType["type"]]; return ( { evt.stopPropagation(); onAdd(type); }} > {Strings.startCase(literal.value)} ); })} { evt.stopPropagation(); window.open( "https://github.com/marimo-team/marimo/issues/new?title=New%20dataframe%20transform:&labels=enhancement&template=feature_request.yaml", "_blank", ); }} > Request a transform ); }; const ICONS: Record> = { aggregate: FunctionSquareIcon, column_conversion: ColumnsIcon, filter_rows: FilterIcon, group_by: GroupIcon, rename_column: PencilIcon, select_columns: SquareMousePointerIcon, sort_column: ArrowUpDownIcon, shuffle_rows: ShuffleIcon, sample_rows: CombineIcon, explode_columns: BracketsIcon, expand_dict: FileJsonIcon, unique: CopySlashIcon, pivot: Table2Icon, };