From a6fc0751685e9fcba4d9e5da2077e80aa4d10731 Mon Sep 17 00:00:00 2001 From: Jamie Rasmussen Date: Thu, 7 Nov 2024 17:30:58 -0600 Subject: [PATCH] feat(ui): object comparison --- weave-js/package.json | 4 +- .../PagePanelComponents/Home/Browse3.tsx | 10 + .../Home/Browse3/compare/Badge.tsx | 121 ++++++ .../Home/Browse3/compare/Badges.tsx | 144 +++++++ .../Home/Browse3/compare/CompareGrid.tsx | 273 +++++++++++++ .../Home/Browse3/compare/CompareGridCell.tsx | 129 ++++++ .../Browse3/compare/CompareGridCellValue.tsx | 55 +++ .../compare/CompareGridCellValueTimestamp.tsx | 39 ++ .../compare/CompareGridGroupingCell.tsx | 125 ++++++ .../Home/Browse3/compare/CompareGridPill.tsx | 43 ++ .../Browse3/compare/CompareGridPillNumber.tsx | 63 +++ .../Home/Browse3/compare/ComparePage.tsx | 63 +++ .../Home/Browse3/compare/ComparePageCalls.tsx | 48 +++ .../Browse3/compare/ComparePageObjects.tsx | 35 ++ .../compare/ComparePageObjectsLoaded.tsx | 372 ++++++++++++++++++ .../Home/Browse3/compare/DiffViewer.tsx | 74 ++++ .../Home/Browse3/compare/compare.ts | 131 ++++++ .../Home/Browse3/compare/hooks.ts | 121 ++++++ .../Home/Browse3/compare/refUtil.ts | 28 ++ .../Home/Browse3/compare/types.ts | 10 + .../Home/Browse3/context.tsx | 54 +++ .../Browse3/pages/CallPage/ObjectViewer.tsx | 8 +- .../CallPage/ValueViewNumberTimestamp.tsx | 19 +- .../Home/Browse3/pages/CallPage/traverse.ts | 2 +- .../Browse3/pages/CallsPage/CallsTable.tsx | 12 +- .../pages/CallsPage/CallsTableButtons.tsx | 26 +- .../Home/Browse3/pages/ObjectVersionsPage.tsx | 88 ++++- .../Home/Browse3/urlQueryUtil.ts | 73 ++++ weave-js/yarn.lock | 9 +- 29 files changed, 2158 insertions(+), 21 deletions(-) create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/Badge.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/Badges.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGrid.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCell.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValue.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValueTimestamp.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridGroupingCell.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridPill.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridPillNumber.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePage.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageCalls.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageObjects.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageObjectsLoaded.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/DiffViewer.tsx create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/compare.ts create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/hooks.ts create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/refUtil.ts create mode 100644 weave-js/src/components/PagePanelComponents/Home/Browse3/compare/types.ts diff --git a/weave-js/package.json b/weave-js/package.json index 6f0a7ab4aeb0..eeb16cfa544a 100644 --- a/weave-js/package.json +++ b/weave-js/package.json @@ -232,14 +232,14 @@ "tailwindcss": "^3.3.2", "ts-jest": "^27.1.4", "ts-node": "^10.9.1", + "tsd": "^0.30.4", "tslint": "^6.1.3", "tslint-config-prettier": "^1.18.0", "tslint-plugin-prettier": "^2.3.0", "typescript": "4.7.4", "uuid": "^9.0.0", "vite": "5.2.9", - "vitest": "^1.6.0", - "tsd": "^0.30.4" + "vitest": "^1.6.0" }, "resolutions": { "@types/react": "^17.0.26", diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3.tsx index 562a0ca5cb8d..6dcda4bdfc78 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3.tsx +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3.tsx @@ -47,6 +47,7 @@ import {Button} from '../../Button'; import {ErrorBoundary} from '../../ErrorBoundary'; import {Browse2EntityPage} from './Browse2/Browse2EntityPage'; import {Browse2HomePage} from './Browse2/Browse2HomePage'; +import {ComparePage} from './Browse3/compare/ComparePage'; import { baseContext, browse2Context, @@ -532,6 +533,9 @@ const Browse3ProjectRoot: FC<{ ]}> + + + ); @@ -1030,6 +1034,12 @@ const TablesPageBinding = () => { return ; }; +const ComparePageBinding = () => { + const params = useParamsDecoded(); + + return ; +}; + const AppBarLink = (props: ComponentProps) => ( ( +
+ +
+)); + +type BadgeProps = { + idx: number; + badge: BadgeDef; + useBaseline: boolean; + isSelected: boolean; + isDeletable: boolean; + onClickBadgeLabel: (value: string) => void; + onSetBaseline: (value: string | null) => void; + onRemoveBadge: (value: string) => void; +}; + +export const Badge = SortableElement( + ({ + idx, + badge, + useBaseline, + isSelected, + isDeletable, + onClickBadgeLabel, + onSetBaseline, + onRemoveBadge, + }: BadgeProps) => { + const [isOpen, setIsOpen] = useState(false); + + const onClickLabel = + idx === 0 + ? undefined + : () => { + onClickBadgeLabel(badge.value); + }; + + const onMakeBaseline = () => { + onSetBaseline(badge.value); + }; + + const onRemoveItem = () => { + onRemoveBadge(badge.value); + }; + + return ( +
+
+ +
+ {badge.label ?? badge.value} +
+ + +
+
+ ); + } +); diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/Badges.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/Badges.tsx new file mode 100644 index 000000000000..0d55bf70bbeb --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/Badges.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import {useHistory} from 'react-router-dom'; +import {SortableContainer} from 'react-sortable-hoc'; + +import {queryToggleString, searchParamsSetArray} from '../urlQueryUtil'; +import {Badge} from './Badge'; +import {BadgeDefs} from './types'; + +type BadgesProps = { + badges: BadgeDefs; + useBaseline: boolean; + selected: string | null; +}; + +type SortableBadgesProps = BadgesProps & { + onClickBadgeLabel: (value: string) => void; + onSetBaseline: (value: string | null) => void; + onRemoveBadge: (value: string) => void; +}; + +const SortableBadges = SortableContainer( + ({ + badges, + useBaseline, + selected, + onClickBadgeLabel, + onSetBaseline, + onRemoveBadge, + }: SortableBadgesProps) => { + return ( +
+ {badges.map((badge, index) => ( + 2} + isSelected={badge.value === selected} + /> + ))} +
+ ); + } +); + +// Create a copy of the specified array, moving an item from one index to another. +function arrayMove(array: readonly T[], from: number, to: number) { + const slicedArray = array.slice(); + slicedArray.splice( + to < 0 ? array.length + to : to, + 0, + slicedArray.splice(from, 1)[0] + ); + return slicedArray; +} + +export const Badges = ({badges, useBaseline, selected}: BadgesProps) => { + const history = useHistory(); + const onSortEnd = ({ + oldIndex, + newIndex, + }: { + oldIndex: number; + newIndex: number; + }) => { + if (oldIndex === newIndex) { + return; + } + const {search} = history.location; + const params = new URLSearchParams(search); + params.delete('baseline'); + const newBadges = arrayMove(badges, oldIndex, newIndex); + const values = newBadges.map(b => b.value); + searchParamsSetArray(params, badges[0].key, values); + history.replace({ + search: params.toString(), + }); + }; + + function moveToFront(list: T[], item: T): T[] { + const index = list.indexOf(item); + if (index !== -1) { + list.splice(index, 1); // Remove the item from its current position + list.unshift(item); // Add the item to the front + } + return list; + } + + const onClickBadgeLabel = (value: string) => { + queryToggleString(history, 'sel', value); + }; + + const onSetBaseline = (value: string | null) => { + const {search} = history.location; + const params = new URLSearchParams(search); + if (value === null) { + params.delete('baseline'); + } else { + let values = badges.map(b => b.value); + values = moveToFront(values, value); + searchParamsSetArray(params, badges[0].key, values); + params.set('baseline', '1'); + } + history.replace({ + search: params.toString(), + }); + }; + + const onRemoveBadge = (value: string) => { + const newBadges = badges.filter(b => b.value !== value); + const {search} = history.location; + const params = new URLSearchParams(search); + searchParamsSetArray( + params, + newBadges[0].key, + newBadges.map(b => b.value) + ); + if (selected === value || selected === newBadges[0].value) { + params.delete('sel'); + } + history.replace({ + search: params.toString(), + }); + }; + + return ( + + ); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGrid.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGrid.tsx new file mode 100644 index 000000000000..8deb8031ef06 --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGrid.tsx @@ -0,0 +1,273 @@ +import { + DataGridProProps, + GRID_TREE_DATA_GROUPING_FIELD, + GridColDef, + GridPinnedColumnFields, + GridRowHeightParams, + GridRowId, + GridValidRowModel, + useGridApiRef, +} from '@mui/x-data-grid-pro'; +import React, {useCallback, useEffect, useMemo} from 'react'; + +import {WeaveObjectRef} from '../../../../../react'; +import {SmallRef} from '../../Browse2/SmallRef'; +import {ObjectVersionSchema} from '../pages/wfReactInterface/wfDataModelHooksInterface'; +import {StyledDataGrid} from '../StyledDataGrid'; +import {RowDataWithDiff, UNCHANGED} from './compare'; +import {CompareGridCell} from './CompareGridCell'; +import {CompareGridGroupingCell} from './CompareGridGroupingCell'; +import {ComparableObject} from './types'; + +type CompareGridProps = { + objectType: 'object' | 'call'; + objectIds: string[]; + objects: ComparableObject[]; + rows: RowDataWithDiff[]; + mode: string; + useBaseline: boolean; + onlyChanged: boolean; + isExpanded?: boolean; + + expandedIds: GridRowId[]; + setExpandedIds: React.Dispatch>; + addExpandedRefs: (path: string, refs: string[]) => void; +}; + +const objectVersionSchemaToRef = ( + objVersion: ObjectVersionSchema +): WeaveObjectRef => { + return { + scheme: 'weave', + entityName: objVersion.entity, + projectName: objVersion.project, + weaveKind: 'object', + artifactName: objVersion.objectId, + artifactVersion: objVersion.versionHash, + }; +}; + +export const CompareGrid = ({ + objectType, + objectIds, + objects, + rows, + mode, + useBaseline, + onlyChanged, + isExpanded, + expandedIds, + setExpandedIds, + addExpandedRefs, +}: CompareGridProps) => { + const apiRef = useGridApiRef(); + + const filteredRows = onlyChanged + ? rows.filter(row => row.changeType !== UNCHANGED) + : rows; + + const pinnedColumns: GridPinnedColumnFields = { + left: [ + GRID_TREE_DATA_GROUPING_FIELD, + ...(useBaseline ? [objectIds[0]] : []), + ], + }; + const columns: GridColDef[] = []; + if (mode === 'single' && objectIds.length === 2) { + columns.push({ + field: 'value', + headerName: 'Value', + flex: 1, + display: 'flex', + sortable: false, + renderCell: cellParams => { + const objId = objectIds[1]; + const compareIdx = useBaseline + ? 0 + : Math.max(0, objectIds.indexOf(objId) - 1); + const compareId = objectIds[compareIdx]; + const compareValue = cellParams.row.values[compareId]; + const compareValueType = cellParams.row.types[compareId]; + const value = cellParams.row.values[objId]; + const valueType = cellParams.row.types[objId]; + const rowChangeType = cellParams.row.changeType; + + return ( +
+ +
+ ); + }, + }); + } else { + const versionCols: GridColDef[] = objectIds.map(objId => ({ + field: objId, + headerName: objId, + flex: 1, + display: 'flex', + width: 500, + sortable: false, + valueGetter: (unused: any, row: any) => { + return row.values[objId]; + }, + renderHeader: (params: any) => { + if (objectType === 'call') { + // TODO: Make this a peek drawer link + return objId; + } + const idx = objectIds.indexOf(objId); + const objVersion = objects[idx]; + const objRef = objectVersionSchemaToRef( + objVersion as ObjectVersionSchema + ); + return ; + }, + renderCell: (cellParams: any) => { + const compareIdx = useBaseline + ? 0 + : Math.max(0, objectIds.indexOf(objId) - 1); + const compareId = objectIds[compareIdx]; + const compareValue = cellParams.row.values[compareId]; + const compareValueType = cellParams.row.types[compareId]; + const value = cellParams.row.values[objId]; + const valueType = cellParams.row.types[objId]; + const rowChangeType = cellParams.row.changeType; + return ( +
+ +
+ ); + }, + })); + columns.push(...versionCols); + } + + // const [expandedIds, setExpandedIds] = useState([]); + + // // Expanded refs are the explicit set of refs that have been expanded by the user. Note that + // // this might contain nested refs not in the `dataRefs` set. The keys are refs and the values + // // are the paths at which the refs were expanded. + // const [expandedRefs, setExpandedRefs] = useState<{[ref: string]: string}>({}); + + // // `addExpandedRef` is a function that can be used to add an expanded ref to the `expandedRefs` state. + // const addExpandedRef = useCallback((path: string, ref: string) => { + // setExpandedRefs(eRefs => ({...eRefs, [path]: ref})); + // }, []); + + // Here, we setup the `Path` column which acts as a grouping column. This + // column is responsible for showing the expand/collapse icons and handling + // the expansion. Importantly, when the column is clicked, we do some + // bookkeeping to add the expanded ref to the `expandedRefs` state. This + // triggers a set of state updates to populate the expanded data. + + const groupingColDef: DataGridProProps['groupingColDef'] = useMemo( + () => ({ + field: '__group__', + headerName: 'Path', + hideDescendantCount: true, + width: 300, + renderCell: params => { + return ( + { + setExpandedIds(eIds => { + if (eIds.includes(params.row.id)) { + return eIds.filter(id => id !== params.row.id); + } + return [...eIds, params.row.id]; + }); + addExpandedRefs(params.row.id, params.row.expandableRefs); + }} + /> + ); + }, + }), + [addExpandedRefs, setExpandedIds] + ); + + const getRowId = (row: GridValidRowModel) => { + return row.path.toString(); + }; + + // Next we define a function that updates the row expansion state. This + // function is responsible for setting the expansion state of rows that have + // been expanded by the user. It is bound to the `rowsSet` event so that it is + // called whenever the rows are updated. The MUI data grid will rerender and + // close all expanded rows when the rows are updated. This function is + // responsible for re-expanding the rows that were previously expanded. + const updateRowExpand = useCallback(() => { + expandedIds.forEach(id => { + if (apiRef.current.getRow(id)) { + const children = apiRef.current.getRowGroupChildren({groupId: id}); + if (children.length !== 0) { + apiRef.current.setRowChildrenExpansion(id, true); + } + } + }); + }, [apiRef, expandedIds]); + useEffect(() => { + updateRowExpand(); + return apiRef.current.subscribeEvent('rowsSet', () => { + updateRowExpand(); + }); + }, [apiRef, expandedIds, updateRowExpand]); + + const getGroupIds = useCallback(() => { + const rowIds = apiRef.current.getAllRowIds(); + return rowIds.filter(rowId => { + const rowNode = apiRef.current.getRowNode(rowId); + return rowNode && rowNode.type === 'group'; + }); + }, [apiRef]); + + // On first render expand groups + useEffect(() => { + setExpandedIds(getGroupIds()); + }, [setExpandedIds, getGroupIds]); + + return ( + row.path.toStringArray()} + columns={columns} + rows={filteredRows} + isGroupExpandedByDefault={node => { + return expandedIds.includes(node.id); + }} + columnHeaderHeight={38} + disableColumnReorder={true} + disableColumnMenu={true} + getRowHeight={(params: GridRowHeightParams) => { + return 'auto'; + }} + rowSelection={false} + hideFooter + pinnedColumns={pinnedColumns} + sx={{ + '& .MuiDataGrid-cell': { + alignItems: 'flex-start', + }, + }} + /> + ); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCell.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCell.tsx new file mode 100644 index 000000000000..021259f7ff1b --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCell.tsx @@ -0,0 +1,129 @@ +import _ from 'lodash'; +import React from 'react'; +import ReactDiffViewer, {DiffMethod} from 'react-diff-viewer'; + +import {ARROW} from './compare'; +import {CompareGridCellValue} from './CompareGridCellValue'; +import {CompareGridPill} from './CompareGridPill'; +import {RESOLVED_REF_KEY} from './refUtil'; + +type CompareGridCellProps = { + displayType: 'both' | 'diff'; + useBaseline: boolean; + value: any; + valueType: any; + compareValue: any; + compareValueType: any; + rowChangeType: any; +}; + +export const CompareGridCell = ({ + displayType, + useBaseline, + value, + valueType, + compareValue, + compareValueType, + rowChangeType, +}: CompareGridCellProps) => { + if (valueType === 'array') { + return null; + } + + // If all of the row values are the same we can just display the value + if (rowChangeType === 'UNCHANGED' && _.isEqual(value, compareValue)) { + return ; + } + + if (valueType === 'object' && compareValueType === 'object') { + if (!(RESOLVED_REF_KEY in value) || !(RESOLVED_REF_KEY in compareValue)) { + return null; + } + } + + if (valueType === 'string' && compareValueType === 'string') { + return ( +
+ +
+ ); + } + + return ( +
+ {displayType === 'both' && ( + <> + + {ARROW} + + )} + + +
+ ); + + // if (valueType === 'array') { + // return null; + // } + // if (valueType === 'object') { + // if (RESOLVED_REF_KEY in value) { + // return ; + // } + // return null; + // } + // if (value === MISSING) { + // return missing; + // } + // if (value === undefined) { + // return undefined; + // } + // if (value === null) { + // return null; + // } + // if (isWeaveRef(value)) { + // return ( + //
+ // + //
+ // ); + // } + // if (valueType === 'number' && compareValueType === 'number') { + // if (isProbablyTimestamp(value) && isProbablyTimestamp(compareValue)) { + // return ; + // } + // return ; + // } + // if (valueType === 'string' && compareValueType === 'string') { + // return ( + // + // ); + // } + + // if (_.isEqual(value, compareValue) || displayType === 'diff') { + // return
{value}
; + // } + // return ( + //
+ // {compareValue} {ARROW} {value} + //
+ // ); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValue.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValue.tsx new file mode 100644 index 000000000000..d7258d2dc434 --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValue.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import {parseRef} from '../../../../../react'; +import {SmallRef} from '../../Browse2/SmallRef'; +import { + isProbablyTimestampMs, + isProbablyTimestampSec, +} from '../pages/CallPage/ValueViewNumberTimestamp'; +import {ValueViewPrimitive} from '../pages/CallPage/ValueViewPrimitive'; +import {MISSING} from './compare'; +import {CompareGridCellValueTimestamp} from './CompareGridCellValueTimestamp'; +import {RESOLVED_REF_KEY} from './refUtil'; + +type CompareGridCellValueProps = { + value: any; + valueType: any; +}; + +export const CompareGridCellValue = ({ + value, + valueType, +}: CompareGridCellValueProps) => { + if (value === MISSING) { + return missing; + } + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + if (valueType === 'object') { + if (RESOLVED_REF_KEY in value) { + return ; + } + // We don't need to show anything for this row because user can expand it to compare child keys + return null; + } + + if (valueType === 'number') { + if (isProbablyTimestampSec(value)) { + return ; + } + if (isProbablyTimestampMs(value)) { + return ; + } + return
{value}
; + } + + if (['string', 'number'].includes(valueType)) { + return
{value}
; + } + + return
{value}
; +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValueTimestamp.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValueTimestamp.tsx new file mode 100644 index 000000000000..953af39c0957 --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValueTimestamp.tsx @@ -0,0 +1,39 @@ +/** + * When we have an integer that appears to be a timestamp, we display it as a + * nice date, but allow clicking to toggle between that and the raw value. + */ +import React, {useState} from 'react'; + +import {Timestamp} from '../../../../Timestamp'; +import {Tooltip} from '../../../../Tooltip'; + +type CompareGridCellValueTimestampProps = { + value: number; + unit: 'ms' | 's'; +}; + +export const CompareGridCellValueTimestamp = ({ + value, + unit, +}: CompareGridCellValueTimestampProps) => { + const [showRaw, setShowRaw] = useState(false); + + let body = null; + if (showRaw) { + body = ( + {value}} + content="Click to format as date" + /> + ); + } else { + const tsValue = unit === 'ms' ? value / 1000 : value; + body = ; + } + + return ( +
setShowRaw(!showRaw)}> + {body} +
+ ); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridGroupingCell.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridGroupingCell.tsx new file mode 100644 index 000000000000..59e5e837ab81 --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridGroupingCell.tsx @@ -0,0 +1,125 @@ +import {Box, BoxProps} from '@mui/material'; +import {GridRenderCellParams, useGridApiContext} from '@mui/x-data-grid-pro'; +import _ from 'lodash'; +import React, {FC, MouseEvent} from 'react'; + +import {Button} from '../../../../Button'; +import {Tooltip} from '../../../../Tooltip'; +import {CursorBox} from '../pages/CallPage/CursorBox'; +import {UNCHANGED} from './compare'; + +const INSET_SPACING = 40; + +/** + * Utility component for the ObjectViewer to allow expanding/collapsing of keys. + */ +export const CompareGridGroupingCell: FC< + GridRenderCellParams & {onClick?: (event: MouseEvent) => void} +> = props => { + const {id, field, rowNode, row} = props; + const isGroup = rowNode.type === 'group'; + const hasExpandableRefs = row.expandableRefs.length > 0; + const apiRef = useGridApiContext(); + const onClick: BoxProps['onClick'] = event => { + if (isGroup) { + apiRef.current.setRowChildrenExpansion(id, !rowNode.childrenExpanded); + apiRef.current.setCellFocus(id, field); + } + + if (props.onClick) { + props.onClick(event); + } + + event.stopPropagation(); + }; + + const tooltipContent = row.path ? row.path.toString() : undefined; + const box = ( + + {_.range(rowNode.depth).map(i => { + return ( + + ); + })} + + {isGroup || hasExpandableRefs ? ( + + + {/* */} + + )} + +
+ {checkedMode !== 'diff' && ( + + )} + {/* {checkedMode === 'diff' && ( + + )} */} +
+ + ), + }, + ]} + /> + ); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/DiffViewer.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/DiffViewer.tsx new file mode 100644 index 000000000000..d70bd8a94e3a --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/DiffViewer.tsx @@ -0,0 +1,74 @@ +import React, {useState} from 'react'; +import ReactDiffViewer, {DiffMethod} from 'react-diff-viewer'; + +import {Button} from '../../../../Button'; + +type DiffViewerProps = { + left: string; + right: string; + compareMethod?: DiffMethod; + hideLineNumbers?: boolean; +}; + +export const DiffViewer = ({left, right, hideLineNumbers}: DiffViewerProps) => { + const [isSplitView, setIsSplitView] = useState(false); + const onSplitTrue = () => setIsSplitView(true); + const onSplitFalse = () => setIsSplitView(false); + + const [compareMethod, setCompareMethod] = useState(DiffMethod.WORDS); + + return ( +
+
+
+
+
+
+
+
+ +
+
+ ); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/compare.ts b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/compare.ts new file mode 100644 index 000000000000..91f70c725ce6 --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/compare.ts @@ -0,0 +1,131 @@ +import _ from 'lodash'; + +import {ObjectPath, traverse, ValueType} from '../pages/CallPage/traverse'; +import {isExpandableRef} from '../pages/wfReactInterface/tsDataModelHooksCallRefExpansion'; +import {RESOLVED_REF_KEY} from './refUtil'; +import {ComparableObject} from './types'; + +// Row change types +export const UNCHANGED = 'UNCHANGED'; +export const DELETED = 'DELETED'; +export const ADDED = 'ADDED'; +export const CHANGED = 'CHANGED'; +export type RowChangeType = + | typeof UNCHANGED + | typeof DELETED + | typeof ADDED + | typeof CHANGED; + +export const MISSING = '__WEAVE_MISSING__'; +export type ColumnType = ValueType | typeof MISSING; +export type ColumnId = string; + +export type RowData = { + // Row id is the string representation of path + id: string; + path: ObjectPath; + values: Record; + types: Record; // ColumnType +}; +export type RowDataWithDiff = RowData & { + // Overall row change type, used for filtering rows and coloring grouping column + changeType: RowChangeType; + changeTypes: Record; + expandableRefs: string[]; +}; + +type PathString = string; +type PathInfo = { + path: ObjectPath; + values: Record; + types: Record; +}; + +// const getRow = (d, columnIds) => { +// const row = []; +// for (const colId of columnIds) { +// if (colId in d) { +// row.push(d[colId]); +// } else { +// row.push(Missing); +// } +// } +// return row; +// }; + +export const mergeObjects = ( + columnIds: string[], + objects: ComparableObject[] +): RowData[] => { + const values: Record = {}; + + for (let i = 0; i < columnIds.length; i++) { + const columnId = columnIds[i]; + const object = objects[i]; + traverse(object, context => { + if (context.path.tail() === RESOLVED_REF_KEY) { + return 'skip'; + } + const key = context.path.toString(); + if (!(key in values)) { + values[key] = { + path: context.path, + values: {}, + types: {}, + }; + } + values[key].values[columnId] = context.value; + values[key].types[columnId] = context.valueType; + return true; + }); + } + + const rows: RowData[] = []; + for (const d of Object.values(values)) { + rows.push({ + id: d.path.toString(), + path: d.path, + values: d.values, + types: d.types, + }); + } + + return rows; +}; + +export const computeDiff = ( + columnIds: ColumnId[], + rows: RowData[], + useBaseline: boolean +): RowDataWithDiff[] => { + const nCols = columnIds.length; + const diffRows: RowDataWithDiff[] = []; + for (const row of rows) { + let rowChangeType: RowChangeType = UNCHANGED; + const changeTypes: Record = {}; + changeTypes[columnIds[0]] = UNCHANGED; + for (let i = 1; i < nCols; i++) { + const leftIdx = useBaseline ? 0 : i - 1; + const rightName = columnIds[i]; + const left = row.values[columnIds[leftIdx]]; + const right = row.values[rightName]; + let changeType: RowChangeType = UNCHANGED; + // TODO: Handle added/deleted and missing + if (!_.isEqual(left, right)) { + changeType = CHANGED; + rowChangeType = CHANGED; + } + changeTypes[rightName] = changeType; + } + const rowWithDiff: RowDataWithDiff = { + ...row, + changeType: rowChangeType, + changeTypes, + expandableRefs: _.uniq(Object.values(row.values).filter(isExpandableRef)), + }; + diffRows.push(rowWithDiff); + } + return diffRows; +}; + +export const ARROW = '→'; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/hooks.ts b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/hooks.ts new file mode 100644 index 000000000000..cd7302fcd9e5 --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/hooks.ts @@ -0,0 +1,121 @@ +import _ from 'lodash'; + +import {useWFHooks} from '../pages/wfReactInterface/context'; +import {ObjectVersionSchema} from '../pages/wfReactInterface/wfDataModelHooksInterface'; + +// Given a list of specifiers like ['obj1:v9', 'obj1:v10', 'obj2:v2'] +// return a list of unique object names like ['obj1', 'obj2'] +const getUniqueNames = (specifiers: string[]): string[] => { + const names = specifiers.map(spec => spec.split(':')[0]); + return [...new Set(names)]; +}; + +// Given a string like v23 return the number 23 +// Return null if can't extract a version number +const parseVersionNumber = (versionStr: string): number | null => { + if (versionStr.startsWith('v')) { + const num = parseInt(versionStr.slice(1), 10); + if (!isNaN(num) && num >= 0) { + return num; + } + } + return null; +}; + +export const parseSpecifier = ( + specifier: string +): {name: string; version: number | null; versionStr: string} => { + const parts = specifier.split(':', 2); + const name = parts[0]; + const versionStr = parts[1] ?? 'latest'; + const version = parseVersionNumber(versionStr); + return {name, version, versionStr}; +}; + +// Check if we have sequentially increasing version numbers of the same object +export const isSequentialVersions = (specifiers: string[]): boolean => { + if (specifiers.length < 2) { + return false; + } + const first = parseSpecifier(specifiers[0]); + if (first.version === null) { + return false; + } + for (let i = 1; i < specifiers.length; i++) { + const next = parseSpecifier(specifiers[i]); + if (next.name !== first.name || next.version !== first.version + i) { + return false; + } + } + return true; +}; + +// Find object version in array by specifier. +// specifiers can be in the forms: +// name, name:latest, name:v#, name:digest +const findObjectVersion = ( + objectVersions: ObjectVersionSchema[], + specifier: string +): ObjectVersionSchema | undefined => { + const {name, versionStr} = parseSpecifier(specifier); + if (versionStr === 'latest') { + const correctName = _.filter(objectVersions, {objectId: name}); + return _.maxBy(correctName, 'versionIndex'); + } + const versionIndex = parseVersionNumber(versionStr); + if (versionIndex !== null) { + return objectVersions.find( + v => v.objectId === name && v.versionIndex === versionIndex + ); + } + return objectVersions.find( + v => v.objectId === name && v.versionHash === versionStr + ); +}; + +type ObjectVersionsResult = { + loading: boolean; + objectVersions: ObjectVersionSchema[]; + lastVersionIndices: Record; // Name to number +}; + +export const useObjectVersions = ( + entity: string, + project: string, + objectVersionSpecifiers: string[] +): ObjectVersionsResult => { + // TODO: Need to introduce a backend query (/objs/read?) to bulk get specific versions of objects + const {useRootObjectVersions} = useWFHooks(); + const objectIds = getUniqueNames(objectVersionSpecifiers); + const rootObjectVersions = useRootObjectVersions( + entity, + project, + { + objectIds, + }, + undefined, // limit + // TODO: This is super wasteful - getting all the data for all versions of every object mentioned + // but hooks on array would be a pain, proper solution is new read API + false // metadataOnly + ); + if (rootObjectVersions.loading) { + return {loading: true, objectVersions: [], lastVersionIndices: {}}; + } + + const result = rootObjectVersions.result ?? []; + // TODO: Allow ommitting version specifier and handling other cases + // objname => latest version - 1, latest version + // obj1, obj2 => latest version for both + // For now, we require a version specifier for each + const objectVersions = objectVersionSpecifiers + .map(spec => findObjectVersion(result, spec)) + .filter((v): v is NonNullable => v !== undefined); + const lastVersionIndices: Record = {}; + for (const obj of result) { + const current = lastVersionIndices[obj.objectId] ?? -1; + if (obj.versionIndex > current) { + lastVersionIndices[obj.objectId] = obj.versionIndex; + } + } + return {loading: false, objectVersions, lastVersionIndices}; +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/refUtil.ts b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/refUtil.ts new file mode 100644 index 000000000000..f5b712a6895d --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/refUtil.ts @@ -0,0 +1,28 @@ +import {isWeaveRef} from '../filters/common'; +import {traverse, TraverseContext} from '../pages/CallPage/traverse'; +import {isExpandableRef} from '../pages/wfReactInterface/tsDataModelHooksCallRefExpansion'; +import {ComparableObjects} from './types'; + +// When we replace a ref with its resolved data we insert this key +// with the ref URI to keep track of the original value. +export const RESOLVED_REF_KEY = '_ref'; + +export type RefValues = Record; // ref URI to value + +// Traverse the data and find all ref URIs. +export const getRefs = (objects: ComparableObjects): string[] => { + const refs = new Set(); + for (const obj of objects) { + traverse(obj, (context: TraverseContext) => { + if (isWeaveRef(context.value)) { + refs.add(context.value); + } + }); + } + return Array.from(refs); +}; + +// Get the refs in the objects that are expandable. +export const getExpandableRefs = (objects: ComparableObjects): string[] => { + return getRefs(objects).filter(isExpandableRef); +}; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/types.ts b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/types.ts new file mode 100644 index 000000000000..ef804bb00a35 --- /dev/null +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/types.ts @@ -0,0 +1,10 @@ +export type BadgeDef = { + key: string; + value: string; + label?: string; +}; + +export type BadgeDefs = BadgeDef[]; + +export type ComparableObject = Record; +export type ComparableObjects = ComparableObject[]; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/context.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/context.tsx index f453e583c546..791fa873e4df 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3/context.tsx +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/context.tsx @@ -201,6 +201,20 @@ export const browse2Context = { ) => { throw new Error('Not implemented'); }, + compareCallsUri: ( + entityName: string, + projectName: string, + callIds: string[] + ) => { + throw new Error('Not implemented'); + }, + compareObjectsUri: ( + entityName: string, + projectName: string, + objectSpecifiers: string[] + ) => { + throw new Error('Not implemented'); + }, }; export const browse3ContextGen = ( @@ -440,6 +454,26 @@ export const browse3ContextGen = ( leaderboardName ? `/${leaderboardName}` : '' }${edit ? '?edit=true' : ''}`; }, + compareCallsUri: ( + entityName: string, + projectName: string, + callIds: string[] + ) => { + const params = callIds + .map(id => 'call=' + encodeURIComponent(id)) + .join('&'); + return `${projectRoot(entityName, projectName)}/compare?${params}`; + }, + compareObjectsUri: ( + entityName: string, + projectName: string, + objectSpecifiers: string[] + ) => { + const params = objectSpecifiers + .map(id => 'obj=' + encodeURIComponent(id)) + .join('&'); + return `${projectRoot(entityName, projectName)}/compare?${params}`; + }, }; return browse3Context; }; @@ -530,6 +564,16 @@ type RouteType = { leaderboardName?: string, edit?: boolean ) => string; + compareCallsUri: ( + entityName: string, + projectName: string, + callIds: string[] + ) => string; + compareObjectsUri: ( + entityName: string, + projectName: string, + objectSpecifiers: string[] + ) => string; }; const useSetSearchParam = () => { @@ -648,6 +692,16 @@ const useMakePeekingRouter = (): RouteType => { ) => { return setSearchParam(PEEK_PARAM, baseContext.leaderboardsUIUrl(...args)); }, + compareCallsUri: ( + ...args: Parameters + ) => { + return setSearchParam(PEEK_PARAM, baseContext.compareCallsUri(...args)); + }, + compareObjectsUri: ( + ...args: Parameters + ) => { + return setSearchParam(PEEK_PARAM, baseContext.compareObjectsUri(...args)); + }, }; }; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/ObjectViewer.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/ObjectViewer.tsx index 1a918d22541d..81ef9da0082d 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/ObjectViewer.tsx +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/ObjectViewer.tsx @@ -100,9 +100,11 @@ export const ObjectViewer = ({ const dataRefs = useMemo(() => getRefs(data).filter(isExpandableRef), [data]); // Expanded refs are the explicit set of refs that have been expanded by the user. Note that - // this might contain nested refs not in the `dataRefs` set. The keys are refs and the values - // are the paths at which the refs were expanded. - const [expandedRefs, setExpandedRefs] = useState<{[ref: string]: string}>({}); + // this might contain nested refs not in the `dataRefs` set. The keys are object paths at which the refs were expanded + // and the values are the corresponding ref string. + const [expandedRefs, setExpandedRefs] = useState<{[path: string]: string}>( + {} + ); // `addExpandedRef` is a function that can be used to add an expanded ref to the `expandedRefs` state. const addExpandedRef = useCallback((path: string, ref: string) => { diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/ValueViewNumberTimestamp.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/ValueViewNumberTimestamp.tsx index ebacccda3917..baf5d8e8937f 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/ValueViewNumberTimestamp.tsx +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/ValueViewNumberTimestamp.tsx @@ -8,16 +8,17 @@ const JAN_1_2000_MS = 1000 * JAN_1_2000_S; const JAN_1_2100_MS = 1000 * JAN_1_2100_S; // TODO: This is only looking at value, but we could also consider the value name, e.g. "created". + +export const isProbablyTimestampMs = (value: number) => { + return JAN_1_2000_MS <= value && value <= JAN_1_2100_MS; +}; + +export const isProbablyTimestampSec = (value: number) => { + return JAN_1_2000_S <= value && value <= JAN_1_2100_S; +}; + export const isProbablyTimestamp = (value: number) => { - const inRangeSec = JAN_1_2000_S <= value && value <= JAN_1_2100_S; - if (inRangeSec) { - return true; - } - const inRangeMs = JAN_1_2000_MS <= value && value <= JAN_1_2100_MS; - if (inRangeMs) { - return true; - } - return false; + return isProbablyTimestampMs(value) || isProbablyTimestampSec(value); }; type ValueViewNumberTimestampProps = { diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/traverse.ts b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/traverse.ts index 0f07c7b450b4..cdfdd6d7cdc3 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/traverse.ts +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallPage/traverse.ts @@ -155,7 +155,7 @@ export class ObjectPath { } } -type ValueType = +export type ValueType = | 'null' | 'undefined' | 'boolean' diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallsPage/CallsTable.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallsPage/CallsTable.tsx index b05fe855f65b..d4f164d17bb0 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallsPage/CallsTable.tsx +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallsPage/CallsTable.tsx @@ -81,6 +81,7 @@ import {CallsCustomColumnMenu} from './CallsCustomColumnMenu'; import { BulkDeleteButton, CompareEvaluationsTableButton, + CompareTracesTableButton, ExportSelector, PaginationButtons, RefreshButton, @@ -814,7 +815,7 @@ export const CallsTable: FC<{ }} /> )} - {isEvaluateTable && ( + {isEvaluateTable ? ( { history.push( @@ -828,6 +829,15 @@ export const CallsTable: FC<{ }} disabled={selectedCalls.length === 0} /> + ) : ( + { + history.push( + router.compareCallsUri(entity, project, selectedCalls) + ); + }} + disabled={selectedCalls.length < 2} + /> )} {!isReadonly && selectedCalls.length !== 0 && ( <> diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallsPage/CallsTableButtons.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallsPage/CallsTableButtons.tsx index 3718e696586b..fa8e9205dca9 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallsPage/CallsTableButtons.tsx +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/CallsPage/CallsTableButtons.tsx @@ -12,8 +12,7 @@ import { import {MOON_500} from '@wandb/weave/common/css/color.styles'; import {useOrgName} from '@wandb/weave/common/hooks/useOrganization'; import {useViewerUserInfo2} from '@wandb/weave/common/hooks/useViewerUserInfo'; -import {Radio} from '@wandb/weave/components'; -import {Switch} from '@wandb/weave/components'; +import {Radio, Switch} from '@wandb/weave/components'; import {Button} from '@wandb/weave/components/Button'; import {CodeEditor} from '@wandb/weave/components/CodeEditor'; import { @@ -424,6 +423,29 @@ export const CompareEvaluationsTableButton: FC<{
); +export const CompareTracesTableButton: FC<{ + onClick: () => void; + disabled?: boolean; + tooltipText?: string; +}> = ({onClick, disabled, tooltipText}) => ( + + + +); + export const BulkDeleteButton: FC<{ disabled?: boolean; onClick: () => void; diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/ObjectVersionsPage.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/ObjectVersionsPage.tsx index eed18b9a70c7..7c9db869c36f 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/ObjectVersionsPage.tsx +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/pages/ObjectVersionsPage.tsx @@ -15,15 +15,21 @@ import { GridRowSelectionModel, GridRowsProp, } from '@mui/x-data-grid-pro'; +import {Checkbox} from '@wandb/weave/components/Checkbox'; import _ from 'lodash'; import React, {useEffect, useMemo, useState} from 'react'; +import {useHistory} from 'react-router-dom'; import {TEAL_600} from '../../../../../common/css/color.styles'; +import {Button} from '../../../../Button'; import {ErrorPanel} from '../../../../ErrorPanel'; import {Loading} from '../../../../Loading'; import {LoadingDots} from '../../../../LoadingDots'; import {Timestamp} from '../../../../Timestamp'; -import {useWeaveflowRouteContext} from '../context'; +import { + useWeaveflowCurrentRouteContext, + useWeaveflowRouteContext, +} from '../context'; import {StyledDataGrid} from '../StyledDataGrid'; import {basicField} from './common/DataTable'; import {Empty} from './common/Empty'; @@ -73,10 +79,17 @@ export const ObjectVersionsPage: React.FC<{ // is responsible for updating the filter. onFilterUpdate?: (filter: WFHighLevelObjectVersionFilter) => void; }> = props => { + const history = useHistory(); + const router = useWeaveflowCurrentRouteContext(); const [filter, setFilter] = useControllableState( props.initialFilter ?? {}, props.onFilterUpdate ); + const {entity, project} = props; + const [selectedVersions, setSelectedVersions] = useState([]); + const onCompare = () => { + history.push(router.compareObjectsUri(entity, project, selectedVersions)); + }; const title = useMemo(() => { if (filter.objectName) { @@ -91,6 +104,14 @@ export const ObjectVersionsPage: React.FC<{ + Compare + + } tabs={[ { label: '', @@ -99,6 +120,8 @@ export const ObjectVersionsPage: React.FC<{ {...props} initialFilter={filter} onFilterUpdate={setFilter} + selectedVersions={selectedVersions} + setSelectedVersions={setSelectedVersions} /> ), }, @@ -120,6 +143,8 @@ export const FilterableObjectVersionsTable: React.FC<{ // Setting this will make the component a controlled component. The parent // is responsible for updating the filter. onFilterUpdate?: (filter: WFHighLevelObjectVersionFilter) => void; + selectedVersions?: string[]; + setSelectedVersions?: (selected: string[]) => void; }> = props => { const {useRootObjectVersions} = useWFHooks(); const {baseRouter} = useWeaveflowRouteContext(); @@ -184,6 +209,8 @@ export const FilterableObjectVersionsTable: React.FC<{ objectVersions={objectVersions} hidePropsAsColumns={!!effectivelyLatestOnly} hidePeerVersionsColumn={!effectivelyLatestOnly} + selectedVersions={props.selectedVersions} + setSelectedVersions={props.setSelectedVersions} /> ); @@ -198,8 +225,11 @@ export const ObjectVersionsTable: React.FC<{ hideCreatedAtColumn?: boolean; hideVersionSuffix?: boolean; onRowClick?: (objectVersion: ObjectVersionSchema) => void; + selectedVersions?: string[]; + setSelectedVersions?: (selected: string[]) => void; }> = props => { // `showPropsAsColumns` probably needs to be a bit more robust + const {selectedVersions, setSelectedVersions} = props; const showPropsAsColumns = !props.hidePropsAsColumns; const rows: GridRowsProp = useMemo(() => { const vals = props.objectVersions.map(ov => ov.val); @@ -231,7 +261,61 @@ export const ObjectVersionsTable: React.FC<{ // extracted and shared. const {cols: columns, groups: columnGroupingModel} = useMemo(() => { let groups: GridColumnGroupingModel = []; + const checkboxColumnArr: GridColDef[] = + selectedVersions != null && setSelectedVersions + ? [ + { + minWidth: 30, + width: 34, + field: 'CustomCheckbox', + sortable: false, + disableColumnMenu: true, + resizable: false, + disableExport: true, + display: 'flex', + renderHeader: (params: any) => { + // TODO: Adding a select all checkbox here not that useful for compare + // but might for be for other bulk actions. + return null; + }, + renderCell: (params: any) => { + const {objectId, versionIndex} = params.row.obj; + const objSpecifier = `${objectId}:v${versionIndex}`; + const isSelected = selectedVersions.includes(objSpecifier); + return ( + { + if (isSelected) { + setSelectedVersions( + selectedVersions.filter(id => id !== objSpecifier) + ); + } else { + // Keep the objects in sorted order, regardless of the order checked. + setSelectedVersions( + [...selectedVersions, objSpecifier].sort((a, b) => { + const [aName, aVer] = a.split(':'); + const [bName, bVer] = b.split(':'); + if (aName !== bName) { + return aName.localeCompare(bName); + } + const aNum = parseInt(aVer.slice(1), 10); + const bNum = parseInt(bVer.slice(1), 10); + return aNum - bNum; + }) + ); + } + }} + /> + ); + }, + }, + ] + : []; const cols: GridColDef[] = [ + ...checkboxColumnArr, + // This field name chosen to reduce possibility of conflict // with the dynamic fields added below. basicField('weave__object_version_link', props.objectTitle ?? 'Object', { @@ -364,7 +448,7 @@ export const ObjectVersionsTable: React.FC<{ } return {cols, groups}; - }, [props, showPropsAsColumns, rows]); + }, [props, showPropsAsColumns, rows, selectedVersions, setSelectedVersions]); // Highlight table row if it matches peek drawer. const query = useURLSearchParamsDict(); diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/urlQueryUtil.ts b/weave-js/src/components/PagePanelComponents/Home/Browse3/urlQueryUtil.ts index 949471f22339..4fe4b23a6d86 100644 --- a/weave-js/src/components/PagePanelComponents/Home/Browse3/urlQueryUtil.ts +++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/urlQueryUtil.ts @@ -24,6 +24,24 @@ export function querySetString(history: History, key: string, value: string) { }); } +export const queryToggleString = ( + history: History, + key: string, + value: string +) => { + const currentValue = queryGetString(history, key); + const {search} = history.location; + const params = new URLSearchParams(search); + if (currentValue === value) { + params.delete(key); + } else { + params.set(key, value); + } + history.replace({ + search: params.toString(), + }); +}; + export function queryGetBoolean( history: History, key: string, @@ -68,3 +86,58 @@ export const queryToggleBoolean = ( search: params.toString(), }); }; + +export const querySetArray = (history: History, key: string, value: any[]) => { + const {search} = history.location; + const params = new URLSearchParams(search); + searchParamsSetArray(params, key, value); + history.replace({ + search: params.toString(), + }); +}; + +export const searchParamsSetArray = ( + params: URLSearchParams, + key: string, + value: any[] +) => { + params.delete(key); + value.forEach(item => { + params.append(key, String(item)); + }); +}; + +export const queryGetDict = (history: History): Record => { + const {search} = history.location; + const searchParams = new URLSearchParams(search); + const params: Record = {}; + searchParams.forEach((value, key) => { + if (params[key]) { + // If the key already exists, convert the existing value to an array (if it isn't already) + // and push the new value into the array + if (Array.isArray(params[key])) { + params[key].push(value); + } else { + params[key] = [params[key], value]; + } + } else { + // If the key doesn't exist, add it to the dictionary + params[key] = value; + } + }); + return params; +}; + +export const getParamArray = ( + d: Record, + key: string, + defaultValue: any[] = [] +): any[] => { + if (d[key] === undefined) { + return defaultValue; + } + if (Array.isArray(d[key])) { + return d[key]; + } + return [d[key]]; +}; diff --git a/weave-js/yarn.lock b/weave-js/yarn.lock index d1bf8a428713..4234cc50a380 100644 --- a/weave-js/yarn.lock +++ b/weave-js/yarn.lock @@ -1366,7 +1366,7 @@ core-js-pure "^3.30.2" regenerator-runtime "^0.14.0" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.22.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.6.tgz#57d64b9ae3cff1d67eb067ae117dac087f5bd438" integrity sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ== @@ -1380,6 +1380,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.2.0": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.23.2": version "7.23.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885"