diff --git a/labextension/src/icons/statusRunning.tsx b/labextension/src/icons/statusRunning.tsx index 1c9a2099c..2e45fd0fb 100644 --- a/labextension/src/icons/statusRunning.tsx +++ b/labextension/src/icons/statusRunning.tsx @@ -13,36 +13,35 @@ // limitations under the License. import * as React from 'react'; -// import { CSSProperties } from 'jss/css'; - -export default class StatusRunning extends React.Component<{ style: any }> { - public render(): JSX.Element { - const { style } = this.props; - return ( - + - - - - + + - + /> - - ); - } + + + ); } diff --git a/labextension/src/icons/statusTerminated.tsx b/labextension/src/icons/statusTerminated.tsx index cc54011d9..26dbdd4c9 100644 --- a/labextension/src/icons/statusTerminated.tsx +++ b/labextension/src/icons/statusTerminated.tsx @@ -13,34 +13,34 @@ // limitations under the License. import * as React from 'react'; -// import { CSSProperties } from 'jss/css'; -export default class StatusTerminated extends React.Component<{ style: any }> { - public render(): JSX.Element { - const { style } = this.props; - return ( - - - - - + + + + - - + fill={style.color as string} + fillRule="nonzero" + /> + - - ); - } + + + ); } diff --git a/labextension/src/widgets/LeftPanel.tsx b/labextension/src/widgets/LeftPanel.tsx index a91bb9b02..873b49bf9 100644 --- a/labextension/src/widgets/LeftPanel.tsx +++ b/labextension/src/widgets/LeftPanel.tsx @@ -15,7 +15,7 @@ import * as React from 'react'; import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook'; import NotebookUtils from '../lib/NotebookUtils'; -import { InlineCellsMetadata } from './cell-metadata/InlineCellMetadata'; +import { InlineCellsMetadata } from './cell-metadata/InlineCellsMetadata'; import { SplitDeployButton } from '../components/DeployButton'; import { Kernel } from '@jupyterlab/services'; import { ExperimentInput } from '../components/ExperimentInput'; diff --git a/labextension/src/widgets/cell-metadata/CellMetadataEditor.tsx b/labextension/src/widgets/cell-metadata/CellMetadataEditor.tsx index 275cba462..898ed9469 100644 --- a/labextension/src/widgets/cell-metadata/CellMetadataEditor.tsx +++ b/labextension/src/widgets/cell-metadata/CellMetadataEditor.tsx @@ -13,751 +13,277 @@ // limitations under the License. import * as React from 'react'; +import { useCallback, useContext, useRef, useState } from 'react'; import { NotebookPanel } from '@jupyterlab/notebook'; import TagsUtils from '../../lib/TagsUtils'; -import { isCodeCellModel } from '@jupyterlab/cells'; import CloseIcon from '@mui/icons-material/Close'; import ColorUtils from '../../lib/ColorUtils'; import { CellMetadataContext } from '../../lib/CellMetadataContext'; -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControl, - FormControlLabel, - IconButton, - Radio, - RadioGroup, - Tooltip, -} from '@mui/material'; -import { CellMetadataEditorDialog } from './CellMetadataEditorDialog'; +import { Button, IconButton, Tooltip } from '@mui/material'; import { Input } from '../../components/Input'; import { Select } from '../../components/Select'; import { SelectMulti } from '../../components/SelectMulti'; - -const CELL_TYPES = [ - { value: 'imports', label: 'Imports' }, - { value: 'functions', label: 'Functions' }, - { value: 'pipeline-parameters', label: 'Pipeline Parameters' }, - { value: 'pipeline-metrics', label: 'Pipeline Metrics' }, - { value: 'step', label: 'Pipeline Step' }, - { value: 'skip', label: 'Skip Cell' }, -]; - -export const RESERVED_CELL_NAMES = [ - 'imports', - 'functions', - 'pipeline-parameters', - 'pipeline-metrics', - 'skip', -]; - -export const RESERVED_CELL_NAMES_HELP_TEXT: { [id: string]: string } = { - imports: - 'The code in this cell will be pre-pended to every step of the pipeline.', - functions: - 'The code in this cell will be pre-pended to every step of the pipeline,' + - ' after `imports`.', - 'pipeline-parameters': - 'The variables in this cell will be transformed into pipeline parameters,' + - ' preserving the current values as defaults.', - 'pipeline-metrics': - 'The variables in this cell will be transformed into pipeline metrics.', - skip: 'This cell will be skipped and excluded from pipeline steps', -}; -export const RESERVED_CELL_NAMES_CHIP_COLOR: { [id: string]: string } = { - skip: 'a9a9a9', - 'pipeline-parameters': 'ee7a1a', - 'pipeline-metrics': '773d0d', - imports: 'a32626', - functions: 'a32626', -}; - -export const DEFAULT_BASE_IMAGE = 'python:3.12'; - -const STEP_NAME_ERROR_MSG = `Step name must consist of lower case alphanumeric - characters or '_', and can not start with a digit.`; +import { GpuDialog } from './dialogs/GpuDialog'; +import { BaseImageDialog } from './dialogs/BaseImageDialog'; +import { CacheDialog } from './dialogs/CacheDialog'; +import { useUpdateCellTags } from './hooks/useCellTags'; +import { useEditorPosition } from './hooks/useEditorPosition'; +import { + CELL_TYPES, + RESERVED_CELL_NAMES, + STEP_NAME_ERROR_MSG, +} from './constants'; + +export { + RESERVED_CELL_NAMES, + RESERVED_CELL_NAMES_HELP_TEXT, + RESERVED_CELL_NAMES_CHIP_COLOR, + DEFAULT_BASE_IMAGE, +} from './constants'; export interface IProps { notebook: NotebookPanel; stepName?: string; stepDependencies: string[]; - // Resource limits, like gpu limits limits?: { [id: string]: string }; - // Base image for this step baseImage?: string; enableCaching?: boolean; pipelineBaseImage?: string; defaultBaseImage?: string; } -// this stores the name of a block and its color (form the name hash) -type BlockDependencyChoice = { value: string; color: string }; -interface IState { - // used to store the closest preceding block name. Used in case the current - // block name is empty, to suggest merging to the previous one. - previousStepName?: string; - stepNameErrorMsg?: string; - // a list of blocks that the current step can be dependent on. - blockDependenciesChoices: BlockDependencyChoice[]; - // flag to open the metadata editor dialog dialog - // XXX (stefano): I would like to set this as required, but the return - // XXX (stefano): statement of updateBlockDependenciesChoices and - // XXX (stefano): updatePreviousStepName don't allow me. - cellMetadataEditorDialog: boolean; - baseImageDialogOpen: boolean; - cacheDialogOpen: boolean; - cachingValue: 'default' | 'enabled' | 'disabled'; -} - -const DefaultState: IState = { - previousStepName: undefined, - stepNameErrorMsg: STEP_NAME_ERROR_MSG, - blockDependenciesChoices: [], - cellMetadataEditorDialog: false, - baseImageDialogOpen: false, - cacheDialogOpen: false, - cachingValue: 'default', -}; - -/** - * Component that allow to edit the Kale cell tags of a notebook cell. - */ -export class CellMetadataEditor extends React.Component { - static contextType = CellMetadataContext; - context!: React.ContextType; - editorRef: React.RefObject; - - constructor(props: IProps) { - super(props); - // We use this element reference in order to move it inside Notebooks's cell - // element. - this.editorRef = React.createRef(); - this.state = DefaultState; - this.updateCurrentBlockName = this.updateCurrentBlockName.bind(this); - this.updateCurrentCellType = this.updateCurrentCellType.bind(this); - this.updatePrevBlocksNames = this.updatePrevBlocksNames.bind(this); - this.toggleTagsEditorDialog = this.toggleTagsEditorDialog.bind(this); - this.toggleBaseImageDialog = this.toggleBaseImageDialog.bind(this); - } - - componentWillUnmount() { - const editor = this.editorRef.current; - if (editor) { - editor.remove(); - } - } - - updateCurrentCellType = (value: string) => { - if (RESERVED_CELL_NAMES.includes(value)) { - this.updateCurrentBlockName(value); - } else { - TagsUtils.resetCell( - this.props.notebook, - this.context.activeCellIndex, - this.props.stepName || '', - ); - } - }; - - isEqual(a: BlockDependencyChoice[], b: BlockDependencyChoice[]): boolean { - return JSON.stringify(a) === JSON.stringify(b); - } - - /** - * When the activeCellIndex of the editor changes, the editor needs to be - * moved to the correct position. - */ - moveEditor() { - if (!this.props.notebook) { - return; - } - // get the HTML element corresponding to the current active cell - const notebookContent = this.props.notebook.content; - if (!notebookContent?.node?.childNodes) { - return; +export const CellMetadataEditor: React.FC = props => { + const { + notebook, + stepName = '', + stepDependencies, + limits = {}, + baseImage, + enableCaching, + pipelineBaseImage, + defaultBaseImage, + } = props; + + const { activeCellIndex, isEditorVisible, onEditorVisibilityChange } = + useContext(CellMetadataContext); + + const editorRef = useRef(null); + + const updateCellTags = useUpdateCellTags({ + notebook, + stepName, + stepDependencies, + limits, + baseImage, + enableCaching, + }); + + useEditorPosition(editorRef, notebook); + + const blockDependenciesChoices = React.useMemo(() => { + if (!notebook) { + return []; } - const metadataWrapper = this.props.notebook.content.widgets[ - this.context.activeCellIndex - ].node as HTMLElement; - - if (!metadataWrapper) { - return; - } - const editor = this.editorRef.current; - const inlineElement = metadataWrapper.querySelector( - '.kale-inline-cell-metadata-container', - ); - const isEditorAlreadInPlace = metadataWrapper.querySelector( - '.kale-metadata-editor-wrapper', - ); - - if (editor && inlineElement && !isEditorAlreadInPlace) { - editor.remove(); - metadataWrapper.prepend(editor); - } - } - - componentDidUpdate(prevProps: Readonly, prevState: Readonly) { - this.hideEditorIfNotCodeCell(); - this.moveEditor(); - // this.setState(this.updateBlockDependenciesChoices); - // this.setState(this.updatePreviousStepName); - const dependenciesState = this.updateBlockDependenciesChoices( - this.state, - this.props, - ); - if (dependenciesState) { - this.setState(dependenciesState); - } - - const previousStepState = this.updatePreviousStepName( - this.state, - this.props, - ); - if (previousStepState) { - this.setState(previousStepState); - } - } - - hideEditorIfNotCodeCell() { - if ( - this.props.notebook && - !this.props.notebook.isDisposed && - this.props.notebook.model - ) { - const cellModel = this.props.notebook.model.cells.get( - this.context.activeCellIndex, - ); - if ( - cellModel && - !isCodeCellModel(cellModel) && - this.context.isEditorVisible - ) { - this.closeEditor(); - } - } - } - - /** - * Scan the notebook for all block tags and get them all, excluded the current - * one (and the reserved cell tags) The value `previousBlockChoices` is used - * by the dependencies select option to select the current step's - * dependencies. - */ - updateBlockDependenciesChoices( - state: Readonly, - props: Readonly, - ): Pick | null { - if (!props.notebook) { - return null; - } - const allBlocks = TagsUtils.getAllBlocks( - props.notebook.content, - this.context.activeCellIndex, - ); - const dependencyChoices: BlockDependencyChoice[] = allBlocks - // remove all reserved names and current step name - .filter( - el => !RESERVED_CELL_NAMES.includes(el) && !(el === props.stepName), - ) + const allBlocks = TagsUtils.getAllBlocks(notebook.content, activeCellIndex); + return allBlocks + .filter(el => !RESERVED_CELL_NAMES.includes(el) && el !== stepName) .map(name => ({ value: name, color: `#${ColorUtils.getColor(name)}` })); + }, [notebook, stepName, stepDependencies, activeCellIndex]); + + const previousStepName = React.useMemo( + () => + notebook + ? TagsUtils.getPreviousBlock(notebook.content, activeCellIndex) + : undefined, + [notebook, activeCellIndex, stepName], + ); + + const cellType = RESERVED_CELL_NAMES.includes(stepName) ? stepName : 'step'; + const cellColor = stepName + ? `#${ColorUtils.getColor(stepName)}` + : 'transparent'; + const isStep = cellType === 'step'; + const hasStepName = !!(stepName && stepName.length > 0); + + const prevStepNotice = + previousStepName && stepName === '' + ? `Leave the step name empty to merge the cell to step '${previousStepName}'` + : null; - if (this.isEqual(state.blockDependenciesChoices, dependencyChoices)) { - return null; - } - // XXX (stefano): By setting state.cellMetadataEditorDialog NOT optional, - // XXX (stefano): this return will require cellMetadataEditorDialog. - return { blockDependenciesChoices: dependencyChoices }; - } - - updatePreviousStepName( - state: Readonly, - props: Readonly, - ): Pick | null { - if (!props.notebook) { - return null; - } - const prevBlockName = TagsUtils.getPreviousBlock( - props.notebook.content, - this.context.activeCellIndex, - ); - if (prevBlockName === this.state.previousStepName) { - return null; - } - // XXX (stefano): By setting state.cellMetadataEditorDialog NOT optional, - // XXX (stefano): this return will require cellMetadataEditorDialog. - return { previousStepName: prevBlockName }; - } - - updateCurrentBlockName = (value: string) => { - const oldBlockName: string = this.props.stepName || ''; - const tags = TagsUtils.getKaleCellTags( - this.props.notebook.content, - this.context.activeCellIndex, - ); - const currentCellMetadata = { - prevBlockNames: this.props.stepDependencies, - limits: this.props.limits || {}, - baseImage: this.props.baseImage, - enableCaching: tags?.enableCaching, - blockName: value, - }; - - TagsUtils.setKaleCellTags( - this.props.notebook, - this.context.activeCellIndex, - currentCellMetadata, - ).then(() => { - TagsUtils.updateKaleCellsTags(this.props.notebook, oldBlockName, value); - }); - }; - - /** - * Even handler of the MultiSelect used to select the dependencies of a block - */ - updatePrevBlocksNames = (previousBlocks: string[]) => { - const tags = TagsUtils.getKaleCellTags( - this.props.notebook.content, - this.context.activeCellIndex, - ); - const currentCellMetadata = { - blockName: this.props.stepName || '', - limits: this.props.limits || {}, - baseImage: this.props.baseImage, - enableCaching: tags?.enableCaching, - prevBlockNames: previousBlocks, - }; - - TagsUtils.setKaleCellTags( - this.props.notebook, - this.context.activeCellIndex, - currentCellMetadata, - ); - }; + const [stepNameErrorMsg, setStepNameErrorMsg] = useState(STEP_NAME_ERROR_MSG); - /** - * Event triggered when the the CellMetadataEditorDialog dialog is closed - */ - updateCurrentLimits = ( - actions: { - action: 'update' | 'delete'; - limitKey: string; - limitValue?: string; - }[], - ) => { - const limits = { ...this.props.limits }; - actions.forEach(action => { - if (action.action === 'update' && action.limitValue !== undefined) { - limits[action.limitKey] = action.limitValue; + const onBeforeUpdate = useCallback( + (value: string) => { + if (value === stepName) { + return false; } - if ( - action.action === 'delete' && - Object.keys(this.props.limits || {}).includes(action.limitKey) - ) { - delete limits[action.limitKey]; + const blockNames = TagsUtils.getAllBlocks(notebook.content); + if (blockNames.includes(value)) { + setStepNameErrorMsg('This name already exists.'); + return true; } - }); - - const tags = TagsUtils.getKaleCellTags( - this.props.notebook.content, - this.context.activeCellIndex, - ); - const currentCellMetadata = { - blockName: this.props.stepName || '', - prevBlockNames: this.props.stepDependencies, - limits: limits, - baseImage: this.props.baseImage, - enableCaching: tags?.enableCaching, - }; - - TagsUtils.setKaleCellTags( - this.props.notebook, - this.context.activeCellIndex, - currentCellMetadata, - ); - }; - - /** - * Function called before updating the value of the block name input text - * field. It acts as a validator. - */ - onBeforeUpdate = (value: string) => { - if (value === this.props.stepName) { + setStepNameErrorMsg(STEP_NAME_ERROR_MSG); return false; - } - const blockNames = TagsUtils.getAllBlocks(this.props.notebook.content); - if (blockNames.includes(value)) { - this.setState({ stepNameErrorMsg: 'This name already exists.' }); - return true; - } - this.setState({ stepNameErrorMsg: STEP_NAME_ERROR_MSG }); - return false; - }; - - getPrevStepNotice = () => { - return this.state.previousStepName && this.props.stepName === '' - ? `Leave the step name empty to merge the cell to step '${this.state.previousStepName}'` - : null; - }; - - /** - * Event handler of close button, positioned on the top right of the cell - */ - closeEditor() { - this.context.onEditorVisibilityChange(false); - } - - toggleTagsEditorDialog() { - this.setState({ - cellMetadataEditorDialog: !this.state.cellMetadataEditorDialog, - }); - } - - toggleBaseImageDialog() { - this.setState({ - baseImageDialogOpen: !this.state.baseImageDialogOpen, - }); - } - - toggleCacheDialog() { - const isOpening = !this.state.cacheDialogOpen; - // When opening the dialog, read current value directly from notebook tags - if (isOpening) { - const tags = TagsUtils.getKaleCellTags( - this.props.notebook.content, - this.context.activeCellIndex, - ); - const currentEnableCaching = tags?.enableCaching; - const cachingValue = - currentEnableCaching === undefined - ? 'default' - : currentEnableCaching - ? 'enabled' - : 'disabled'; - this.setState({ - cacheDialogOpen: true, - cachingValue: cachingValue, - }); - } else { - this.setState({ - cacheDialogOpen: false, - }); - } - } - - updateBaseImage = (value: string) => { - const tags = TagsUtils.getKaleCellTags( - this.props.notebook.content, - this.context.activeCellIndex, - ); - const currentCellMetadata = { - blockName: this.props.stepName || '', - prevBlockNames: this.props.stepDependencies, - limits: this.props.limits || {}, - baseImage: value || undefined, - enableCaching: tags?.enableCaching, - }; - - TagsUtils.setKaleCellTags( - this.props.notebook, - this.context.activeCellIndex, - currentCellMetadata, - ); - }; - - updateEnableCaching = (value: boolean | undefined) => { - const currentCellMetadata = { - blockName: this.props.stepName || '', - prevBlockNames: this.props.stepDependencies, - limits: this.props.limits || {}, - baseImage: this.props.baseImage, - enableCaching: value, - }; - - TagsUtils.setKaleCellTags( - this.props.notebook, - this.context.activeCellIndex, - currentCellMetadata, - ); - }; - - render() { - const cellType = RESERVED_CELL_NAMES.includes(this.props.stepName || '') - ? this.props.stepName - : 'step'; - const cellColor = this.props.stepName - ? `#${ColorUtils.getColor(this.props.stepName)}` - : 'transparent'; - - const prevStepNotice = this.getPrevStepNotice(); - - return ( - -
+ }, + [notebook, stepName], + ); + + const [gpuDialogOpen, setGpuDialogOpen] = useState(false); + const [baseImageDialogOpen, setBaseImageDialogOpen] = useState(false); + const [cacheDialogOpen, setCacheDialogOpen] = useState(false); + + const closeEditor = useCallback(() => { + onEditorVisibilityChange(false); + }, [onEditorVisibilityChange]); + + return ( + <> +
+
-
- - {cellType === 'step' ? ( + {isStep && ( + <> - ) : ( - '' - )} - {cellType === 'step' ? ( + -
+
0 - ) || this.state.blockDependenciesChoices.length === 0 + !hasStepName || blockDependenciesChoices.length === 0 } - updateSelected={this.updatePrevBlocksNames} - options={this.state.blockDependenciesChoices} + updateSelected={updateCellTags.updateDependencies} + options={blockDependenciesChoices} variant="outlined" - selected={this.props.stepDependencies || []} + selected={stepDependencies || []} />
- ) : ( - '' - )} - {cellType === 'step' ? (
- ) : ( - '' - )} - {cellType === 'step' ? (
- ) : ( - '' - )} - {cellType === 'step' ? (
- ) : ( - '' - )} + + )} - this.closeEditor()} - > - - -
-
-

{prevStepNotice}

-
+ + + +
+
+

{prevStepNotice}

- - this.toggleBaseImageDialog()} - fullWidth={true} - maxWidth={'sm'} - > - Base Image for Step - -

- Default:{' '} - - {this.props.defaultBaseImage || DEFAULT_BASE_IMAGE} - -

- this.updateBaseImage(v)} - placeholder={ - this.props.pipelineBaseImage || - this.props.defaultBaseImage || - DEFAULT_BASE_IMAGE - } - style={{ width: '100%', marginTop: '8px' }} - /> -
- - - - -
- - {/* Cache Dialog */} - this.toggleCacheDialog()} - fullWidth={true} - maxWidth={'sm'} - > - Step Caching Control - -

- Control whether this step's results are cached. When enabled, - Kubeflow Pipelines will reuse previous execution results if inputs - haven't changed. -

- - ) => { - const val = e.target.value as - | 'default' - | 'enabled' - | 'disabled'; - // Update local state immediately for responsive UI - this.setState({ cachingValue: val }); - // Save to notebook - this.updateEnableCaching( - val === 'default' ? undefined : val === 'enabled', - ); - }} - > - } - label="Use Pipeline Default" - /> - } - label="Enable Caching" - /> - } - label="Disable Caching" - /> - - -
- - - -
- - ); - } -} +
+ + setGpuDialogOpen(prev => !prev)} + stepName={stepName} + limits={limits} + updateLimits={updateCellTags.updateLimits} + /> + + setBaseImageDialogOpen(false)} + baseImage={baseImage} + pipelineBaseImage={pipelineBaseImage} + defaultBaseImage={defaultBaseImage} + onUpdateBaseImage={updateCellTags.updateBaseImage} + /> + + setCacheDialogOpen(false)} + enableCaching={enableCaching} + onUpdateCaching={updateCellTags.updateCaching} + /> + + ); +}; diff --git a/labextension/src/widgets/cell-metadata/InlineCellMetadata.tsx b/labextension/src/widgets/cell-metadata/InlineCellMetadata.tsx deleted file mode 100644 index 7a4c76d97..000000000 --- a/labextension/src/widgets/cell-metadata/InlineCellMetadata.tsx +++ /dev/null @@ -1,418 +0,0 @@ -// Copyright 2026 The Kubeflow Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import * as React from 'react'; -import { CellList, Notebook, NotebookPanel } from '@jupyterlab/notebook'; -import { DocumentRegistry } from '@jupyterlab/docregistry'; -import { IObservableList } from '@jupyterlab/observables'; -import { - Cell, - CodeCellModel, - ICellModel, - isCodeCellModel, -} from '@jupyterlab/cells'; -import CellUtils from '../../lib/CellUtils'; -import TagsUtils from '../../lib/TagsUtils'; -import { InlineMetadata } from './InlineMetadata'; -import { - CellMetadataEditor, - IProps as EditorProps, -} from './CellMetadataEditor'; -import { CellMetadataContext } from '../../lib/CellMetadataContext'; -import { Switch } from '@mui/material'; -import NotebookUtils from '../../lib/NotebookUtils'; - -import { createPortal } from 'react-dom'; - -interface IProps { - notebook: NotebookPanel; - onMetadataEnable: (isEnabled: boolean) => void; - pipelineBaseImage?: string; - defaultBaseImage?: string; - initialChecked?: boolean; -} - -type Editors = { [index: string]: EditorProps }; - -interface IState { - activeCellIndex: number; - prevBlockName?: string; - metadataCmp?: React.ReactPortal[]; - checked?: boolean; - editors?: Editors; - isEditorVisible: boolean; -} - -const DefaultState: IState = { - activeCellIndex: 0, - prevBlockName: undefined, - metadataCmp: [], - checked: false, - editors: {}, - isEditorVisible: false, -}; - -type SaveState = 'started' | 'completed' | 'failed'; - -export class InlineCellsMetadata extends React.Component { - state = DefaultState; - - constructor(props: IProps) { - super(props); - this.onEditorVisibilityChange = this.onEditorVisibilityChange.bind(this); - } - - static getDerivedStateFromProps( - nextProps: IProps, - prevState: IState, - ): Partial | null { - if ( - nextProps.initialChecked !== undefined && - nextProps.initialChecked !== prevState.checked - ) { - return { checked: nextProps.initialChecked }; - } - return null; - } - - componentDidMount = () => { - if (this.props.notebook) { - this.connectAndInitWhenReady(this.props.notebook); - } - }; - - componentDidUpdate = async ( - prevProps: Readonly, - prevState: Readonly, - ) => { - if (!this.props.notebook && prevProps.notebook) { - // no notebook - this.clearEditorsPropsAndInlineMetadata(); - } - - const preNotebookId = prevProps.notebook ? prevProps.notebook.id : ''; - const notebookId = this.props.notebook ? this.props.notebook.id : ''; - if (preNotebookId !== notebookId) { - // notebook changed - if (prevProps.notebook) { - this.disconnectHandlersFromNotebook(prevProps.notebook); - } - if (this.props.notebook) { - this.connectAndInitWhenReady(this.props.notebook); - } - // hide editor on notebook change - this.setState({ isEditorVisible: false }); - } - - if (!prevState.checked && this.state.checked && this.props.notebook) { - this.runEnableKaleLogic(); - } - }; - - connectAndInitWhenReady = (notebook: NotebookPanel) => { - notebook.context.ready.then(() => { - this.connectHandlersToNotebook(this.props.notebook); - this.refreshEditorsPropsAndInlineMetadata(); - this.setState({ - activeCellIndex: notebook.content.activeCellIndex, - }); - }); - }; - - connectHandlersToNotebook = (notebook: NotebookPanel) => { - notebook.context.saveState.connect(this.handleSaveState); - notebook.content.activeCellChanged.connect(this.onActiveCellChanged); - if (notebook.model) { - notebook.model.cells.changed.connect(this.handleCellChange); - } - - if (notebook.content.activeCell?.model.type === 'code') { - notebook.content.activeCell.model.metadataChanged.connect( - this.onActiveCellMetadataChange, - ); - this.setState({ activeCellIndex: notebook.content.activeCellIndex }); - } - }; - - disconnectHandlersFromNotebook = (notebook: NotebookPanel) => { - notebook.context.saveState.disconnect(this.handleSaveState); - notebook.content.activeCellChanged.disconnect(this.onActiveCellChanged); - // when closing the notebook tab, notebook.model becomes null - if (notebook.model) { - notebook.model.cells.changed.disconnect(this.handleCellChange); - } - }; - - onActiveCellChanged = (notebook: Notebook, activeCell: Cell | null) => { - const prevCell = notebook.model?.cells.get(this.state.activeCellIndex); - if (prevCell) { - prevCell.metadataChanged.disconnect(this.onActiveCellMetadataChange); - } - this.setState({ - activeCellIndex: notebook.activeCellIndex, - }); - activeCell?.model.metadataChanged.connect(this.onActiveCellMetadataChange); - }; - - onActiveCellMetadataChange = (_: any) => { - this.refreshEditorsPropsAndInlineMetadata(true); - }; - - handleSaveState = (context: DocumentRegistry.Context, state: SaveState) => { - if (this.state.checked && state === 'completed') { - this.generateEditorsPropsAndInlineMetadata(); - } - }; - - handleCellChange = ( - cells: CellList, - args: IObservableList.IChangedArgs, - ) => { - const prevValue = args.oldValues[0]; - // Change type 'set' is when a cell changes its type. Even if a user changes - // multiple cells using Shift + click the args.oldValues has only one cell - // each time. - if (args.type === 'set' && prevValue instanceof CodeCellModel) { - CellUtils.setCellMetaData(this.props.notebook, args.newIndex, 'tags', []); - } - - // Change type 'remove' is when a cell is removed from the notebook. - if (args.type === 'remove') { - TagsUtils.removeOldDependencies(this.props.notebook); - } - - this.refreshEditorsPropsAndInlineMetadata(); - }; - - private runEnableKaleLogic = () => { - if (!this.props.notebook || !this.props.notebook.model) { - return; - } - this.props.notebook.context.ready.then(() => { - this.generateEditorsPropsAndInlineMetadata(); - if ( - this.props.notebook && - this.props.notebook.content && - this.props.notebook.content.activeCellIndex !== undefined && - this.props.notebook.content.activeCellIndex >= 0 - ) { - const activeCell = this.props.notebook.content.activeCell; - if (activeCell && activeCell.node) { - setTimeout( - NotebookUtils.selectAndScrollToCell, - 200, - this.props.notebook, - { - cell: activeCell, - index: this.props.notebook.content.activeCellIndex, - }, - ); - } - } - }); - }; - - /** - * Event handler for the global Kale switch (the one below the Kale title in - * the left panel). Enabling the switch propagates to the father component - * (LeftPanel) to enable the rest of the UI. - */ - toggleGlobalKaleSwitch(checked: boolean) { - this.setState({ checked }); - this.props.onMetadataEnable(checked); - - if (checked) { - this.runEnableKaleLogic(); - } else { - this.setState({ isEditorVisible: false }); - this.clearEditorsPropsAndInlineMetadata(); - } - } - - refreshEditorsPropsAndInlineMetadata(isEditorVisible: boolean = false) { - if (this.state.checked) { - this.clearEditorsPropsAndInlineMetadata(() => { - this.generateEditorsPropsAndInlineMetadata(); - }); - this.setState({ isEditorVisible: isEditorVisible }); - } - } - - clearEditorsPropsAndInlineMetadata = (callback?: () => void) => { - // triggers cleanup in InlineMetadata - this.setState({ metadataCmp: [], editors: {} }, () => { - if (callback) { - callback(); - } - }); - }; - - generateEditorsPropsAndInlineMetadata = () => { - if (!this.props.notebook || !this.props.notebook.model) { - return; - } - const metadata: React.ReactPortal[] = []; - const editors: Editors = {}; - const cells = this.props.notebook.model.cells; - for (let index = 0; index < cells.length; index++) { - const cellModel = this.props.notebook.model.cells.get(index); - const isCodeCell = isCodeCellModel(cellModel); - if (!isCodeCell) { - continue; - } - - let tags = TagsUtils.getKaleCellTags(this.props.notebook.content, index); - if (!tags) { - tags = { - blockName: '', - prevBlockNames: [], - }; - } - let previousBlockName: string | undefined = ''; - - if (!tags.blockName) { - previousBlockName = TagsUtils.getPreviousBlock( - this.props.notebook.content, - index, - ); - } - editors[index] = { - notebook: this.props.notebook, - stepName: tags.blockName || '', - stepDependencies: tags.prevBlockNames || [], - limits: tags.limits || {}, - baseImage: tags.baseImage, - enableCaching: tags.enableCaching, - }; - - const cellElement = this.props.notebook.content.widgets[index] - .node as HTMLElement; - - if (!cellElement) { - console.warn( - `Failed to get cell element for index ${index}, skipping metadata creation`, - ); - continue; - } - - const metadataParent = document.createElement('div'); - // When the cell was newly added Jupyter still didn't add elements to it - // so we wait for the first child to be added and then we prepend the metadata element. - if (cellElement.childNodes.length === 0) { - new MutationObserver((_, obs) => { - cellElement.prepend(metadataParent); - obs.disconnect(); - }).observe(cellElement, { childList: true }); - } else { - cellElement.prepend(metadataParent); - } - const inlineMetadataPortal = createPortal( - , - metadataParent, - ); - metadata.push(inlineMetadataPortal); - } - - this.setState({ - metadataCmp: metadata, - editors: editors, - }); - }; - - /** - * Callback passed to the CellMetadataEditor context - */ - onEditorVisibilityChange(isEditorVisible: boolean) { - this.setState({ isEditorVisible }); - } - - render() { - // Get the editor props of the active cell, so that just one editor is - // rendered at any given time. - const activeEditorData = this.state.editors?.[this.state.activeCellIndex]; - - //notebook is always a NotebookPanel (never undefined) - // stepName is always a string (never undefined) - // stepDependencies is always a string array (never undefined) - // limits is always an object (never undefined) - const editorProps: EditorProps = activeEditorData - ? { - notebook: activeEditorData.notebook, - stepName: activeEditorData.stepName || '', - stepDependencies: activeEditorData.stepDependencies || [], - limits: activeEditorData.limits || {}, - baseImage: activeEditorData.baseImage, - } - : { - notebook: this.props.notebook, - stepName: '', - stepDependencies: [], - limits: {}, - baseImage: undefined, - }; - - const cellMetadataEditor = createPortal( - , - document.body, - ); - return ( - -
-
Enable
- this.toggleGlobalKaleSwitch(c.target.checked)} - color="primary" - name="enableKale" - inputProps={{ 'aria-label': 'primary checkbox' }} - classes={{ root: 'material-switch' }} - /> -
-
- - {cellMetadataEditor} - {this.state.metadataCmp} - -
-
- ); - } -} diff --git a/labextension/src/widgets/cell-metadata/InlineCellsMetadata.tsx b/labextension/src/widgets/cell-metadata/InlineCellsMetadata.tsx new file mode 100644 index 000000000..3f019255d --- /dev/null +++ b/labextension/src/widgets/cell-metadata/InlineCellsMetadata.tsx @@ -0,0 +1,159 @@ +// Copyright 2026 The Kubeflow Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { NotebookPanel } from '@jupyterlab/notebook'; +import { + CellMetadataEditor, + IProps as EditorProps, +} from './CellMetadataEditor'; +import { CellMetadataContext } from '../../lib/CellMetadataContext'; +import { Switch } from '@mui/material'; +import NotebookUtilities from '../../lib/NotebookUtils'; +import { createPortal } from 'react-dom'; +import { useInlineCellsMetadata } from './hooks/useInlineCellsMetadata'; + +interface IProps { + notebook: NotebookPanel; + onMetadataEnable: (isEnabled: boolean) => void; + pipelineBaseImage?: string; + defaultBaseImage?: string; + initialChecked?: boolean; +} + +export function InlineCellsMetadata({ + notebook, + onMetadataEnable, + pipelineBaseImage, + defaultBaseImage, + initialChecked, +}: IProps) { + const [checked, setChecked] = useState(initialChecked ?? false); + + useEffect(() => { + if (initialChecked !== undefined) { + setChecked(initialChecked); + } + }, [initialChecked]); + + const { + activeCellIndex, + inlineMetadataPortals, + editors, + isEditorVisible, + setIsEditorVisible, + } = useInlineCellsMetadata({ + notebook, + checked, + pipelineBaseImage, + defaultBaseImage, + }); + + // Scroll to active cell when checked transitions false → true + const prevCheckedRef = useRef(checked); + useEffect(() => { + if (!prevCheckedRef.current && checked && notebook?.model) { + notebook.context.ready.then(() => { + const activeIdx = notebook.content.activeCellIndex; + if (activeIdx !== undefined && activeIdx >= 0) { + const activeCell = notebook.content.activeCell; + if (activeCell?.node) { + setTimeout(NotebookUtilities.selectAndScrollToCell, 200, notebook, { + cell: activeCell, + index: activeIdx, + }); + } + } + }); + } + prevCheckedRef.current = checked; + }, [checked, notebook]); + + const toggleGlobalKaleSwitch = useCallback( + (newChecked: boolean) => { + setChecked(newChecked); + onMetadataEnable(newChecked); + }, + [onMetadataEnable], + ); + + const onEditorVisibilityChange = useCallback( + (visible: boolean) => { + setIsEditorVisible(visible); + }, + [setIsEditorVisible], + ); + + const activeEditorData = editors[activeCellIndex]; + const editorProps: EditorProps = activeEditorData + ? { + notebook: activeEditorData.notebook, + stepName: activeEditorData.stepName || '', + stepDependencies: activeEditorData.stepDependencies || [], + limits: activeEditorData.limits || {}, + baseImage: activeEditorData.baseImage, + enableCaching: activeEditorData.enableCaching, + } + : { + notebook, + stepName: '', + stepDependencies: [], + limits: {}, + baseImage: undefined, + enableCaching: undefined, + }; + + const cellMetadataEditor = createPortal( + , + document.body, + ); + + return ( + <> +
+
Enable
+ toggleGlobalKaleSwitch(c.target.checked)} + color="primary" + name="enableKale" + inputProps={{ 'aria-label': 'primary checkbox' }} + classes={{ root: 'material-switch' }} + /> +
+
+ + {cellMetadataEditor} + {inlineMetadataPortals} + +
+ + ); +} diff --git a/labextension/src/widgets/cell-metadata/InlineMetadata.tsx b/labextension/src/widgets/cell-metadata/InlineMetadata.tsx index 596c4aebe..579ce56d9 100644 --- a/labextension/src/widgets/cell-metadata/InlineMetadata.tsx +++ b/labextension/src/widgets/cell-metadata/InlineMetadata.tsx @@ -13,6 +13,7 @@ // limitations under the License. import * as React from 'react'; +import { useContext, useEffect, useRef } from 'react'; import { Chip, Tooltip } from '@mui/material'; import ColorUtils from '../../lib/ColorUtils'; import { @@ -36,22 +37,6 @@ interface IProps { defaultBaseImage?: string; } -interface IState { - cellTypeClass: string; - color: string; - dependencies: React.ReactNode[]; - showEditor: boolean; - isMergedCell: boolean; -} - -const DefaultState: IState = { - cellTypeClass: '', - color: '', - dependencies: [], - showEditor: false, - isMergedCell: false, -}; -// Check if an object is DOMElement function isDOMElement(obj: any): obj is HTMLElement { return ( obj && @@ -69,292 +54,161 @@ function isDOMElement(obj: any): obj is HTMLElement { * When a cell is tagged with a step name and some dependencies, a chip with the * step name and a series of coloured dots for its dependencies are show. */ -export class InlineMetadata extends React.Component { - static contextType = CellMetadataContext; - context!: React.ContextType; - wrapperRef: React.RefObject | null = null; - state = DefaultState; - - constructor(props: IProps) { - super(props); - this.openEditor = this.openEditor.bind(this); - } - - componentDidMount() { - this.setState(this.updateIsMergedState); - this.checkIfReservedName(); - this.updateStyles(); - this.updateDependencies(); - } - - componentWillUnmount() { - const cellElement = this.props.cellElement; - if (isDOMElement(cellElement)) { - cellElement.classList.remove('kale-merged-cell'); - const codeMirrorElem = cellElement.querySelector( - '.CodeMirror', - ) as HTMLElement; - if (codeMirrorElem) { - codeMirrorElem.style.border = ''; - } - } - - if (this.wrapperRef?.current) { - this.wrapperRef.current.remove(); - } - } - - componentDidUpdate(prevProps: Readonly, prevState: Readonly) { - const mergedState = this.updateIsMergedState(this.state, this.props); - if (mergedState) { - this.setState(mergedState); - } - - if ( - prevProps.blockName !== this.props.blockName || - prevProps.previousBlockName !== this.props.previousBlockName - ) { - this.updateStyles(); - } - - if (prevProps.stepDependencies !== this.props.stepDependencies) { - this.updateDependencies(); - } - - this.checkIfReservedName(); - const editorState = this.updateEditorState(this.state, this.props); - if (editorState) { - this.setState(editorState); - } - } - - updateEditorState = (state: IState, props: IProps) => { - let showEditor = false; - - if (this.context && this.context.isEditorVisible) { - if (this.context.activeCellIndex === props.cellIndex) { - showEditor = true; - } - } +export const InlineMetadata: React.FC = ({ + blockName, + previousBlockName, + stepDependencies, + limits, + baseImage, + enableCaching, + cellElement, + cellIndex, + pipelineBaseImage, + defaultBaseImage, +}) => { + const context = useContext(CellMetadataContext); + const wrapperRef = useRef(null); + + const isMergedCell = !blockName; + const isReserved = RESERVED_CELL_NAMES.includes(blockName); + const cellTypeClass = isReserved ? 'kale-reserved-cell' : ''; + const name = blockName || previousBlockName; + const color = name ? ColorUtils.getColor(name) : ''; + const showEditor = + context.isEditorVisible && context.activeCellIndex === cellIndex; + + const dependencies = stepDependencies.map((depName, i) => { + const rgb = ColorUtils.getColor(depName); + return ( + +
+ + ); + }); - if (showEditor === state.showEditor) { - return null; + useEffect(() => { + if (!isDOMElement(cellElement)) { + return; } - - return { showEditor }; - }; - - updateIsMergedState = (state: IState, props: IProps) => { - let newIsMergedCell = false; - const cellElement = props.cellElement; - if (!props.blockName) { - newIsMergedCell = true; - - // TODO: This is a side effect, consider moving it somewhere else. - if (isDOMElement(cellElement)) { - cellElement.classList.add('kale-merged-cell'); - } + if (isMergedCell) { + cellElement.classList.add('kale-merged-cell'); } else { - if (isDOMElement(cellElement)) { - cellElement.classList.remove('kale-merged-cell'); - } - } - - if (newIsMergedCell === state.isMergedCell) { - return null; + cellElement.classList.remove('kale-merged-cell'); } + }, [cellElement, isMergedCell]); - return { isMergedCell: newIsMergedCell }; - }; - - /** - * Check if the block tag of che current cell has a reserved name. If so, - * apply the corresponding css class to the HTML Cell element. - */ - checkIfReservedName() { - this.setState((state: IState, props: IProps) => { - let cellTypeClass = ''; - if (RESERVED_CELL_NAMES.includes(props.blockName)) { - cellTypeClass = 'kale-reserved-cell'; - } - - if (cellTypeClass === state.cellTypeClass) { - return null; - } - return { cellTypeClass }; - }); - } - - /** - * Update the style of the active cell, by changing the left border with - * the correct color, based on the current block name. - */ - updateStyles() { - if (!isDOMElement(this.props.cellElement)) { + useEffect(() => { + if (!isDOMElement(cellElement)) { return; } - const name = this.props.blockName || this.props.previousBlockName; - const codeMirrorElem = this.props.cellElement.querySelector( + const codeMirrorElem = cellElement.querySelector( '.CodeMirror', ) as HTMLElement; - if (codeMirrorElem) { - codeMirrorElem.style.borderLeft = '2px solid transparent'; - } - if (!name) { - this.setState({ color: '' }); - return; + codeMirrorElem.style.borderLeft = color + ? `2px solid #${color}` + : '2px solid transparent'; } - const rgb = this.getColorFromName(name); - this.setState({ color: rgb }); - - if (codeMirrorElem) { - codeMirrorElem.style.borderLeft = `2px solid #${rgb}`; - } - } - - getColorFromName(name: string) { - return ColorUtils.getColor(name); - } - - createLimitsText() { - const gpuType = Object.keys(this.props.limits)[0] || undefined; - return gpuType ? ( - -

- GPU request: {gpuType + ' - '} - {this.props.limits[gpuType]} -

-
- ) : ( - '' - ); - } - - createBaseImageText() { - const effectiveImage = - this.props.baseImage || - this.props.pipelineBaseImage || - this.props.defaultBaseImage || - DEFAULT_BASE_IMAGE; - const isDefault = !this.props.baseImage; + }, [cellElement, color]); - return ( - !isDefault && ( -

- Base Image: {effectiveImage} -

- ) - ); - } + useEffect(() => { + return () => { + if (isDOMElement(cellElement)) { + cellElement.classList.remove('kale-merged-cell'); + const codeMirrorElem = cellElement.querySelector( + '.CodeMirror', + ) as HTMLElement; + if (codeMirrorElem) { + codeMirrorElem.style.border = ''; + } + } + if (wrapperRef.current) { + wrapperRef.current.remove(); + } + }; + }, [cellElement]); - createCacheText() { - // Only show if caching is explicitly set (not using pipeline default) - if (this.props.enableCaching === undefined) { - return null; - } + const openEditor = () => { + context.onEditorVisibilityChange(true); + }; - const cacheStatus = this.props.enableCaching ? 'enabled' : 'disabled'; - return ( + const gpuType = Object.keys(limits)[0] || undefined; + const limitsText = gpuType ? ( +

+ GPU request: {gpuType + ' - '} + {limits[gpuType]} +

+ ) : null; + + const baseImageText = baseImage ? ( +

+ Base Image:{' '} + {baseImage || pipelineBaseImage || defaultBaseImage || DEFAULT_BASE_IMAGE} +

+ ) : null; + + const cacheText = + enableCaching !== undefined ? (

- Cache: {cacheStatus} + Cache: {enableCaching ? 'enabled' : 'disabled'}

- ); - } - - /** - * Create a list of div dots that represent the dependencies of the current - * block - */ - updateDependencies() { - const dependencies = this.props.stepDependencies.map((name, i) => { - const rgb = this.getColorFromName(name); - return ( - -
-
- ); - }); - this.setState({ dependencies }); - } - - openEditor = () => { - const showEditor = true; - this.setState({ showEditor }); - this.context.onEditorVisibilityChange(showEditor); - }; - - render() { - const details = RESERVED_CELL_NAMES.includes( - this.props.blockName, - ) ? null : ( - <> - {/* Add a `depends on: ` string before the deps dots in case there are some*/} - {this.state.dependencies.length > 0 ? ( -

depends on:

- ) : null} - {this.state.dependencies} - - {this.createLimitsText()} - {this.createBaseImageText()} - {this.createCacheText()} - - ); + ) : null; + + const details = isReserved ? null : ( + <> + {dependencies.length > 0 ? ( +

depends on:

+ ) : null} + {dependencies} + {limitsText} + {baseImageText} + {cacheText} + + ); - return ( + return ( +
-
step:

+ )} + + - {/* Add a `step: ` string before the Chip in case the chip belongs to a pipeline step*/} - {RESERVED_CELL_NAMES.includes(this.props.blockName) ? ( - '' - ) : ( -

step:

- )} - - - - + +
- {details} -
+ {details} +
-
- -
+
+
- ); - } -} +
+ ); +}; diff --git a/labextension/src/widgets/cell-metadata/constants.ts b/labextension/src/widgets/cell-metadata/constants.ts new file mode 100644 index 000000000..db5200aaa --- /dev/null +++ b/labextension/src/widgets/cell-metadata/constants.ts @@ -0,0 +1,57 @@ +// Copyright 2026 The Kubeflow Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const CELL_TYPES = [ + { value: 'imports', label: 'Imports' }, + { value: 'functions', label: 'Functions' }, + { value: 'pipeline-parameters', label: 'Pipeline Parameters' }, + { value: 'pipeline-metrics', label: 'Pipeline Metrics' }, + { value: 'step', label: 'Pipeline Step' }, + { value: 'skip', label: 'Skip Cell' }, +]; + +export const RESERVED_CELL_NAMES = [ + 'imports', + 'functions', + 'pipeline-parameters', + 'pipeline-metrics', + 'skip', +]; + +export const RESERVED_CELL_NAMES_HELP_TEXT: { [id: string]: string } = { + imports: + 'The code in this cell will be pre-pended to every step of the pipeline.', + functions: + 'The code in this cell will be pre-pended to every step of the pipeline,' + + ' after `imports`.', + 'pipeline-parameters': + 'The variables in this cell will be transformed into pipeline parameters,' + + ' preserving the current values as defaults.', + 'pipeline-metrics': + 'The variables in this cell will be transformed into pipeline metrics.', + skip: 'This cell will be skipped and excluded from pipeline steps', +}; + +export const RESERVED_CELL_NAMES_CHIP_COLOR: { [id: string]: string } = { + skip: 'a9a9a9', + 'pipeline-parameters': 'ee7a1a', + 'pipeline-metrics': '773d0d', + imports: 'a32626', + functions: 'a32626', +}; + +export const DEFAULT_BASE_IMAGE = 'python:3.12'; + +export const STEP_NAME_ERROR_MSG = + "Step name must consist of lower case alphanumeric characters or '_', and can not start with a digit."; diff --git a/labextension/src/widgets/cell-metadata/dialogs/BaseImageDialog.tsx b/labextension/src/widgets/cell-metadata/dialogs/BaseImageDialog.tsx new file mode 100644 index 000000000..2d27f7d87 --- /dev/null +++ b/labextension/src/widgets/cell-metadata/dialogs/BaseImageDialog.tsx @@ -0,0 +1,77 @@ +// Copyright 2026 The Kubeflow Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material'; +import { Input } from '../../../components/Input'; +import { DEFAULT_BASE_IMAGE } from '../constants'; + +interface IBaseImageDialogProps { + open: boolean; + onClose: () => void; + baseImage?: string; + pipelineBaseImage?: string; + defaultBaseImage?: string; + onUpdateBaseImage: (value: string) => void; +} + +export const BaseImageDialog: React.FC = ({ + open, + onClose, + baseImage, + pipelineBaseImage, + defaultBaseImage, + onUpdateBaseImage, +}) => { + return ( + + Base Image for Step + +

+ Default: {defaultBaseImage || DEFAULT_BASE_IMAGE} +

+ onUpdateBaseImage(v)} + placeholder={ + pipelineBaseImage || defaultBaseImage || DEFAULT_BASE_IMAGE + } + style={{ width: '100%', marginTop: '8px' }} + /> +
+ + + + +
+ ); +}; diff --git a/labextension/src/widgets/cell-metadata/dialogs/CacheDialog.tsx b/labextension/src/widgets/cell-metadata/dialogs/CacheDialog.tsx new file mode 100644 index 000000000..8104ec50e --- /dev/null +++ b/labextension/src/widgets/cell-metadata/dialogs/CacheDialog.tsx @@ -0,0 +1,100 @@ +// Copyright 2026 The Kubeflow Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + Radio, + RadioGroup, +} from '@mui/material'; + +type CachingValue = 'default' | 'enabled' | 'disabled'; + +interface ICacheDialogProps { + open: boolean; + onClose: () => void; + enableCaching?: boolean; + onUpdateCaching: (value: boolean | undefined) => void; +} + +export const CacheDialog: React.FC = ({ + open, + onClose, + enableCaching, + onUpdateCaching, +}) => { + const [cachingValue, setCachingValue] = + React.useState('default'); + + React.useEffect(() => { + if (open) { + setCachingValue( + enableCaching === undefined + ? 'default' + : enableCaching + ? 'enabled' + : 'disabled', + ); + } + }, [open, enableCaching]); + + const handleChange = (e: React.ChangeEvent) => { + const val = e.target.value as CachingValue; + setCachingValue(val); + onUpdateCaching(val === 'default' ? undefined : val === 'enabled'); + }; + + return ( + + Step Caching Control + +

+ Control whether this step's results are cached. When enabled, Kubeflow + Pipelines will reuse previous execution results if inputs haven't + changed. +

+ + + } + label="Use Pipeline Default" + /> + } + label="Enable Caching" + /> + } + label="Disable Caching" + /> + + +
+ + + +
+ ); +}; diff --git a/labextension/src/widgets/cell-metadata/CellMetadataEditorDialog.tsx b/labextension/src/widgets/cell-metadata/dialogs/GpuDialog.tsx similarity index 95% rename from labextension/src/widgets/cell-metadata/CellMetadataEditorDialog.tsx rename to labextension/src/widgets/cell-metadata/dialogs/GpuDialog.tsx index 1fdb67acb..50ea28865 100644 --- a/labextension/src/widgets/cell-metadata/CellMetadataEditorDialog.tsx +++ b/labextension/src/widgets/cell-metadata/dialogs/GpuDialog.tsx @@ -24,9 +24,9 @@ import { Switch, Box, } from '@mui/material'; -import ColorUtils from '../../lib/ColorUtils'; -import { Input } from '../../components/Input'; -import { Select } from '../../components/Select'; +import ColorUtils from '../../../lib/ColorUtils'; +import { Input } from '../../../components/Input'; +import { Select } from '../../../components/Select'; const GPU_TYPES = [ { value: 'nvidia.com/gpu', label: 'Nvidia' }, @@ -40,7 +40,7 @@ export interface ILimitAction { limitValue?: string; } -interface ICellMetadataEditorDialog { +interface IGpuDialogProps { open: boolean; stepName: string; limits: { [id: string]: string }; @@ -48,9 +48,7 @@ interface ICellMetadataEditorDialog { toggleDialog: () => void; } -export const CellMetadataEditorDialog: React.FunctionComponent< - ICellMetadataEditorDialog -> = props => { +export const GpuDialog: React.FC = props => { const handleClose = () => { props.toggleDialog(); }; diff --git a/labextension/src/widgets/cell-metadata/hooks/useCellTags.ts b/labextension/src/widgets/cell-metadata/hooks/useCellTags.ts new file mode 100644 index 000000000..8756d06b1 --- /dev/null +++ b/labextension/src/widgets/cell-metadata/hooks/useCellTags.ts @@ -0,0 +1,174 @@ +// Copyright 2026 The Kubeflow Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useCallback, useContext } from 'react'; +import { NotebookPanel } from '@jupyterlab/notebook'; +import TagsUtils from '../../../lib/TagsUtils'; +import { CellMetadataContext } from '../../../lib/CellMetadataContext'; +import { RESERVED_CELL_NAMES } from '../constants'; + +interface IUseCellTagsParams { + notebook: NotebookPanel; + stepName?: string; + stepDependencies: string[]; + limits?: { [id: string]: string }; + baseImage?: string; + enableCaching?: boolean; +} + +/** + * Hook that encapsulates all Kale cell metadata write operations. + * Each returned function reads the current tags, merges the change, + * and writes back via TagsUtils. + */ +export function useUpdateCellTags({ + notebook, + stepName, + stepDependencies, + limits, + baseImage, + enableCaching, +}: IUseCellTagsParams) { + const { activeCellIndex } = useContext(CellMetadataContext); + + const updateBlockName = useCallback( + (value: string) => { + const oldBlockName = stepName || ''; + TagsUtils.setKaleCellTags(notebook, activeCellIndex, { + prevBlockNames: stepDependencies, + limits: limits || {}, + baseImage, + enableCaching, + blockName: value, + }).then(() => { + TagsUtils.updateKaleCellsTags(notebook, oldBlockName, value); + }); + }, + [ + notebook, + activeCellIndex, + stepName, + stepDependencies, + limits, + baseImage, + enableCaching, + ], + ); + + const updateCellType = useCallback( + (value: string) => { + if (RESERVED_CELL_NAMES.includes(value)) { + updateBlockName(value); + } else { + TagsUtils.resetCell(notebook, activeCellIndex, stepName || ''); + } + }, + [notebook, activeCellIndex, stepName, updateBlockName], + ); + + const updateDependencies = useCallback( + (previousBlocks: string[]) => { + TagsUtils.setKaleCellTags(notebook, activeCellIndex, { + blockName: stepName || '', + limits: limits || {}, + baseImage, + enableCaching, + prevBlockNames: previousBlocks, + }); + }, + [notebook, activeCellIndex, stepName, limits, baseImage, enableCaching], + ); + + const updateLimits = useCallback( + ( + actions: { + action: 'update' | 'delete'; + limitKey: string; + limitValue?: string; + }[], + ) => { + const newLimits = { ...limits }; + actions.forEach(a => { + if (a.action === 'update' && a.limitValue !== undefined) { + newLimits[a.limitKey] = a.limitValue; + } + if ( + a.action === 'delete' && + Object.keys(limits || {}).includes(a.limitKey) + ) { + delete newLimits[a.limitKey]; + } + }); + + TagsUtils.setKaleCellTags(notebook, activeCellIndex, { + blockName: stepName || '', + prevBlockNames: stepDependencies, + limits: newLimits, + baseImage, + enableCaching, + }); + }, + [ + notebook, + activeCellIndex, + stepName, + stepDependencies, + limits, + baseImage, + enableCaching, + ], + ); + + const updateBaseImage = useCallback( + (value: string) => { + TagsUtils.setKaleCellTags(notebook, activeCellIndex, { + blockName: stepName || '', + prevBlockNames: stepDependencies, + limits: limits || {}, + baseImage: value || undefined, + enableCaching, + }); + }, + [ + notebook, + activeCellIndex, + stepName, + stepDependencies, + limits, + enableCaching, + ], + ); + + const updateCaching = useCallback( + (value: boolean | undefined) => { + TagsUtils.setKaleCellTags(notebook, activeCellIndex, { + blockName: stepName || '', + prevBlockNames: stepDependencies, + limits: limits || {}, + baseImage, + enableCaching: value, + }); + }, + [notebook, activeCellIndex, stepName, stepDependencies, limits, baseImage], + ); + + return { + updateCellType, + updateBlockName, + updateDependencies, + updateLimits, + updateBaseImage, + updateCaching, + }; +} diff --git a/labextension/src/widgets/cell-metadata/hooks/useEditorPosition.ts b/labextension/src/widgets/cell-metadata/hooks/useEditorPosition.ts new file mode 100644 index 000000000..aaf1ef982 --- /dev/null +++ b/labextension/src/widgets/cell-metadata/hooks/useEditorPosition.ts @@ -0,0 +1,73 @@ +// Copyright 2026 The Kubeflow Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { NotebookPanel } from '@jupyterlab/notebook'; +import { isCodeCellModel } from '@jupyterlab/cells'; +import { CellMetadataContext } from '../../../lib/CellMetadataContext'; + +/** + * Hook that manages the editor's DOM positioning inside the active notebook + * cell and auto-hides the editor when the active cell is not a code cell. + */ +export function useEditorPosition( + editorRef: React.RefObject, + notebook: NotebookPanel, +) { + const { activeCellIndex, isEditorVisible, onEditorVisibilityChange } = + React.useContext(CellMetadataContext); + + React.useEffect(() => { + const ref = editorRef; + return () => { + ref.current?.remove(); + }; + }, [editorRef]); + + React.useLayoutEffect(() => { + // hide editor if active cell is not code cell + if (notebook && !notebook.isDisposed && notebook.model) { + const cellModel = notebook.model.cells.get(activeCellIndex); + if (cellModel && !isCodeCellModel(cellModel) && isEditorVisible) { + onEditorVisibilityChange(false); + } + } + + if (!notebook?.content?.node?.childNodes) { + return; + } + + const cellWidget = notebook.content.widgets[activeCellIndex]; + if (!cellWidget) { + return; + } + + const metadataWrapper = cellWidget.node as HTMLElement; + if (!metadataWrapper) { + return; + } + + const editor = editorRef.current; + if (editor && editor.parentElement !== metadataWrapper) { + editor.remove(); + metadataWrapper.prepend(editor); + } + }, [ + activeCellIndex, + notebook, + isEditorVisible, + onEditorVisibilityChange, + editorRef, + ]); +} diff --git a/labextension/src/widgets/cell-metadata/hooks/useInlineCellsMetadata.tsx b/labextension/src/widgets/cell-metadata/hooks/useInlineCellsMetadata.tsx new file mode 100644 index 000000000..d1e25b29e --- /dev/null +++ b/labextension/src/widgets/cell-metadata/hooks/useInlineCellsMetadata.tsx @@ -0,0 +1,209 @@ +// Copyright 2026 The Kubeflow Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { NotebookPanel } from '@jupyterlab/notebook'; +import { isCodeCellModel } from '@jupyterlab/cells'; +import { createPortal } from 'react-dom'; +import TagsUtils from '../../../lib/TagsUtils'; +import { InlineMetadata } from '../InlineMetadata'; +import { IProps as EditorProps } from '../CellMetadataEditor'; +import { useLatestRef } from './useLatestRef'; +import { useNotebookSignals } from './useNotebookSignals'; + +type Editors = { [index: string]: EditorProps }; + +interface IUseInlineCellsMetadataParams { + notebook: NotebookPanel; + checked: boolean; + pipelineBaseImage?: string; + defaultBaseImage?: string; +} + +/** + * Hook that manages the inline metadata portals, editor props, active cell + * tracking, and all JupyterLab notebook signal wiring. + */ +export function useInlineCellsMetadata({ + notebook, + checked, + pipelineBaseImage, + defaultBaseImage, +}: IUseInlineCellsMetadataParams) { + const [activeCellIndex, setActiveCellIndex] = useState(0); + const [inlineMetadataPortals, setInlineMetadataPortals] = useState< + React.ReactPortal[] + >([]); + const [editors, setEditors] = useState({}); + const [isEditorVisible, setIsEditorVisible] = useState(false); + + const portalContainersRef = useRef([]); + const notebookRef = useLatestRef(notebook); + const pipelineBaseImageRef = useLatestRef(pipelineBaseImage); + const defaultBaseImageRef = useLatestRef(defaultBaseImage); + + // --- Inline metadata + portal generation & cleanup --- + + const removeOldPortalContainers = useCallback(() => { + portalContainersRef.current.forEach(el => el.remove()); + portalContainersRef.current = []; + }, []); + + const generate = useCallback(() => { + const nb = notebookRef.current; + if (!nb || !nb.model) { + return; + } + + removeOldPortalContainers(); + + const portals: React.ReactPortal[] = []; + const newEditors: Editors = {}; + const newContainers: HTMLDivElement[] = []; + const cells = nb.model.cells; + + for (let index = 0; index < cells.length; index++) { + if (!isCodeCellModel(cells.get(index))) { + continue; + } + + let tags = TagsUtils.getKaleCellTags(nb.content, index); + if (!tags) { + tags = { blockName: '', prevBlockNames: [] }; + } + + let previousBlockName: string | undefined = ''; + if (!tags.blockName) { + previousBlockName = TagsUtils.getPreviousBlock(nb.content, index); + } + + newEditors[index] = { + notebook: nb, + stepName: tags.blockName || '', + stepDependencies: tags.prevBlockNames || [], + limits: tags.limits || {}, + baseImage: tags.baseImage, + enableCaching: tags.enableCaching, + }; + + const cellElement = nb.content.widgets[index]?.node as HTMLElement; + if (!cellElement) { + console.warn( + `Failed to get cell element for index ${index}, skipping metadata creation`, + ); + continue; + } + + const metadataParent = document.createElement('div'); + newContainers.push(metadataParent); + + // When the cell was newly added Jupyter still didn't add elements to it + // so we wait for the first child to be added and then prepend. + if (cellElement.childNodes.length === 0) { + new MutationObserver((_, obs) => { + cellElement.prepend(metadataParent); + obs.disconnect(); + }).observe(cellElement, { childList: true }); + } else { + cellElement.prepend(metadataParent); + } + + portals.push( + createPortal( + , + metadataParent, + ), + ); + } + + portalContainersRef.current = newContainers; + setInlineMetadataPortals(portals); + setEditors(newEditors); + }, [ + removeOldPortalContainers, + notebookRef, + pipelineBaseImageRef, + defaultBaseImageRef, + ]); + + const clear = useCallback(() => { + removeOldPortalContainers(); + setInlineMetadataPortals([]); + setEditors({}); + }, [removeOldPortalContainers]); + + // --- Notebook signal wiring --- + + const generateRef = useLatestRef(generate); + const clearRef = useLatestRef(clear); + + const signalCallbacks = useMemo( + () => ({ + onGenerate: () => generateRef.current(), + onClear: () => clearRef.current(), + onActiveCellIndexChange: setActiveCellIndex, + onEditorVisibilityChange: setIsEditorVisible, + }), + [generateRef, clearRef], + ); + + useNotebookSignals(notebook, checked, signalCallbacks); + + // --- React to checked toggle --- + + const isFirstRenderRef = useRef(true); + useEffect(() => { + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + return; + } + if (checked && notebook?.model) { + notebook.context.ready.then(() => { + generateRef.current(); + }); + } else if (!checked) { + clearRef.current(); + setIsEditorVisible(false); + } + }, [checked, notebook, generateRef, clearRef]); + + // --- Cleanup on unmount --- + + useEffect(() => { + return removeOldPortalContainers; + }, [removeOldPortalContainers]); + + return { + activeCellIndex, + inlineMetadataPortals, + editors, + isEditorVisible, + setIsEditorVisible, + generate, + clear, + }; +} diff --git a/labextension/src/widgets/cell-metadata/hooks/useLatestRef.ts b/labextension/src/widgets/cell-metadata/hooks/useLatestRef.ts new file mode 100644 index 000000000..20d3cc13b --- /dev/null +++ b/labextension/src/widgets/cell-metadata/hooks/useLatestRef.ts @@ -0,0 +1,28 @@ +// Copyright 2026 The Kubeflow Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useEffect, useRef } from 'react'; + +/** + * Returns a ref that always holds the latest value. Useful for reading + * current props/state inside stable callbacks (e.g. signal handlers) + * without causing stale closures. + */ +export function useLatestRef(value: T) { + const ref = useRef(value); + useEffect(() => { + ref.current = value; + }, [value]); + return ref; +} diff --git a/labextension/src/widgets/cell-metadata/hooks/useNotebookSignals.ts b/labextension/src/widgets/cell-metadata/hooks/useNotebookSignals.ts new file mode 100644 index 000000000..fdfb68487 --- /dev/null +++ b/labextension/src/widgets/cell-metadata/hooks/useNotebookSignals.ts @@ -0,0 +1,155 @@ +// Copyright 2026 The Kubeflow Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useCallback, useEffect } from 'react'; +import { CellList, Notebook, NotebookPanel } from '@jupyterlab/notebook'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { IObservableList } from '@jupyterlab/observables'; +import { Cell, CodeCellModel, ICellModel } from '@jupyterlab/cells'; +import CellUtils from '../../../lib/CellUtils'; +import TagsUtils from '../../../lib/TagsUtils'; +import { useLatestRef } from './useLatestRef'; + +type SaveState = 'started' | 'completed' | 'failed'; + +interface ISignalCallbacks { + onGenerate: () => void; + onClear: () => void; + onActiveCellIndexChange: (index: number) => void; + onEditorVisibilityChange: (visible: boolean) => void; +} + +/** + * Connects and disconnects JupyterLab notebook signals (save, cell change, + * active cell change, metadata change). Calls back into the parent hook + * via stable callback refs so handler identity never changes. + */ +export function useNotebookSignals( + notebook: NotebookPanel, + checked: boolean, + callbacks: ISignalCallbacks, +) { + const notebookRef = useLatestRef(notebook); + const checkedRef = useLatestRef(checked); + const callbacksRef = useLatestRef(callbacks); + + // --- Stable signal handlers (never change identity) --- + + const handleMetadataChange = useCallback( + (_: any) => { + if (checkedRef.current) { + callbacksRef.current.onGenerate(); + callbacksRef.current.onEditorVisibilityChange(true); + } + }, + [checkedRef, callbacksRef], + ); + + const handleActiveCellChanged = useCallback( + (nb: Notebook, activeCell: Cell | null) => { + const prevIndex = nb.activeCellIndex; + const prevCell = nb.model?.cells.get(prevIndex); + if (prevCell) { + prevCell.metadataChanged.disconnect(handleMetadataChange); + } + callbacksRef.current.onActiveCellIndexChange(nb.activeCellIndex); + activeCell?.model.metadataChanged.connect(handleMetadataChange); + }, + [handleMetadataChange, callbacksRef], + ); + + const handleSaveState = useCallback( + (_context: DocumentRegistry.Context, state: SaveState) => { + if (checkedRef.current && state === 'completed') { + callbacksRef.current.onGenerate(); + } + }, + [checkedRef, callbacksRef], + ); + + const handleCellChange = useCallback( + (cells: CellList, args: IObservableList.IChangedArgs) => { + const nb = notebookRef.current; + const prevValue = args.oldValues[0]; + + if (args.type === 'set' && prevValue instanceof CodeCellModel) { + CellUtils.setCellMetaData(nb, args.newIndex, 'tags', []); + } + if (args.type === 'remove') { + TagsUtils.removeOldDependencies(nb); + } + + if (checkedRef.current) { + callbacksRef.current.onGenerate(); + callbacksRef.current.onEditorVisibilityChange(false); + } + }, + [notebookRef, checkedRef, callbacksRef], + ); + + // Connect on notebook ready, disconnect on cleanup + useEffect(() => { + if (!notebook) { + callbacksRef.current.onClear(); + return; + } + + let cancelled = false; + + notebook.context.ready.then(() => { + if (cancelled) { + return; + } + + notebook.context.saveState.connect(handleSaveState); + notebook.content.activeCellChanged.connect(handleActiveCellChanged); + if (notebook.model) { + notebook.model.cells.changed.connect(handleCellChange); + } + + if (notebook.content.activeCell?.model.type === 'code') { + notebook.content.activeCell.model.metadataChanged.connect( + handleMetadataChange, + ); + } + + callbacksRef.current.onActiveCellIndexChange( + notebook.content.activeCellIndex, + ); + + if (checkedRef.current) { + callbacksRef.current.onGenerate(); + } + }); + + callbacksRef.current.onEditorVisibilityChange(false); + + return () => { + cancelled = true; + notebook.context.saveState.disconnect(handleSaveState); + notebook.content.activeCellChanged.disconnect(handleActiveCellChanged); + if (notebook.model) { + notebook.model.cells.changed.disconnect(handleCellChange); + } + }; + }, [ + notebook, + handleSaveState, + handleActiveCellChanged, + handleCellChange, + handleMetadataChange, + checkedRef, + callbacksRef, + ]); +}