From 188d09e124d2ac9d4cf12f86512da168cf2ef55e Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Wed, 22 Jan 2025 20:16:28 -0800 Subject: [PATCH] feat(weave): dataset editing UI (#3341) --- .../Home/Browse3/datasets/CellRenderers.tsx | 699 ++++++++++++++++++ .../Browse3/datasets/DatasetEditorContext.tsx | 174 +++++ .../Browse3/datasets/DatasetVersionPage.tsx | 300 ++++++-- .../Home/Browse3/datasets/EditPopover.tsx | 165 +++++ .../Browse3/datasets/EditableDatasetView.tsx | 600 +++++++++++++++ .../Browse3/datasets/editors/CodeEditor.tsx | 85 +++ .../Browse3/datasets/editors/DiffEditor.tsx | 97 +++ .../Browse3/datasets/editors/TextEditor.tsx | 59 ++ .../Browse3/pages/CallPage/DataTableView.tsx | 4 +- .../pages/ObjectsPage/ObjectVersionPage.tsx | 11 +- 10 files changed, 2118 insertions(+), 76 deletions(-) create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/CellRenderers.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/DatasetEditorContext.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/EditPopover.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/EditableDatasetView.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/CodeEditor.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/DiffEditor.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/TextEditor.tsx diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/CellRenderers.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/CellRenderers.tsx new file mode 100644 index 000000000000..9693266e3474 --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/CellRenderers.tsx @@ -0,0 +1,699 @@ +import {Box, Tooltip} from '@mui/material'; +import { + GridRenderCellParams, + GridRenderEditCellParams, +} from '@mui/x-data-grid-pro'; +import {Button} from '@wandb/weave/components/Button'; +import {Icon} from '@wandb/weave/components/Icon'; +import set from 'lodash/set'; +import React, {useCallback, useState} from 'react'; + +import {CellValue} from '../../Browse2/CellValue'; +import {DatasetRow, useDatasetEditContext} from './DatasetEditorContext'; +import {CodeEditor} from './editors/CodeEditor'; +import {DiffEditor} from './editors/DiffEditor'; +import {TextEditor} from './editors/TextEditor'; +import {EditorMode, EditPopover} from './EditPopover'; + +export const CELL_COLORS = { + DELETED: 'rgba(255, 0, 0, 0.1)', + EDITED: 'rgba(0, 128, 128, 0.1)', + NEW: 'rgba(0, 255, 0, 0.1)', + TRANSPARENT: 'transparent', +} as const; + +export const DELETED_CELL_STYLES = { + opacity: 0.5, + textDecoration: 'line-through' as const, +} as const; + +const cellViewingStyles = { + height: '100%', + width: '100%', + fontFamily: '"Source Sans Pro", sans-serif', + fontSize: '14px', + lineHeight: '1.5', + padding: '8px 12px', + display: 'flex', + alignItems: 'center', + transition: 'background-color 0.2s ease', +}; + +interface CellViewingRendererProps { + isEdited?: boolean; + isDeleted?: boolean; + isNew?: boolean; + isEditing?: boolean; + serverValue?: any; +} + +const ShimmerOverlay: React.FC = () => ( + +); + +export const CellViewingRenderer: React.FC< + GridRenderCellParams & CellViewingRendererProps +> = ({ + value, + isEdited = false, + isDeleted = false, + isNew = false, + isEditing = false, + api, + id, + field, + serverValue, +}) => { + const [isHovered, setIsHovered] = useState(false); + const {setEditedRows} = useDatasetEditContext(); + + const isEditable = typeof value !== 'object' && typeof value !== 'boolean'; + + const handleEditClick = (event: React.MouseEvent) => { + event.stopPropagation(); + if (isEditable) { + api.startCellEditMode({id, field}); + } + }; + + const handleRevert = (event: React.MouseEvent) => { + event.stopPropagation(); + const existingRow = api.getRow(id); + const updatedRow = {...existingRow}; + set(updatedRow, field, serverValue); + api.updateRows([{id, ...updatedRow}]); + api.setEditCellValue({id, field, value: serverValue}); + setEditedRows(prev => { + const newMap = new Map(prev); + newMap.set(existingRow.___weave?.index, updatedRow); + return newMap; + }); + }; + + const getBackgroundColor = () => { + if (isDeleted) { + return CELL_COLORS.DELETED; + } + if (isEdited) { + return CELL_COLORS.EDITED; + } + if (isNew) { + return CELL_COLORS.NEW; + } + return CELL_COLORS.TRANSPARENT; + }; + + if (typeof value === 'boolean') { + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const existingRow = api.getRow(id); + const updatedRow = {...existingRow, [field]: !value}; + api.updateRows([{id, ...updatedRow}]); + setEditedRows(prev => { + const newMap = new Map(prev); + newMap.set(existingRow.___weave?.index, updatedRow); + return newMap; + }); + }; + + return ( + + + + + + ); + } + + if (!isEditable) { + return ( + + e.stopPropagation()} + onDoubleClick={e => e.stopPropagation()} + sx={{ + backgroundColor: getBackgroundColor(), + opacity: isDeleted ? DELETED_CELL_STYLES.opacity : 1, + textDecoration: isDeleted + ? DELETED_CELL_STYLES.textDecoration + : 'none', + }}> + + + + ); + } + + return ( + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + sx={{ + ...cellViewingStyles, + position: 'relative', + cursor: 'pointer', + backgroundColor: getBackgroundColor(), + opacity: isDeleted ? DELETED_CELL_STYLES.opacity : 1, + textDecoration: isDeleted + ? DELETED_CELL_STYLES.textDecoration + : 'none', + '@keyframes shimmer': { + '0%': { + transform: 'translateX(-100%)', + }, + '100%': { + transform: 'translateX(100%)', + }, + }, + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.04)', + }, + ...(isEditing && { + outline: '2px solid rgb(77, 208, 225)', + }), + }}> + + {value} + {isEditing && } + + {isHovered && ( + + {isEdited ? ( + + + + ); + }; + return ( -
-
-

Name

- -
- {objectName} - {objectVersions.loading ? ( - - ) : ( - - ({objectVersionCount} version - {objectVersionCount !== 1 ? 's' : ''}) - - )} - -
-
-
-
-

Version

-

{objectVersionIndex}

-
-
-

Created

-

- -

-
- {objectVersion.userId && ( +
+
-

Created by

- +

Name

+ +
+ {objectName} + {objectVersions.loading ? ( + + ) : ( + + ({maybePluralize(objectVersionCount, 'version')}) + + )} + +
+
- )} - {showDeleteButton && ( -
- +
+

Version

+

{objectVersionIndex}

+
+
+

Created

+

+ +

- )} +
+
+ {isEditing ? ( + renderEditingControls() + ) : ( +
} @@ -136,22 +302,16 @@ export const DatasetVersionPage: React.FC<{ label: 'Rows', content: ( - + {data.loading ? ( ) : ( - diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/EditPopover.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/EditPopover.tsx new file mode 100644 index 000000000000..3cf6596398d7 --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/EditPopover.tsx @@ -0,0 +1,165 @@ +import {Box, Popover} from '@mui/material'; +import {Button} from '@wandb/weave/components/Button'; +import React from 'react'; +import {ResizableBox} from 'react-resizable'; + +import {DraggableGrow, DraggableHandle} from '../../../../DraggablePopups'; + +export type EditorMode = 'text' | 'code' | 'diff'; + +interface EditPopoverProps { + anchorEl: HTMLElement | null; + onClose: () => void; + initialWidth?: number; + initialHeight?: number; + editorMode: EditorMode; + setEditorMode: (mode: EditorMode) => void; + onRevert?: () => void; + children: React.ReactNode; +} + +export const EditPopover: React.FC = ({ + anchorEl, + onClose, + initialWidth = 400, + initialHeight = 300, + editorMode, + setEditorMode, + onRevert, + children, +}) => { + const [position, setPosition] = React.useState<{ + top: number; + left: number; + } | null>(null); + + React.useLayoutEffect(() => { + if (anchorEl) { + const rect = anchorEl.getBoundingClientRect(); + const isInTopHalf = rect.top < window.innerHeight / 2; + + setPosition({ + left: rect.left + window.scrollX, + top: isInTopHalf + ? rect.bottom + window.scrollY // Position below for top half + : rect.top + window.scrollY - initialHeight, // Position above for bottom half + }); + } else { + setPosition(null); + } + }, [anchorEl, initialHeight]); + + if (!position) { + return null; + } + + return ( + + + }> + + + + + + + )} + + + + + ); + }, [isEditing, handleAddRowsClick]); + + return ( +
+ col.field), + }, + }} + onColumnWidthChange={handleColumnWidthChange} + columnBufferPx={50} + autoHeight={false} + disableColumnMenu={true} + density="compact" + rows={combinedRows} + columns={columns} + sortingMode="server" + sortModel={sortModel} + onSortModelChange={onSortModelChange} + editMode="cell" + pagination + paginationMode="server" + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + rowCount={ + (numRowsQuery.result?.count ?? 0) + (isEditing ? numAddedRows : 0) + } + disableMultipleColumnsSorting + loading={!fetchQueryLoaded} + disableRowSelectionOnClick + keepBorders={false} + pageSizeOptions={[50]} + slots={{ + footer: isEditing ? CustomFooter : undefined, + }} + sx={{ + border: 'none', + flex: 1, + height: '100%', + '& .MuiDataGrid-cell': { + padding: '0', + }, + '& .MuiDataGrid-columnHeaders': { + borderBottom: '1px solid rgba(224, 224, 224, 1)', + marginBottom: '-1px', // offset the border + }, + '& .MuiDataGrid-cell[data-field="controls"]': { + borderLeft: 'none', + boxShadow: 'none', + '&:focus, &:focus-within': { + outline: 'none', + }, + '&:hover': { + backgroundColor: 'transparent', + }, + '&.MuiDataGrid-cell--editing': { + backgroundColor: 'transparent', + boxShadow: 'none', + }, + '&.Mui-selected, &.Mui-selected:hover, &.Mui-selected:focus': { + backgroundColor: 'transparent', + boxShadow: 'none', + }, + }, + '& .MuiDataGrid-columnHeader[data-field="controls"]': { + borderLeft: 'none', + boxShadow: 'none', + border: 'none', + '&:focus, &:focus-within': { + outline: 'none', + }, + }, + '& .MuiDataGrid-footerContainer': { + backgroundColor: 'white', + border: 'none', + borderTop: '1px solid rgba(224, 224, 224, 1)', + }, + '& .MuiDataGrid-columnSeparator': { + visibility: 'visible', + }, + '& .MuiDataGrid-filler--pinnedRight': { + borderLeft: 'none', + }, + }} + getRowId={(row: GridRowModel) => row.___weave?.id ?? row.id} + /> +
+ ); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/CodeEditor.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/CodeEditor.tsx new file mode 100644 index 000000000000..df4776ae0180 --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/CodeEditor.tsx @@ -0,0 +1,85 @@ +import {Editor} from '@monaco-editor/react'; +import {Box} from '@mui/material'; +import React from 'react'; + +interface CodeEditorProps { + value: string; + onChange: (value: string) => void; + onClose: () => void; +} + +export const CodeEditor: React.FC = ({ + value, + onChange, + onClose, +}) => { + return ( + + onChange(newValue ?? '')} + onMount={(editor, monacoInstance) => { + editor.addAction({ + id: 'closeEditor', + label: 'Close Editor', + keybindings: [ + monacoInstance.KeyMod.CtrlCmd + monacoInstance.KeyCode.Enter, + ], + run: () => { + onClose(); + }, + }); + const disposable = editor.onKeyDown(e => { + if (e.browserEvent.key === 'Enter' && !e.browserEvent.metaKey) { + e.browserEvent.preventDefault(); + e.browserEvent.stopPropagation(); + editor.trigger('keyboard', 'type', {text: '\n'}); + } + }); + + editor.onDidDispose(() => { + disposable.dispose(); + }); + }} + options={{ + minimap: {enabled: false}, + scrollBeyondLastLine: true, + fontSize: 12, + fontFamily: 'monospace', + lineNumbers: 'on', + folding: false, + automaticLayout: true, + padding: {top: 12, bottom: 12}, + fixedOverflowWidgets: true, + wordWrap: 'off', + scrollbar: { + horizontal: 'auto', + useShadows: false, + verticalScrollbarSize: 10, + horizontalScrollbarSize: 10, + }, + }} + /> + + ); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/DiffEditor.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/DiffEditor.tsx new file mode 100644 index 000000000000..559754b5308d --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/DiffEditor.tsx @@ -0,0 +1,97 @@ +import {DiffEditor as MonacoDiffEditor} from '@monaco-editor/react'; +import {Box} from '@mui/material'; +import type {editor as monacoEditor} from 'monaco-editor'; +import React from 'react'; + +interface DiffEditorProps { + value: string; + originalValue: string; + onChange: (value: string) => void; + onClose: () => void; +} + +export const DiffEditor: React.FC = ({ + value, + originalValue, + onChange, + onClose, +}) => { + return ( + + { + const modifiedEditor = editor.getModifiedEditor(); + modifiedEditor.addAction({ + id: 'closeEditor', + label: 'Close Editor', + keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], + run: () => { + onClose(); + }, + }); + + const keyDisposable = modifiedEditor.onKeyDown(e => { + if (e.browserEvent.key === 'Enter' && !e.browserEvent.metaKey) { + e.browserEvent.preventDefault(); + e.browserEvent.stopPropagation(); + modifiedEditor.trigger('keyboard', 'type', {text: '\n'}); + } + }); + + const changeDisposable = modifiedEditor.onDidChangeModelContent( + () => { + const model = modifiedEditor.getModel(); + if (model) { + onChange(model.getValue()); + } + } + ); + + modifiedEditor.onDidDispose(() => { + keyDisposable.dispose(); + changeDisposable.dispose(); + }); + }} + options={{ + minimap: {enabled: false}, + scrollBeyondLastLine: true, + fontSize: 12, + fontFamily: 'monospace', + lineNumbers: 'on', + folding: false, + automaticLayout: true, + padding: {top: 12, bottom: 12}, + fixedOverflowWidgets: true, + wordWrap: 'off', + scrollbar: { + horizontal: 'auto', + useShadows: false, + verticalScrollbarSize: 10, + horizontalScrollbarSize: 10, + }, + }} + /> + + ); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/TextEditor.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/TextEditor.tsx new file mode 100644 index 000000000000..8b2aaa76f3fd --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/datasets/editors/TextEditor.tsx @@ -0,0 +1,59 @@ +import {TextField} from '@mui/material'; +import React from 'react'; + +interface TextEditorProps { + value: string; + onChange: (value: string) => void; + onClose: () => void; + inputRef?: React.RefObject; +} + +export const TextEditor: React.FC = ({ + value, + onChange, + onClose, + inputRef, +}) => { + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !event.metaKey) { + event.stopPropagation(); + } else if (event.key === 'Enter' && event.metaKey) { + onClose(); + } + }; + + return ( + onChange(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={e => { + const target = e.target as HTMLTextAreaElement; + target.setSelectionRange(0, target.value.length); + }} + fullWidth + multiline + autoFocus + sx={{ + width: '100%', + '& .MuiInputBase-root': { + fontFamily: '"Source Sans Pro", sans-serif', + fontSize: '14px', + border: 'none', + backgroundColor: 'white', + }, + '& .MuiInputBase-input': { + padding: '0px', + }, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none', + }, + '& textarea': { + overflow: 'hidden !important', + resize: 'none', + }, + }} + /> + ); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/DataTableView.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/DataTableView.tsx index 800b6a0fdf2b..ac8f56a05c46 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/DataTableView.tsx +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/DataTableView.tsx @@ -48,7 +48,7 @@ import {TABLE_ID_EDGE_NAME} from '../wfReactInterface/constants'; import {useWFHooks} from '../wfReactInterface/context'; import {SortBy} from '../wfReactInterface/traceServerClientTypes'; -const RowId = styled.span` +export const RowId = styled.span` font-family: 'Inconsolata', monospace; `; RowId.displayName = 'S.RowId'; @@ -238,7 +238,7 @@ export const WeaveCHTable: FC<{ ); }; -type DataTableServerSidePaginationControls = { +export type DataTableServerSidePaginationControls = { paginationModel: GridPaginationModel; onPaginationModelChange: (model: GridPaginationModel) => void; totalRows: number; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/ObjectsPage/ObjectVersionPage.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/ObjectsPage/ObjectVersionPage.tsx index be9cc8e69e77..eddb23998aa9 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/ObjectsPage/ObjectVersionPage.tsx +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/ObjectsPage/ObjectVersionPage.tsx @@ -9,6 +9,7 @@ import {LoadingDots} from '../../../../../LoadingDots'; import {Tailwind} from '../../../../../Tailwind'; import {Timestamp} from '../../../../../Timestamp'; import {Tooltip} from '../../../../../Tooltip'; +import {DatasetEditProvider} from '../../datasets/DatasetEditorContext'; import {DatasetVersionPage} from '../../datasets/DatasetVersionPage'; import {NotFoundPanel} from '../../NotFoundPanel'; import {CustomWeaveTypeProjectContext} from '../../typeViews/CustomWeaveTypeDispatcher'; @@ -211,10 +212,12 @@ const ObjectVersionPageInner: React.FC<{ if (isDataset) { return ( - + + + ); }