diff --git a/web/libs/datamanager/src/components/Common/Table/utils.js b/web/libs/datamanager/src/components/Common/Table/utils.js index d80bc8aab96e..592fbcccae4a 100644 --- a/web/libs/datamanager/src/components/Common/Table/utils.js +++ b/web/libs/datamanager/src/components/Common/Table/utils.js @@ -1,6 +1,7 @@ export const prepareColumns = (columns, hidden) => { + if (!hidden?.length) return columns; return columns.filter((col) => { - return !(hidden ?? []).includes(col.id); + return !hidden.includes(col.id); }); }; diff --git a/web/libs/datamanager/src/components/MainView/GridView/GridPreview.module.scss b/web/libs/datamanager/src/components/MainView/GridView/GridPreview.module.scss new file mode 100644 index 000000000000..df3ca960d29c --- /dev/null +++ b/web/libs/datamanager/src/components/MainView/GridView/GridPreview.module.scss @@ -0,0 +1,68 @@ +.modal { + padding: 16px; + position: relative; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: content-box; + margin-bottom: 16px; +} + +.tooltip { + font-size: 12px; + max-width: 300px; + line-height: 16px; + + p { + margin-bottom: 4px; + } + + p:last-child { + margin-bottom: 0; + } +} + +.actions { + margin-left: auto; + + & > * { + width: 20px; + margin-left: 16px; + text-align: center; + cursor: pointer; + } +} + +.container { + overflow: hidden; + width: 100%; + height: 100%; + min-height: 100px; + display: flex; + position: relative; +} + +.container button { + padding: 0; + flex: 20px 0 0; + cursor: pointer; + background: none; + + &:hover { + background: var(--sand_200); + } +} + +.image { + pointer-events: none; + user-select: none; + cursor: move; + width: 100%; + height: 100%; + object-fit: contain; + overflow: hidden; + max-height: calc(90vh - 120px); +} diff --git a/web/libs/datamanager/src/components/MainView/GridView/GridPreview.tsx b/web/libs/datamanager/src/components/MainView/GridView/GridPreview.tsx new file mode 100644 index 000000000000..6428cc47e4c0 --- /dev/null +++ b/web/libs/datamanager/src/components/MainView/GridView/GridPreview.tsx @@ -0,0 +1,184 @@ +import { CloseOutlined, LeftCircleOutlined, QuestionCircleOutlined, RightCircleOutlined } from "@ant-design/icons"; +import { Checkbox } from "@humansignal/ui"; +import { observer } from "mobx-react"; +import type { PropsWithChildren } from "react"; +import { createContext, useCallback, useEffect, useRef, useState } from "react"; +import { modal } from "../../Common/Modal/Modal"; +import { Icon } from "../../Common/Icon/Icon"; +import { Tooltip } from "../../Common/Tooltip/Tooltip"; +import styles from "./GridPreview.module.scss"; + +type Task = { + id: number; + data: Record; +}; + +type GridViewContextType = { + tasks: Task[]; + imageField: string | undefined; + currentTaskId: number | null; + setCurrentTaskId: (id: number | null) => void; +}; + +type TaskModalProps = GridViewContextType & { view: any }; + +export const GridViewContext = createContext({ + tasks: [], + imageField: undefined, + currentTaskId: null, + setCurrentTaskId: () => {}, +}); + +const TaskModal = observer(({ view, tasks, imageField, currentTaskId, setCurrentTaskId }: TaskModalProps) => { + const index = tasks.findIndex((task) => task.id === currentTaskId); + const task = tasks[index]; + const src = imageField ? task?.data?.[imageField] || "" : ""; + + const goToNext = useCallback(() => { + if (index < tasks.length - 1) { + setCurrentTaskId(tasks[index + 1].id); + } + }, [index, tasks]); + + const goToPrev = useCallback(() => { + if (index > 0) { + setCurrentTaskId(tasks[index - 1].id); + } + }, [index, tasks]); + + const onSelect = useCallback(() => { + if (task) { + view.toggleSelected(task.id); + } + }, [task, view]); + + const onClose = useCallback(() => { + setCurrentTaskId(null); + }, []); + + // assign hotkeys + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + goToPrev(); + } else if (event.key === "ArrowRight") { + goToNext(); + } else if (event.key === " ") { + onSelect(); + event.preventDefault(); + } else if (event.key === "Escape") { + onClose(); + } else { + // pass this event through for other keys + return; + } + + event.stopPropagation(); + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [goToNext, goToPrev, onSelect, onClose]); + + if (!task) { + return null; + } + + const tooltip = ( +
+

Preview of the task image to quickly navigate through the tasks and select the ones you want to work on.

+

Use [arrow keys] to navigate.

+

[Escape] to close the modal.

+

[Space] to select/unselect the task.

+
+ ); + + return ( +
+
+ + Task {task.id} + +
+ + + + +
+
+
+ + Task Preview + +
+
+ ); +}); + +type GridViewProviderProps = PropsWithChildren<{ + data: Task[]; + view: any; + fields: { alias: string; currentType: string }[]; +}>; + +export const GridViewProvider: React.FC = ({ children, data, view, fields }) => { + const [currentTaskId, setCurrentTaskId] = useState(null); + const modalRef = useRef<{ update: (props: object) => void; close: () => void } | null>(null); + const imageField = fields.find((f) => f.currentType === "Image")?.alias; + + const onClose = useCallback(() => { + modalRef.current = null; + setCurrentTaskId(null); + }, []); + + useEffect(() => { + if (currentTaskId === null) { + modalRef.current?.close(); + return; + } + + if (!imageField) return; + + const children = ( + + ); + + if (!modalRef.current) { + modalRef.current = modal({ + bare: true, + title: "Task Preview", + style: { width: 800 }, + children, + onHidden: onClose, + }); + } else { + modalRef.current.update({ children }); + } + }, [currentTaskId, data, onClose]); + + // close the modal when we leave the view (by browser controls or by hotkeys) + useEffect(() => () => modalRef.current?.close(), []); + + return ( + + {children} + + ); +}; diff --git a/web/libs/datamanager/src/components/MainView/GridView/GridView.jsx b/web/libs/datamanager/src/components/MainView/GridView/GridView.jsx index c303869cb05f..d5a6e3e43128 100644 --- a/web/libs/datamanager/src/components/MainView/GridView/GridView.jsx +++ b/web/libs/datamanager/src/components/MainView/GridView/GridView.jsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import React from "react"; +import { useCallback, useContext, useMemo } from "react"; import AutoSizer from "react-virtualized-auto-sizer"; import { FixedSizeGrid } from "react-window"; import InfiniteLoader from "react-window-infinite-loader"; @@ -8,9 +8,10 @@ import { Checkbox } from "@humansignal/ui"; import { Space } from "../../Common/Space/Space"; import { getProperty, prepareColumns } from "../../Common/Table/utils"; import * as DataGroups from "../../DataGroups"; -import "./GridView.scss"; -import { FF_LOPS_E_3, isFF } from "../../../utils/feature-flags"; +import { FF_GRID_PREVIEW, FF_LOPS_E_3, isFF } from "../../../utils/feature-flags"; import { SkeletonLoader } from "../../Common/SkeletonLoader"; +import { GridViewContext, GridViewProvider } from "./GridPreview"; +import "./GridView.scss"; const GridHeader = observer(({ row, selected }) => { const isSelected = selected.isSelected(row.id); @@ -56,11 +57,25 @@ const GridDataGroup = observer(({ type, value, field, row }) => { }); const GridCell = observer(({ view, selected, row, fields, onClick, ...props }) => { + const { setCurrentTaskId, imageField } = useContext(GridViewContext); + + const handleBodyClick = useCallback( + (e) => { + if (!isFF(FF_GRID_PREVIEW) || !imageField) return; + // @todo skip this interaction if there are no images in the task + e.stopPropagation(); + setCurrentTaskId(row.id); + }, + [imageField, row.id], + ); + return ( - + + + ); @@ -71,7 +86,7 @@ export const GridView = observer(({ data, view, loadMore, fields, onChange, hidd const getCellIndex = (row, column) => columnCount * row + column; - const fieldsData = React.useMemo(() => { + const fieldsData = useMemo(() => { return prepareColumns(fields, hiddenFields); }, [fields, hiddenFields]); @@ -83,7 +98,7 @@ export const GridView = observer(({ data, view, loadMore, fields, onChange, hidd return res + height; }, 16); - const renderItem = React.useCallback( + const renderItem = useCallback( ({ style, rowIndex, columnIndex }) => { const index = getCellIndex(rowIndex, columnIndex); const row = data[index]; @@ -124,7 +139,7 @@ export const GridView = observer(({ data, view, loadMore, fields, onChange, hidd const itemCount = Math.ceil(data.length / columnCount); - const isItemLoaded = React.useCallback( + const isItemLoaded = useCallback( (index) => { const rowIndex = index * columnCount; const rowFullfilled = data.slice(rowIndex, columnCount).length === columnCount; @@ -135,37 +150,39 @@ export const GridView = observer(({ data, view, loadMore, fields, onChange, hidd ); return ( - - - {({ width, height }) => ( - - {({ onItemsRendered, ref }) => ( - - {renderItem} - - )} - - )} - - + + + + {({ width, height }) => ( + + {({ onItemsRendered, ref }) => ( + + {renderItem} + + )} + + )} + + + ); }); diff --git a/web/libs/datamanager/src/utils/feature-flags.js b/web/libs/datamanager/src/utils/feature-flags.js index 4c0e30e80a3e..9a692554cbd5 100644 --- a/web/libs/datamanager/src/utils/feature-flags.js +++ b/web/libs/datamanager/src/utils/feature-flags.js @@ -68,6 +68,9 @@ export const FF_LOPS_86 = "fflag_feat_front_lops_86_datasets_storage_edit_short" */ export const FF_SELF_SERVE = "fflag_feat_front_leap_482_self_serve_short"; +/** Add ability to preview image tasks in Data Manager Grid View */ +export const FF_GRID_PREVIEW = "fflag_feat_front_leap_1424_grid_preview_short"; + // Customize flags const flags = {};