From b7e1e25a14e27fdabdda86f101d9a4141784c1d6 Mon Sep 17 00:00:00 2001 From: Arthur de Moulins Date: Wed, 12 Jun 2024 20:13:58 +0200 Subject: [PATCH] WIP --- databox/api/src/Elasticsearch/AssetSearch.php | 8 +- databox/client/src/api/asset.ts | 1 + .../AssetList/Toolbar/SelectionActions.tsx | 12 ++- .../client/src/components/AssetList/types.ts | 2 +- .../components/AttributeEditor/AssetItem.tsx | 68 +++++++++++++ .../AttributeEditor/AttributeEditor.tsx | 88 +++++++++++++++++ .../AttributeEditor/AttributeEditorView.tsx | 42 ++++++++ .../components/AttributeEditor/Attributes.tsx | 76 +++++++++++++++ .../components/AttributeEditor/ThumbList.tsx | 85 ++++++++++++++++ .../AttributeEditor/attributeGroup.ts | 97 +++++++++++++++++++ .../src/components/Media/Asset/AssetView.tsx | 14 ++- databox/client/src/components/Root.tsx | 6 +- .../Preferences/UserPreferencesProvider.tsx | 4 +- .../src/components/Workflow/WorkflowView.tsx | 6 +- databox/client/src/constants.ts | 2 + databox/client/src/routes.ts | 7 +- lib/js/navigation/index.ts | 3 +- .../navigation/src/Overlay/OverlayOutlet.tsx | 7 +- .../navigation/src/useNavigateToOverlay.tsx | 14 +-- 19 files changed, 513 insertions(+), 29 deletions(-) create mode 100644 databox/client/src/components/AttributeEditor/AssetItem.tsx create mode 100644 databox/client/src/components/AttributeEditor/AttributeEditor.tsx create mode 100644 databox/client/src/components/AttributeEditor/AttributeEditorView.tsx create mode 100644 databox/client/src/components/AttributeEditor/Attributes.tsx create mode 100644 databox/client/src/components/AttributeEditor/ThumbList.tsx create mode 100644 databox/client/src/components/AttributeEditor/attributeGroup.ts create mode 100644 databox/client/src/constants.ts diff --git a/databox/api/src/Elasticsearch/AssetSearch.php b/databox/api/src/Elasticsearch/AssetSearch.php index addeafad9..b23ad6963 100644 --- a/databox/api/src/Elasticsearch/AssetSearch.php +++ b/databox/api/src/Elasticsearch/AssetSearch.php @@ -33,6 +33,8 @@ public function search( array $groupIds, array $options = [] ): array { + $maxLimit = 50; + $filterQueries = []; $aclBoolQuery = $this->createACLBoolQuery($userId, $groupIds); @@ -50,6 +52,11 @@ public function search( $filterQueries[] = new Query\Terms('collectionPaths', $paths); } + if (isset($options['ids'])) { + $filterQueries[] = new Query\Terms('_id', $options['ids']); + $maxLimit = 500; + } + if (isset($options['workspaces'])) { $filterQueries[] = new Query\Terms('workspaceId', $options['workspaces']); } @@ -82,7 +89,6 @@ public function search( } } - $maxLimit = 50; $limit = $options['limit'] ?? $maxLimit; if ($limit > $maxLimit) { $limit = $maxLimit; diff --git a/databox/client/src/api/asset.ts b/databox/client/src/api/asset.ts index eecf372b7..a1eb773c1 100644 --- a/databox/client/src/api/asset.ts +++ b/databox/client/src/api/asset.ts @@ -12,6 +12,7 @@ export interface GetAssetOptions { url?: string; query?: string; workspaces?: string[]; + ids?: string[]; parents?: string[]; filters?: any; order?: Record; diff --git a/databox/client/src/components/AssetList/Toolbar/SelectionActions.tsx b/databox/client/src/components/AssetList/Toolbar/SelectionActions.tsx index 67d088e9e..3f4d050c4 100644 --- a/databox/client/src/components/AssetList/Toolbar/SelectionActions.tsx +++ b/databox/client/src/components/AssetList/Toolbar/SelectionActions.tsx @@ -174,7 +174,11 @@ export default function SelectionActions({ id: selectedAssets[0].id, }); } else { - alert('Multi edit is coming soon...'); + navigateToModal(modalRoutes.attributesBatchEdit, {}, { + state: { + selection: selectedAssets.map(a => a.id), + } + }); } }; @@ -185,7 +189,11 @@ export default function SelectionActions({ id: selectedAssets[0].id, }); } else { - alert('Multi edit attributes is coming soon...'); + navigateToModal(modalRoutes.attributesBatchEdit, {}, { + state: { + selection: selectedAssets.map(a => a.id), + } + }); } }; diff --git a/databox/client/src/components/AssetList/types.ts b/databox/client/src/components/AssetList/types.ts index 81872a661..86c098416 100644 --- a/databox/client/src/components/AssetList/types.ts +++ b/databox/client/src/components/AssetList/types.ts @@ -37,7 +37,7 @@ export type AssetActions = { }; export type AssetItemProps = { - itemComponent: AssetItemComponent | undefined; + itemComponent?: AssetItemComponent | undefined; item: Item; asset: Asset; selected: boolean; diff --git a/databox/client/src/components/AttributeEditor/AssetItem.tsx b/databox/client/src/components/AttributeEditor/AssetItem.tsx new file mode 100644 index 000000000..adb65937c --- /dev/null +++ b/databox/client/src/components/AttributeEditor/AssetItem.tsx @@ -0,0 +1,68 @@ +import {Asset} from "../../types.ts"; +import AssetItemWrapper from "../AssetList/Layouts/AssetItemWrapper.tsx"; +import assetClasses from "../AssetList/classes.ts"; +import {Checkbox} from "@mui/material"; +import {stopPropagation} from "../../lib/stdFuncs.ts"; +import React from "react"; +import AssetThumb from "../Media/Asset/AssetThumb.tsx"; +import {OnPreviewToggle, OnToggle} from "../AssetList/types.ts"; + +type Props = { + asset: Asset; + selected: boolean; + onToggle: OnToggle; + onPreviewToggle: OnPreviewToggle; +}; + +export default function AssetItem({ + asset, + selected, + onToggle, + onPreviewToggle, +}: Props) { + return ( + + item={asset} + onToggle={onToggle} + selected={selected} + > +
+ + onToggle(asset, { + ctrlKey: true, + preventDefault() {}, + } as React.MouseEvent) + } + /> +
+ + onPreviewToggle( + asset, + true, + e.currentTarget as HTMLElement + ) + : undefined + } + onMouseLeave={ + onPreviewToggle + ? e => + onPreviewToggle( + asset, + false, + e.currentTarget as HTMLElement + ) + : undefined + } + /> + + ); +} diff --git a/databox/client/src/components/AttributeEditor/AttributeEditor.tsx b/databox/client/src/components/AttributeEditor/AttributeEditor.tsx new file mode 100644 index 000000000..5d5c63786 --- /dev/null +++ b/databox/client/src/components/AttributeEditor/AttributeEditor.tsx @@ -0,0 +1,88 @@ +import {Asset, AttributeDefinition} from "../../types.ts"; +import {useTranslation} from "react-i18next"; +import {getAssets} from "../../api/asset.ts"; +import {Box, Typography} from "@mui/material"; +import {FullPageLoader} from "@alchemy/phrasea-ui"; +import React from "react"; +import ThumbList from "./ThumbList.tsx"; +import {AssetSelectionContext} from "../../context/AssetSelectionContext.tsx"; +import DisplayProvider from "../Media/DisplayProvider.tsx"; +import {OnToggle} from "../AssetList/types.ts"; +import {getItemListFromEvent} from "../AssetList/selection.ts"; +import Attributes from "./Attributes.tsx"; +import {getWorkspaceAttributeDefinitions} from "../../api/attributes.ts"; + +type Props = { + ids: string[]; +}; + +export default function AttributeEditor({ + ids, +}: Props) { + const {t} = useTranslation(); + const [assets, setAssets] = React.useState(); + const [subSelection, setSubSelection] = React.useState([]); + const [attributeDefinitions, setAttributeDefinitions] = React.useState(); + + const onToggleAsset = React.useCallback>( + (asset, e): void => { + e?.preventDefault(); + setSubSelection(prev => { + return getItemListFromEvent(prev, asset, [assets!], e); + }); + }, + [assets] + ); + + React.useEffect(() => { + getAssets({ + ids, + }).then(r => { + setAssets(r.result); + setSubSelection(r.result); + }); + }, [ids]); + + React.useEffect(() => { + if (assets) { + getWorkspaceAttributeDefinitions(assets[0].workspace.id).then(r => { + setAttributeDefinitions(r); + }); + } + }, [assets]); + + if (!assets) { + return + } + + return + + {t('attribute.editor.title', 'Attribute Editor')} + + + + + + {attributeDefinitions ? : <>Loading attributes...} + + + +} diff --git a/databox/client/src/components/AttributeEditor/AttributeEditorView.tsx b/databox/client/src/components/AttributeEditor/AttributeEditorView.tsx new file mode 100644 index 000000000..7bae33add --- /dev/null +++ b/databox/client/src/components/AttributeEditor/AttributeEditorView.tsx @@ -0,0 +1,42 @@ +import {useLocation} from '@alchemy/navigation'; +import {AppDialog} from "@alchemy/phrasea-ui"; +import RouteDialog from "../Dialog/RouteDialog.tsx"; +import AttributeEditor from "./AttributeEditor.tsx"; +import {useCloseModal} from "../Routing/ModalLink.tsx"; +import React from "react"; + +type Props = {}; + +export default function AttributeEditorView({}: Props) { + const {state} = useLocation(); + const closeDrawer = useCloseModal(); + + React.useEffect(() => { + if (!state?.selection) { + closeDrawer({ + replace: true, + }); + } + }, [state]); + + if (!state?.selection) { + return <>; + } + + return ( + + {({open, onClose}) => ( + + + + )} + + ); +} diff --git a/databox/client/src/components/AttributeEditor/Attributes.tsx b/databox/client/src/components/AttributeEditor/Attributes.tsx new file mode 100644 index 000000000..b2a8c187b --- /dev/null +++ b/databox/client/src/components/AttributeEditor/Attributes.tsx @@ -0,0 +1,76 @@ +import {Asset, AttributeDefinition} from "../../types.ts"; +import {useAttributeValues} from "./attributeGroup.ts"; +import {Box, ListItem, ListItemButton, TextField} from "@mui/material"; +import React from "react"; + +type Props = { + assets: Asset[]; + subSelection: Asset[]; + attributeDefinitions: AttributeDefinition[]; +}; + +export default function Attributes({ + assets, + attributeDefinitions, + subSelection, +}: Props) { + const {values, setValue} = useAttributeValues(attributeDefinitions, assets, subSelection); + const inputRef = React.useRef(null); + const [definition, setDefinition] = React.useState(attributeDefinitions[0]); + + const value = definition ? values[definition.id] : undefined; + + React.useEffect(() => { + inputRef.current?.focus(); + }, [definition]); + + return
+ + {attributeDefinitions.map((def) => { + return + setDefinition(def)} + > + + {def.name} + +
+ {values[def.id].indeterminate ? + Indeterminate : values[def.id].values[0] ?? ""} +
+
+
+ })} +
+ + + {value ? <> + setValue(definition!.id, e.target.value)} + placeholder={value.indeterminate ? '-------' : undefined} + /> + : ''} + +
+} diff --git a/databox/client/src/components/AttributeEditor/ThumbList.tsx b/databox/client/src/components/AttributeEditor/ThumbList.tsx new file mode 100644 index 000000000..194eb3fd3 --- /dev/null +++ b/databox/client/src/components/AttributeEditor/ThumbList.tsx @@ -0,0 +1,85 @@ +import {Asset} from "../../types.ts"; +import React from "react"; +import {DisplayContext} from "../Media/DisplayContext.tsx"; +import AssetItem from "./AssetItem.tsx"; +import {OnToggle} from "../AssetList/types.ts"; +import {Box, Theme} from "@mui/material"; +import {createSizeTransition, thumbSx} from "../Media/Asset/AssetThumb.tsx"; +import assetClasses from "../AssetList/classes.ts"; +import {scrollbarWidth} from "../../constants.ts"; + +type Props = { + assets: Asset[]; + subSelection: Asset[]; + onToggle: OnToggle; +}; + +export default function ThumbList({ + assets, + subSelection, + onToggle, +}: Props) { + const d = React.useContext(DisplayContext)!; + + const selectedIds = React.useMemo(() => { + return subSelection.map(a => a.id); + }, [subSelection]); + + + const listSx = React.useCallback( + (theme: Theme) => { + let totalHeight = d.thumbSize; + + return { + overflow: "auto", + height: d!.thumbSize + scrollbarWidth, + display: 'flex', + ...thumbSx(d.thumbSize, theme), + px: 2, + backgroundColor: theme.palette.common.white, + [`.${assetClasses.item}`]: { + 'width': d.thumbSize, + 'height': totalHeight, + 'transition': createSizeTransition(theme), + 'position': 'relative', + opacity: 0.5, + [`.${assetClasses.controls}`]: { + 'position': 'absolute', + 'zIndex': 2, + 'left': 0, + 'top': 0, + 'right': 0, + 'padding': '1px', + '> div': { + float: 'right', + }, + 'background': + 'linear-gradient(to bottom, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0) 100%)', + }, + '&.selected': { + opacity: 1 + }, + }, + [`.${assetClasses.thumbActive}`]: { + display: 'none', + }, + }; + }, + [d] + ); + + return + {assets.map(a => { + return { + }} + /> + })} + +} diff --git a/databox/client/src/components/AttributeEditor/attributeGroup.ts b/databox/client/src/components/AttributeEditor/attributeGroup.ts new file mode 100644 index 000000000..269475330 --- /dev/null +++ b/databox/client/src/components/AttributeEditor/attributeGroup.ts @@ -0,0 +1,97 @@ +import {Asset, AttributeDefinition} from "../../types.ts"; +import React from "react"; + +type Values = { + indeterminate?: boolean; + values: T[]; +} + +type AttributeValues = Record; + +type AssetValueIndex = Record; +type AttributesIndex = Record; + +export function useAttributeValues( + attributeDefinitions: AttributeDefinition[], + assets: Asset[], + subSelection: Asset[], +) { + const initialIndex = React.useMemo(() => { + const index: AttributesIndex = {}; + + attributeDefinitions.forEach((def) => { + index[def.id] ??= {}; + }); + + assets.forEach((a) => { + a.attributes.forEach((attr) => { + index[attr.definition.id][a.id] = attr.value; + }); + }); + + return index; + }, [attributeDefinitions, assets]); + + const [index, setIndex] = React.useState(initialIndex); + + const values = React.useMemo(() => { + const values: AttributeValues = {}; + console.log('index', index); + + attributeDefinitions.forEach((def) => { + values[def.id] ??= { + values: [], + }; + }); + + subSelection.forEach((a) => { + Object.keys(index).forEach((defId) => { + const v = index[defId][a.id]; + + const g = values[defId]; + if (g.values.length === 0) { + g.indeterminate = false; + } else { + if (g.values.some(sv => sv !== v)) { + g.indeterminate = true; + } + } + + g.values.push(v); + }); + }); + + return values; + }, [subSelection, index]); + + const reset = React.useCallback(() => { + setIndex(initialIndex); + }, [initialIndex]); + + React.useEffect(() => { + reset(); + }, [reset]); + + const setValue = React.useCallback((defId: string, value: any) => { + setIndex(p => { + const np = {...p}; + const na = {...p[defId]}; + + subSelection.forEach(a => { + na[a.id] = value; + }); + + np[defId] = na; + + console.log('np', defId, np[defId]); + return np; + }) + }, [subSelection]); + + return { + values, + setValue, + reset, + index, + }; +} diff --git a/databox/client/src/components/Media/Asset/AssetView.tsx b/databox/client/src/components/Media/Asset/AssetView.tsx index 140a854b9..68b410c54 100644 --- a/databox/client/src/components/Media/Asset/AssetView.tsx +++ b/databox/client/src/components/Media/Asset/AssetView.tsx @@ -14,6 +14,7 @@ import {getAssetRenditions} from '../../../api/rendition'; import MenuItem from '@mui/material/MenuItem'; import {useCloseModal, useNavigateToModal} from '../../Routing/ModalLink'; import {modalRoutes} from '../../../routes'; +import {scrollbarWidth} from "../../../constants.ts"; export type IntegrationOverlayCommonProps = { dimensions: Dimensions; @@ -31,14 +32,11 @@ export type SetIntegrationOverlayFunction

= ( replace?: boolean ) => void; -const menuWidth = 300; - -const headerHeight = 60; -const scrollBarDelta = 8; - type Props = {} & StackedModalProps; export default function AssetView({modalIndex}: Props) { + const menuWidth = 300; + const headerHeight = 60; const {id: assetId, renditionId} = useParams(); const navigateToModal = useNavigateToModal(); const closeModal = useCloseModal(); @@ -81,7 +79,7 @@ export default function AssetView({modalIndex}: Props) { const dimensions = useMemo(() => { return { - width: winSize.innerWidth - menuWidth - scrollBarDelta, + width: winSize.innerWidth - menuWidth - scrollbarWidth, height: winSize.innerHeight - headerHeight - 2, }; }, [winSize]); @@ -147,8 +145,8 @@ export default function AssetView({modalIndex}: Props) { sx={{ overflowY: 'auto', height: dimensions.height, - width: dimensions.width + scrollBarDelta, - maxWidth: dimensions.width + scrollBarDelta, + width: dimensions.width + scrollbarWidth, + maxWidth: dimensions.width + scrollbarWidth, }} >

- + {children} diff --git a/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx b/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx index 472a76306..50117aeb6 100644 --- a/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx +++ b/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx @@ -11,6 +11,7 @@ import {CssBaseline, GlobalStyles} from '@mui/material'; import {useAuth} from '@alchemy/react-auth'; import {ThemeEditorProvider} from '@alchemy/theme-editor'; import {Classes} from '../../../classes.ts'; +import {scrollbarWidth} from "../../../constants.ts"; const sessionStorageKey = 'userPrefs'; @@ -74,8 +75,6 @@ export default function UserPreferencesProvider({children}: Props) { }; }, [preferences, updatePreference]); - const scrollbarWidth = 8; - return ( (); @@ -70,7 +69,6 @@ export default function WorkflowView({modalIndex}: Props) { maxHeight: headerHeight, }, }} - modalIndex={modalIndex} fullScreen={true} title={ >(); @@ -83,6 +85,9 @@ export default function OverlayOutlet({ path={finalUrl} queryParam={queryParam} routes={routes} + options={{ + RouteProxyComponent, + }} /> : ''} diff --git a/lib/js/navigation/src/useNavigateToOverlay.tsx b/lib/js/navigation/src/useNavigateToOverlay.tsx index 2fd74a3d6..e06f88962 100644 --- a/lib/js/navigation/src/useNavigateToOverlay.tsx +++ b/lib/js/navigation/src/useNavigateToOverlay.tsx @@ -1,18 +1,18 @@ -import {useLocation, useNavigate} from "react-router-dom"; +import {useLocation, useNavigate, NavigateOptions} from "react-router-dom"; import React from "react"; import {RouteDefinition, RouteParameters} from "./types"; import {getPath} from "./Router"; -export type NavigateToOverlayFunction = (route: RouteDefinition, params?: RouteParameters) => void; -export type CloseOverlayFunction = () => void; +export type NavigateToOverlayFunction = (route: RouteDefinition, params?: RouteParameters, options?: NavigateOptions) => void; +export type CloseOverlayFunction = (options?: NavigateOptions) => void; export function useNavigateToOverlay(queryParam: string): NavigateToOverlayFunction { const navigate = useNavigate(); - return React.useCallback((route, params) => { + return React.useCallback((route, params, options) => { navigate({ search: `${queryParam}=${getPath(route, params)}`, - }); + }, options); }, []); } @@ -20,13 +20,13 @@ export function useCloseOverlay(queryParam: string): CloseOverlayFunction { const navigate = useNavigate(); const location = useLocation(); - return React.useCallback(() => { + return React.useCallback((options) => { const searchParams = new URLSearchParams(location.search); searchParams.delete(queryParam); navigate({ pathname: location.pathname, search: searchParams.toString(), - }); + }, options); }, [navigate, location]); }