From dbe80eb25c1e48f1f9c7bfb796c381440adaaaf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Mon, 17 Jun 2024 17:25:03 +0200 Subject: [PATCH 01/84] Add dataset metadata input to details view in dashboard --- app/controllers/DatasetController.scala | 1 + app/models/dataset/Dataset.scala | 9 +- frontend/javascripts/admin/admin_rest_api.ts | 1 + .../advanced_dataset/dataset_table.tsx | 86 +-------- .../dashboard/folders/details_sidebar.tsx | 178 ++++++++++++++++-- frontend/javascripts/libs/react_hooks.ts | 15 ++ frontend/javascripts/types/api_flow_types.ts | 4 +- frontend/stylesheets/_dashboard.less | 6 +- 8 files changed, 198 insertions(+), 102 deletions(-) diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index c955e16257c..cfecab6e246 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -35,6 +35,7 @@ case class DatasetUpdateParameters( sortingKey: Option[Instant], isPublic: Option[Boolean], tags: Option[List[String]], + details: Option[JsObject], folderId: Option[ObjectId] ) diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index dc5859ff6fe..4de71fcfe66 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -75,6 +75,7 @@ case class DatasetCompactInfo( lastUsedByUser: Instant, status: String, tags: List[String], + details: Option[JsObject], isUnreported: Boolean, colorLayerNames: List[String], segmentationLayerNames: List[String], @@ -265,6 +266,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA COALESCE(lastUsedTimes.lastUsedTime, ${Instant.zero}), d.status, d.tags, + d.details, cl.names AS colorLayerNames, sl.names AS segmentationLayerNames FROM @@ -294,6 +296,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA String, String, String, + String, String)]) } yield rows.toList.map( @@ -310,9 +313,10 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA lastUsedByUser = row._9, status = row._10, tags = parseArrayLiteral(row._11), + details = JsonHelper.parseAndValidateJson[JsObject](row._12), isUnreported = unreportedStatusList.contains(row._10), - colorLayerNames = parseArrayLiteral(row._12), - segmentationLayerNames = parseArrayLiteral(row._13) + colorLayerNames = parseArrayLiteral(row._13), + segmentationLayerNames = parseArrayLiteral(row._14) )) private def buildSelectionPredicates(isActiveOpt: Option[Boolean], @@ -473,6 +477,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA params.isPublic.map(v => q"isPublic = $v"), params.tags.map(v => q"tags = $v"), params.folderId.map(v => q"_folder = $v"), + params.details.map(v => q"details = $v"), ).flatten if (setQueries.isEmpty) { Fox.successful(()) diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index 04726e1dfbe..955cd385cd2 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -1174,6 +1174,7 @@ export type DatasetUpdater = { isPublic?: boolean; tags?: string[]; folderId?: string; + details?: APIDataset["details"]; }; export function updateDatasetPartial( diff --git a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx index 8ace3958fdf..8cfe0ccdbe5 100644 --- a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx +++ b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx @@ -1,4 +1,4 @@ -import { FileOutlined, FolderOpenOutlined, PlusOutlined, WarningOutlined } from "@ant-design/icons"; +import { FileOutlined, FolderOpenOutlined } from "@ant-design/icons"; import { Link } from "react-router-dom"; import { Dropdown, MenuProps, TableProps, Tag, Tooltip } from "antd"; import type { FilterValue, SorterResult, TablePaginationConfig } from "antd/lib/table/interface"; @@ -14,12 +14,9 @@ import type { } from "types/api_flow_types"; import { type DatasetFilteringMode } from "dashboard/dataset_view"; import { stringToColor } from "libs/format_utils"; -import { trackAction } from "oxalis/model/helpers/analytics"; -import CategorizationLabel from "oxalis/view/components/categorization_label"; import DatasetActionView, { getDatasetActionContextMenu, } from "dashboard/advanced_dataset/dataset_action_view"; -import EditableTextIcon from "oxalis/view/components/editable_text_icon"; import FormattedDate from "components/formatted_date"; import * as Utils from "libs/utils"; import FixedExpandableTable from "components/fixed_expandable_table"; @@ -309,7 +306,6 @@ class DatasetRenderer { {this.data.name} - {this.renderTags()} {this.datasetTable.props.context.globalSearchQuery != null ? ( <>
@@ -320,23 +316,6 @@ class DatasetRenderer { ); } - renderTags() { - return this.data.isActive ? ( - - ) : ( - - - - ); - } renderCreationDateColumn() { return ; } @@ -766,69 +745,6 @@ class DatasetTable extends React.PureComponent { } } -export function DatasetTags({ - dataset, - onClickTag, - updateDataset, -}: { - dataset: APIDatasetCompact; - onClickTag?: (t: string) => void; - updateDataset: (id: APIDatasetId, updater: DatasetUpdater) => void; -}) { - const editTagFromDataset = ( - shouldAddTag: boolean, - tag: string, - event: React.SyntheticEvent, - ): void => { - event.stopPropagation(); // prevent the onClick event - - if (!dataset.isActive) { - console.error( - `Tags can only be modified for active datasets. ${dataset.name} is not active.`, - ); - return; - } - let updater = {}; - if (shouldAddTag) { - if (!dataset.tags.includes(tag)) { - updater = { - tags: [...dataset.tags, tag], - }; - } - } else { - const newTags = _.without(dataset.tags, tag); - updater = { - tags: newTags, - }; - } - - trackAction("Edit dataset tag"); - updateDataset(dataset, updater); - }; - - return ( -
- {dataset.tags.map((tag) => ( - - ))} - {dataset.isEditable ? ( - } - onChange={_.partial(editTagFromDataset, true)} - label="Add Tag" - /> - ) : null} -
- ); -} - export function DatasetLayerTags({ dataset }: { dataset: APIMaybeUnimportedDataset }) { return (
diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 0f816a92e22..7fa97c46cdd 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -4,8 +4,10 @@ import { SearchOutlined, EditOutlined, LoadingOutlined, + DeleteOutlined, + PlusCircleOutlined, } from "@ant-design/icons"; -import { Result, Spin, Tag, Tooltip } from "antd"; +import { Button, Typography, Input, Result, Spin, Table, Tag, Tooltip } from "antd"; import { stringToColor, formatCountToDataAmountUnit } from "libs/format_utils"; import { pluralize } from "libs/utils"; import _ from "lodash"; @@ -14,15 +16,19 @@ import { OwningOrganizationRow, VoxelSizeRow, } from "oxalis/view/right-border-tabs/dataset_info_tab_view"; -import React, { useEffect } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { APIDatasetCompact, Folder } from "types/api_flow_types"; -import { DatasetLayerTags, DatasetTags, TeamTags } from "../advanced_dataset/dataset_table"; -import { useDatasetCollectionContext } from "../dataset/dataset_collection_context"; +import { DatasetLayerTags, TeamTags } from "../advanced_dataset/dataset_table"; +import { + DatasetCollectionContextValue, + useDatasetCollectionContext, +} from "../dataset/dataset_collection_context"; import { SEARCH_RESULTS_LIMIT, useDatasetQuery, useFolderQuery } from "../dataset/queries"; import { useSelector } from "react-redux"; import { OxalisState } from "oxalis/store"; import { getOrganization } from "admin/admin_rest_api"; import { useQuery } from "@tanstack/react-query"; +import { useEffectOnUpdate } from "libs/react_hooks"; export function DetailsSidebar({ selectedDatasets, @@ -59,6 +65,7 @@ export function DetailsSidebar({ setSelectedDataset(null); } }, [selectedDatasets, context.activeFolderId]); + console.log("showing2", selectedDatasets?.[0]); return (
@@ -86,9 +93,157 @@ function getMaybeSelectMessage(datasetCount: number) { return datasetCount > 0 ? "Select one to see details." : ""; } -function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompact }) { +function MetadataTable({ + selectedDataset, + setIgnoreFetching, +}: { selectedDataset: APIDatasetCompact; setIgnoreFetching: (value: boolean) => void }) { const context = useDatasetCollectionContext(); + const [datasetDetails, setDatasetDetails] = useState( + selectedDataset.details, + ); + const [errors, setErrors] = useState>({}); // propName -> error message. + const [focusedRow, setFocusedRow] = useState(null); + + const updateCachedDatasetDebounced = useMemo( + () => + _.debounce( + async ( + context: DatasetCollectionContextValue, + selectedDataset: APIDatasetCompact, + datasetDetails: APIDatasetCompact["details"], + ) => { + console.log("updating", selectedDataset, datasetDetails); + // Explicitly ignoring fetching here to avoid unnecessary rendering of the loading spinner and thus hiding the metadata table. + setIgnoreFetching(true); + await context.updateCachedDataset(selectedDataset, { details: datasetDetails }); + setIgnoreFetching(false); + }, + 2000, + ), + [setIgnoreFetching], + ); + + useEffectOnUpdate(() => { + updateCachedDatasetDebounced(context, selectedDataset, datasetDetails); + }, [datasetDetails]); + + const updatePropName = (previousPropName: string, newPropName: string) => { + setDatasetDetails((prev) => { + if (prev && newPropName in prev) { + setErrors((prev) => ({ + ...prev, + [previousPropName]: `Property ${newPropName} already exists.`, + })); + return prev; + } + if (prev && previousPropName in prev) { + const { [previousPropName]: value, ...rest } = prev; + setErrors((prev) => { + const { [previousPropName]: _, ...rest } = prev; + return rest; + }); + return { ...rest, [newPropName]: value }; + } + return { ...prev, [newPropName]: "" }; + }); + }; + const updateValue = (propName: string, newValue: string) => { + setDatasetDetails((prev) => { + if (prev) { + return { ...prev, [propName]: newValue }; + } + return { key: newValue }; + }); + }; + + const deleteKey = (propName: string) => { + setDatasetDetails((prev) => { + if (prev) { + const { [propName]: _, ...rest } = prev; + return rest; + } + return prev; + }); + }; + + const columnData = + // "": "" is added to always have a row for adding new metadata. + Object.entries({ ...datasetDetails, "": "" }).map(([propName, value], index) => ({ + propName, + value, + key: index, + })); + + return ( +
+
Metadata
+ { + const error = errors[propName] ? ( + {errors[propName]} + ) : null; + return ( + <> + setFocusedRow(record.key)} + variant={record.key === focusedRow ? "outlined" : "borderless"} + value={propName} + onChange={(evt) => updatePropName(propName, evt.target.value)} + /> +
+ {error} + + ); + }, + }, + { + title: "Value", + dataIndex: "value", + className: "top-aligned-column", // Needed in case of an error in the propName column. + render: (value, record) => ( + setFocusedRow(record.key)} + onBlur={() => setFocusedRow(null)} + variant={record.key === focusedRow ? "outlined" : "borderless"} + value={value} + onChange={(evt) => updateValue(record.propName, evt.target.value)} + /> + ), + }, + { + title: "", + key: "del", + render: (_, record) => deleteKey(record.propName)} />, + }, + ]} + pagination={false} + size="small" + /> +
+ +
+ + ); +} + +function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompact }) { const { data: fullDataset, isFetching } = useDatasetQuery(selectedDataset); + const [ignoreFetching, setIgnoreFetching] = useState(false); const activeUser = useSelector((state: OxalisState) => state.activeUser); const { data: owningOrganization } = useQuery( ["organizations", selectedDataset.owningOrganization], @@ -117,7 +272,7 @@ function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompac return ( <>

- {isFetching ? ( + {isFetching && !ignoreFetching ? ( ) : ( @@ -174,13 +329,6 @@ function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompac - {selectedDataset.isActive ? ( -
-
Tags
- -
- ) : null} - {fullDataset?.usedStorageBytes && fullDataset.usedStorageBytes > 10000 ? (
Used Storage
@@ -189,6 +337,10 @@ function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompac
) : null} + + {selectedDataset.isActive ? ( + + ) : null} ); } diff --git a/frontend/javascripts/libs/react_hooks.ts b/frontend/javascripts/libs/react_hooks.ts index 12ed35e27cf..f225dbba4b3 100644 --- a/frontend/javascripts/libs/react_hooks.ts +++ b/frontend/javascripts/libs/react_hooks.ts @@ -200,3 +200,18 @@ export function useEffectOnlyOnce(callback: () => void | (() => void)) { return callback(); }, []); } + +export function useEffectOnUpdate( + callback: () => void | (() => void), + dependencies: React.DependencyList, +) { + const isInitialRunRef = useRef(true); + // biome-ignore lint/correctness/useExhaustiveDependencies: Not updating on callback recompilation. + useEffect(() => { + if (isInitialRunRef.current) { + isInitialRunRef.current = false; + return; + } + return callback(); + }, dependencies); +} diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index c8969102265..359837ef9f5 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -167,7 +167,7 @@ export type APIDatasetDetails = { readonly species?: string; readonly brainRegion?: string; readonly acquisition?: string; -}; +} & Record; type MutableAPIDatasetBase = MutableAPIDatasetId & { isUnreported: boolean; @@ -220,6 +220,7 @@ export type APIDatasetCompactWithoutStatusAndLayerNames = Pick< | "lastUsedByUser" | "tags" | "isUnreported" + | "details" >; export type APIDatasetCompact = APIDatasetCompactWithoutStatusAndLayerNames & { id?: string; @@ -245,6 +246,7 @@ export function convertDatasetToCompact(dataset: APIDataset): APIDatasetCompact lastUsedByUser: dataset.lastUsedByUser, status: dataset.dataSource.status, tags: dataset.tags, + details: dataset.details, isUnreported: dataset.isUnreported, colorLayerNames: colorLayerNames, segmentationLayerNames: segmentationLayerNames, diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index 928bb1e1b74..c5b7b401ad9 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -212,7 +212,7 @@ pre.dataset-import-folder-structure-hint { font-size: 16px; font-weight: 500; display: block; - min-height: 30px + min-height: 30px; } .dataset-table-name-container { @@ -220,3 +220,7 @@ pre.dataset-import-folder-structure-hint { vertical-align: middle; max-width: 520px; } + +.top-aligned-column { + vertical-align: top; +} From 4ab38dc8e11baed028d0461e47fcaa9de905898f Mon Sep 17 00:00:00 2001 From: Tom Herold Date: Wed, 12 Jun 2024 15:19:06 +0200 Subject: [PATCH 02/84] update docs --- docs/wkw.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/wkw.md b/docs/wkw.md index 783d6cb19f9..4c61922448e 100644 --- a/docs/wkw.md +++ b/docs/wkw.md @@ -79,4 +79,4 @@ volumetracing.zip # A ZIP file containing the volume annotation └─ volumetracing.nml # Annotation metadata NML file ``` -After unzipping the archives, the WKW files can be read or modified with the [WEBKNOSSOS Python library](http://localhost:8197/webknossos-py/examples/load_annotation_from_file.html). \ No newline at end of file +After unzipping the archives, the WKW files can be read or modified with the [WEBKNOSSOS Python library](https://docs.webknossos.org/webknossos-py/examples/load_annotation_from_file.html). \ No newline at end of file From 242cf1d442cc352d658ce831da669f851ac3d0c9 Mon Sep 17 00:00:00 2001 From: MichaelBuessemeyer <39529669+MichaelBuessemeyer@users.noreply.github.com> Date: Wed, 12 Jun 2024 17:42:15 +0200 Subject: [PATCH 03/84] Improve mobile support of views (#7876) * make login, registration and forgot password view more responsive to mobile devices - includes adding a link back to login for password reset view * make dashboard dataset view less awful * add changelog entry --- CHANGELOG.unreleased.md | 1 + frontend/javascripts/admin/auth/login_view.tsx | 2 +- frontend/javascripts/admin/auth/registration_view.tsx | 4 ++-- frontend/javascripts/admin/auth/start_reset_password_view.tsx | 3 ++- frontend/javascripts/components/brain_spinner.tsx | 2 +- frontend/javascripts/dashboard/dataset_folder_view.tsx | 4 ++-- frontend/stylesheets/main.less | 4 ++-- 7 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 19556d5c46f..69eef2c9f0b 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -20,6 +20,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Changed - The "WEBKNOSSOS Changelog" modal now lazily loads its content potentially speeding up the initial loading time of WEBKNOSSOS and thus improving the UX. [#7843](https://github.com/scalableminds/webknossos/pull/7843) - Updated the min max settings for the histogram to allow floating point color layers to have negative min / max values. [#7873](https://github.com/scalableminds/webknossos/pull/7873) +- Made the login, registration, forgot password and dataset dashboard pages more mobile friendly. [#7876](https://github.com/scalableminds/webknossos/pull/7876) - From now on only project owner get a notification email upon project overtime. The organization specific email list `overTimeMailingList` was removed. [#7842](https://github.com/scalableminds/webknossos/pull/7842) - Replaced skeleton comment tab component with antd's ``component. [#7802](https://github.com/scalableminds/webknossos/pull/7802) diff --git a/frontend/javascripts/admin/auth/login_view.tsx b/frontend/javascripts/admin/auth/login_view.tsx index 88da77c3552..a1ec6980e26 100644 --- a/frontend/javascripts/admin/auth/login_view.tsx +++ b/frontend/javascripts/admin/auth/login_view.tsx @@ -29,7 +29,7 @@ function LoginView({ history, redirect }: Props) { return ( -

+

Login

diff --git a/frontend/javascripts/admin/auth/registration_view.tsx b/frontend/javascripts/admin/auth/registration_view.tsx index fc16fbe37ec..587d7fcfae2 100644 --- a/frontend/javascripts/admin/auth/registration_view.tsx +++ b/frontend/javascripts/admin/auth/registration_view.tsx @@ -83,7 +83,7 @@ function RegistrationViewGeneric() {
- +

Sign Up

{content} Already have an account? Login instead. @@ -99,7 +99,7 @@ function RegistrationViewWkOrg() { return (
- +

Sign Up

{ diff --git a/frontend/javascripts/admin/auth/start_reset_password_view.tsx b/frontend/javascripts/admin/auth/start_reset_password_view.tsx index fa3b743ae3a..b2194cfcfa7 100644 --- a/frontend/javascripts/admin/auth/start_reset_password_view.tsx +++ b/frontend/javascripts/admin/auth/start_reset_password_view.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { RouteComponentProps, withRouter } from "react-router-dom"; +import { Link, RouteComponentProps, withRouter } from "react-router-dom"; import { Form, Input, Button, Col, Row, Card } from "antd"; import { MailOutlined } from "@ant-design/icons"; import Request from "libs/request"; @@ -61,6 +61,7 @@ function StartResetPasswordView({ history }: Props) { + Back to Login
diff --git a/frontend/javascripts/components/brain_spinner.tsx b/frontend/javascripts/components/brain_spinner.tsx index a222f5152d0..ecb0cf17e09 100644 --- a/frontend/javascripts/components/brain_spinner.tsx +++ b/frontend/javascripts/components/brain_spinner.tsx @@ -106,7 +106,7 @@ export function CoverWithLogin({ onLoggedIn }: { onLoggedIn: () => void }) { }} align="middle" > -
+

Try logging in to view the dataset.

diff --git a/frontend/javascripts/dashboard/dataset_folder_view.tsx b/frontend/javascripts/dashboard/dataset_folder_view.tsx index 75d68e30a3e..d314e8d7dc1 100644 --- a/frontend/javascripts/dashboard/dataset_folder_view.tsx +++ b/frontend/javascripts/dashboard/dataset_folder_view.tsx @@ -203,7 +203,7 @@ function DatasetFolderViewInner(props: Props) {
-
+
ul { From 3c139fe222624a30a3335b768ddc795ff39c0c9e Mon Sep 17 00:00:00 2001 From: MichaelBuessemeyer <39529669+MichaelBuessemeyer@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:16:41 +0200 Subject: [PATCH 04/84] Mapping in default view config (#7858) * allow viewing a mapping * WIP: enable setting default active mappings in default view configuration * add default active mapping to default view configuration - add config settings to dataset settings default view config tab - auto load initial default mapping * fix bottom margin of layer settings - was broken in view mode with multiple segmentation layers; the "Save View Config..." Button had no spacing to towards * fix having the mapping settings enabled for layers with default mapping * WIP: move default mapping store proeprty to dataset layer config - undo moving mapping selection to a shared component * moved default active mapping from separate select to view config layer config - Also added validation check whether the mapping exists in the dataset view config settings * allow saving no default active mapping from view mode & model init to new dataset config version * rename defaultMapping to mapping * cache available mappings fetched from backend when checking config in dataset settings * remove logging statements * clean up * add changelog entry * apply pr feedback --- CHANGELOG.unreleased.md | 1 + .../dataset/dataset_settings_view.tsx | 5 +- .../dataset_settings_viewconfig_tab.tsx | 118 ++++++++++++++++-- frontend/javascripts/messages.tsx | 3 + .../oxalis/model_initialization.ts | 19 +++ frontend/javascripts/oxalis/store.ts | 1 + .../left-border-tabs/layer_settings_tab.tsx | 50 ++++++-- .../dataset_view_configuration.schema.ts | 14 +++ 8 files changed, 191 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 69eef2c9f0b..3daec7a7108 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -12,6 +12,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added - Added the option for the owner to lock explorative annotations. Locked annotations cannot be modified by any user. An annotation can be locked in the annotations table and when viewing the annotation via the navbar dropdown menu. [#7801](https://github.com/scalableminds/webknossos/pull/7801) +- Added the option to set a default mapping for a dataset in the dataset view configuration. The default mapping is loaded when the dataset is opened and the user / url does not configure something else. [#7858](https://github.com/scalableminds/webknossos/pull/7858) - Uploading an annotation into a dataset that it was not created for now also works if the dataset is in a different organization. [#7816](https://github.com/scalableminds/webknossos/pull/7816) - When downloading + reuploading an annotation that is based on a segmentation layer with active mapping, that mapping is now still be selected after the reupload. [#7822](https://github.com/scalableminds/webknossos/pull/7822) - In the Voxelytics workflow list, the name of the WEBKNOSSOS user who started the job is displayed. [#7794](https://github.com/scalableminds/webknossos/pull/7795) diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx index 05fb176b619..b6137353e2e 100644 --- a/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx +++ b/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx @@ -890,7 +890,10 @@ class DatasetSettingsView extends React.PureComponent
updatePropName(propName, evt.target.value)} + placeholder="New property" + size="small" /> {error != null && error[0] === propName ? ( <> @@ -215,6 +217,8 @@ function MetadataTable({ variant={record.key === focusedRow ? "outlined" : "borderless"} value={value} onChange={(evt) => updateValue(record.propName, evt.target.value)} + placeholder="Value" + size="small" /> ), }, @@ -223,7 +227,10 @@ function MetadataTable({ key: "del", render: (_, record) => record.propName === "" ? null : ( - deleteKey(record.propName)} /> + deleteKey(record.propName)} + style={{ color: "var(--ant-table-header-icon-color)" }} + /> ), }, ]} diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index c5b7b401ad9..96adbe969e6 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -224,3 +224,11 @@ pre.dataset-import-folder-structure-hint { .top-aligned-column { vertical-align: top; } +.metadata-table { + th.ant-table-cell { + padding: 4px !important; + } + .ant-table-cell { + padding: 4px 4px 4px 0px !important; + } +} From eaaaaeab0d87c89713c3623d074fb87d245e0358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 26 Jun 2024 14:27:51 +0200 Subject: [PATCH 19/84] WIP: use new json schema to save dataset & folder details and re-style metadata table --- .../dashboard/folders/details_sidebar.tsx | 238 +++++++++++++----- frontend/javascripts/types/api_flow_types.ts | 8 +- tools/postgres/schema.sql | 6 +- 3 files changed, 179 insertions(+), 73 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 3645b30a343..2ef950fb3a5 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -5,6 +5,7 @@ import { EditOutlined, LoadingOutlined, DeleteOutlined, + PlusOutlined, } from "@ant-design/icons"; import { Typography, Input, Result, Spin, Table, Tag, Tooltip } from "antd"; import { stringToColor, formatCountToDataAmountUnit } from "libs/format_utils"; @@ -16,7 +17,7 @@ import { VoxelSizeRow, } from "oxalis/view/right-border-tabs/dataset_info_tab_view"; import React, { useEffect, useState } from "react"; -import { APIDatasetCompact, APIDetails, Folder } from "types/api_flow_types"; +import { APIDatasetCompact, APIDetail, APIDetails, Folder } from "types/api_flow_types"; import { DatasetLayerTags, TeamTags } from "../advanced_dataset/dataset_table"; import { DatasetCollectionContextValue, @@ -123,9 +124,9 @@ function MetadataTable({ setIgnoreFetching: (value: boolean) => void; }) { const context = useDatasetCollectionContext(); - const [details, setDetails] = useState(selectedDatasetOrFolder.details || {}); - const [error, setError] = useState<[string, string] | null>(null); // propName -> error message. - const [focusedRow, setFocusedRow] = useState(null); + const [details, setDetails] = useState(selectedDatasetOrFolder.details || []); + const [error, setError] = useState<[string, string] | null>(null); // [propName, error message] + const [focusedRow, setFocusedRow] = useState(null); useEffectOnUpdate(() => { updateCachedDatasetOrFolderDebounced( @@ -138,105 +139,204 @@ function MetadataTable({ const updatePropName = (previousPropName: string, newPropName: string) => { setDetails((prev) => { - if (prev && newPropName in prev) { + const entry = prev.find((prop) => prop.key === previousPropName); + const maybeAlreadyExistingEntry = prev.find((prop) => prop.key === newPropName); + if (maybeAlreadyExistingEntry) { setError([previousPropName, `Property ${newPropName} already exists.`]); return prev; } - if (prev && previousPropName in prev) { + if (entry) { setError(null); - const { [previousPropName]: value, ...rest } = prev; - return { ...rest, [newPropName]: value }; + const detailsWithoutEditedEntry = prev.filter((prop) => prop.key !== previousPropName); + return [ + ...detailsWithoutEditedEntry, + { + ...entry, + key: newPropName, + }, + ]; + } else { + const highestIndex = prev.reduce((acc, curr) => Math.max(acc, curr.index), 0); + const newEntry: APIDetail = { + key: newPropName, + value: "", + type: "string", + index: highestIndex + 1, + }; + return [...prev, newEntry]; } - return { ...prev, [newPropName]: "" }; }); }; const updateValue = (propName: string, newValue: string) => { setDetails((prev) => { - if (prev) { - return { ...prev, [propName]: newValue }; + const entry = prev.find((prop) => prop.key === propName); + if (!entry) { + return prev; } - return { key: newValue }; + const updatedEntry = { ...entry, value: newValue }; + const detailsWithoutEditedEntry = prev.filter((prop) => prop.key !== propName); + return [...detailsWithoutEditedEntry, updatedEntry]; }); }; const deleteKey = (propName: string) => { setDetails((prev) => { - if (prev) { - const { [propName]: _, ...rest } = prev; - return rest; - } - return prev; + return prev.filter((prop) => prop.key !== propName); }); }; + debugger; - const columnData = - // "": "" is added to always have a row for adding new metadata. - Object.entries({ ...details, "": "" }).map(([propName, value], index) => ({ - propName, - value, - key: index, - })); - return ( -
-
Metadata
-
( - <> + const sortedDetails = details.sort((a, b) => a.index - b.index); + + // Not using AntD Table to have more control over the styling. + const alternativeTable = ( +
+
+ + + + + + + + {sortedDetails.map((record) => ( + + + + + + + ))} + +
+ + Property + + + Value + +
+ setFocusedRow(record.key)} onBlur={() => setFocusedRow(null)} variant={record.key === focusedRow ? "outlined" : "borderless"} - value={propName} - onChange={(evt) => updatePropName(propName, evt.target.value)} + value={record.key} + onChange={(evt) => updatePropName(record.key, evt.target.value)} placeholder="New property" size="small" /> - {error != null && error[0] === propName ? ( + {error != null && error[0] === record.key ? ( <>
{error[1]} ) : null} - - ), - }, - { - title: "Value", - dataIndex: "value", - className: "top-aligned-column", // Needed in case of an error in the propName column. - render: (value, record) => ( - setFocusedRow(record.key)} - onBlur={() => setFocusedRow(null)} - variant={record.key === focusedRow ? "outlined" : "borderless"} - value={value} - onChange={(evt) => updateValue(record.propName, evt.target.value)} - placeholder="Value" - size="small" - /> - ), - }, - { - title: "", - key: "del", - render: (_, record) => - record.propName === "" ? null : ( +
: + setFocusedRow(record.key)} + onBlur={() => setFocusedRow(null)} + variant={record.key === focusedRow ? "outlined" : "borderless"} + value={record.value} + onChange={(evt) => updateValue(record.key, evt.target.value)} + placeholder="Value" + size="small" + /> + + deleteKey(record.key)} + style={{ + color: "var(--ant-color-text-tertiary)", + visibility: record.key === "" ? "hidden" : "visible", + }} + disabled={record.key === ""} + /> +
+
+
+ updatePropName("", "")} + /> +
+
+
+ ); + return ( +
+
Metadata
+ {/*
+ ( + <> + setFocusedRow(record.key)} + onBlur={() => setFocusedRow(null)} + variant={record.key === focusedRow ? "outlined" : "borderless"} + value={propName} + onChange={(evt) => updatePropName(propName, evt.target.value)} + placeholder="New property" + size="small" + /> + {error != null && error[0] === propName ? ( + <> +
+ {error[1]} + + ) : null} + + ), + }, + { + title: "Value", + dataIndex: "value", + className: "top-aligned-column", // Needed in case of an error in the propName column. + render: (value, record) => ( + setFocusedRow(record.key)} + onBlur={() => setFocusedRow(null)} + variant={record.key === focusedRow ? "outlined" : "borderless"} + value={value} + onChange={(evt) => updateValue(record.propName, evt.target.value)} + placeholder="Value" + size="small" + /> + ), + }, + { + title: "", + key: "del", + render: (_, record) => ( deleteKey(record.propName)} - style={{ color: "var(--ant-table-header-icon-color)" }} + style={{ + color: "var(--ant-table-header-icon-color)", + visibility: record.propName === "" ? "hidden" : "visible", + }} + disabled={record.propName === ""} /> ), - }, - ]} - pagination={false} - size="small" - /> + }, + ]} + pagination={false} + size="small" + /> + */} + {alternativeTable} ); } diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 3ec73739b28..f9dc9930099 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -163,7 +163,13 @@ export type MutableAPIDatasetId = { name: string; }; export type APIDatasetId = Readonly; -export type APIDetails = Record; +export type APIDetail = { + type: "number" | "string" | "string[]"; + key: string; + value: string | number | string[]; + index: number; +}; +export type APIDetails = APIDetail[]; type MutableAPIDatasetBase = MutableAPIDatasetId & { isUnreported: boolean; diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index 678f8b3fd90..13f95cb8d51 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -128,7 +128,7 @@ CREATE TABLE webknossos.datasets( UNIQUE (name, _organization), CONSTRAINT defaultViewConfigurationIsJsonObject CHECK(jsonb_typeof(defaultViewConfiguration) = 'object'), CONSTRAINT adminViewConfigurationIsJsonObject CHECK(jsonb_typeof(adminViewConfiguration) = 'object'), - CONSTRAINT detailsIsJsonObject CHECK(jsonb_typeof(details) = 'object') + CONSTRAINT detailsIsJsonObject CHECK(jsonb_typeof(details) = 'array') ); CREATE TYPE webknossos.DATASET_LAYER_CATEGORY AS ENUM ('color', 'mask', 'segmentation'); @@ -516,8 +516,8 @@ CREATE TABLE webknossos.folders( _id CHAR(24) PRIMARY KEY, name TEXT NOT NULL CHECK (name !~ '/'), isDeleted BOOLEAN NOT NULL DEFAULT false, - details JSONB DEFAULT '{}', - CONSTRAINT detailsIsJsonObject CHECK(jsonb_typeof(details) = 'object') + details JSONB DEFAULT '[]', + CONSTRAINT detailsIsJsonObject CHECK(jsonb_typeof(details) = 'array') ); CREATE TABLE webknossos.folder_paths( From 846b5003d91551d8ef9596e1fed9304433dd941e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 27 Jun 2024 17:37:38 +0200 Subject: [PATCH 20/84] fix backend to work with new type of details dataset / folder field & WIP restyling of metadata / details table --- app/controllers/FolderController.scala | 4 +- app/controllers/InitialDataController.scala | 4 +- app/models/dataset/Dataset.scala | 9 +-- app/models/dataset/DatasetService.scala | 7 +- app/models/folder/Folder.scala | 16 ++--- .../organization/OrganizationService.scala | 4 +- .../dashboard/folders/details_sidebar.tsx | 67 +++++++++++++++---- tools/postgres/schema.sql | 2 +- 8 files changed, 80 insertions(+), 33 deletions(-) diff --git a/app/controllers/FolderController.scala b/app/controllers/FolderController.scala index bb13bb0efe9..414c1005ac0 100644 --- a/app/controllers/FolderController.scala +++ b/app/controllers/FolderController.scala @@ -7,7 +7,7 @@ import models.folder.{Folder, FolderDAO, FolderParameters, FolderService} import models.organization.OrganizationDAO import models.team.{TeamDAO, TeamService} import models.user.UserService -import play.api.libs.json.{JsObject, Json} +import play.api.libs.json.{JsArray, Json} import play.api.mvc.{Action, AnyContent, PlayBodyParsers} import security.WkEnv import utils.ObjectId @@ -104,7 +104,7 @@ class FolderController @Inject()( for { parentIdValidated <- ObjectId.fromString(parentId) _ <- folderService.assertValidFolderName(name) - newFolder = Folder(ObjectId.generate, name, JsObject.empty) + newFolder = Folder(ObjectId.generate, name, JsArray.empty) _ <- folderDAO.findOne(parentIdValidated) ?~> "folder.notFound" _ <- folderDAO.insertAsChild(parentIdValidated, newFolder) ?~> "folder.create.failed" organization <- organizationDAO.findOne(request.identity._organization) ?~> "folder.notFound" diff --git a/app/controllers/InitialDataController.scala b/app/controllers/InitialDataController.scala index 7bf9bd34d53..66ab22755b3 100644 --- a/app/controllers/InitialDataController.scala +++ b/app/controllers/InitialDataController.scala @@ -13,7 +13,7 @@ import models.task.{TaskType, TaskTypeDAO} import models.team._ import models.user._ import net.liftweb.common.{Box, Full} -import play.api.libs.json.{JsObject, Json} +import play.api.libs.json.{JsArray, JsObject, Json} import utils.{ObjectId, StoreModules, WkConf} import javax.inject.Inject @@ -170,7 +170,7 @@ Samplecountry folderDAO.findOne(defaultOrganization._rootFolder).futureBox.flatMap { case Full(_) => Fox.successful(()) case _ => - folderDAO.insertAsRoot(Folder(defaultOrganization._rootFolder, folderService.defaultRootName, JsObject.empty)) + folderDAO.insertAsRoot(Folder(defaultOrganization._rootFolder, folderService.defaultRootName, JsArray.empty)) } private def insertDefaultUser(userEmail: String, diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index 6538f788521..f9a785f10a0 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -55,7 +55,7 @@ case class Dataset(_id: ObjectId, status: String, logoUrl: Option[String], sortingKey: Instant = Instant.now, - details: Option[JsObject] = None, + details: Option[JsArray] = None, tags: List[String] = List.empty, created: Instant = Instant.now, isDeleted: Boolean = false) @@ -77,7 +77,7 @@ case class DatasetCompactInfo( lastUsedByUser: Instant, status: String, tags: List[String], - details: Option[JsObject], + details: Option[JsArray], isUnreported: Boolean, colorLayerNames: List[String], segmentationLayerNames: List[String], @@ -120,7 +120,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA JsonHelper.parseAndValidateJson[DatasetViewConfiguration](_)) adminViewConfigurationOpt <- Fox.runOptional(r.adminviewconfiguration)( JsonHelper.parseAndValidateJson[DatasetViewConfiguration](_)) - details <- Fox.runOptional(r.details)(JsonHelper.parseAndValidateJson[JsObject](_)) + details <- Fox.runOptional(r.details)(JsonHelper.parseAndValidateJson[JsArray](_)) } yield { Dataset( ObjectId(r._Id), @@ -320,7 +320,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA lastUsedByUser = row._9, status = row._10, tags = parseArrayLiteral(row._11), - details = JsonHelper.parseAndValidateJson[JsObject](row._12), + details = JsonHelper.parseAndValidateJson[JsArray](row._12), isUnreported = unreportedStatusList.contains(row._10), colorLayerNames = parseArrayLiteral(row._13), segmentationLayerNames = parseArrayLiteral(row._14) @@ -477,6 +477,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA } yield () def updatePartial(datasetId: ObjectId, params: DatasetUpdateParameters)(implicit ctx: DBAccessContext): Fox[Unit] = { + System.out.println(s"Trying to update a dataset with $DatasetUpdateParameters" ) val setQueries = List( params.description.map(d => q"description = $d"), params.displayName.map(v => q"displayName = $v"), diff --git a/app/models/dataset/DatasetService.scala b/app/models/dataset/DatasetService.scala index f10f9ae9801..3cbbb8120b4 100644 --- a/app/models/dataset/DatasetService.scala +++ b/app/models/dataset/DatasetService.scala @@ -78,7 +78,12 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, implicit val ctx: DBAccessContext = GlobalAccessContext val newId = ObjectId.generate val details = - Json.obj("species" -> "species name", "brainRegion" -> "brain region", "acquisition" -> "acquisition method") + Json.arr( + Json.obj("type" -> "string", "key" -> "species", "value" -> "species name", "index" -> 0), + Json.obj("type" -> "string", "key" -> "brainRegion", "value" -> "brain region", "index" -> 1), + Json.obj("type" -> "string", "key" -> "acquisition", "value" -> "acquisition method", "index" -> 2) + ) + val dataSourceHash = if (dataSource.isUsable) Some(dataSource.hashCode()) else None for { organization <- organizationDAO.findOneByName(owningOrganization) diff --git a/app/models/folder/Folder.scala b/app/models/folder/Folder.scala index c2301442fd1..8606293cf70 100644 --- a/app/models/folder/Folder.scala +++ b/app/models/folder/Folder.scala @@ -8,7 +8,7 @@ import com.typesafe.scalalogging.LazyLogging import models.organization.{Organization, OrganizationDAO} import models.team.{TeamDAO, TeamService} import models.user.User -import play.api.libs.json.{JsObject, Json, OFormat} +import play.api.libs.json.{JsArray, JsObject, Json, OFormat} import slick.jdbc.PostgresProfile.api._ import slick.lifted.Rep import slick.sql.SqlAction @@ -19,11 +19,11 @@ import javax.inject.Inject import scala.annotation.tailrec import scala.concurrent.ExecutionContext -case class Folder(_id: ObjectId, name: String, details: JsObject) +case class Folder(_id: ObjectId, name: String, details: JsArray) -case class FolderWithParent(_id: ObjectId, name: String, details: JsObject, _parent: Option[ObjectId]) +case class FolderWithParent(_id: ObjectId, name: String, details: JsArray, _parent: Option[ObjectId]) -case class FolderParameters(name: String, allowedTeams: List[ObjectId], details: JsObject) +case class FolderParameters(name: String, allowedTeams: List[ObjectId], details: JsArray) object FolderParameters { implicit val jsonFormat: OFormat[FolderParameters] = Json.format[FolderParameters] } @@ -121,7 +121,7 @@ class FolderService @Inject()(teamDAO: TeamDAO, remainingPathNames match { case pathNamesHead :: pathNamesTail => for { - newFolder <- Fox.successful(Folder(ObjectId.generate, pathNamesHead, JsObject.empty)) + newFolder <- Fox.successful(Folder(ObjectId.generate, pathNamesHead, JsArray.empty)) _ <- folderDAO.insertAsChild(parentFolderId, newFolder) folderId <- createMissingFoldersForPathNames(newFolder._id, pathNamesTail) } yield folderId @@ -138,13 +138,13 @@ class FolderDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) protected def parse(r: FoldersRow): Fox[Folder] = for { - details <- JsonHelper.parseAndValidateJson[JsObject](r.details.getOrElse("{}")).toFox + details <- JsonHelper.parseAndValidateJson[JsArray](r.details.getOrElse("[]")).toFox folder <- Fox.successful(Folder(ObjectId(r._Id), r.name, details)) } yield folder private def parseWithParent(t: (String, String, Option[String], Option[String])): Fox[FolderWithParent] = for { - details <- JsonHelper.parseAndValidateJson[JsObject](t._3.getOrElse("{}")).toFox + details <- JsonHelper.parseAndValidateJson[JsArray](t._3.getOrElse("[]")).toFox folderWithParent <- Fox.successful(FolderWithParent(ObjectId(t._1), t._2, details, t._4.map(ObjectId(_)))) } yield folderWithParent @@ -256,7 +256,7 @@ class FolderDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) _ <- run(q"UPDATE webknossos.folders SET name = $name WHERE _id = $folderId".asUpdate) } yield () - def updateDetails(folderId: ObjectId, details: JsObject)(implicit ctx: DBAccessContext): Fox[Unit] = + def updateDetails(folderId: ObjectId, details: JsArray)(implicit ctx: DBAccessContext): Fox[Unit] = for { _ <- assertUpdateAccess(folderId) _ <- run(q"UPDATE webknossos.folders SET details = $details WHERE _id = $folderId".asUpdate) diff --git a/app/models/organization/OrganizationService.scala b/app/models/organization/OrganizationService.scala index 5d4addea013..98392061329 100644 --- a/app/models/organization/OrganizationService.scala +++ b/app/models/organization/OrganizationService.scala @@ -10,7 +10,7 @@ import models.dataset.{DataStore, DataStoreDAO} import models.folder.{Folder, FolderDAO, FolderService} import models.team.{PricingPlan, Team, TeamDAO} import models.user.{Invite, MultiUserDAO, User, UserDAO, UserService} -import play.api.libs.json.{JsObject, Json} +import play.api.libs.json.{JsArray, JsObject, Json} import utils.{ObjectId, WkConf} import scala.concurrent.{ExecutionContext, Future} @@ -111,7 +111,7 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, _ <- bool2Fox(existingOrganization.isEmpty) ?~> "organization.name.alreadyInUse" initialPricingParameters = if (conf.Features.isWkorgInstance) (PricingPlan.Basic, Some(3), Some(50000000000L)) else (PricingPlan.Custom, None, None) - organizationRootFolder = Folder(ObjectId.generate, folderService.defaultRootName, JsObject.empty) + organizationRootFolder = Folder(ObjectId.generate, folderService.defaultRootName, JsArray.empty) organization = Organization( ObjectId.generate, diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 2ef950fb3a5..61fba73fac4 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -126,7 +126,7 @@ function MetadataTable({ const context = useDatasetCollectionContext(); const [details, setDetails] = useState(selectedDatasetOrFolder.details || []); const [error, setError] = useState<[string, string] | null>(null); // [propName, error message] - const [focusedRow, setFocusedRow] = useState(null); + const [focusedRow, setFocusedRow] = useState(null); useEffectOnUpdate(() => { updateCachedDatasetOrFolderDebounced( @@ -184,36 +184,76 @@ function MetadataTable({ return prev.filter((prop) => prop.key !== propName); }); }; - debugger; - const sortedDetails = details.sort((a, b) => a.index - b.index); + const sortedDetails = + details.length > 0 + ? details.sort((a, b) => a.index - b.index) + : [{ key: "", value: "", index: 0, type: "string" as APIDetail["type"] }]; + + const renderType = (type: APIDetail["type"]) => { + switch (type) { + case "string": + return "str"; + case "number": + return "012"; + case "string[]": + return "[]"; + } + }; // Not using AntD Table to have more control over the styling. const alternativeTable = ( -
-
+
+
{sortedDetails.map((record) => ( - - + - + - + From 667872527fb9e1171e04023472fd56af3e5de22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 19 Jul 2024 12:32:10 +0200 Subject: [PATCH 47/84] clean up code for review --- .circleci/not-on-master.sh | 3 +- .../evolutions/118-add-details-to-folders.sql | 4 +- .../javascripts/dashboard/dataset/queries.tsx | 1 + .../dashboard/folders/details_sidebar.tsx | 79 ++++++++++-------- .../dashboard/publication_card.tsx | 4 +- .../volumetracing_saga_integration.spec.js.md | 2 +- ...olumetracing_saga_integration.spec.js.snap | Bin 1476 -> 1488 bytes 7 files changed, 49 insertions(+), 44 deletions(-) diff --git a/.circleci/not-on-master.sh b/.circleci/not-on-master.sh index 0c326ea45c2..581393ebead 100755 --- a/.circleci/not-on-master.sh +++ b/.circleci/not-on-master.sh @@ -4,6 +4,5 @@ set -Eeuo pipefail if [ "${CIRCLE_BRANCH}" == "master" ]; then echo "Skipping this step on master..." else - #exec "$@" - echo "done" + exec "$@" fi diff --git a/conf/evolutions/118-add-details-to-folders.sql b/conf/evolutions/118-add-details-to-folders.sql index d2347932f1d..af5da0a59c9 100644 --- a/conf/evolutions/118-add-details-to-folders.sql +++ b/conf/evolutions/118-add-details-to-folders.sql @@ -19,7 +19,7 @@ SET metadata = CASE metadata || jsonb_build_array( jsonb_build_object( 'type', 'string', - 'index', 2, + 'index', 0, 'key', 'species', 'value', details->>'species' ) @@ -34,7 +34,7 @@ SET metadata = CASE metadata || jsonb_build_array( jsonb_build_object( 'type', 'string', - 'index', 2, + 'index', 1, 'key', 'brainRegion', 'value', details->>'brainRegion' ) diff --git a/frontend/javascripts/dashboard/dataset/queries.tsx b/frontend/javascripts/dashboard/dataset/queries.tsx index 6134939778e..9a7f926f528 100644 --- a/frontend/javascripts/dashboard/dataset/queries.tsx +++ b/frontend/javascripts/dashboard/dataset/queries.tsx @@ -426,6 +426,7 @@ export function useUpdateDatasetMutation(folderId: string | null) { name: updatedDataset.name, owningOrganization: updatedDataset.owningOrganization, }; + // Also update the cached dataset under the key "datasetById". queryClient.setQueryData(["datasetById", updatedDatasetId], updatedDataset); const targetFolderId = updatedDataset.folderId; if (targetFolderId !== folderId) { diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 3757f81577c..a51f3aacb06 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -22,6 +22,7 @@ import { InputNumber, Select, Button, + InputNumberProps, } from "antd"; import { stringToColor, formatCountToDataAmountUnit } from "libs/format_utils"; import { pluralize } from "libs/utils"; @@ -146,10 +147,11 @@ const updateCachedDatasetOrFolderDebounced = _.debounce( metadata: APIMetadataEntries, ) => { isDatasetUpdatePending = false; - // Explicitly ignoring fetching here to avoid unnecessary rendering of the loading spinner and thus hiding the metadata table. if ("folderId" in selectedDatasetOrFolder) { + // In case of a dataset, update the dataset's metadata. await context.updateCachedDataset(selectedDatasetOrFolder, { metadata: metadata }); } else { + // Else update the folders metadata. const folder = selectedDatasetOrFolder; await context.queries.updateFolderMutation.mutateAsync({ ...folder, @@ -161,6 +163,7 @@ const updateCachedDatasetOrFolderDebounced = _.debounce( 3000, ); const originalFlush = updateCachedDatasetOrFolderDebounced.flush; +// Overwrite the debounce flush function to avoid flushing when no update is pending. updateCachedDatasetOrFolderDebounced.flush = async () => { if (!isDatasetUpdatePending) return; isDatasetUpdatePending = false; @@ -231,7 +234,7 @@ function MetadataTable({ }); }; - const updateValue = (index: number, newValue: string | string[]) => { + const updateValue = (index: number, newValue: number | string | string[]) => { setMetadata((prev) => { const entry = prev.find((prop) => prop.index === index); if (!entry) { @@ -248,7 +251,8 @@ function MetadataTable({ const highestIndex = prev.reduce((acc, curr) => Math.max(acc, curr.index), 0); const newEntry: APIMetadata = { key: "", - value: type === APIMetadataType.STRING_ARRAY ? [] : "", + value: + type === APIMetadataType.STRING_ARRAY ? [] : type === APIMetadataType.NUMBER ? 0 : "", index: highestIndex + 1, type, }; @@ -280,44 +284,39 @@ function MetadataTable({ const getValueInput = (record: APIMetadata) => { const isFocused = record.index === focusedRow; + const sharedProps = { + className: isFocused ? undefined : "transparent-input", + onFocus: () => setFocusedRow(record.index), + onBlur: () => setFocusedRow(null), + placeholder: "Value", + size: "small" as InputNumberProps["size"], + }; switch (record.type) { case "number": return ( setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} value={record.value as number} - onChange={(newNum) => updateValue(record.index, newNum?.toString() || "")} - placeholder="Value" - size="small" + onChange={(newNum) => updateValue(record.index, newNum || 0)} + {...sharedProps} /> ); case "string": return ( setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} value={record.value} onChange={(evt) => updateValue(record.index, evt.target.value)} - placeholder="Value" - size="small" + {...sharedProps} /> ); case "string[]": return ( setFocusedRow(record.index)} + onBlur={() => setFocusedRow(null)} + value={record.key} + onChange={(evt) => updatePropName(record.index, evt.target.value)} + placeholder="Property" + size="small" + /> + {error != null && error[0] === record.index ? ( + <> +
+ {error[1]} + + ) : null} + + ); + }; + return (
Metadata
@@ -341,26 +363,9 @@ function MetadataTable({
{sortedDetails.map((record) => { - const isFocused = record.index === focusedRow; return ( - + + + + ); +} + let isDatasetUpdatePending = false; const updateCachedDatasetOrFolderDebounced = _.debounce( async ( @@ -124,11 +134,14 @@ export default function MetadataTable({ useEffectOnUpdate(() => { if (error == null) { const metadataWithoutIndex = metadata.map(({ index: _ignored, ...rest }) => rest); - updateCachedDatasetOrFolderDebouncedTracked( - context, - selectedDatasetOrFolder, - metadataWithoutIndex, - ); + const didMetadataChange = !_.isEqual(metadataWithoutIndex, selectedDatasetOrFolder.metadata); + if (didMetadataChange) { + updateCachedDatasetOrFolderDebouncedTracked( + context, + selectedDatasetOrFolder, + metadataWithoutIndex, + ); + } } }, [metadata, error]); @@ -137,7 +150,7 @@ export default function MetadataTable({ updateCachedDatasetOrFolderDebounced.flush(); }); - const updatePropName = (index: number, newPropName: string) => { + const updateMetadataKey = (index: number, newPropName: string) => { setMetadata((prev) => { const entry = prev.find((prop) => prop.index === index); if (!entry) { @@ -160,7 +173,7 @@ export default function MetadataTable({ }); }; - const updateValue = (index: number, newValue: number | string | string[]) => { + const updateMetadataValue = (index: number, newValue: number | string | string[]) => { setMetadata((prev) => { const entry = prev.find((prop) => prop.index === index); if (!entry) { @@ -172,7 +185,7 @@ export default function MetadataTable({ }); }; - const addType = (type: APIMetadata["type"]) => { + const addNewEntryWithType = (type: APIMetadata["type"]) => { setMetadata((prev) => { const highestIndex = prev.reduce((acc, curr) => Math.max(acc, curr.index), 0); const newEntry: APIMetadataWithIndex = { @@ -182,6 +195,8 @@ export default function MetadataTable({ index: highestIndex + 1, type, }; + // Auto focus the key input of the new entry. + setTimeout(() => document.getElementById(getKeyInputId(newEntry))?.focus(), 50); return [...prev, newEntry]; }); }; @@ -203,11 +218,37 @@ export default function MetadataTable({ return { key: type, label: metadataTypeToString(type as APIMetadata["type"]), - onClick: () => addType(type as APIMetadata["type"]), + onClick: () => addNewEntryWithType(type as APIMetadata["type"]), }; }), }); + const getKeyInputId = (record: APIMetadataWithIndex) => `metadata-key-input-id-${record.index}`; + + const getKeyInput = (record: APIMetadataWithIndex) => { + const isFocused = record.index === focusedRow; + return ( + <> + setFocusedRow(record.index)} + onBlur={() => setFocusedRow(null)} + value={record.key} + onChange={(evt) => updateMetadataKey(record.index, evt.target.value)} + placeholder="Property" + size="small" + id={getKeyInputId(record)} + /> + {error != null && error[0] === record.index ? ( + <> +
+ {error[1]} + + ) : null} + + ); + }; + const getValueInput = (record: APIMetadataWithIndex) => { const isFocused = record.index === focusedRow; const sharedProps = { @@ -222,7 +263,7 @@ export default function MetadataTable({ return ( updateValue(record.index, newNum || 0)} + onChange={(newNum) => updateMetadataValue(record.index, newNum || 0)} {...sharedProps} /> ); @@ -230,7 +271,7 @@ export default function MetadataTable({ return ( updateValue(record.index, evt.target.value)} + onChange={(evt) => updateMetadataValue(record.index, evt.target.value)} {...sharedProps} /> ); @@ -239,7 +280,7 @@ export default function MetadataTable({ setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} - value={record.key} - onChange={(evt) => updatePropName(record.index, evt.target.value)} - placeholder="Property" - size="small" + const getDeleteEntryButton = (record: APIMetadataWithIndex) => ( + + + + + + + ); + + const AddNewMetadataEntryRow = memo(function AddNewMetadataEntryRow() { + return ( + + + ); - }; + }); return (
+
+ Name: {selectedDatasetOrFolder.name} +
Metadata
{/* Not using AntD Table to have more control over the styling. */} @@ -288,40 +355,11 @@ export default function MetadataTable({
- {sortedDetails.map((record) => { - return ( - - - - - - - ); - })} - - - + {sortedDetails.map(renderMetadataRow)} + {sortedDetails.length === 0 && ( + + )} +
- Property + + Property + - Value + + Value +
+
+ {renderType(record.type)} + setFocusedRow(record.key)} + onFocus={() => setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - variant={record.key === focusedRow ? "outlined" : "borderless"} + variant={record.index === focusedRow ? "outlined" : "borderless"} value={record.key} onChange={(evt) => updatePropName(record.key, evt.target.value)} placeholder="New property" @@ -229,9 +269,10 @@ function MetadataTable({ : setFocusedRow(record.key)} + className=".antd-app-theme.ant-input-css-var" + onFocus={() => setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - variant={record.key === focusedRow ? "outlined" : "borderless"} + variant={record.index === focusedRow ? "outlined" : "borderless"} value={record.value} onChange={(evt) => updateValue(record.key, evt.target.value)} placeholder="Value" diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index 93d424b802f..eb5a4c5fcd1 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -519,7 +519,7 @@ CREATE TABLE webknossos.folders( name TEXT NOT NULL CHECK (name !~ '/'), isDeleted BOOLEAN NOT NULL DEFAULT false, details JSONB DEFAULT '[]', - CONSTRAINT detailsIsJsonObject CHECK(jsonb_typeof(details) = 'array') + CONSTRAINT detailsIsJsonArray CHECK(jsonb_typeof(details) = 'array') ); CREATE TABLE webknossos.folder_paths( From e7ed7f8360a35d90b90c2afef5f155e813cfedc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 27 Jun 2024 22:52:34 +0200 Subject: [PATCH 21/84] WIP: improve dataset details table styling and add support for different types --- .../dashboard/folders/details_sidebar.tsx | 97 ++++++++++++++++--- frontend/javascripts/libs/utils.ts | 4 + 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 61fba73fac4..de6e6f3d973 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -7,9 +7,19 @@ import { DeleteOutlined, PlusOutlined, } from "@ant-design/icons"; -import { Typography, Input, Result, Spin, Table, Tag, Tooltip } from "antd"; +import { + Typography, + Input, + Result, + Spin, + Tag, + Tooltip, + Dropdown, + MenuProps, + InputNumber, +} from "antd"; import { stringToColor, formatCountToDataAmountUnit } from "libs/format_utils"; -import { pluralize } from "libs/utils"; +import { parseFloatOrZero, pluralize } from "libs/utils"; import _ from "lodash"; import { DatasetExtentRow, @@ -179,6 +189,34 @@ function MetadataTable({ }); }; + const updateType = (index: number, newType: APIDetail["type"]) => { + setDetails((prev) => { + const entry = prev.find((prop) => prop.index === index); + if (!entry) { + return prev; + } + let updatedEntry = { ...entry, type: newType }; + if (newType === "string[]" && entry.type !== "string[]") { + updatedEntry = { ...updatedEntry, value: [entry.value.toString()] }; + } else if (newType === "number" && entry.type !== "number") { + updatedEntry = { + ...updatedEntry, + value: parseFloatOrZero( + Array.isArray(entry.value) ? entry.value.join(" ") : entry.value.toString(), + ), + }; + } else if (newType === "string" && entry.type !== "string") { + updatedEntry = { + ...updatedEntry, + value: Array.isArray(entry.value) ? entry.value.join(" ") : entry.value.toString(), + }; + } + + const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); + return [...detailsWithoutEditedEntry, updatedEntry]; + }); + }; + const deleteKey = (propName: string) => { setDetails((prev) => { return prev.filter((prop) => prop.key !== propName); @@ -201,6 +239,26 @@ function MetadataTable({ } }; + const getTypeSelectDropdownMenu: (arg0: number) => MenuProps = (propertyIndex: number) => ({ + items: [ + { + key: 0, + label: {renderType("string")}, + onClick: () => updateType(propertyIndex, "string"), + }, + { + key: 1, + label: {renderType("number")}, + onClick: () => updateType(propertyIndex, "number"), + }, + { + key: 2, + label: {renderType("string[]")}, + onClick: () => updateType(propertyIndex, "string[]"), + }, + ], + }); + // Not using AntD Table to have more control over the styling. const alternativeTable = (
@@ -247,7 +305,9 @@ function MetadataTable({ {sortedDetails.map((record) => (
- {renderType(record.type)} + + {renderType(record.type)} + : - setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} - variant={record.index === focusedRow ? "outlined" : "borderless"} - value={record.value} - onChange={(evt) => updateValue(record.key, evt.target.value)} - placeholder="Value" - size="small" - /> + {record.type === "number" ? ( + setFocusedRow(record.index)} + onBlur={() => setFocusedRow(null)} + variant={record.index === focusedRow ? "outlined" : "borderless"} + value={record.value as number} + onChange={(newNum) => updateValue(record.key, newNum?.toString() || "")} + placeholder="Value" + size="small" + /> + ) : ( + setFocusedRow(record.index)} + onBlur={() => setFocusedRow(null)} + variant={record.index === focusedRow ? "outlined" : "borderless"} + value={record.value} + onChange={(evt) => updateValue(record.key, evt.target.value)} + placeholder="Value" + size="small" + /> + )} (promise: Promise): Promise { try { return await promise; From 5e2912bd21818cafe13bb9cbb517f4c316f6e47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 28 Jun 2024 15:53:07 +0200 Subject: [PATCH 22/84] details table for folders and datasets version 2 --- .../javascripts/dashboard/dataset/queries.tsx | 2 +- .../dashboard/folders/details_sidebar.tsx | 333 ++++++++---------- .../dashboard/publication_card.tsx | 32 +- .../backend-snapshot-tests/folders.e2e.ts | 4 +- .../backend-snapshot-tests/folders.e2e.js.md | 34 +- .../folders.e2e.js.snap | Bin 865 -> 916 bytes frontend/stylesheets/_dashboard.less | 27 ++ 7 files changed, 219 insertions(+), 213 deletions(-) diff --git a/frontend/javascripts/dashboard/dataset/queries.tsx b/frontend/javascripts/dashboard/dataset/queries.tsx index 1e23839204b..c663cd3737f 100644 --- a/frontend/javascripts/dashboard/dataset/queries.tsx +++ b/frontend/javascripts/dashboard/dataset/queries.tsx @@ -279,7 +279,7 @@ export function useCreateFolderMutation() { queryClient.setQueryData( mutationKey, transformHierarchy((oldItems: FlatFolderTreeItem[] | undefined) => - (oldItems || []).concat([{ ...newFolder, parent: parentId, details: {} }]), + (oldItems || []).concat([{ ...newFolder, parent: parentId, details: [] }]), ), ); }, diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index de6e6f3d973..27eb9423aa5 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -17,6 +17,7 @@ import { Dropdown, MenuProps, InputNumber, + Select, } from "antd"; import { stringToColor, formatCountToDataAmountUnit } from "libs/format_utils"; import { parseFloatOrZero, pluralize } from "libs/utils"; @@ -177,7 +178,7 @@ function MetadataTable({ } }); }; - const updateValue = (propName: string, newValue: string) => { + const updateValue = (propName: string, newValue: string | string[]) => { setDetails((prev) => { const entry = prev.find((prop) => prop.key === propName); if (!entry) { @@ -228,14 +229,18 @@ function MetadataTable({ ? details.sort((a, b) => a.index - b.index) : [{ key: "", value: "", index: 0, type: "string" as APIDetail["type"] }]; - const renderType = (type: APIDetail["type"]) => { + const availableStrArrayTagOptions = _.uniq( + sortedDetails.flatMap((detail) => (detail.type === "string[]" ? detail.value : [])), + ).map((tag) => ({ value: tag, label: tag })); + + const renderTypeTag = (type: APIDetail["type"]) => { switch (type) { case "string": - return "str"; + return str; case "number": - return "012"; + return 012; case "string[]": - return "[]"; + return {`[""]`}; } }; @@ -243,212 +248,172 @@ function MetadataTable({ items: [ { key: 0, - label: {renderType("string")}, + label: str, onClick: () => updateType(propertyIndex, "string"), }, { key: 1, - label: {renderType("number")}, + label: 012, onClick: () => updateType(propertyIndex, "number"), }, { key: 2, - label: {renderType("string[]")}, + label: {`[""]`}, onClick: () => updateType(propertyIndex, "string[]"), }, ], }); - // Not using AntD Table to have more control over the styling. - const alternativeTable = ( -
- - - - - - - - - {sortedDetails.map((record) => ( - - - - - - - - ))} - -
- - - Property - - - - - Value - - -
- - {renderType(record.type)} - - - setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} - variant={record.index === focusedRow ? "outlined" : "borderless"} - value={record.key} - onChange={(evt) => updatePropName(record.key, evt.target.value)} - placeholder="New property" - size="small" - /> - {error != null && error[0] === record.key ? ( - <> -
- {error[1]} - - ) : null} -
: - {record.type === "number" ? ( - setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} - variant={record.index === focusedRow ? "outlined" : "borderless"} - value={record.value as number} - onChange={(newNum) => updateValue(record.key, newNum?.toString() || "")} - placeholder="Value" - size="small" - /> - ) : ( - setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} - variant={record.index === focusedRow ? "outlined" : "borderless"} - value={record.value} - onChange={(evt) => updateValue(record.key, evt.target.value)} - placeholder="Value" - size="small" - /> - )} - - deleteKey(record.key)} - style={{ - color: "var(--ant-color-text-tertiary)", - visibility: record.key === "" ? "hidden" : "visible", - }} - disabled={record.key === ""} - /> -
-
-
- updatePropName("", "")} + const getValueInput = (record: APIDetail) => { + switch (record.type) { + case "number": + return ( + setFocusedRow(record.index)} + onBlur={() => setFocusedRow(null)} + variant={record.index === focusedRow ? "outlined" : "borderless"} + value={record.value as number} + onChange={(newNum) => updateValue(record.key, newNum?.toString() || "")} + placeholder="Value" + size="small" /> -
-
-
- ); + ); + case "string": + return ( + setFocusedRow(record.index)} + onBlur={() => setFocusedRow(null)} + variant={record.index === focusedRow ? "outlined" : "borderless"} + value={record.value} + onChange={(evt) => updateValue(record.key, evt.target.value)} + placeholder="Value" + size="small" + /> + ); + case "string[]": + return ( + ( - <> +
+ {/* Not using AntD Table to have more control over the styling. */} +
+ + + + + + + + + {sortedDetails.map((record) => ( + + + + + + + + ))} + +
+ + Type + + + + Property + + + + + Value + + +
+ + {renderTypeTag(record.type)} + + setFocusedRow(record.key)} + onFocus={() => setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - variant={record.key === focusedRow ? "outlined" : "borderless"} - value={propName} - onChange={(evt) => updatePropName(propName, evt.target.value)} + variant={record.index === focusedRow ? "outlined" : "borderless"} + value={record.key} + onChange={(evt) => updatePropName(record.key, evt.target.value)} placeholder="New property" size="small" /> - {error != null && error[0] === propName ? ( + {error != null && error[0] === record.key ? ( <>
{error[1]} ) : null} - - ), - }, - { - title: "Value", - dataIndex: "value", - className: "top-aligned-column", // Needed in case of an error in the propName column. - render: (value, record) => ( - setFocusedRow(record.key)} - onBlur={() => setFocusedRow(null)} - variant={record.key === focusedRow ? "outlined" : "borderless"} - value={value} - onChange={(evt) => updateValue(record.propName, evt.target.value)} - placeholder="Value" - size="small" - /> - ), - }, - { - title: "", - key: "del", - render: (_, record) => ( - deleteKey(record.propName)} - style={{ - color: "var(--ant-table-header-icon-color)", - visibility: record.propName === "" ? "hidden" : "visible", - }} - disabled={record.propName === ""} - /> - ), - }, - ]} - pagination={false} - size="small" - /> - */} - {alternativeTable} +
:{getValueInput(record)} + deleteKey(record.key)} + style={{ + color: "var(--ant-color-text-tertiary)", + visibility: record.key === "" ? "hidden" : "visible", + }} + disabled={record.key === ""} + /> +
+
+
+ updatePropName("", "")} + /> +
+
+ ); } diff --git a/frontend/javascripts/dashboard/publication_card.tsx b/frontend/javascripts/dashboard/publication_card.tsx index 962c112b4f4..39325f66903 100644 --- a/frontend/javascripts/dashboard/publication_card.tsx +++ b/frontend/javascripts/dashboard/publication_card.tsx @@ -18,7 +18,8 @@ import { getDatasetExtentAsString, } from "oxalis/model/accessors/dataset_accessor"; import { compareBy } from "libs/utils"; -type ExtendedDatasetDetails = APIDetails & { +type ExtendedDatasetDetails = { + details: APIDetails; name: string; scale: string; extent: string; @@ -49,10 +50,10 @@ function getDisplayName(item: PublicationItem): string { : item.dataset.displayName; } -function getDetails(item: PublicationItem): ExtendedDatasetDetails { +function getExtendedDetails(item: PublicationItem): ExtendedDatasetDetails { const { dataSource, details } = item.dataset; return { - ...details, + details: details || [], scale: formatScale(dataSource.scale, 0), name: getDisplayName(item), extent: getDatasetExtentAsString(item.dataset, false), @@ -65,28 +66,31 @@ function getUrl(item: PublicationItem): string { : `/datasets/${item.dataset.owningOrganization}/${item.dataset.name}`; } -function ThumbnailOverlay({ details }: { details: ExtendedDatasetDetails }) { +function ThumbnailOverlay({ extendedDetails }: { extendedDetails: ExtendedDatasetDetails }) { + const species = extendedDetails.details.find((d) => d.key === "species")?.value; + const brainRegion = extendedDetails.details.find((d) => d.key === "brain region")?.value; + const acquisition = extendedDetails.details.find((d) => d.key === "acquisition")?.value; return (
- {details.species && ( + {species && (
- {details.species} + {species}
)} - {details.brainRegion && ( + {brainRegion && (
- {details.brainRegion} + {brainRegion}
)}
@@ -95,7 +99,7 @@ function ThumbnailOverlay({ details }: { details: ExtendedDatasetDetails }) { fontSize: 18, }} > - {details.name} + {extendedDetails.name}
-
{details.acquisition}
+
{acquisition}
- {details.scale} + {extendedDetails.scale}
- {details.extent} + {extendedDetails.extent}
@@ -264,7 +268,7 @@ function PublicationThumbnail({ const segmentationThumbnailURL = hasSegmentation(activeItem.dataset) ? getSegmentationThumbnailURL(activeItem.dataset) : null; - const details = getDetails(activeItem); + const extendedDetails = getExtendedDetails(activeItem); return (
@@ -293,7 +297,7 @@ function PublicationThumbnail({ }} /> )} - + {sortedItems.length > 1 && ( { id: organizationXRootFolderId, allowedTeams: [], name: newName, - details: {}, + details: [], }); t.is(updatedFolder.name, newName); @@ -70,7 +70,7 @@ test("addAllowedTeamToFolder", async (t) => { id: subFolderId, allowedTeams: [teamId], name: "A subfolder!", - details: { stuff: "blub" }, + details: [{ type: "string", key: "foo", value: "bar", index: 0 }], }); t.snapshot(updatedFolderWithTeam, { diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.md index 0f28aca3c84..bc9f7c2e57e 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.md @@ -8,21 +8,21 @@ Generated by [AVA](https://avajs.dev). [ { - details: {}, + details: [], id: '570b9f4e4bb848d08880712a', isEditable: true, name: 'A subfolder!', parent: '570b9f4e4bb848d0885ea917', }, { - details: {}, + details: [], id: '570b9f4e4bb848d08880712b', isEditable: true, name: 'Another subfolder!', parent: '570b9f4e4bb848d0885ea917', }, { - details: {}, + details: [], id: '570b9f4e4bb848d0885ea917', isEditable: true, name: 'Organization_X', @@ -35,7 +35,7 @@ Generated by [AVA](https://avajs.dev). { allowedTeams: [], allowedTeamsCumulative: [], - details: {}, + details: [], id: '570b9f4e4bb848d0885ea917', isEditable: true, name: 'Organization_X', @@ -46,7 +46,7 @@ Generated by [AVA](https://avajs.dev). { allowedTeams: [], allowedTeamsCumulative: [], - details: {}, + details: [], id: '570b9f4e4bb848d0885ea917', isEditable: true, name: 'renamed organization x root folder', @@ -57,7 +57,7 @@ Generated by [AVA](https://avajs.dev). { allowedTeams: [], allowedTeamsCumulative: [], - details: {}, + details: [], id: 'id', isEditable: true, name: 'a newly created folder!', @@ -80,9 +80,14 @@ Generated by [AVA](https://avajs.dev). organization: 'Organization_X', }, ], - details: { - stuff: 'blub', - }, + details: [ + { + index: 0, + key: 'foo', + type: 'string', + value: 'bar', + }, + ], id: '570b9f4e4bb848d08880712a', isEditable: true, name: 'A subfolder!', @@ -105,9 +110,14 @@ Generated by [AVA](https://avajs.dev). organization: 'Organization_X', }, ], - details: { - stuff: 'blub', - }, + details: [ + { + index: 0, + key: 'foo', + type: 'string', + value: 'bar', + }, + ], id: '570b9f4e4bb848d08880712a', isEditable: false, name: 'A subfolder!', diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.snap index ebaf58047e9ec54b05115ded8df2bb37eda780c5..ff3293cd8a8cf2e7aff5d5836eaf015ba305cefd 100644 GIT binary patch literal 916 zcmV;F18e+2RzVA1Iv=bes)u>t%ur8Nu?ku8{KWz9IQ!9Nlw8MTFJriP3GG;x|s>Hvzm>E zSV8d8fDQ*8=(0mvfPpS5&(^W-2@yVz$M@+ zNsDBw7a1h|PXgu$m?xlt0UZqRn3SfjNttnt6L-KjFEIwrf}<}Kz)e2!uHCzafO!7ZAeQ-MJ>JCYCGO>;cjSS*D7sn zGr+lSQ%cR7pi?X13%nY+m2+l8NzUvllQZo~IdkJ76eKoAz!gF%>Lm6v0h3aYDhEvy z@S7B*%fz_Mfxr8o*O&TJNv~&l*6m?HF9W_?A+J^YQe=)(?=#>L13pxMR+-y0Ib8D; zU_=2fE5J^Gv(gvmb_{iQY%@B|<1JK7}0R69lZSr=4q zVLhsps9ClthJ8(iV|qZr)%{{la}1^g}G zZvlTj;LiYfP%iLyl>~mgOyD1RBj7X3tmxbRK9AVqbSfL^cLn!^8F`8J&HOQ{oMl@| q4*TmehwZ)vzoZ|#BBz>>V$ei}Bd+6kE9=;dZM*=Aa+zJj5C8yKBfVPy literal 865 zcmV-n1D^arRzV?;8Fd-;un_5xsr*WgNI7a4v=B}kqL}VJ$`njzuW2kIC!gm+CEN(m1hIr zd)=d~q_w$rpsxJY@yj5?I+FuPuo|zj3tn03F)hjPhwD|p&Xs^LOw3^l zumpLRh$%Tu+BqKd_jBDJ3bwa^>FbLy|m++PA@w8f= zRqYH%6kjNy3T>|`(Av#45!6$dxuuk@u1iWvS}AR-4u6uC2)E_bd!knF^6$1~6B6iVzN0VAd$i#ceFfahio zGqFlWj0>lYi*{MhVuqe==n3bS!+ON$TgpZWL==OSk1(Kmk^kuWO>biHw>^y zK(U$oCA7u?d`=ZwyOTo8og%bO{S~yBUrp Date: Fri, 28 Jun 2024 16:34:03 +0200 Subject: [PATCH 23/84] WIP: adapt migration to new details format --- ...ails-to-folders.sql => 118-add-details-to-folders.sql} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename conf/evolutions/{117-add-details-to-folders.sql => 118-add-details-to-folders.sql} (57%) diff --git a/conf/evolutions/117-add-details-to-folders.sql b/conf/evolutions/118-add-details-to-folders.sql similarity index 57% rename from conf/evolutions/117-add-details-to-folders.sql rename to conf/evolutions/118-add-details-to-folders.sql index 12f451d161a..bbbe1d31f95 100644 --- a/conf/evolutions/117-add-details-to-folders.sql +++ b/conf/evolutions/118-add-details-to-folders.sql @@ -1,13 +1,13 @@ START TRANSACTION; -do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 116, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; +do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 117, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; DROP VIEW webknossos.folders_; -ALTER TABLE webknossos.folders ADD details JSONB DEFAULT '{}'; -ALTER TABLE webknossos.folders ADD CONSTRAINT detailsIsJsonObject CHECK(jsonb_typeof(details) = 'object'); +ALTER TABLE webknossos.folders ADD details JSONB DEFAULT '[]'; +ALTER TABLE webknossos.folders ADD CONSTRAINT detailsIsJsonObject CHECK(jsonb_typeof(details) = 'array'); CREATE VIEW webknossos.folders_ as SELECT * FROM webknossos.folders WHERE NOT isDeleted; -UPDATE webknossos.releaseInformation SET schemaVersion = 117; +UPDATE webknossos.releaseInformation SET schemaVersion = 118; COMMIT TRANSACTION; From c091c0ecd1733a1deadacf6851aa7dd328cc560e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 28 Jun 2024 16:35:57 +0200 Subject: [PATCH 24/84] uncomment ci tests --- .circleci/not-on-master.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.circleci/not-on-master.sh b/.circleci/not-on-master.sh index 581393ebead..0c326ea45c2 100755 --- a/.circleci/not-on-master.sh +++ b/.circleci/not-on-master.sh @@ -4,5 +4,6 @@ set -Eeuo pipefail if [ "${CIRCLE_BRANCH}" == "master" ]; then echo "Skipping this step on master..." else - exec "$@" + #exec "$@" + echo "done" fi From 98892cad439fdafd8307e60ba359bc8958b83c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 28 Jun 2024 16:59:09 +0200 Subject: [PATCH 25/84] mini migration fix --- conf/evolutions/118-add-details-to-folders.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/evolutions/118-add-details-to-folders.sql b/conf/evolutions/118-add-details-to-folders.sql index bbbe1d31f95..e0c44f55450 100644 --- a/conf/evolutions/118-add-details-to-folders.sql +++ b/conf/evolutions/118-add-details-to-folders.sql @@ -5,7 +5,7 @@ do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 1 DROP VIEW webknossos.folders_; ALTER TABLE webknossos.folders ADD details JSONB DEFAULT '[]'; -ALTER TABLE webknossos.folders ADD CONSTRAINT detailsIsJsonObject CHECK(jsonb_typeof(details) = 'array'); +ALTER TABLE webknossos.folders ADD CONSTRAINT detailsIsJsonArray CHECK(jsonb_typeof(details) = 'array'); CREATE VIEW webknossos.folders_ as SELECT * FROM webknossos.folders WHERE NOT isDeleted; UPDATE webknossos.releaseInformation SET schemaVersion = 118; From d4ebd9fc6207e294fec809148b2fb5e66f939df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Tue, 2 Jul 2024 10:29:46 +0200 Subject: [PATCH 26/84] - rename details to metadata --- app/controllers/ConfigurationController.scala | 2 +- app/controllers/DatasetController.scala | 2 +- app/controllers/FolderController.scala | 2 +- app/models/dataset/Dataset.scala | 22 +++--- app/models/dataset/DatasetService.scala | 6 +- app/models/folder/Folder.scala | 26 +++---- .../evolutions/118-add-details-to-folders.sql | 60 +++++++++++++++- .../reversions/118-add-details-to-folders.sql | 65 ++++++++++++++++++ frontend/javascripts/admin/admin_rest_api.ts | 2 +- .../advanced_dataset/dataset_table.tsx | 2 +- .../javascripts/dashboard/dataset/queries.tsx | 6 +- .../dashboard/folders/details_sidebar.tsx | 52 +++++++++----- .../dashboard/publication_card.tsx | 54 ++++++++------- .../dashboard/publication_view.tsx | 2 +- frontend/javascripts/oxalis/default_state.ts | 2 +- .../backend-snapshot-tests/folders.e2e.ts | 4 +- .../backend-snapshot-tests/datasets.e2e.js.md | 10 +-- .../datasets.e2e.js.snap | Bin 4044 -> 4028 bytes .../backend-snapshot-tests/folders.e2e.js.md | 24 +++---- .../folders.e2e.js.snap | Bin 916 -> 929 bytes .../volumetracing_saga_integration.spec.js.md | 2 +- ...olumetracing_saga_integration.spec.js.snap | Bin 1488 -> 1476 bytes frontend/javascripts/types/api_flow_types.ts | 18 ++--- tools/postgres/schema.sql | 8 +-- 24 files changed, 251 insertions(+), 120 deletions(-) create mode 100644 conf/evolutions/reversions/118-add-details-to-folders.sql diff --git a/app/controllers/ConfigurationController.scala b/app/controllers/ConfigurationController.scala index c9cc3a95243..d51177885e9 100755 --- a/app/controllers/ConfigurationController.scala +++ b/app/controllers/ConfigurationController.scala @@ -81,7 +81,7 @@ class ConfigurationController @Inject()( dataset <- datasetDAO.findOneByNameAndOrganizationName(datasetName, organizationName) ?~> "dataset.notFound" ~> NOT_FOUND _ <- datasetService.isEditableBy(dataset, Some(request.identity)) ?~> "notAllowed" ~> FORBIDDEN jsObject <- request.body.asOpt[JsObject].toFox ?~> "user.configuration.dataset.invalid" - _ <- datasetConfigurationService.updateAdminViewConfigurationFor(dataset, jsObject.fields.toMap) + p_ <- datasetConfigurationService.updateAdminViewConfigurationFor(dataset, jsObject.fields.toMap) } yield JsonOk(Messages("user.configuration.dataset.updated")) } } diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index cfecab6e246..ea4d2db801a 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -35,7 +35,7 @@ case class DatasetUpdateParameters( sortingKey: Option[Instant], isPublic: Option[Boolean], tags: Option[List[String]], - details: Option[JsObject], + metadata: Option[JsArray], folderId: Option[ObjectId] ) diff --git a/app/controllers/FolderController.scala b/app/controllers/FolderController.scala index 414c1005ac0..e4f189b264f 100644 --- a/app/controllers/FolderController.scala +++ b/app/controllers/FolderController.scala @@ -53,7 +53,7 @@ class FolderController @Inject()( _ <- folderDAO.findOne(idValidated) ?~> "folder.notFound" - <- Fox.assertTrue(folderDAO.isEditable(idValidated)) ?~> "folder.update.notAllowed" ~> FORBIDDEN _ <- folderService.assertValidFolderName(params.name) - _ <- folderDAO.updateDetails(idValidated, params.details) + _ <- folderDAO.updateMetadata(idValidated, params.metadata) _ <- folderDAO.updateName(idValidated, params.name) ?~> "folder.update.name.failed" _ <- folderService .updateAllowedTeams(idValidated, params.allowedTeams, request.identity) ?~> "folder.update.teams.failed" diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index f9a785f10a0..b4c450d10fc 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -55,7 +55,7 @@ case class Dataset(_id: ObjectId, status: String, logoUrl: Option[String], sortingKey: Instant = Instant.now, - details: Option[JsArray] = None, + metadata: Option[JsArray] = None, tags: List[String] = List.empty, created: Instant = Instant.now, isDeleted: Boolean = false) @@ -77,15 +77,13 @@ case class DatasetCompactInfo( lastUsedByUser: Instant, status: String, tags: List[String], - details: Option[JsArray], + metadata: Option[JsArray], isUnreported: Boolean, colorLayerNames: List[String], segmentationLayerNames: List[String], ) object DatasetCompactInfo { - // Enforce serializing null values for optional fields as frontend expects them to be set (currently only details). - implicit val config: Aux[Json.MacroOptions] = JsonConfiguration(optionHandlers = OptionHandlers.WritesNull) implicit val jsonFormat: Format[DatasetCompactInfo] = Json.format[DatasetCompactInfo] } @@ -120,7 +118,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA JsonHelper.parseAndValidateJson[DatasetViewConfiguration](_)) adminViewConfigurationOpt <- Fox.runOptional(r.adminviewconfiguration)( JsonHelper.parseAndValidateJson[DatasetViewConfiguration](_)) - details <- Fox.runOptional(r.details)(JsonHelper.parseAndValidateJson[JsArray](_)) + metadata <- Fox.runOptional(r.metadata)(JsonHelper.parseAndValidateJson[JsArray](_)) } yield { Dataset( ObjectId(r._Id), @@ -142,7 +140,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA r.status, r.logourl, Instant.fromSql(r.sortingkey), - details, + metadata, parseArrayLiteral(r.tags).sorted, Instant.fromSql(r.created), r.isdeleted @@ -273,7 +271,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA COALESCE(lastUsedTimes.lastUsedTime, ${Instant.zero}), d.status, d.tags, - d.details, + d.metadata, cl.names AS colorLayerNames, sl.names AS segmentationLayerNames FROM @@ -320,7 +318,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA lastUsedByUser = row._9, status = row._10, tags = parseArrayLiteral(row._11), - details = JsonHelper.parseAndValidateJson[JsArray](row._12), + metadata = JsonHelper.parseAndValidateJson[JsArray](row._12), isUnreported = unreportedStatusList.contains(row._10), colorLayerNames = parseArrayLiteral(row._13), segmentationLayerNames = parseArrayLiteral(row._14) @@ -477,7 +475,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA } yield () def updatePartial(datasetId: ObjectId, params: DatasetUpdateParameters)(implicit ctx: DBAccessContext): Fox[Unit] = { - System.out.println(s"Trying to update a dataset with $DatasetUpdateParameters" ) + System.out.println(s"Trying to update a dataset with $DatasetUpdateParameters") val setQueries = List( params.description.map(d => q"description = $d"), params.displayName.map(v => q"displayName = $v"), @@ -485,7 +483,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA params.isPublic.map(v => q"isPublic = $v"), params.tags.map(v => q"tags = $v"), params.folderId.map(v => q"_folder = $v"), - params.details.map(v => q"details = $v"), + params.metadata.map(v => q"metadata = $v"), ).flatten if (setQueries.isEmpty) { Fox.successful(()) @@ -557,7 +555,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA inboxSourceHash, defaultViewConfiguration, adminViewConfiguration, description, displayName, isPublic, isUsable, name, voxelSizeFactor, voxelSizeUnit, status, - sharingToken, sortingKey, details, tags, + sharingToken, sortingKey, metadata, tags, created, isDeleted ) VALUES( @@ -566,7 +564,7 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA ${d.inboxSourceHash}, $defaultViewConfiguration, $adminViewConfiguration, ${d.description}, ${d.displayName}, ${d.isPublic}, ${d.isUsable}, ${d.name}, ${d.voxelSize.map(_.factor)}, ${d.voxelSize.map(_.unit)}, ${d.status.take(1024)}, - ${d.sharingToken}, ${d.sortingKey}, ${d.details}, ${d.tags}, + ${d.sharingToken}, ${d.sortingKey}, ${d.metadata}, ${d.tags}, ${d.created}, ${d.isDeleted} )""".asUpdate) } yield () diff --git a/app/models/dataset/DatasetService.scala b/app/models/dataset/DatasetService.scala index 3cbbb8120b4..1bb90157ee2 100644 --- a/app/models/dataset/DatasetService.scala +++ b/app/models/dataset/DatasetService.scala @@ -77,7 +77,7 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, ): Fox[Dataset] = { implicit val ctx: DBAccessContext = GlobalAccessContext val newId = ObjectId.generate - val details = + val metadata = Json.arr( Json.obj("type" -> "string", "key" -> "species", "value" -> "species name", "index" -> 0), Json.obj("type" -> "string", "key" -> "brainRegion", "value" -> "brain region", "index" -> 1), @@ -107,7 +107,7 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, sharingToken = None, status = dataSource.statusOpt.getOrElse(""), logoUrl = None, - details = publication.map(_ => details) + metadata = publication.map(_ => metadata) ) _ <- datasetDAO.insertOne(dataset) _ <- datasetDataLayerDAO.updateLayers(newId, dataSource) @@ -358,7 +358,7 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, "lastUsedByUser" -> lastUsedByUser, "logoUrl" -> logoUrl, "sortingKey" -> dataset.sortingKey, - "details" -> dataset.details, + "metadata" -> dataset.metadata, "isUnreported" -> Json.toJson(isUnreported(dataset)), "tags" -> dataset.tags, "folderId" -> dataset._folder, diff --git a/app/models/folder/Folder.scala b/app/models/folder/Folder.scala index 8606293cf70..4e9b2309b27 100644 --- a/app/models/folder/Folder.scala +++ b/app/models/folder/Folder.scala @@ -19,11 +19,11 @@ import javax.inject.Inject import scala.annotation.tailrec import scala.concurrent.ExecutionContext -case class Folder(_id: ObjectId, name: String, details: JsArray) +case class Folder(_id: ObjectId, name: String, metadata: JsArray) -case class FolderWithParent(_id: ObjectId, name: String, details: JsArray, _parent: Option[ObjectId]) +case class FolderWithParent(_id: ObjectId, name: String, metadata: JsArray, _parent: Option[ObjectId]) -case class FolderParameters(name: String, allowedTeams: List[ObjectId], details: JsArray) +case class FolderParameters(name: String, allowedTeams: List[ObjectId], metadata: JsArray) object FolderParameters { implicit val jsonFormat: OFormat[FolderParameters] = Json.format[FolderParameters] } @@ -51,7 +51,7 @@ class FolderService @Inject()(teamDAO: TeamDAO, Json.obj( "id" -> folder._id, "name" -> folder.name, - "details" -> folder.details, + "metadata" -> folder.metadata, "allowedTeams" -> teamsJs, "allowedTeamsCumulative" -> teamsCumulativeJs, "isEditable" -> isEditable @@ -62,7 +62,7 @@ class FolderService @Inject()(teamDAO: TeamDAO, "id" -> folderWithParent._id, "name" -> folderWithParent.name, "parent" -> folderWithParent._parent, - "details" -> folderWithParent.details, + "metadata" -> folderWithParent.metadata, "isEditable" -> allEditableIds.contains(folderWithParent._id) ) @@ -138,14 +138,14 @@ class FolderDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) protected def parse(r: FoldersRow): Fox[Folder] = for { - details <- JsonHelper.parseAndValidateJson[JsArray](r.details.getOrElse("[]")).toFox - folder <- Fox.successful(Folder(ObjectId(r._Id), r.name, details)) + metadata <- JsonHelper.parseAndValidateJson[JsArray](r.metadata.getOrElse("[]")).toFox + folder <- Fox.successful(Folder(ObjectId(r._Id), r.name, metadata)) } yield folder private def parseWithParent(t: (String, String, Option[String], Option[String])): Fox[FolderWithParent] = for { - details <- JsonHelper.parseAndValidateJson[JsArray](t._3.getOrElse("[]")).toFox - folderWithParent <- Fox.successful(FolderWithParent(ObjectId(t._1), t._2, details, t._4.map(ObjectId(_)))) + metadata <- JsonHelper.parseAndValidateJson[JsArray](t._3.getOrElse("[]")).toFox + folderWithParent <- Fox.successful(FolderWithParent(ObjectId(t._1), t._2, metadata, t._4.map(ObjectId(_)))) } yield folderWithParent override protected def readAccessQ(requestingUserId: ObjectId): SqlToken = @@ -256,10 +256,10 @@ class FolderDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) _ <- run(q"UPDATE webknossos.folders SET name = $name WHERE _id = $folderId".asUpdate) } yield () - def updateDetails(folderId: ObjectId, details: JsArray)(implicit ctx: DBAccessContext): Fox[Unit] = + def updateMetadata(folderId: ObjectId, metadata: JsArray)(implicit ctx: DBAccessContext): Fox[Unit] = for { _ <- assertUpdateAccess(folderId) - _ <- run(q"UPDATE webknossos.folders SET details = $details WHERE _id = $folderId".asUpdate) + _ <- run(q"UPDATE webknossos.folders SET metadata = $metadata WHERE _id = $folderId".asUpdate) } yield () def findAllEditableIds(implicit ctx: DBAccessContext): Fox[List[ObjectId]] = @@ -285,7 +285,7 @@ class FolderDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) for { accessQueryWithPrefix <- accessQueryFromAccessQWithPrefix(readAccessQWithPrefix, prefix = q"f.") accessQuery <- readAccessQuery - rows <- run(q"""SELECT f._id, f.name, f.details, fp._ancestor + rows <- run(q"""SELECT f._id, f.name, f.metadata, fp._ancestor FROM webknossos.folders_ f JOIN webknossos.folder_paths fp -- join to find immediate parent, this will also kick out self ON f._id = fp._descendant @@ -294,7 +294,7 @@ class FolderDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) FROM webknossos.folder_paths WHERE _ancestor = $folderId) AND $accessQueryWithPrefix - UNION ALL SELECT _id, name, details, NULL -- find self again, with no parent + UNION ALL SELECT _id, name, metadata, NULL -- find self again, with no parent FROM webknossos.folders_ WHERE _id = $folderId AND $accessQuery diff --git a/conf/evolutions/118-add-details-to-folders.sql b/conf/evolutions/118-add-details-to-folders.sql index e0c44f55450..d2347932f1d 100644 --- a/conf/evolutions/118-add-details-to-folders.sql +++ b/conf/evolutions/118-add-details-to-folders.sql @@ -3,11 +3,67 @@ START TRANSACTION; do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 117, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; DROP VIEW webknossos.folders_; +DROP VIEW webknossos.datasets_; -ALTER TABLE webknossos.folders ADD details JSONB DEFAULT '[]'; -ALTER TABLE webknossos.folders ADD CONSTRAINT detailsIsJsonArray CHECK(jsonb_typeof(details) = 'array'); +-- Folder part +ALTER TABLE webknossos.folders ADD COLUMN metadata JSONB DEFAULT '[]'; +ALTER TABLE webknossos.folders ADD CONSTRAINT metadataIsJsonArray CHECK(jsonb_typeof(metadata) = 'array'); + +-- Dataset part +ALTER TABLE webknossos.datasets ADD COLUMN metadata JSONB DEFAULT '[]'; +ALTER TABLE webknossos.datasets ADD CONSTRAINT metadataIsJsonArray CHECK(jsonb_typeof(metadata) = 'array'); +-- Add existing details on species to metadata +UPDATE webknossos.datasets +SET metadata = CASE + WHEN details->>'species' IS NOT NULL THEN + metadata || jsonb_build_array( + jsonb_build_object( + 'type', 'string', + 'index', 2, + 'key', 'species', + 'value', details->>'species' + ) + ) + ELSE + metadata +END; +-- Add existing details on brain region to metadata +UPDATE webknossos.datasets +SET metadata = CASE + WHEN details->>'brainRegion' IS NOT NULL THEN + metadata || jsonb_build_array( + jsonb_build_object( + 'type', 'string', + 'index', 2, + 'key', 'brainRegion', + 'value', details->>'brainRegion' + ) + ) + ELSE + metadata +END; + +-- Add existing details on acquisition to metadata +UPDATE webknossos.datasets +SET metadata = CASE + WHEN details->>'acquisition' IS NOT NULL THEN + metadata || jsonb_build_array( + jsonb_build_object( + 'type', 'string', + 'index', 2, + 'key', 'acquisition', + 'value', details->>'acquisition' + ) + ) + ELSE + metadata +END; + +-- Drop details +ALTER TABLE webknossos.datasets DROP COLUMN details; CREATE VIEW webknossos.folders_ as SELECT * FROM webknossos.folders WHERE NOT isDeleted; +CREATE VIEW webknossos.datasets_ as SELECT * FROM webknossos.datasets WHERE NOT isDeleted; UPDATE webknossos.releaseInformation SET schemaVersion = 118; COMMIT TRANSACTION; diff --git a/conf/evolutions/reversions/118-add-details-to-folders.sql b/conf/evolutions/reversions/118-add-details-to-folders.sql new file mode 100644 index 00000000000..bec185c1750 --- /dev/null +++ b/conf/evolutions/reversions/118-add-details-to-folders.sql @@ -0,0 +1,65 @@ +START TRANSACTION; + +do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 118, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; + +DROP VIEW webknossos.folders_; +DROP VIEW webknossos.datasets_; + +-- Folder part +ALTER TABLE webknossos.folders DROP COLUMN metadata; + +-- Dataset part +ALTER TABLE webknossos.datasets ADD COLUMN details JSONB DEFAULT '{}'; +ALTER TABLE webknossos.datasets ADD CONSTRAINT detailsIsJsonObject CHECK(jsonb_typeof(details) = 'object'); + +-- Add existing info on species of metadata to details +UPDATE webknossos.datasets +SET details = jsonb_set(details, '{species}', ( + SELECT to_jsonb(m.value) + FROM jsonb_to_recordset(metadata) AS m(key text, value text) + WHERE m.key = 'species' + LIMIT 1 +)) +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements(metadata) AS m + WHERE m->>'key' = 'species' +); + + +UPDATE webknossos.datasets +SET details = jsonb_set(details, '{brainRegion}', ( + SELECT to_jsonb(m.value) + FROM jsonb_to_recordset(metadata) AS m(key text, value text) + WHERE m.key = 'brainRegion' + LIMIT 1 +)) +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements(metadata) AS m + WHERE m->>'key' = 'brainRegion' +); + + +UPDATE webknossos.datasets +SET details = jsonb_set(details, '{acquisition}', ( + SELECT to_jsonb(m.value) + FROM jsonb_to_recordset(metadata) AS m(key text, value text) + WHERE m.key = 'acquisition' + LIMIT 1 +)) +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements(metadata) AS m + WHERE m->>'key' = 'acquisition' +); + + +-- Drop details +ALTER TABLE webknossos.datasets DROP COLUMN metadata; + +CREATE VIEW webknossos.folders_ as SELECT * FROM webknossos.folders WHERE NOT isDeleted; +CREATE VIEW webknossos.datasets_ as SELECT * FROM webknossos.datasets WHERE NOT isDeleted; +UPDATE webknossos.releaseInformation SET schemaVersion = 117; + +COMMIT TRANSACTION; diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index b03dd3c0c38..4a881093840 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -1164,7 +1164,7 @@ export type DatasetUpdater = { isPublic?: boolean; tags?: string[]; folderId?: string; - details?: APIDataset["details"]; + metadata?: APIDataset["metadata"]; }; export function updateDatasetPartial( diff --git a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx index 8452a81c99f..7d2472f3c41 100644 --- a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx +++ b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx @@ -520,7 +520,7 @@ class DatasetTable extends React.PureComponent { dataSourceSortedByRank.map((dataset, rank) => [dataset, rank]), ); const getNameAndMetaData = (datasetOrFolder: DatasetOrFolder) => { - return `${datasetOrFolder}${Object.entries(datasetOrFolder.details || {}) + return `${datasetOrFolder}${Object.entries(datasetOrFolder.metadata || {}) .map(([key, value]) => `${key}:${value}`) .join(",")}`; }; diff --git a/frontend/javascripts/dashboard/dataset/queries.tsx b/frontend/javascripts/dashboard/dataset/queries.tsx index c663cd3737f..f1f3c9dafe0 100644 --- a/frontend/javascripts/dashboard/dataset/queries.tsx +++ b/frontend/javascripts/dashboard/dataset/queries.tsx @@ -279,7 +279,7 @@ export function useCreateFolderMutation() { queryClient.setQueryData( mutationKey, transformHierarchy((oldItems: FlatFolderTreeItem[] | undefined) => - (oldItems || []).concat([{ ...newFolder, parent: parentId, details: [] }]), + (oldItems || []).concat([{ ...newFolder, parent: parentId, metadata: [] }]), ), ); }, @@ -324,7 +324,7 @@ export function useUpdateFolderMutation() { ? { ...updatedFolder, parent: oldFolder.parent, - details: oldFolder.details, + metadata: oldFolder.metadata, } : oldFolder, ), @@ -586,7 +586,7 @@ export function getFolderHierarchy(folderTree: FlatFolderTreeItem[]): FolderHier title: folderTreeItem.name, isEditable: folderTreeItem.isEditable, parent: folderTreeItem.parent, - details: folderTreeItem.details, + metadata: folderTreeItem.metadata, children: [], }; if (folderTreeItem.parent == null) { diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 27eb9423aa5..71d833497f5 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -28,7 +28,7 @@ import { VoxelSizeRow, } from "oxalis/view/right-border-tabs/dataset_info_tab_view"; import React, { useEffect, useState } from "react"; -import { APIDatasetCompact, APIDetail, APIDetails, Folder } from "types/api_flow_types"; +import { APIDatasetCompact, APIMetadata, APIMetadataEntries, Folder } from "types/api_flow_types"; import { DatasetLayerTags, TeamTags } from "../advanced_dataset/dataset_table"; import { DatasetCollectionContextValue, @@ -107,19 +107,19 @@ const updateCachedDatasetOrFolderDebounced = _.debounce( async ( context: DatasetCollectionContextValue, selectedDatasetOrFolder: APIDatasetCompact | Folder, - details: APIDetails, + metadata: APIMetadataEntries, setIgnoreFetching: (value: boolean) => void, ) => { // Explicitly ignoring fetching here to avoid unnecessary rendering of the loading spinner and thus hiding the metadata table. setIgnoreFetching(true); if ("status" in selectedDatasetOrFolder) { - await context.updateCachedDataset(selectedDatasetOrFolder, { details: details }); + await context.updateCachedDataset(selectedDatasetOrFolder, { metadata: metadata }); } else { const folder = selectedDatasetOrFolder as Folder; await context.queries.updateFolderMutation.mutateAsync({ ...folder, allowedTeams: folder.allowedTeams.map((t) => t.id), - details, + metadata, }); } setIgnoreFetching(false); @@ -135,21 +135,38 @@ function MetadataTable({ setIgnoreFetching: (value: boolean) => void; }) { const context = useDatasetCollectionContext(); - const [details, setDetails] = useState(selectedDatasetOrFolder.details || []); + const [metadata, setMetadata] = useState( + selectedDatasetOrFolder.metadata != null && selectedDatasetOrFolder.metadata.length > 0 + ? selectedDatasetOrFolder.metadata + : [{ key: "", value: "", index: 0, type: "string" as APIMetadata["type"] }], + ); const [error, setError] = useState<[string, string] | null>(null); // [propName, error message] const [focusedRow, setFocusedRow] = useState(null); + console.log("datasetMeta", selectedDatasetOrFolder.metadata, "metadata state", metadata); + + useEffect(() => { + // Flush pending updates: + updateCachedDatasetOrFolderDebounced.flush(); + // Update state to newest metadata from selectedDatasetOrFolder. + setMetadata( + selectedDatasetOrFolder.metadata != null && selectedDatasetOrFolder.metadata.length > 0 + ? selectedDatasetOrFolder.metadata + : [{ key: "", value: "", index: 0, type: "string" as APIMetadata["type"] }], + ); + }, [selectedDatasetOrFolder]); + useEffectOnUpdate(() => { updateCachedDatasetOrFolderDebounced( context, selectedDatasetOrFolder, - details, + metadata, setIgnoreFetching, ); - }, [details]); + }, [metadata]); const updatePropName = (previousPropName: string, newPropName: string) => { - setDetails((prev) => { + setMetadata((prev: APIMetadataEntries) => { const entry = prev.find((prop) => prop.key === previousPropName); const maybeAlreadyExistingEntry = prev.find((prop) => prop.key === newPropName); if (maybeAlreadyExistingEntry) { @@ -168,7 +185,7 @@ function MetadataTable({ ]; } else { const highestIndex = prev.reduce((acc, curr) => Math.max(acc, curr.index), 0); - const newEntry: APIDetail = { + const newEntry: APIMetadata = { key: newPropName, value: "", type: "string", @@ -179,7 +196,7 @@ function MetadataTable({ }); }; const updateValue = (propName: string, newValue: string | string[]) => { - setDetails((prev) => { + setMetadata((prev) => { const entry = prev.find((prop) => prop.key === propName); if (!entry) { return prev; @@ -190,8 +207,8 @@ function MetadataTable({ }); }; - const updateType = (index: number, newType: APIDetail["type"]) => { - setDetails((prev) => { + const updateType = (index: number, newType: APIMetadata["type"]) => { + setMetadata((prev) => { const entry = prev.find((prop) => prop.index === index); if (!entry) { return prev; @@ -219,21 +236,18 @@ function MetadataTable({ }; const deleteKey = (propName: string) => { - setDetails((prev) => { + setMetadata((prev) => { return prev.filter((prop) => prop.key !== propName); }); }; - const sortedDetails = - details.length > 0 - ? details.sort((a, b) => a.index - b.index) - : [{ key: "", value: "", index: 0, type: "string" as APIDetail["type"] }]; + const sortedDetails = metadata.sort((a, b) => a.index - b.index); const availableStrArrayTagOptions = _.uniq( sortedDetails.flatMap((detail) => (detail.type === "string[]" ? detail.value : [])), ).map((tag) => ({ value: tag, label: tag })); - const renderTypeTag = (type: APIDetail["type"]) => { + const renderTypeTag = (type: APIMetadata["type"]) => { switch (type) { case "string": return str; @@ -264,7 +278,7 @@ function MetadataTable({ ], }); - const getValueInput = (record: APIDetail) => { + const getValueInput = (record: APIMetadata) => { switch (record.type) { case "number": return ( diff --git a/frontend/javascripts/dashboard/publication_card.tsx b/frontend/javascripts/dashboard/publication_card.tsx index 39325f66903..ef310a79520 100644 --- a/frontend/javascripts/dashboard/publication_card.tsx +++ b/frontend/javascripts/dashboard/publication_card.tsx @@ -4,12 +4,7 @@ import Markdown from "libs/markdown_adapter"; import React, { useState } from "react"; import classNames from "classnames"; import { Link } from "react-router-dom"; -import type { - APIDataset, - APIDetails, - APIPublication, - APIPublicationAnnotation, -} from "types/api_flow_types"; +import type { APIDataset, APIPublication, APIPublicationAnnotation } from "types/api_flow_types"; import { formatScale } from "libs/format_utils"; import { getThumbnailURL, @@ -18,8 +13,14 @@ import { getDatasetExtentAsString, } from "oxalis/model/accessors/dataset_accessor"; import { compareBy } from "libs/utils"; -type ExtendedDatasetDetails = { - details: APIDetails; + +type DatasetDetails = { + species?: string; + brainRegion?: string; + acquisition?: string; +}; + +type ExtendedDatasetDetails = DatasetDetails & { name: string; scale: string; extent: string; @@ -50,10 +51,16 @@ function getDisplayName(item: PublicationItem): string { : item.dataset.displayName; } -function getExtendedDetails(item: PublicationItem): ExtendedDatasetDetails { - const { dataSource, details } = item.dataset; +function getDetails(item: PublicationItem): ExtendedDatasetDetails { + const { dataSource, metadata } = item.dataset; + const details = {} as DatasetDetails; + metadata?.forEach((entry) => { + if (entry.key === "species" || entry.key === "brainRegion" || entry.key === "acquisition") { + details[entry.key] = entry.value.toString(); + } + }); return { - details: details || [], + ...details, scale: formatScale(dataSource.scale, 0), name: getDisplayName(item), extent: getDatasetExtentAsString(item.dataset, false), @@ -66,31 +73,28 @@ function getUrl(item: PublicationItem): string { : `/datasets/${item.dataset.owningOrganization}/${item.dataset.name}`; } -function ThumbnailOverlay({ extendedDetails }: { extendedDetails: ExtendedDatasetDetails }) { - const species = extendedDetails.details.find((d) => d.key === "species")?.value; - const brainRegion = extendedDetails.details.find((d) => d.key === "brain region")?.value; - const acquisition = extendedDetails.details.find((d) => d.key === "acquisition")?.value; +function ThumbnailOverlay({ details }: { details: ExtendedDatasetDetails }) { return (
- {species && ( + {details.species && (
- {species} + {details.species}
)} - {brainRegion && ( + {details.brainRegion && (
- {brainRegion} + {details.brainRegion}
)}
@@ -99,7 +103,7 @@ function ThumbnailOverlay({ extendedDetails }: { extendedDetails: ExtendedDatase fontSize: 18, }} > - {extendedDetails.name} + {details.name}
-
{acquisition}
+
{details.acquisition}
- {extendedDetails.scale} + {details.scale}
- {extendedDetails.extent} + {details.extent}
@@ -268,7 +272,7 @@ function PublicationThumbnail({ const segmentationThumbnailURL = hasSegmentation(activeItem.dataset) ? getSegmentationThumbnailURL(activeItem.dataset) : null; - const extendedDetails = getExtendedDetails(activeItem); + const extendedDetails = getDetails(activeItem); return (
@@ -297,7 +301,7 @@ function PublicationThumbnail({ }} /> )} - + {sortedItems.length > 1 && ( model.description, (model) => model.title, (model) => - model.datasets.flatMap((dataset) => [dataset.name, dataset.description, dataset.details]), + model.datasets.flatMap((dataset) => [dataset.name, dataset.description, dataset.metadata]), ], props.searchQuery, ).sort(Utils.compareBy((publication) => publication.publicationDate, false)); diff --git a/frontend/javascripts/oxalis/default_state.ts b/frontend/javascripts/oxalis/default_state.ts index e7eac96a5d4..3e35a77e48b 100644 --- a/frontend/javascripts/oxalis/default_state.ts +++ b/frontend/javascripts/oxalis/default_state.ts @@ -126,7 +126,7 @@ const defaultState: OxalisState = { team: "", }, }, - details: null, + metadata: null, tags: [], isPublic: false, isActive: true, diff --git a/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts b/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts index 21e66c64fea..e35afccdadf 100644 --- a/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts +++ b/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts @@ -42,7 +42,7 @@ test("updateFolder", async (t) => { id: organizationXRootFolderId, allowedTeams: [], name: newName, - details: [], + metadata: [], }); t.is(updatedFolder.name, newName); @@ -70,7 +70,7 @@ test("addAllowedTeamToFolder", async (t) => { id: subFolderId, allowedTeams: [teamId], name: "A subfolder!", - details: [{ type: "string", key: "foo", value: "bar", index: 0 }], + metadata: [{ type: "string", key: "foo", value: "bar", index: 0 }], }); t.snapshot(updatedFolderWithTeam, { diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md index a5be4bec28b..f17f4a49f2b 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md @@ -10,7 +10,6 @@ Generated by [AVA](https://avajs.dev). { colorLayerNames: [], created: 1460379470082, - details: null, displayName: null, folderId: '570b9f4e4bb848d0885ea917', id: '570b9f4e4bb848d0885ee711', @@ -27,7 +26,6 @@ Generated by [AVA](https://avajs.dev). { colorLayerNames: [], created: 1460379470080, - details: null, displayName: null, folderId: '570b9f4e4bb848d0885ea917', id: '570b9f4e4bb848d0885ee713', @@ -44,7 +42,6 @@ Generated by [AVA](https://avajs.dev). { colorLayerNames: [], created: 1460379470079, - details: null, displayName: null, folderId: '570b9f4e4bb848d0885ea917', id: '570b9f4e4bb848d0885ee712', @@ -65,7 +62,6 @@ Generated by [AVA](https://avajs.dev). 'color_3', ], created: 1508495293763, - details: null, displayName: null, folderId: '570b9f4e4bb848d0885ea917', id: '59e9cfbdba632ac2ab8b23b3', @@ -84,7 +80,6 @@ Generated by [AVA](https://avajs.dev). 'color', ], created: 1508495293789, - details: null, displayName: null, folderId: '570b9f4e4bb848d0885ea917', id: '59e9cfbdba632ac2ab8b23b5', @@ -103,7 +98,6 @@ Generated by [AVA](https://avajs.dev). { colorLayerNames: [], created: 1460379603792, - details: null, displayName: null, folderId: '570b9f4e4bb848d0885ea917', id: '570b9fd34bb848d0885ee716', @@ -289,7 +283,6 @@ Generated by [AVA](https://avajs.dev). url: 'http://localhost:9000', }, description: null, - details: null, displayName: null, folderId: '570b9f4e4bb848d0885ea917', isActive: true, @@ -298,6 +291,7 @@ Generated by [AVA](https://avajs.dev). isUnreported: false, lastUsedByUser: 0, logoUrl: '/assets/images/mpi-logos.svg', + metadata: null, name: 'confocal-multi_knossos', owningOrganization: 'Organization_X', publication: null, @@ -431,7 +425,6 @@ Generated by [AVA](https://avajs.dev). url: 'http://localhost:9000', }, description: null, - details: null, displayName: null, folderId: '570b9f4e4bb848d0885ea917', isActive: true, @@ -440,6 +433,7 @@ Generated by [AVA](https://avajs.dev). isUnreported: false, lastUsedByUser: 0, logoUrl: '/assets/images/mpi-logos.svg', + metadata: null, name: 'l4_sample', owningOrganization: 'Organization_X', publication: null, diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.snap index 5e2f40859fb53b2df769efd81b22b0f41d4fe2ee..dd11da71c0baa44687401d6fc5eceabb61c59fdd 100644 GIT binary patch literal 4028 zcmV;t4@2-lRzV62@+p}A_H{tjBhT;spKbqi z=esLYkACBfPu{4czivMPc0YT|bGCcmI`;10*6seuo;ItJ{<`fHcy!qhlD|EE@gp~! z(D>P=wGFo^>8}GDz=|WSS#c`XPM{B13sfogO2xJ=nbf8fCHt4(??-W?SWrBQaz;kb zqbLuV|0|}B+Cc?QR$zw$*D7$g0uL+jyaE;rv{+z=1+K8bUJHE30?%1MwZd8}3|rwQ zD?Da}SFKR3!m;W>KK3mm7wO@YV#)m(2n5pAo~^=pDom=dSA_#A{7i*6RH(PXCu}fj zgD=?NA8hcX4gS{#jdnQS4wu?tuO0r~4sY0DnFCfkM775jR_jHnLGdZdg$}4ztSGih zr9z9v5|`?cb9HUpNS9udS6Z82x_)weGNz@XyL1#kRf;8xa)gt*meM2X->QB6XSNLv zD`{PK<>_)qw3N0jF_{eO>9T*zD|4mGQi-Hg?TP3{I2oNtMHBJ#H>Yr7(dFQyr(?S>v1n%ZvY$&7)K z=8q-D65Erp?C-`^nqlZEV^wrq8`F(d;}g*pCd622>>864$MuwE8o#vZ)OgmarZa>S z@zF$Bi>(-+jHRN(7snHZkuZcvFmY)-8Xwz|9Mj^_%e7p`nXSqHIDEeB$cePAv_p5b z+ejo+(fHUI`c$#21)MYPq^7hn$ty5v=n>P!v@v~XDy17aW9G)i@y>GN;%w((j*A^L zWOq3aJudEaempLYs&V0p^$r`__(V*0(uX<}^1SQ$c@G7rVtL027FXefYAu*hor;k4fWc0d1_Fd#Xw0+lli|u={_oKG&pL-X8eVvNB&kNu3!Yf|z`@~RC}WGa289nXw@;&t@VdH^`4Q=&Vh*5r-#dVp(CYhwdCC0Kl4fl={N=f75-n#LOFCLK1ZSd!eN+pl5=p6O zG9FFkf%StKfkuc&Q3})zDon#DYa)TdHAuH6*Ly)@pdD8h$Co z8j3=uwFX=@P*(%T))dCUHW3Hyn`_|Q8n~neZmEF-HSl7MsH3ZBWk)U8Yavt%TWeu= zE!}K1Of!reaxxlE4U~2Gw65WkvvaOnykI!@qzRq5_)wfP6GnxpCWBCw+JnCpMSjTCq&k#fZ|}<0gM)*E z1HJvdUEN)sgS~mpzh(h6KdZ~9DEA(+@tI|_Pf>n7PsSC7K4y|j$H%!4x0}R!y5%l9 zrX|O8BegA~?cgzrYJIc6ty~*L?LI|mtebm3JCtBo*UIi5vm5*SySlmty83!W*O$c{ z^aiET3`e%J>)^aPn4G5Yg*v#WPDC(wQCxnk4t`VzKdpmb*1>Ca@J?M}B&!gC*5;@O zZ#^7W52x2dxL(v#RaDP4^>AH1e7+vOR1aURhkf;h_0-6E_SeJr>fwcYpaJR{L_Li~ z_4GHui4Cx^0nTZF9SyLvp|GB%vL2%WrW)Xu2KZ(J{7}}@T~yDj4e6P^y(+H~?;jBi8HNv%xqMj3q>bav4?rwy?Z-fUL;ros7q^RdaIn$`ZV{H+A zB9+--sumPcYTv0x$9AT&#hJCmbB!}z>_{ah&eTWc4$*dLG%};rlJ;*~dI40iB)b$E zT#{S(jxHg)RQfaX&&(S6LGw@bNU?r($ziarUz%mTacPcqYH5!3?Mrj4A76STS--vX zFj#ji%d+0SEXR7!vK;HLFUzrhcG(n|_Qv3_iMj`bVMGpwD8+S&}qHN%=_IK8=W=z8Rl z!UfIn>1LQ{hHC`aE5SE4!>!HmH_dRL0Q)5PUz*{GY3oFP~of@4C3 z;D7|L3BlSBYz=`Xz?Bl548dg~xGn^@2ynFoe>DX6gkXON9v9$R34SgFzX-wWA+Wa; zf)`2fq83=v0xMeJBmrJ5!CP8jdkg3-uuFjJB>1`(xM5oR-WK><0j`(e@3z3hE%0Ou zyd=O&B=`?4@Ky`BTcM$~5WGx+k8g$URv2o9Z35gR!I4(Ds1+`6h3f@)xdeZy75-{k z`@UBAo&YyX@RP0Z%(V93w8Gm09FpLQHVC#ss0~)P6@q&tcw-x!)doA-ATGeGCHSf~ zxV8;$ZiBl7xKD!ru?_Z5Yya0a_zwZ@m*C&E!Ryo7ZS7FoUI-qL;P!S{(GDlJ!)5^< zl;GiZ(A!~GJN&T#e?o$9YlpA2!#CRDfB>H)!9Q+?r`qA=c6d{OPnKYJ2Lw8xsROz? z3c+h7c&Gz5b-?)@5EbBc5`1|FTs^J*#tyh$fQRHoiC8%!R>_DJF(tQEiriKyav`mh zl(R@ViOYURR;k25W3^p$%@f!J{_#l?|MBIL;1R?XbrV-?qbx zb_mI=#iC1H|DXd-cEG0{@Hq#VS%uFzAn1fHC!FJiNhjRqgomB*o)g+!Fe;NyicYiq zSGZu03+`~iBQE&0nX}@C?QTf9;bu2{%MCwsL%;*4dEgQc{HaXh@D-`P(*s}gzz;m| zUmgfnz!?>AWd)eY4i8ko3l(7Z!tq|vy>NpUzUH0nZp44U3y*l=WiM3tV5JYnd~ll& z?(@MjK6uv$&3+j2!)`y^;fELHm2+)ThNv&S>3!2bv+4Dx4`tK&4Q4KxyigQLZkE$V zZRg5Ej1Mput;AJcfr#GUC9mm3`kG9jm^FQ^M1Got%qc&u;8?h_yR)l%MQ7iN?t$U; zre=AJ{eF_ke2P*N$jesDKXN`~N7{{)mYU2RdN1FcXo@A`V|ubl%ch4kCE`tHvWsD6 zB(6-?(-kPnHOqO?S%LX_5ev_YdX5G!s>yiKpy@^WWnI0ihdcW(lNT57)03hfk|$kT z#*=QJwF{@+t|sItYIxK-FbxsVP&os7%@7GG&GLenn?V1zC3aXG#UB zZ24zP1^Hr`mkNdYvguT%$_mnu@{Xv~USJJy3b19iW2wwp(*?O>sl7ADQqPk)mU1et zkOIdjFs#6J3f!;2QwqGLz;X+mYMJeH$z?vl4O`%g7C3P5@G5A9UMqac3Rhcadtc%dIbCx&DvTauQb3a_iHU3)zY-=Ty{9HrQ-~ z^KB5e&GMo#ZiAEn$p=H0D{b&O0rHfNiGO0t#>D-$Tul5=TP7yvo|QG&3!j;tZqJ6{ zDLY(chkXYh`Oor9>Mc{y0X+^lPoAjTir~8)V7~Le*8xw>dH}D!;{b~jRy*O7&e<~l z)z3KLvrhP?6P}UqilV4`1YT!&qlWnuyX??A#0!qu2n-GkbdU7(cSZ&V21f>ZJG(pe z(MXRR1f>W_4=zQZXPyN5=0adD7U(?+x?uhU=F$aSd8;4W0B{m827Dg)_G~L4RW7W` i&=sq?GQK62d1ag+gS5^}cO{X%qyG;i{BpvkMF0T2KIowU literal 4044 zcmV;-4>RyVRzVDJaEd&Y$Sw$#ZSXT=r2)onWGddwPaR zgrti6Gr#`b?>YDTJNMjkU*{$UXjnqqV%I#n^4>$eYV6ScXZaMVe+N6E^1@X)G960e-H{K3y| zXt=6zWBpAk{noM-tT@t|l~Y-RKp(IXs8sfHW!scU=u=9~KKcEAlpAG1d6e3o5%eha zFR8yOrK4t0!TS_kq~L&pZz%Yog69-iEYN6yAq!k?ftxIF*aA;m;7toOTj8TtxZVo? zW`*Bap;Cj@+6*82*&`Plp`@~89|M6vy4qbDd{~2|2DfN%p9arp@COZQZP0In^KI~X z8+^+KkJ;e2HmI`0pdBXdaI+o$(+;oMVTA*F9HQEj@~bUFRik`LUEqK!WkuP_l~<2O zL#(!Z{j`hKCij>|cxY;m zX(Te{N7^SEAB*ovL}!0DtkX@?NSf;+OLZl*oX)F>OJ3BF^$0C>M*>+3O&3!q1o@}X!v@!JgTiR{L z6Uj(yY`Za4=xILZoHuDneN6HSOqxbGT+JI00AwaB9sPPMyl)c0oXZ zYyza!xuDSnr@3I4ON>koIUqX5TrfVZ_c|9GbirdTu)1NjTZncD(L5HbCDjYu@G*Bj z)srRYVmC|(&>HzYm$~6eH{9ZegOb#`g710I4L_RJ`#)|7c%a(@ArD;b5u#g5ea|*I zlTqpU%OOvCCX@aBQs#FVsh0F9wcIP>%MN|YNMz#JmYgz6B%FRXk@L=GhV+y#m!a&k z@1?_@xo098*TcnouPb8i4kh$tXm9bp>A5&QVs440y8m47^(l384ue%G%|0e%L3WZKXPIrwk#t`QZ*f z{L~L`NL8m4e9hVb^afxo0M`Trd~E^A9|Yim06ZGV|L~yv@TUUsYyfQKLg(fJoxyVG zD~FvDdPb=aKQph!Os~mv7E7dHS~1eC_lJT;&qy#h5Z3#QP$@5TBn^Fh_`ELJf8vW7 zYmYZR?ld2dQpxh!_32&Z%!Z!lQ;(K3Ys=Bk&b1WLQkJo#qeX4ST(q!{>Y-#jAr(!= zBFSt}i0QHTxREpx$DehNH$K_jh^-Z~t$jsBrnMJp;(2G9i)_{JF1=Nq%Kobgc)0?c zm9VxF&Z~q^R*JCTk`cuIpZ)h@oB9jcscx5`9wWjS`RnW!#(xzBUw*(K|L?m!*A>1je4**KzRc+ zH00OQE9>cKfHe)Uy#X$6fU6orJsS$@`MU=AdINl`0lwD&4>rJKqMpByBaJ3J))qD< zl9>gjWQR`Uzuh7$jW2M`t_AZ z!Mc6bEbHB?vaGLMm1X^ptFo-0UbRTpvQ)6v(ySegZ@JC)Yb46B=Apc%F`=XYI?+)_BN z89v$!v1ZsWz-1DAT{GO+3}0)8?+CC@f*)#zN2j&_ycu2&5vljTT7WiEYXs!9+ zWfEN73iYjUax0uBz{@50tXA0F3ZYg=3UI9iA83VZrnTSP3jZL$brSsjR`}Oec&rtk z7vL2V{J*X6$5wE*L2X+;c$Ea7)CR#e*w_Z=2ymkWkF>$wHrUq&R|{~H1mDyKUzyf^ zunq1P;ARPatPP%=*8XA}ye_~k66|h=Ksz+GLr;4?xJQDww8QpxxUe0f0=!;=uV{y> zra@n5hdTtgPlErs9S%=x|MzxyMu7Vz_`loXchlOf9Z=Pg4<3-q16UUeqyh1SNjU|6R{LY!Q zxZJ`fEiSjPNsE8q!X_=+gzuU3s7#uZujokSDQ*RJ0T%+Jz$buDE|kA$Pp=U^lg(dz zf$VZ(i^?u1?oip~gw>K+PI#0WNj=(9^#|^^z=IZe(E?s8oMeR^RybgV?^@xfR`{b8 z>NVJ?K}>_MYw%+Y{I-R18Ut-MIN1g}Y%pPiYi)4Q1~1v5!44bj@DV%gx5FKFc)|{T z2lPAOQklD0aJU=z>@?&H4!FkwPdeZ=2P}8OIZpVr6H;l1PVl;*-vtvc_>v2r zlnE#WJ6nNQT=07rRJ&o&4d=RH$_;nB;a6_ZJkaKWZ5|l+z*ju*lqVgnS>+Y#eFf^* zdtrkYKH`Ndy>Ocs?(@RyUO1%;wwFP)3_e!|_msiYWl-sZ4L&$uo_rUamjvQInDoJo zJ~-rqCw*|l2R(im^}_)_e9aFJ`{5No_yTZd0OA4oraW7&Daaf3rx(LN49qQt{prox zblyWMiA)|eirhEL`$ugblA9avT8dhc1He2HqrXcY?uo26nRc;YR$Gy@H3?Z%+FIUb za(Q>Kt9wnbZ%y~W@aB|ex%vHeQqX)#{UWD*YjQWL=WS-Ynbeb$*{$-Xo$`XP^|9LWrTQ2rnveCI2tHPw@v*^_j~V;Ade;vJ z`}fJikhke)>&xF6KRc&XBLgGdJtGSQvbUWAeyno5|IF7H zd`i7?l#WJPi@RceH)^j~svBK&qPfxf6Tyv+NxHvJsqGcV!gD4{c+TG}*>iU7-g{h+ zaN!;?!u6d9Bb+rcHga@6^Y=&TGm9P89w_vx`~+WdBP6d(D^X@EQRXO7<}6X>DpBSs zQRXdCR&a9qTm`&P0dG~nvdaA38h;5zff8l%sM8Xxgg&9XQi66=!UqMYszfc-CCZkS zC|h2lEdLC%U|%dRV=#ArEH5oDcb_aT_bqelB46KZYFM$dyp*b(?KZ6!I2AYpID4Tj zw#>fS1=%gOTj#deo+Gox=2Wg01*;VdEBK6pLkgZ$a700q1-4oi+9h*cWPy+czHEWi zhTQKhP+^5$D_m%WE3FINm2!R23b$I}aVxxGg|!;&nAxwpL4!l`{;|7YL&qtT7>sc} zlD)vOe9|-$!<#myuBxPB#CRkYHrIyY<3h?c8cCSRojF0yo{?O5krK{iT=#}&GOkDE za1${%g~ubY;?dU=F*n8Hv8nO+OmHiR@qvhWMk=Zq$+PsBJ~k7f3vU~y!g%fnXhZR- zP3b83A6m|(on+=7=prpQv}DT-m9SjjQY=?+LFmtFxuLY>-deilHZR$7n@d=(|6R45 zgcY^i=CtKnY=xF{Ds7t$cG}=P8-#3>8jF=BW`m>v$;(fcPubwp0^}(k6I0vxnV9%} zTQ(;C#+HeR*(-AO_WUbzAF$7c;i;J||GQ_l{1>?LbtEM?^S>FKD^CS%1@Oxq@RvEK z0t?&_`;!AKPFU}R^PLObUeK;^!c|WAwiBL~aBo3WJq9;T$|7d!R(Z*;H_vU9g$N7| z40Mn5^asNO1A`+2y}|CFF&gfXouC*2>A}Sa^emA;-(m$c^+P`J`9mLH1<00000000B+ zS4(IVNf`eAs_vdqlgz|7dss(NR5UV)i5V4kjWO#3As`W7;82~eXzR>$=;?_WbBG>< zJqd!a5OPovWY_g#+&u^if{2JW59&dDfbLVH-Le|`0R zO`if_Ai4Xe-UXNM>wO!38T_?+Fi9x~LCo!V;cPO0|qg%c4=$>h2L4`=Ib_CzIR`3J)ED-sHL>Ppr6#L?UX8mx*a5 zv67?L>H(|*uns_L@wngC(0lt$MxnzgL_3Fyrw|=0{FA@DURbiimc}apdf_$^u!Dds z0sW*P$=*jYOvbG@>30dJU_cE6)-#}sDP`J-vU3a=V89awgg7vl1Dm;0W{kKudLYCT z@y6OZ6}C|jw|PLsE1F{2oY~=|Ea}cw5{*vb%8WOL%lK(CnPZ#jNYI(4Hf0=duXMl4 zWI_0OGU#L@_&MrSyfO$u^)j*0hsisB-CR&sH#dD|-3-DaC#Y^JED94lPCy@_oO}{H zO~4;YlBOnIAmE~sWT@geq=Q%eckDa+xnj>_MdjVffL#nY%7F8#&T&=ew8sqimjOR= zV6Ccex|*)%IMBm^-#G9$2kxpRp%GVte;iD+PBga1<#IC~Pgr7wY%hmM$de+S++M5X zR5&FB8kJQOupdn+)a{HVyM0T8665=2et6yCIBH*BzH_)QBkQ?#W{sa zq!oKd_b>T*G=803UG}uMeZ?=+Y)}m5xw2qdu~OQ^x?`^6cyICe5^%f*1No%I%Mbtn DtJ%a0 literal 916 zcmV;F18e+2RzVA1Iv=bes)u>t%ur8Nu?ku8{KWz9IQ!9Nlw8MTFJriP3GG;x|s>Hvzm>E zSV8d8fDQ*8=(0mvfPpS5&(^W-2@yVz$M@+ zNsDBw7a1h|PXgu$m?xlt0UZqRn3SfjNttnt6L-KjFEIwrf}<}Kz)e2!uHCzafO!7ZAeQ-MJ>JCYCGO>;cjSS*D7sn zGr+lSQ%cR7pi?X13%nY+m2+l8NzUvllQZo~IdkJ76eKoAz!gF%>Lm6v0h3aYDhEvy z@S7B*%fz_Mfxr8o*O&TJNv~&l*6m?HF9W_?A+J^YQe=)(?=#>L13pxMR+-y0Ib8D; zU_=2fE5J^Gv(gvmb_{iQY%@B|<1JK7}0R69lZSr=4q zVLhsps9ClthJ8(iV|qZr)%{{la}1^g}G zZvlTj;LiYfP%iLyl>~mgOyD1RBj7X3tmxbRK9AVqbSfL^cLn!^8F`8J&HOQ{oMl@| q4*TmehwZ)vzoZ|#BBz>>V$ei}Bd+6kE9=;dZM*=Aa+zJj5C8yKBfVPy diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.md index 8814008d35c..387cca38e83 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.md @@ -1,4 +1,4 @@ -# Snapshot report for `public/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js` +# Snapshot report for `public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js` The actual snapshot is saved in `volumetracing_saga_integration.spec.js.snap`. diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.snap index 270cd5455f2e7cf9e58a8dcadf5c17c7372125e9..a01872b6b9ab9f5760c2002e005f426efb458291 100644 GIT binary patch literal 1476 zcmZ<^b5sb#KMc?T+SuV(QjY#3t^(p!j~;s{^irt z?Y|#Z_pkf;w| z53|?bU-tg^C@DxeEUn%PvZCgePv&_{r{_WyRUoy?|FXzJHLI* z>$jWt_U`$2+28Kh{r>+ilJEccbAEo+yWY?7SE_#Zr5XSK{jdDJ?e%kZf6rgPU-9;y z_jkLf*KaSMRnFYB^Iy}f5M$q>Ij20e9^aC<`r%F0Jy{t!U9snAbfwwjS8qcnOZ8;EPP%#4Fa7iBcUgO1nR}Yv++#U|fD?9CJxQ$BCEt3m z&T^OBme9-s)MsJ={0TVAb00RCDXOXZ3fl z{<`YE%GH$5&k};pnw%`uQeWAVog8v_^Xtm;f{HB>bM#l|oqy{$`_}n)uO`Ob%lYAO zcj|BU-E#YmW_wOjp&_dJJ>`~P=&G|js;aLpVOxA_S47$7%QydQ+8wq2mv4}-+2$a^ wp~ga4kTHafrXFYrQYZDiId}({c)ng+$4FVk+;hVdF@NRPw#zX1v2ZW|01uHYsQ>@~ literal 1488 zcmZ<^b5sbZ}AI_OR)874v^2aOZ z?LMsj)#`r#Z|Aq`^E3ZH;NO4pMx~L_w*C40KOFhH{_jU_{r#U#{g$u(`16kcf4Q|4 z--_*PivFEGzWjad^Ec}AE3dEizwaY|TIhWJ^V#)3->m=tYkB&+_4mFn)!+B!=^g+0 zGi7`J+}bPO|L@wbZxug&uw)+2%yHBq2*S@O$UwMB2`}uVi z-!G>}->vw&e16TB{qp}W9^d!z=Y0FGcci=HpVa;8yVU)^_W$nh^Hxur|L^(h{5@sw zmsZQ4{rlzRCyyyF-u`#&+w3#D_S&Ckm7Cpe%BK8(R24ga&b(Z+?%Zc@i%UaX{gNVP zZD)i8GTEecrNqpMp*@>Q=csNnKGA$Scdt$Sy7jNF)?Lv%yH{xMZ1%@Gm(%9E9@`VS z_zmBt`og{I{Qb_G%|2@O`P)vbRZ8lHcWR$J{1;BP{Sb}sdTe^moX~Q+X?0HMrsR`@ zPq)R*)4v}6HOoG0`m?ysduN*qr)6$FuatamO<)=O&HX##uB$IkpM5rIcIEFF+0coT zCEnS6D)_HWp)<K4Crgy&K`ShSDTL1j?*Q=|x%5U{9Uhne$*+GlbTW+RL zG|a!IvAg+Ae9ZdSlP_;Ro3`=np4xSNt4?_Gl*?xxo&8mQ#qLl44#s`I|0vsYk_rt` z)o&5}@pt1S;eZpOFw{0oZl5R_c~m3%c%+hbv(1h>*G_+a`fF9(Tlcq@?r2}!U1@O6 zDEC`r%9+`wr`{?27Qg=b*OMyw^{E7S)EX|F*a+^WD@JcWornXPW)iF?x1ZDz$vV-|p9?wMA9iZt85C zx&8drKvB!ihwqg6zwc+F%ozpgPZCqy!VFJcE1Y)XoC3ez_OG96cJ5mh{yT8D$#<_W zd2&6VTrDQ~Ijwh-^@)E+zwWZLw7s1pcKgij=dV_D%{fx=ePY<2o6S45-^*W#uI8o8 VIUmd>;fb8z_G{Z^82nf`7yyckHFp32 diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index e7cecbe6c22..bc9109ed7aa 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -168,13 +168,13 @@ export type MutableAPIDatasetId = { name: string; }; export type APIDatasetId = Readonly; -export type APIDetail = { +export type APIMetadata = { type: "number" | "string" | "string[]"; key: string; value: string | number | string[]; index: number; }; -export type APIDetails = APIDetail[]; +export type APIMetadataEntries = APIMetadata[]; type MutableAPIDatasetBase = MutableAPIDatasetId & { isUnreported: boolean; @@ -184,7 +184,7 @@ type MutableAPIDatasetBase = MutableAPIDatasetId & { created: number; dataStore: APIDataStore; description: string | null | undefined; - details: APIDetails | null | undefined; + metadata: APIMetadataEntries | null | undefined; isEditable: boolean; isPublic: boolean; displayName: string | null | undefined; @@ -227,7 +227,7 @@ export type APIDatasetCompactWithoutStatusAndLayerNames = Pick< | "lastUsedByUser" | "tags" | "isUnreported" - | "details" + | "metadata" >; export type APIDatasetCompact = APIDatasetCompactWithoutStatusAndLayerNames & { id?: string; @@ -253,7 +253,7 @@ export function convertDatasetToCompact(dataset: APIDataset): APIDatasetCompact lastUsedByUser: dataset.lastUsedByUser, status: dataset.dataSource.status, tags: dataset.tags, - details: dataset.details, + metadata: dataset.metadata, isUnreported: dataset.isUnreported, colorLayerNames: colorLayerNames, segmentationLayerNames: segmentationLayerNames, @@ -1072,7 +1072,7 @@ export type FlatFolderTreeItem = { name: string; id: string; parent: string | null; - details: APIDetails; + metadata: APIMetadataEntries; isEditable: boolean; }; @@ -1083,7 +1083,7 @@ export type FolderItem = { parent: string | null | undefined; children: FolderItem[]; isEditable: boolean; - details: APIDetails; + metadata: APIMetadataEntries; // Can be set so that the antd tree component can disable // individual folder items. disabled?: boolean; @@ -1094,7 +1094,7 @@ export type Folder = { id: string; allowedTeams: APITeam[]; allowedTeamsCumulative: APITeam[]; - details: APIDetails; + metadata: APIMetadataEntries; isEditable: boolean; }; @@ -1102,7 +1102,7 @@ export type FolderUpdater = { id: string; name: string; allowedTeams: string[]; - details: APIDetails; + metadata: APIMetadataEntries; }; export enum CAMERA_POSITIONS { diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index eb5a4c5fcd1..612a60504a2 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -123,14 +123,14 @@ CREATE TABLE webknossos.datasets( sharingToken CHAR(256), logoUrl VARCHAR(2048), sortingKey TIMESTAMPTZ NOT NULL, - details JSONB, + metadata JSONB, tags VARCHAR(256)[] NOT NULL DEFAULT '{}', created TIMESTAMPTZ NOT NULL DEFAULT NOW(), isDeleted BOOLEAN NOT NULL DEFAULT false, UNIQUE (name, _organization), CONSTRAINT defaultViewConfigurationIsJsonObject CHECK(jsonb_typeof(defaultViewConfiguration) = 'object'), CONSTRAINT adminViewConfigurationIsJsonObject CHECK(jsonb_typeof(adminViewConfiguration) = 'object'), - CONSTRAINT detailsIsJsonObject CHECK(jsonb_typeof(details) = 'array') + CONSTRAINT metadataIsJsonArray CHECK(jsonb_typeof(metadata) = 'array') ); CREATE TYPE webknossos.DATASET_LAYER_CATEGORY AS ENUM ('color', 'mask', 'segmentation'); @@ -518,8 +518,8 @@ CREATE TABLE webknossos.folders( _id CHAR(24) PRIMARY KEY, name TEXT NOT NULL CHECK (name !~ '/'), isDeleted BOOLEAN NOT NULL DEFAULT false, - details JSONB DEFAULT '[]', - CONSTRAINT detailsIsJsonArray CHECK(jsonb_typeof(details) = 'array') + metadata JSONB DEFAULT '[]', + CONSTRAINT metadataIsJsonArray CHECK(jsonb_typeof(metadata) = 'array') ); CREATE TABLE webknossos.folder_paths( From b305e7c9dc6b692bc55aae4b25cc31646325fa94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Tue, 2 Jul 2024 10:55:30 +0200 Subject: [PATCH 27/84] fix schema --- tools/postgres/schema.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index 612a60504a2..57a8e095219 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -20,7 +20,7 @@ CREATE TABLE webknossos.releaseInformation ( schemaVersion BIGINT NOT NULL ); -INSERT INTO webknossos.releaseInformation(schemaVersion) values(117); +INSERT INTO webknossos.releaseInformation(schemaVersion) values(118); COMMIT TRANSACTION; @@ -123,7 +123,7 @@ CREATE TABLE webknossos.datasets( sharingToken CHAR(256), logoUrl VARCHAR(2048), sortingKey TIMESTAMPTZ NOT NULL, - metadata JSONB, + metadata JSONB DEFAULT '[]', tags VARCHAR(256)[] NOT NULL DEFAULT '{}', created TIMESTAMPTZ NOT NULL DEFAULT NOW(), isDeleted BOOLEAN NOT NULL DEFAULT false, From a85361170604e460cbb9846da1fece5c69f873f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Tue, 2 Jul 2024 11:14:35 +0200 Subject: [PATCH 28/84] remove unused backend imports --- app/controllers/InitialDataController.scala | 2 +- app/models/dataset/Dataset.scala | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/controllers/InitialDataController.scala b/app/controllers/InitialDataController.scala index 66ab22755b3..01b230337e7 100644 --- a/app/controllers/InitialDataController.scala +++ b/app/controllers/InitialDataController.scala @@ -13,7 +13,7 @@ import models.task.{TaskType, TaskTypeDAO} import models.team._ import models.user._ import net.liftweb.common.{Box, Full} -import play.api.libs.json.{JsArray, JsObject, Json} +import play.api.libs.json.{JsArray, Json} import utils.{ObjectId, StoreModules, WkConf} import javax.inject.Inject diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index b4c450d10fc..fccd8f33807 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -24,7 +24,6 @@ import controllers.DatasetUpdateParameters import javax.inject.Inject import models.organization.OrganizationDAO -import play.api.libs.json.JsonConfiguration.Aux import play.api.libs.json._ import play.utils.UriEncoding import slick.jdbc.PostgresProfile.api._ From 113bb6ee1c787158b083e71547fe1f3ab4b40f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Tue, 2 Jul 2024 12:00:48 +0200 Subject: [PATCH 29/84] only update metadata set of metadata table when folder / dataset changes --- frontend/javascripts/dashboard/folders/details_sidebar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 71d833497f5..67592a65852 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -145,6 +145,7 @@ function MetadataTable({ console.log("datasetMeta", selectedDatasetOrFolder.metadata, "metadata state", metadata); + // biome-ignore lint/correctness/useExhaustiveDependencies: Only update when the actual dataset / folder changes. useEffect(() => { // Flush pending updates: updateCachedDatasetOrFolderDebounced.flush(); @@ -154,7 +155,7 @@ function MetadataTable({ ? selectedDatasetOrFolder.metadata : [{ key: "", value: "", index: 0, type: "string" as APIMetadata["type"] }], ); - }, [selectedDatasetOrFolder]); + }, [selectedDatasetOrFolder.id || selectedDatasetOrFolder.name]); useEffectOnUpdate(() => { updateCachedDatasetOrFolderDebounced( From e1bb6609e680872e97f8b6c445e988ed8aad9e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Tue, 2 Jul 2024 13:20:47 +0200 Subject: [PATCH 30/84] ensure flushing updates on unmount of metadata table & increase debounce time --- .../javascripts/dashboard/folders/details_sidebar.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 67592a65852..2d3d6ce9d9e 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -40,6 +40,7 @@ import { OxalisState } from "oxalis/store"; import { getOrganization } from "admin/admin_rest_api"; import { useQuery } from "@tanstack/react-query"; import { useEffectOnUpdate } from "libs/react_hooks"; +import { useWillUnmount } from "beautiful-react-hooks"; export function DetailsSidebar({ selectedDatasets, @@ -124,7 +125,7 @@ const updateCachedDatasetOrFolderDebounced = _.debounce( } setIgnoreFetching(false); }, - 2000, + 3000, ); function MetadataTable({ @@ -166,6 +167,11 @@ function MetadataTable({ ); }, [metadata]); + // On component unmount flush pending updates to avoid potential data loss. + useWillUnmount(() => { + updateCachedDatasetOrFolderDebounced.flush(); + }); + const updatePropName = (previousPropName: string, newPropName: string) => { setMetadata((prev: APIMetadataEntries) => { const entry = prev.find((prop) => prop.key === previousPropName); From eee0471208af5fe02eb507a0179445b095288d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 4 Jul 2024 13:26:21 +0200 Subject: [PATCH 31/84] remove accidental change --- app/controllers/ConfigurationController.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/ConfigurationController.scala b/app/controllers/ConfigurationController.scala index d51177885e9..c9cc3a95243 100755 --- a/app/controllers/ConfigurationController.scala +++ b/app/controllers/ConfigurationController.scala @@ -81,7 +81,7 @@ class ConfigurationController @Inject()( dataset <- datasetDAO.findOneByNameAndOrganizationName(datasetName, organizationName) ?~> "dataset.notFound" ~> NOT_FOUND _ <- datasetService.isEditableBy(dataset, Some(request.identity)) ?~> "notAllowed" ~> FORBIDDEN jsObject <- request.body.asOpt[JsObject].toFox ?~> "user.configuration.dataset.invalid" - p_ <- datasetConfigurationService.updateAdminViewConfigurationFor(dataset, jsObject.fields.toMap) + _ <- datasetConfigurationService.updateAdminViewConfigurationFor(dataset, jsObject.fields.toMap) } yield JsonOk(Messages("user.configuration.dataset.updated")) } } From 48665a75420942b103d9e73c1d2d3b90eba410d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 4 Jul 2024 13:26:58 +0200 Subject: [PATCH 32/84] WIP apply styling feedback & refactor handling metadataentry type --- .../dashboard/folders/details_sidebar.tsx | 199 ++++++++---------- frontend/javascripts/types/api_flow_types.ts | 8 +- frontend/stylesheets/_dashboard.less | 9 + 3 files changed, 106 insertions(+), 110 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 2d3d6ce9d9e..99fe6d6dc5d 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -28,7 +28,13 @@ import { VoxelSizeRow, } from "oxalis/view/right-border-tabs/dataset_info_tab_view"; import React, { useEffect, useState } from "react"; -import { APIDatasetCompact, APIMetadata, APIMetadataEntries, Folder } from "types/api_flow_types"; +import { + APIDatasetCompact, + APIMetadata, + APIMetadataEntries, + APIMetadataType, + Folder, +} from "types/api_flow_types"; import { DatasetLayerTags, TeamTags } from "../advanced_dataset/dataset_table"; import { DatasetCollectionContextValue, @@ -42,6 +48,17 @@ import { useQuery } from "@tanstack/react-query"; import { useEffectOnUpdate } from "libs/react_hooks"; import { useWillUnmount } from "beautiful-react-hooks"; +function metadataTypeToString(type: APIMetadata["type"]) { + switch (type) { + case "string": + return "abc"; + case "number": + return "123"; + case "string[]": + return "a,b"; + } +} + export function DetailsSidebar({ selectedDatasets, setSelectedDataset, @@ -144,8 +161,6 @@ function MetadataTable({ const [error, setError] = useState<[string, string] | null>(null); // [propName, error message] const [focusedRow, setFocusedRow] = useState(null); - console.log("datasetMeta", selectedDatasetOrFolder.metadata, "metadata state", metadata); - // biome-ignore lint/correctness/useExhaustiveDependencies: Only update when the actual dataset / folder changes. useEffect(() => { // Flush pending updates: @@ -195,7 +210,7 @@ function MetadataTable({ const newEntry: APIMetadata = { key: newPropName, value: "", - type: "string", + type: APIMetadataType.STRING, index: highestIndex + 1, }; return [...prev, newEntry]; @@ -254,45 +269,27 @@ function MetadataTable({ sortedDetails.flatMap((detail) => (detail.type === "string[]" ? detail.value : [])), ).map((tag) => ({ value: tag, label: tag })); - const renderTypeTag = (type: APIMetadata["type"]) => { - switch (type) { - case "string": - return str; - case "number": - return 012; - case "string[]": - return {`[""]`}; - } - }; - - const getTypeSelectDropdownMenu: (arg0: number) => MenuProps = (propertyIndex: number) => ({ - items: [ - { - key: 0, - label: str, - onClick: () => updateType(propertyIndex, "string"), - }, - { - key: 1, - label: 012, - onClick: () => updateType(propertyIndex, "number"), - }, - { - key: 2, - label: {`[""]`}, - onClick: () => updateType(propertyIndex, "string[]"), - }, - ], + const getTypeSelectDropdownMenu: (propertyIndex: number) => MenuProps = ( + propertyIndex: number, + ) => ({ + items: Object.values(APIMetadataType).map((type) => { + return { + key: type, + label: metadataTypeToString(type as APIMetadata["type"]), + onClick: () => updateType(propertyIndex, type as APIMetadata["type"]), + }; + }), }); const getValueInput = (record: APIMetadata) => { + const isFocused = record.index === focusedRow; switch (record.type) { case "number": return ( setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - variant={record.index === focusedRow ? "outlined" : "borderless"} + style={{ borderColor: isFocused ? undefined : "transparent" }} value={record.value as number} onChange={(newNum) => updateValue(record.key, newNum?.toString() || "")} placeholder="Value" @@ -304,7 +301,7 @@ function MetadataTable({ setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - variant={record.index === focusedRow ? "outlined" : "borderless"} + style={{ borderColor: isFocused ? undefined : "transparent" }} value={record.value} onChange={(evt) => updateValue(record.key, evt.target.value)} placeholder="Value" @@ -316,9 +313,8 @@ function MetadataTable({ - - + + + - {sortedDetails.map((record) => ( - - - - - - - - ))} + {sortedDetails.map((record) => { + const isFocused = record.index === focusedRow; + return ( + + + + + + + + ); + })}
- - Type - - - - Property - - TypeProperty - - - Value - - Value
- - {renderTypeTag(record.type)} - - - setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} - variant={record.index === focusedRow ? "outlined" : "borderless"} - value={record.key} - onChange={(evt) => updatePropName(record.key, evt.target.value)} - placeholder="New property" - size="small" - /> - {error != null && error[0] === record.key ? ( - <> -
- {error[1]} - - ) : null} -
:{getValueInput(record)} - deleteKey(record.key)} - style={{ - color: "var(--ant-color-text-tertiary)", - visibility: record.key === "" ? "hidden" : "visible", - }} - disabled={record.key === ""} - /> -
+ + + {metadataTypeToString(record.type)} + + + + setFocusedRow(record.index)} + onBlur={() => setFocusedRow(null)} + style={{ borderColor: isFocused ? undefined : "transparent" }} + value={record.key} + onChange={(evt) => updatePropName(record.key, evt.target.value)} + placeholder="New property" + size="small" + /> + {error != null && error[0] === record.key ? ( + <> +
+ {error[1]} + + ) : null} +
:{getValueInput(record)} + deleteKey(record.key)} + style={{ + color: "var(--ant-color-text-tertiary)", + visibility: record.key === "" ? "hidden" : "visible", + }} + disabled={record.key === ""} + /> +
; + +export enum APIMetadataType { + STRING = "string", + NUMBER = "number", + STRING_ARRAY = "string[]", +} export type APIMetadata = { - type: "number" | "string" | "string[]"; + type: APIMetadataType; key: string; value: string | number | string[]; index: number; diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index 8f433d729c9..d498c7ccdf9 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -240,6 +240,15 @@ pre.dataset-import-folder-structure-hint { border-radius: var(--ant-border-radius-sm); border-collapse: separate; table-layout: fixed; + + th { + padding: 4px !important; + font-weight: normal; + } + .ant-table-cell { + padding: 4px 4px 4px 0px !important; + } + thead tr th:first-child, tbody tr td:first-child { width: 35px; From 9a8c6bb0e3fc112ca909f2f34bd9259aac98a3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 4 Jul 2024 15:54:36 +0200 Subject: [PATCH 33/84] remove unused css --- frontend/stylesheets/_dashboard.less | 8 -------- 1 file changed, 8 deletions(-) diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index d498c7ccdf9..8cc3bf7936b 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -224,14 +224,6 @@ pre.dataset-import-folder-structure-hint { .top-aligned-column { vertical-align: top; } -.metadata-table { - th.ant-table-cell { - padding: 4px !important; - } - .ant-table-cell { - padding: 4px 4px 4px 0px !important; - } -} .metadata-table { border: var(--ant-line-width) var(--ant-line-type) var(--ant-color-border); From 6cdfa743a4231051eec0dc0ab05c9e0f366c50b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 4 Jul 2024 22:31:28 +0200 Subject: [PATCH 34/84] keep old dataset while updating & refetching in the dataset details view --- frontend/javascripts/dashboard/dataset/queries.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/javascripts/dashboard/dataset/queries.tsx b/frontend/javascripts/dashboard/dataset/queries.tsx index f1f3c9dafe0..928f2480c61 100644 --- a/frontend/javascripts/dashboard/dataset/queries.tsx +++ b/frontend/javascripts/dashboard/dataset/queries.tsx @@ -66,6 +66,7 @@ export function useDatasetQuery(datasetId: APIDatasetId) { }, { refetchOnWindowFocus: false, + keepPreviousData: true, }, ); } From 128f84c576bce57124e225d66f61c264df7367bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 4 Jul 2024 22:32:12 +0200 Subject: [PATCH 35/84] remove unused code as search support for metadata entries is currently not planned --- .../dashboard/advanced_dataset/dataset_table.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx index 7d2472f3c41..3a8ab818dfa 100644 --- a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx +++ b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.tsx @@ -519,11 +519,6 @@ class DatasetTable extends React.PureComponent { const datasetToRankMap: Map = new Map( dataSourceSortedByRank.map((dataset, rank) => [dataset, rank]), ); - const getNameAndMetaData = (datasetOrFolder: DatasetOrFolder) => { - return `${datasetOrFolder}${Object.entries(datasetOrFolder.metadata || {}) - .map(([key, value]) => `${key}:${value}`) - .join(",")}`; - }; const sortedDataSource = // Sort using the dice coefficient if the table is not sorted by another key // and if the query is at least 3 characters long to avoid sorting *all* datasets @@ -531,8 +526,7 @@ class DatasetTable extends React.PureComponent { ? _.chain([...filteredDataSource, ...activeSubfolders]) // TODO: Check whether this is dead code as columnKey never seems to be null. .map((datasetOrFolder) => { - const nameAndMetadata = getNameAndMetaData(datasetOrFolder); - const diceCoefficient = dice(nameAndMetadata, this.props.searchQuery); + const diceCoefficient = dice(datasetOrFolder.name, this.props.searchQuery); const rank = useLruRank ? datasetToRankMap.get(datasetOrFolder) || 0 : 0; const rankCoefficient = 1 - rank / filteredDataSource.length; const coefficient = (diceCoefficient + rankCoefficient) / 2; From ee5865d65a4fc2d39e72157808089ebe0e9dc6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 4 Jul 2024 22:40:37 +0200 Subject: [PATCH 36/84] do not include metadata in dataset compact version --- app/models/dataset/Dataset.scala | 8 ++---- app/models/dataset/DatasetService.scala | 2 +- .../dashboard/folders/details_sidebar.tsx | 28 +++++++++---------- .../backend-snapshot-tests/folders.e2e.ts | 3 +- frontend/javascripts/types/api_flow_types.ts | 2 -- 5 files changed, 19 insertions(+), 24 deletions(-) diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index fccd8f33807..12a12c8aca0 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -76,7 +76,6 @@ case class DatasetCompactInfo( lastUsedByUser: Instant, status: String, tags: List[String], - metadata: Option[JsArray], isUnreported: Boolean, colorLayerNames: List[String], segmentationLayerNames: List[String], @@ -270,7 +269,6 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA COALESCE(lastUsedTimes.lastUsedTime, ${Instant.zero}), d.status, d.tags, - d.metadata, cl.names AS colorLayerNames, sl.names AS segmentationLayerNames FROM @@ -300,7 +298,6 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA String, String, String, - String, String)]) } yield rows.toList.map( @@ -317,10 +314,9 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA lastUsedByUser = row._9, status = row._10, tags = parseArrayLiteral(row._11), - metadata = JsonHelper.parseAndValidateJson[JsArray](row._12), isUnreported = unreportedStatusList.contains(row._10), - colorLayerNames = parseArrayLiteral(row._13), - segmentationLayerNames = parseArrayLiteral(row._14) + colorLayerNames = parseArrayLiteral(row._12), + segmentationLayerNames = parseArrayLiteral(row._13) )) private def buildSelectionPredicates(isActiveOpt: Option[Boolean], diff --git a/app/models/dataset/DatasetService.scala b/app/models/dataset/DatasetService.scala index 1bb90157ee2..f11b570df45 100644 --- a/app/models/dataset/DatasetService.scala +++ b/app/models/dataset/DatasetService.scala @@ -107,7 +107,7 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, sharingToken = None, status = dataSource.statusOpt.getOrElse(""), logoUrl = None, - metadata = publication.map(_ => metadata) + metadata = Some(metadata) ) _ <- datasetDAO.insertOne(dataset) _ <- datasetDataLayerDAO.updateLayers(newId, dataSource) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 99fe6d6dc5d..92a4cdccd61 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -29,6 +29,7 @@ import { } from "oxalis/view/right-border-tabs/dataset_info_tab_view"; import React, { useEffect, useState } from "react"; import { + APIDataset, APIDatasetCompact, APIMetadata, APIMetadataEntries, @@ -124,19 +125,19 @@ function getMaybeSelectMessage(datasetCount: number) { const updateCachedDatasetOrFolderDebounced = _.debounce( async ( context: DatasetCollectionContextValue, - selectedDatasetOrFolder: APIDatasetCompact | Folder, + selectedDatasetOrFolder: APIDataset | Folder, metadata: APIMetadataEntries, - setIgnoreFetching: (value: boolean) => void, + setIgnoreFetching: (value: boolean) => void, // remove me ) => { // Explicitly ignoring fetching here to avoid unnecessary rendering of the loading spinner and thus hiding the metadata table. setIgnoreFetching(true); - if ("status" in selectedDatasetOrFolder) { + if ("folderId" in selectedDatasetOrFolder) { await context.updateCachedDataset(selectedDatasetOrFolder, { metadata: metadata }); } else { - const folder = selectedDatasetOrFolder as Folder; + const folder = selectedDatasetOrFolder; await context.queries.updateFolderMutation.mutateAsync({ ...folder, - allowedTeams: folder.allowedTeams.map((t) => t.id), + allowedTeams: folder.allowedTeams?.map((t) => t.id) || [], metadata, }); } @@ -149,7 +150,7 @@ function MetadataTable({ selectedDatasetOrFolder, setIgnoreFetching, }: { - selectedDatasetOrFolder: APIDatasetCompact | Folder; + selectedDatasetOrFolder: APIDataset | Folder; setIgnoreFetching: (value: boolean) => void; }) { const context = useDatasetCollectionContext(); @@ -171,7 +172,7 @@ function MetadataTable({ ? selectedDatasetOrFolder.metadata : [{ key: "", value: "", index: 0, type: "string" as APIMetadata["type"] }], ); - }, [selectedDatasetOrFolder.id || selectedDatasetOrFolder.name]); + }, [selectedDatasetOrFolder.name]); useEffectOnUpdate(() => { updateCachedDatasetOrFolderDebounced( @@ -506,6 +507,12 @@ function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompac )}
+ {fullDataset && ( + + )} {fullDataset?.usedStorageBytes && fullDataset.usedStorageBytes > 10000 ? ( @@ -516,13 +523,6 @@ function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompac
) : null} - - {selectedDataset.isActive ? ( - - ) : null} ); } diff --git a/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts b/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts index e35afccdadf..c8bf55720a3 100644 --- a/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts +++ b/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts @@ -10,6 +10,7 @@ import { import Request from "libs/request"; import * as foldersApi from "admin/api/folders"; import test from "ava"; +import { APIMetadataType } from "types/api_flow_types"; test.before("Reset database and change token", async () => { resetDatabase(); setCurrToken(tokenUserA); @@ -70,7 +71,7 @@ test("addAllowedTeamToFolder", async (t) => { id: subFolderId, allowedTeams: [teamId], name: "A subfolder!", - metadata: [{ type: "string", key: "foo", value: "bar", index: 0 }], + metadata: [{ type: APIMetadataType.STRING, key: "foo", value: "bar", index: 0 }], }); t.snapshot(updatedFolderWithTeam, { diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 5346d2d45dd..1ae2feee992 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -233,7 +233,6 @@ export type APIDatasetCompactWithoutStatusAndLayerNames = Pick< | "lastUsedByUser" | "tags" | "isUnreported" - | "metadata" >; export type APIDatasetCompact = APIDatasetCompactWithoutStatusAndLayerNames & { id?: string; @@ -259,7 +258,6 @@ export function convertDatasetToCompact(dataset: APIDataset): APIDatasetCompact lastUsedByUser: dataset.lastUsedByUser, status: dataset.dataSource.status, tags: dataset.tags, - metadata: dataset.metadata, isUnreported: dataset.isUnreported, colorLayerNames: colorLayerNames, segmentationLayerNames: segmentationLayerNames, From 050129b9ee402e80fd2d3d478beeb76c5fe204ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 5 Jul 2024 14:05:31 +0200 Subject: [PATCH 37/84] Fix Dataset refetching - also update datasetById when updating a dataset in the dashboard - Avoid spinner when updating metadata - Avoid unnecessary updates by guarding the debounced flush against not having pending calls to the debounced function --- .../javascripts/dashboard/dataset/queries.tsx | 6 +- .../dashboard/folders/details_sidebar.tsx | 56 ++++++++++--------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/frontend/javascripts/dashboard/dataset/queries.tsx b/frontend/javascripts/dashboard/dataset/queries.tsx index 928f2480c61..6134939778e 100644 --- a/frontend/javascripts/dashboard/dataset/queries.tsx +++ b/frontend/javascripts/dashboard/dataset/queries.tsx @@ -66,7 +66,6 @@ export function useDatasetQuery(datasetId: APIDatasetId) { }, { refetchOnWindowFocus: false, - keepPreviousData: true, }, ); } @@ -423,6 +422,11 @@ export function useUpdateDatasetMutation(folderId: string | null) { }) .filter((dataset: APIDatasetCompact) => dataset.folderId === folderId), ); + const updatedDatasetId = { + name: updatedDataset.name, + owningOrganization: updatedDataset.owningOrganization, + }; + queryClient.setQueryData(["datasetById", updatedDatasetId], updatedDataset); const targetFolderId = updatedDataset.folderId; if (targetFolderId !== folderId) { // The dataset was moved to another folder. Add the dataset to that target folder diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 92a4cdccd61..b8262ef2b63 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -122,15 +122,15 @@ function getMaybeSelectMessage(datasetCount: number) { return datasetCount > 0 ? "Select one to see details." : ""; } +let isDatasetUpdatePending = false; const updateCachedDatasetOrFolderDebounced = _.debounce( async ( context: DatasetCollectionContextValue, selectedDatasetOrFolder: APIDataset | Folder, metadata: APIMetadataEntries, - setIgnoreFetching: (value: boolean) => void, // remove me ) => { + isDatasetUpdatePending = false; // Explicitly ignoring fetching here to avoid unnecessary rendering of the loading spinner and thus hiding the metadata table. - setIgnoreFetching(true); if ("folderId" in selectedDatasetOrFolder) { await context.updateCachedDataset(selectedDatasetOrFolder, { metadata: metadata }); } else { @@ -141,18 +141,28 @@ const updateCachedDatasetOrFolderDebounced = _.debounce( metadata, }); } - setIgnoreFetching(false); }, 3000, ); +const originalFlush = updateCachedDatasetOrFolderDebounced.flush; +updateCachedDatasetOrFolderDebounced.flush = async () => { + if (!isDatasetUpdatePending) return; + isDatasetUpdatePending = false; + originalFlush(); +}; +function updateCachedDatasetOrFolderDebouncedTracked( + context: DatasetCollectionContextValue, + selectedDatasetOrFolder: APIDataset | Folder, + metadata: APIMetadataEntries, +) { + isDatasetUpdatePending = true; + updateCachedDatasetOrFolderDebounced(context, selectedDatasetOrFolder, metadata); + return updateCachedDatasetOrFolderDebounced; +} function MetadataTable({ selectedDatasetOrFolder, - setIgnoreFetching, -}: { - selectedDatasetOrFolder: APIDataset | Folder; - setIgnoreFetching: (value: boolean) => void; -}) { +}: { selectedDatasetOrFolder: APIDataset | Folder }) { const context = useDatasetCollectionContext(); const [metadata, setMetadata] = useState( selectedDatasetOrFolder.metadata != null && selectedDatasetOrFolder.metadata.length > 0 @@ -161,8 +171,6 @@ function MetadataTable({ ); const [error, setError] = useState<[string, string] | null>(null); // [propName, error message] const [focusedRow, setFocusedRow] = useState(null); - - // biome-ignore lint/correctness/useExhaustiveDependencies: Only update when the actual dataset / folder changes. useEffect(() => { // Flush pending updates: updateCachedDatasetOrFolderDebounced.flush(); @@ -172,15 +180,10 @@ function MetadataTable({ ? selectedDatasetOrFolder.metadata : [{ key: "", value: "", index: 0, type: "string" as APIMetadata["type"] }], ); - }, [selectedDatasetOrFolder.name]); + }, [selectedDatasetOrFolder.metadata]); useEffectOnUpdate(() => { - updateCachedDatasetOrFolderDebounced( - context, - selectedDatasetOrFolder, - metadata, - setIgnoreFetching, - ); + updateCachedDatasetOrFolderDebouncedTracked(context, selectedDatasetOrFolder, metadata); }, [metadata]); // On component unmount flush pending updates to avoid potential data loss. @@ -422,8 +425,12 @@ function MetadataTable({ } function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompact }) { - const { data: fullDataset, isFetching } = useDatasetQuery(selectedDataset); - const [ignoreFetching, setIgnoreFetching] = useState(false); + // exactDatasetId is needed to prevent refetching when some dataset property of selectedDataset was changed. + const exactDatasetId = { + owningOrganization: selectedDataset.owningOrganization, + name: selectedDataset.name, + }; + const { data: fullDataset, isFetching } = useDatasetQuery(exactDatasetId); const activeUser = useSelector((state: OxalisState) => state.activeUser); const { data: owningOrganization } = useQuery( ["organizations", selectedDataset.owningOrganization], @@ -452,7 +459,7 @@ function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompac return ( <>

- {isFetching && !ignoreFetching ? ( + {isFetching ? ( ) : ( @@ -507,12 +514,7 @@ function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompac )}

- {fullDataset && ( - - )} + {fullDataset && } {fullDataset?.usedStorageBytes && fullDataset.usedStorageBytes > 10000 ? ( @@ -617,7 +619,7 @@ function FolderDetails({
- {}} /> + ) : error ? ( "Could not load folder." From 539fc5d66838647e6d5e1d8338564cd970f5554a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 5 Jul 2024 15:11:27 +0200 Subject: [PATCH 38/84] have fix width for metadata table cell contents to ensure consistent table column width and consistent layout - Ignore adding new metadata entry when there is already an empty one to avoid showing an error --- .../dashboard/folders/details_sidebar.tsx | 18 ++++++++++++------ frontend/stylesheets/_dashboard.less | 6 +++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index b8262ef2b63..ba2ea57241d 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -196,7 +196,9 @@ function MetadataTable({ const entry = prev.find((prop) => prop.key === previousPropName); const maybeAlreadyExistingEntry = prev.find((prop) => prop.key === newPropName); if (maybeAlreadyExistingEntry) { - setError([previousPropName, `Property ${newPropName} already exists.`]); + if (newPropName !== "") { + setError([previousPropName, `Property ${newPropName} already exists.`]); + } return prev; } if (entry) { @@ -293,7 +295,7 @@ function MetadataTable({ setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - style={{ borderColor: isFocused ? undefined : "transparent" }} + style={{ width: 100.5, borderColor: isFocused ? undefined : "transparent" }} value={record.value as number} onChange={(newNum) => updateValue(record.key, newNum?.toString() || "")} placeholder="Value" @@ -305,7 +307,7 @@ function MetadataTable({ setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - style={{ borderColor: isFocused ? undefined : "transparent" }} + style={{ width: 100.5, borderColor: isFocused ? undefined : "transparent" }} value={record.value} onChange={(evt) => updateValue(record.key, evt.target.value)} placeholder="Value" @@ -317,7 +319,7 @@ function MetadataTable({ setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - style={{ borderColor: isFocused ? undefined : "transparent" }} + style={{ width: 100.5, borderColor: isFocused ? undefined : "transparent" }} value={record.key} onChange={(evt) => updatePropName(record.key, evt.target.value)} placeholder="New property" @@ -384,7 +386,9 @@ function MetadataTable({ ) : null}
: + : + {getValueInput(record)} @@ -409,6 +414,7 @@ function MetadataTable({ borderColor: "var(--ant-color-border)", width: 18, height: 18, + marginLeft: 22, }} className="flex-center-child" > diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index 8cc3bf7936b..e840af9a14b 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -249,14 +249,14 @@ pre.dataset-import-folder-structure-hint { thead tr th:nth-child(4), tbody tr td:nth-child(2), tbody tr td:nth-child(4) { - width: 103.5px; + width: 105px; } thead tr th:nth-child(3), tbody tr td:nth-child(3) { - width: 5; + width: 5px; } thead tr th:nth-child(5), tbody tr td:nth-child(5) { - width: 16; + width: 16px; } } From 51b1286785ab940f2cd9cb2688e6228c858ea6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 5 Jul 2024 15:58:11 +0200 Subject: [PATCH 39/84] enable selecting current folder of folder tree view as active details element --- frontend/javascripts/dashboard/folders/folder_tree.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/javascripts/dashboard/folders/folder_tree.tsx b/frontend/javascripts/dashboard/folders/folder_tree.tsx index 16988e24396..44c31211964 100644 --- a/frontend/javascripts/dashboard/folders/folder_tree.tsx +++ b/frontend/javascripts/dashboard/folders/folder_tree.tsx @@ -89,6 +89,7 @@ export function FolderTreeSidebar({ const doesEventReferToTreeUi = event.nativeEvent.target.closest(".ant-tree") != null; if (keys.length > 0 && doesEventReferToTreeUi) { context.setActiveFolderId(keys[0] as string); + context.setSelectedDatasets([]); } }, [context], From 5d2bd7d3a623643de8dd76cf6bc4c05ba77cd7f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 5 Jul 2024 16:26:19 +0200 Subject: [PATCH 40/84] do not have initial empty metadata row --- .../dashboard/folders/details_sidebar.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index ba2ea57241d..0e7e85cc87c 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -165,9 +165,7 @@ function MetadataTable({ }: { selectedDatasetOrFolder: APIDataset | Folder }) { const context = useDatasetCollectionContext(); const [metadata, setMetadata] = useState( - selectedDatasetOrFolder.metadata != null && selectedDatasetOrFolder.metadata.length > 0 - ? selectedDatasetOrFolder.metadata - : [{ key: "", value: "", index: 0, type: "string" as APIMetadata["type"] }], + selectedDatasetOrFolder.metadata || [], ); const [error, setError] = useState<[string, string] | null>(null); // [propName, error message] const [focusedRow, setFocusedRow] = useState(null); @@ -175,11 +173,7 @@ function MetadataTable({ // Flush pending updates: updateCachedDatasetOrFolderDebounced.flush(); // Update state to newest metadata from selectedDatasetOrFolder. - setMetadata( - selectedDatasetOrFolder.metadata != null && selectedDatasetOrFolder.metadata.length > 0 - ? selectedDatasetOrFolder.metadata - : [{ key: "", value: "", index: 0, type: "string" as APIMetadata["type"] }], - ); + setMetadata(selectedDatasetOrFolder.metadata || []); }, [selectedDatasetOrFolder.metadata]); useEffectOnUpdate(() => { @@ -376,7 +370,7 @@ function MetadataTable({ style={{ width: 100.5, borderColor: isFocused ? undefined : "transparent" }} value={record.key} onChange={(evt) => updatePropName(record.key, evt.target.value)} - placeholder="New property" + placeholder="Property" size="small" /> {error != null && error[0] === record.key ? ( From 5ea7a3482d916f8769926fa97393e84c6f2d9079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 17 Jul 2024 15:22:19 +0200 Subject: [PATCH 41/84] WIP: Apply styling update --- .../dashboard/folders/details_sidebar.tsx | 172 +++++++----------- frontend/javascripts/libs/utils.ts | 4 - frontend/stylesheets/_dashboard.less | 86 ++++++--- 3 files changed, 125 insertions(+), 137 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 0e7e85cc87c..11f2a6e513b 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -18,9 +18,10 @@ import { MenuProps, InputNumber, Select, + Button, } from "antd"; import { stringToColor, formatCountToDataAmountUnit } from "libs/format_utils"; -import { parseFloatOrZero, pluralize } from "libs/utils"; +import { pluralize } from "libs/utils"; import _ from "lodash"; import { DatasetExtentRow, @@ -52,11 +53,11 @@ import { useWillUnmount } from "beautiful-react-hooks"; function metadataTypeToString(type: APIMetadata["type"]) { switch (type) { case "string": - return "abc"; + return "Text"; case "number": - return "123"; + return "Number"; case "string[]": - return "a,b"; + return "Multi-Item Text"; } } @@ -167,7 +168,7 @@ function MetadataTable({ const [metadata, setMetadata] = useState( selectedDatasetOrFolder.metadata || [], ); - const [error, setError] = useState<[string, string] | null>(null); // [propName, error message] + const [error, setError] = useState<[number, string] | null>(null); // [index, error message] const [focusedRow, setFocusedRow] = useState(null); useEffect(() => { // Flush pending updates: @@ -185,81 +186,58 @@ function MetadataTable({ updateCachedDatasetOrFolderDebounced.flush(); }); - const updatePropName = (previousPropName: string, newPropName: string) => { + const updatePropName = (index: number, newPropName: string) => { setMetadata((prev: APIMetadataEntries) => { - const entry = prev.find((prop) => prop.key === previousPropName); + const entry = prev.find((prop) => prop.index === index); const maybeAlreadyExistingEntry = prev.find((prop) => prop.key === newPropName); if (maybeAlreadyExistingEntry) { if (newPropName !== "") { - setError([previousPropName, `Property ${newPropName} already exists.`]); + setError([entry?.index || -1, `Property ${newPropName} already exists.`]); } + } + if (maybeAlreadyExistingEntry || !entry) { return prev; } - if (entry) { - setError(null); - const detailsWithoutEditedEntry = prev.filter((prop) => prop.key !== previousPropName); - return [ - ...detailsWithoutEditedEntry, - { - ...entry, - key: newPropName, - }, - ]; - } else { - const highestIndex = prev.reduce((acc, curr) => Math.max(acc, curr.index), 0); - const newEntry: APIMetadata = { + setError(null); + const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); + return [ + ...detailsWithoutEditedEntry, + { + ...entry, key: newPropName, - value: "", - type: APIMetadataType.STRING, - index: highestIndex + 1, - }; - return [...prev, newEntry]; - } + }, + ]; }); }; - const updateValue = (propName: string, newValue: string | string[]) => { + + const updateValue = (index: number, newValue: string | string[]) => { setMetadata((prev) => { - const entry = prev.find((prop) => prop.key === propName); + const entry = prev.find((prop) => prop.index === index); if (!entry) { return prev; } const updatedEntry = { ...entry, value: newValue }; - const detailsWithoutEditedEntry = prev.filter((prop) => prop.key !== propName); + const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); return [...detailsWithoutEditedEntry, updatedEntry]; }); }; - const updateType = (index: number, newType: APIMetadata["type"]) => { + const addType = (type: APIMetadata["type"]) => { setMetadata((prev) => { - const entry = prev.find((prop) => prop.index === index); - if (!entry) { - return prev; - } - let updatedEntry = { ...entry, type: newType }; - if (newType === "string[]" && entry.type !== "string[]") { - updatedEntry = { ...updatedEntry, value: [entry.value.toString()] }; - } else if (newType === "number" && entry.type !== "number") { - updatedEntry = { - ...updatedEntry, - value: parseFloatOrZero( - Array.isArray(entry.value) ? entry.value.join(" ") : entry.value.toString(), - ), - }; - } else if (newType === "string" && entry.type !== "string") { - updatedEntry = { - ...updatedEntry, - value: Array.isArray(entry.value) ? entry.value.join(" ") : entry.value.toString(), - }; - } - - const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); - return [...detailsWithoutEditedEntry, updatedEntry]; + const highestIndex = prev.reduce((acc, curr) => Math.max(acc, curr.index), 0); + const newEntry: APIMetadata = { + key: "", + value: type === APIMetadataType.STRING_ARRAY ? [] : "", + index: highestIndex + 1, + type, + }; + return [...prev, newEntry]; }); }; - const deleteKey = (propName: string) => { + const deleteKey = (index: number) => { setMetadata((prev) => { - return prev.filter((prop) => prop.key !== propName); + return prev.filter((prop) => prop.index !== index); }); }; @@ -269,14 +247,12 @@ function MetadataTable({ sortedDetails.flatMap((detail) => (detail.type === "string[]" ? detail.value : [])), ).map((tag) => ({ value: tag, label: tag })); - const getTypeSelectDropdownMenu: (propertyIndex: number) => MenuProps = ( - propertyIndex: number, - ) => ({ + const getTypeSelectDropdownMenu: () => MenuProps = () => ({ items: Object.values(APIMetadataType).map((type) => { return { key: type, label: metadataTypeToString(type as APIMetadata["type"]), - onClick: () => updateType(propertyIndex, type as APIMetadata["type"]), + onClick: () => addType(type as APIMetadata["type"]), }; }), }); @@ -287,11 +263,12 @@ function MetadataTable({ case "number": return ( setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - style={{ width: 100.5, borderColor: isFocused ? undefined : "transparent" }} + style={{ width: 116.5, borderColor: isFocused ? undefined : "transparent" }} value={record.value as number} - onChange={(newNum) => updateValue(record.key, newNum?.toString() || "")} + onChange={(newNum) => updateValue(record.index, newNum?.toString() || "")} placeholder="Value" size="small" /> @@ -299,11 +276,12 @@ function MetadataTable({ case "string": return ( setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - style={{ width: 100.5, borderColor: isFocused ? undefined : "transparent" }} + style={{ width: 116.5, borderColor: isFocused ? undefined : "transparent" }} value={record.value} - onChange={(evt) => updateValue(record.key, evt.target.value)} + onChange={(evt) => updateValue(record.index, evt.target.value)} placeholder="Value" size="small" /> @@ -313,11 +291,12 @@ function MetadataTable({ - @@ -348,32 +326,18 @@ function MetadataTable({ const isFocused = record.index === focusedRow; return ( - ); })} + + +
Type Property Value
- - - {metadataTypeToString(record.type)} - - - setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - style={{ width: 100.5, borderColor: isFocused ? undefined : "transparent" }} + style={{ width: 116.5, borderColor: isFocused ? undefined : "transparent" }} value={record.key} - onChange={(evt) => updatePropName(record.key, evt.target.value)} + onChange={(evt) => updatePropName(record.index, evt.target.value)} placeholder="Property" size="small" /> - {error != null && error[0] === record.key ? ( + {error != null && error[0] === record.index ? ( <>
{error[1]} @@ -386,7 +350,7 @@ function MetadataTable({
{getValueInput(record)} deleteKey(record.key)} + onClick={() => deleteKey(record.index)} style={{ color: "var(--ant-color-text-tertiary)", visibility: record.key === "" ? "hidden" : "visible", @@ -398,27 +362,23 @@ function MetadataTable({
+
+ + + +
+
-
-
- updatePropName("", "")} - /> -
-
); diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 02a943cf538..1daec09389e 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -146,10 +146,6 @@ export function parseMaybe(str: string | null | undefined): unknown | null { } } -export function parseFloatOrZero(str: string): number { - return Number.parseFloat(str) || 0; -} - export async function tryToAwaitPromise(promise: Promise): Promise { try { return await promise; diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index e840af9a14b..c25d0918a96 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -225,38 +225,70 @@ pre.dataset-import-folder-structure-hint { vertical-align: top; } -.metadata-table { +.metadata-table-wrapper { border: var(--ant-line-width) var(--ant-line-type) var(--ant-color-border); color: var(--ant-tag-default-color); background: var(--ant-tag-default-bg); border-radius: var(--ant-border-radius-sm); - border-collapse: separate; - table-layout: fixed; + padding: 2px; + .metadata-table { + color: var(--ant-tag-default-color); + background: var(--ant-tag-default-bg); + border-collapse: collapse; /* Ensure border-collapse is set to separate */ + table-layout: fixed; + + tr td { + padding-top: 4px; + padding-bottom: 4px; + } + tbody tr td:last-child { + padding: 0px; + } + /*tr { + border-bottom: var(--ant-line-width) var(--ant-line-type) var(--ant-color-border); /* Add bottom border to each row * + }*/ + + tr { + position: relative; /* Ensure the pseudo-element is positioned relative to the tr */ + &:not(:last-child)::after { + content: ""; + position: absolute; + left: 8px; /* Margin on the left side */ + right: 8px; /* Margin on the right side */ + top: 0; + height: var(--ant-line-width); /* Height of the border line */ + background-color: var(--ant-color-border); /* Color of the border line */ + } + } - th { - padding: 4px !important; - font-weight: normal; - } - .ant-table-cell { - padding: 4px 4px 4px 0px !important; - } + .transparent-input { + background: transparent; + .ant-select-selector { + border-color: transparent; + } + } - thead tr th:first-child, - tbody tr td:first-child { - width: 35px; - } - thead tr th:nth-child(2), - thead tr th:nth-child(4), - tbody tr td:nth-child(2), - tbody tr td:nth-child(4) { - width: 105px; - } - thead tr th:nth-child(3), - tbody tr td:nth-child(3) { - width: 5px; - } - thead tr th:nth-child(5), - tbody tr td:nth-child(5) { - width: 16px; + th { + padding: 4px !important; + font-weight: normal; + } + .ant-table-cell { + padding: 4px 4px 4px 0px !important; + } + + thead tr th:first-child, + tbody tr td:first-child, + thead tr th:nth-child(2), + tbody tr td:nth-child(2) { + width: 116.5; + } + thead tr th:nth-child(2), + tbody tr td:nth-child(2) { + width: 5px; + } + thead tr th:nth-child(5), + tbody tr td:nth-child(5) { + width: 16px; + } } } From df40a6c8e63924d3168438de514bf6b09d586d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 17 Jul 2024 16:06:09 +0200 Subject: [PATCH 42/84] Finish next version --- .../dashboard/folders/details_sidebar.tsx | 29 +++++++++++----- frontend/stylesheets/_dashboard.less | 33 +++++++++++-------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 11f2a6e513b..b97b4165dfd 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -6,6 +6,9 @@ import { LoadingOutlined, DeleteOutlined, PlusOutlined, + TagsOutlined, + FieldNumberOutlined, + FieldStringOutlined, } from "@ant-design/icons"; import { Typography, @@ -53,11 +56,23 @@ import { useWillUnmount } from "beautiful-react-hooks"; function metadataTypeToString(type: APIMetadata["type"]) { switch (type) { case "string": - return "Text"; + return ( + + Text + + ); case "number": - return "Number"; + return ( + + Number + + ); case "string[]": - return "Multi-Item Text"; + return ( + + Multi-Item Text + + ); } } @@ -266,7 +281,6 @@ function MetadataTable({ className={isFocused ? undefined : "transparent-input"} onFocus={() => setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - style={{ width: 116.5, borderColor: isFocused ? undefined : "transparent" }} value={record.value as number} onChange={(newNum) => updateValue(record.index, newNum?.toString() || "")} placeholder="Value" @@ -279,7 +293,6 @@ function MetadataTable({ className={isFocused ? undefined : "transparent-input"} onFocus={() => setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} - style={{ width: 116.5, borderColor: isFocused ? undefined : "transparent" }} value={record.value} onChange={(evt) => updateValue(record.index, evt.target.value)} placeholder="Value" @@ -292,7 +305,6 @@ function MetadataTable({ onFocus={() => setFocusedRow(record.index)} onBlur={() => setFocusedRow(null)} className={isFocused ? undefined : "transparent-input"} - style={{ width: 116.5, borderColor: isFocused ? undefined : "transparent" }} mode="tags" placeholder="Values" value={record.value as string[]} @@ -344,9 +356,7 @@ function MetadataTable({ ) : null}
- : - : {getValueInput(record)}
- setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} - value={record.key} - onChange={(evt) => updatePropName(record.index, evt.target.value)} - placeholder="Property" - size="small" - /> - {error != null && error[0] === record.index ? ( - <> -
- {error[1]} - - ) : null} -
{getKeyInput(record)} : {getValueInput(record)} diff --git a/frontend/javascripts/dashboard/publication_card.tsx b/frontend/javascripts/dashboard/publication_card.tsx index ef310a79520..108612f0e17 100644 --- a/frontend/javascripts/dashboard/publication_card.tsx +++ b/frontend/javascripts/dashboard/publication_card.tsx @@ -51,7 +51,7 @@ function getDisplayName(item: PublicationItem): string { : item.dataset.displayName; } -function getDetails(item: PublicationItem): ExtendedDatasetDetails { +function getExtendedDetails(item: PublicationItem): ExtendedDatasetDetails { const { dataSource, metadata } = item.dataset; const details = {} as DatasetDetails; metadata?.forEach((entry) => { @@ -272,7 +272,7 @@ function PublicationThumbnail({ const segmentationThumbnailURL = hasSegmentation(activeItem.dataset) ? getSegmentationThumbnailURL(activeItem.dataset) : null; - const extendedDetails = getDetails(activeItem); + const extendedDetails = getExtendedDetails(activeItem); return (
diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.md index 387cca38e83..8814008d35c 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.md @@ -1,4 +1,4 @@ -# Snapshot report for `public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js` +# Snapshot report for `public/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js` The actual snapshot is saved in `volumetracing_saga_integration.spec.js.snap`. diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/sagas/volumetracing/volumetracing_saga_integration.spec.js.snap index a01872b6b9ab9f5760c2002e005f426efb458291..270cd5455f2e7cf9e58a8dcadf5c17c7372125e9 100644 GIT binary patch literal 1488 zcmZ<^b5sbZ}AI_OR)874v^2aOZ z?LMsj)#`r#Z|Aq`^E3ZH;NO4pMx~L_w*C40KOFhH{_jU_{r#U#{g$u(`16kcf4Q|4 z--_*PivFEGzWjad^Ec}AE3dEizwaY|TIhWJ^V#)3->m=tYkB&+_4mFn)!+B!=^g+0 zGi7`J+}bPO|L@wbZxug&uw)+2%yHBq2*S@O$UwMB2`}uVi z-!G>}->vw&e16TB{qp}W9^d!z=Y0FGcci=HpVa;8yVU)^_W$nh^Hxur|L^(h{5@sw zmsZQ4{rlzRCyyyF-u`#&+w3#D_S&Ckm7Cpe%BK8(R24ga&b(Z+?%Zc@i%UaX{gNVP zZD)i8GTEecrNqpMp*@>Q=csNnKGA$Scdt$Sy7jNF)?Lv%yH{xMZ1%@Gm(%9E9@`VS z_zmBt`og{I{Qb_G%|2@O`P)vbRZ8lHcWR$J{1;BP{Sb}sdTe^moX~Q+X?0HMrsR`@ zPq)R*)4v}6HOoG0`m?ysduN*qr)6$FuatamO<)=O&HX##uB$IkpM5rIcIEFF+0coT zCEnS6D)_HWp)<K4Crgy&K`ShSDTL1j?*Q=|x%5U{9Uhne$*+GlbTW+RL zG|a!IvAg+Ae9ZdSlP_;Ro3`=np4xSNt4?_Gl*?xxo&8mQ#qLl44#s`I|0vsYk_rt` z)o&5}@pt1S;eZpOFw{0oZl5R_c~m3%c%+hbv(1h>*G_+a`fF9(Tlcq@?r2}!U1@O6 zDEC`r%9+`wr`{?27Qg=b*OMyw^{E7S)EX|F*a+^WD@JcWornXPW)iF?x1ZDz$vV-|p9?wMA9iZt85C zx&8drKvB!ihwqg6zwc+F%ozpgPZCqy!VFJcE1Y)XoC3ez_OG96cJ5mh{yT8D$#<_W zd2&6VTrDQ~Ijwh-^@)E+zwWZLw7s1pcKgij=dV_D%{fx=ePY<2o6S45-^*W#uI8o8 VIUmd>;fb8z_G{Z^82nf`7yyckHFp32 literal 1476 zcmZ<^b5sb#KMc?T+SuV(QjY#3t^(p!j~;s{^irt z?Y|#Z_pkf;w| z53|?bU-tg^C@DxeEUn%PvZCgePv&_{r{_WyRUoy?|FXzJHLI* z>$jWt_U`$2+28Kh{r>+ilJEccbAEo+yWY?7SE_#Zr5XSK{jdDJ?e%kZf6rgPU-9;y z_jkLf*KaSMRnFYB^Iy}f5M$q>Ij20e9^aC<`r%F0Jy{t!U9snAbfwwjS8qcnOZ8;EPP%#4Fa7iBcUgO1nR}Yv++#U|fD?9CJxQ$BCEt3m z&T^OBme9-s)MsJ={0TVAb00RCDXOXZ3fl z{<`YE%GH$5&k};pnw%`uQeWAVog8v_^Xtm;f{HB>bM#l|oqy{$`_}n)uO`Ob%lYAO zcj|BU-E#YmW_wOjp&_dJJ>`~P=&G|js;aLpVOxA_S47$7%QydQ+8wq2mv4}-+2$a^ wp~ga4kTHafrXFYrQYZDiId}({c)ng+$4FVk+;hVdF@NRPw#zX1v2ZW|01uHYsQ>@~ From 47f96fc3da8d01000a5cbb8899921fe85e6990f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 19 Jul 2024 12:48:20 +0200 Subject: [PATCH 48/84] add changelog & migration entry; rename evolution --- CHANGELOG.unreleased.md | 3 ++- MIGRATIONS.unreleased.md | 1 + ...olders.sql => 118-add-metadata-to-folders-and-datasets.sql} | 0 3 files changed, 3 insertions(+), 1 deletion(-) rename conf/evolutions/{118-add-details-to-folders.sql => 118-add-metadata-to-folders-and-datasets.sql} (100%) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 2f620aff16a..2ab72ad4c3c 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -12,8 +12,9 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added - Added the option to move a bounding box via dragging while pressing ctrl / meta. [#7892](https://github.com/scalableminds/webknossos/pull/7892) +- Added the option to add metadata entries to dataasets and folders. The metadata can be viewed and edited in the dashboard in the right details tab.[#7886](https://github.com/scalableminds/webknossos/pull/7886) - Added route `/import?url=` to automatically import and view remote datasets. [#7844](https://github.com/scalableminds/webknossos/pull/7844) -- The context menu that is opened upon right-clicking a segment in the dataview port now contains the segment's name. [#7920](https://github.com/scalableminds/webknossos/pull/7920) +- The context menu that is opened upon right-clicking a segment in the dataview port now contains the segment's name. [#7920](https://github.com/scalableminds/webknossos/pull/7920) - Upgraded backend dependencies for improved performance and stability. [#7922](https://github.com/scalableminds/webknossos/pull/7922) ### Changed diff --git a/MIGRATIONS.unreleased.md b/MIGRATIONS.unreleased.md index 7a4a1e4a4de..3f87d8831eb 100644 --- a/MIGRATIONS.unreleased.md +++ b/MIGRATIONS.unreleased.md @@ -9,3 +9,4 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md). [Commits](https://github.com/scalableminds/webknossos/compare/24.07.0...HEAD) ### Postgres Evolutions: +- [118-add-metadata-to-folders-and-datasets.sql](conf/evolutions/118-add-metadata-to-folders-and-datasets.sql) diff --git a/conf/evolutions/118-add-details-to-folders.sql b/conf/evolutions/118-add-metadata-to-folders-and-datasets.sql similarity index 100% rename from conf/evolutions/118-add-details-to-folders.sql rename to conf/evolutions/118-add-metadata-to-folders-and-datasets.sql From 1459f317b03c21bf7dacb363c0596e4499e791ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 19 Jul 2024 12:56:25 +0200 Subject: [PATCH 49/84] also rename revision; add comments to revision; remove dev logging --- app/models/dataset/Dataset.scala | 1 - ...folders.sql => 118-add-metadata-to-folders-and-datasets.sql} | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) rename conf/evolutions/reversions/{118-add-details-to-folders.sql => 118-add-metadata-to-folders-and-datasets.sql} (94%) diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index 12a12c8aca0..0b51fca4dc8 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -470,7 +470,6 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA } yield () def updatePartial(datasetId: ObjectId, params: DatasetUpdateParameters)(implicit ctx: DBAccessContext): Fox[Unit] = { - System.out.println(s"Trying to update a dataset with $DatasetUpdateParameters") val setQueries = List( params.description.map(d => q"description = $d"), params.displayName.map(v => q"displayName = $v"), diff --git a/conf/evolutions/reversions/118-add-details-to-folders.sql b/conf/evolutions/reversions/118-add-metadata-to-folders-and-datasets.sql similarity index 94% rename from conf/evolutions/reversions/118-add-details-to-folders.sql rename to conf/evolutions/reversions/118-add-metadata-to-folders-and-datasets.sql index bec185c1750..b28f240eee3 100644 --- a/conf/evolutions/reversions/118-add-details-to-folders.sql +++ b/conf/evolutions/reversions/118-add-metadata-to-folders-and-datasets.sql @@ -27,6 +27,7 @@ WHERE EXISTS ( ); +-- Add existing info on species of brainRegion to details UPDATE webknossos.datasets SET details = jsonb_set(details, '{brainRegion}', ( SELECT to_jsonb(m.value) @@ -41,6 +42,7 @@ WHERE EXISTS ( ); +-- Add existing info on species of acquisition to details UPDATE webknossos.datasets SET details = jsonb_set(details, '{acquisition}', ( SELECT to_jsonb(m.value) From aeba1f9ce1c7474b526bc9304f5936dc2a62add6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Tue, 23 Jul 2024 11:55:13 +0200 Subject: [PATCH 50/84] Apply PR Feedback --- CHANGELOG.unreleased.md | 2 +- app/models/folder/Folder.scala | 7 +++-- .../dashboard/folders/details_sidebar.tsx | 26 +++++++++++++------ .../backend-snapshot-tests/folders.e2e.ts | 2 +- frontend/javascripts/types/api_flow_types.ts | 1 - 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index c2e260db3ca..43fe23f1ccc 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -13,7 +13,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added - WEBKNOSSOS now automatically searches in subfolder / sub-collection identifiers for valid datasets in case a provided link to a remote dataset does not directly point to a dataset. [#7912](https://github.com/scalableminds/webknossos/pull/7912) - Added the option to move a bounding box via dragging while pressing ctrl / meta. [#7892](https://github.com/scalableminds/webknossos/pull/7892) -- Added the option to add metadata entries to dataasets and folders. The metadata can be viewed and edited in the dashboard in the right details tab.[#7886](https://github.com/scalableminds/webknossos/pull/7886) +- Added the option to add metadata entries to datasets and folders. The metadata can be viewed and edited in the dashboard in the right details tab.[#7886](https://github.com/scalableminds/webknossos/pull/7886) - Added route `/import?url=` to automatically import and view remote datasets. [#7844](https://github.com/scalableminds/webknossos/pull/7844) - The context menu that is opened upon right-clicking a segment in the dataview port now contains the segment's name. [#7920](https://github.com/scalableminds/webknossos/pull/7920) - Upgraded backend dependencies for improved performance and stability. [#7922](https://github.com/scalableminds/webknossos/pull/7922) diff --git a/app/models/folder/Folder.scala b/app/models/folder/Folder.scala index 4e9b2309b27..6a5c18a0825 100644 --- a/app/models/folder/Folder.scala +++ b/app/models/folder/Folder.scala @@ -138,16 +138,19 @@ class FolderDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) protected def parse(r: FoldersRow): Fox[Folder] = for { - metadata <- JsonHelper.parseAndValidateJson[JsArray](r.metadata.getOrElse("[]")).toFox + metadata <- parseMetadata(r.metadata) folder <- Fox.successful(Folder(ObjectId(r._Id), r.name, metadata)) } yield folder private def parseWithParent(t: (String, String, Option[String], Option[String])): Fox[FolderWithParent] = for { - metadata <- JsonHelper.parseAndValidateJson[JsArray](t._3.getOrElse("[]")).toFox + metadata <- parseMetadata(t._3) folderWithParent <- Fox.successful(FolderWithParent(ObjectId(t._1), t._2, metadata, t._4.map(ObjectId(_)))) } yield folderWithParent + private def parseMetadata(literal: Option[String]): Fox[JsArray] = + JsonHelper.parseAndValidateJson[JsArray](literal.getOrElse("[]")) + override protected def readAccessQ(requestingUserId: ObjectId): SqlToken = readAccessQWithPrefix(requestingUserId, q"") diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index a51f3aacb06..c0bc0eb7b27 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -179,12 +179,15 @@ function updateCachedDatasetOrFolderDebouncedTracked( return updateCachedDatasetOrFolderDebounced; } +type APIMetadataWithIndex = APIMetadata & { index: number }; +type IndexedMetadataEntries = APIMetadataWithIndex[]; + function MetadataTable({ selectedDatasetOrFolder, }: { selectedDatasetOrFolder: APIDataset | Folder }) { const context = useDatasetCollectionContext(); - const [metadata, setMetadata] = useState( - selectedDatasetOrFolder.metadata || [], + const [metadata, setMetadata] = useState( + selectedDatasetOrFolder?.metadata?.map((entry, index) => ({ ...entry, index })) || [], ); const [error, setError] = useState<[number, string] | null>(null); // [index, error message] const [focusedRow, setFocusedRow] = useState(null); @@ -196,13 +199,20 @@ function MetadataTable({ updateCachedDatasetOrFolderDebounced.flush(); } else { // Update state to newest metadata from selectedDatasetOrFolder. - setMetadata(selectedDatasetOrFolder.metadata || []); + setMetadata( + selectedDatasetOrFolder.metadata?.map((entry, index) => ({ ...entry, index })) || [], + ); } }, [selectedDatasetOrFolder.metadata]); useEffectOnUpdate(() => { if (error == null) { - updateCachedDatasetOrFolderDebouncedTracked(context, selectedDatasetOrFolder, metadata); + const metadataWithoutIndex = metadata.map(({ index: _ignored, ...rest }) => rest); + updateCachedDatasetOrFolderDebouncedTracked( + context, + selectedDatasetOrFolder, + metadataWithoutIndex, + ); } }, [metadata, error]); @@ -212,7 +222,7 @@ function MetadataTable({ }); const updatePropName = (index: number, newPropName: string) => { - setMetadata((prev: APIMetadataEntries) => { + setMetadata((prev) => { const entry = prev.find((prop) => prop.index === index); if (!entry) { return prev; @@ -249,7 +259,7 @@ function MetadataTable({ const addType = (type: APIMetadata["type"]) => { setMetadata((prev) => { const highestIndex = prev.reduce((acc, curr) => Math.max(acc, curr.index), 0); - const newEntry: APIMetadata = { + const newEntry: APIMetadataWithIndex = { key: "", value: type === APIMetadataType.STRING_ARRAY ? [] : type === APIMetadataType.NUMBER ? 0 : "", @@ -282,7 +292,7 @@ function MetadataTable({ }), }); - const getValueInput = (record: APIMetadata) => { + const getValueInput = (record: APIMetadataWithIndex) => { const isFocused = record.index === focusedRow; const sharedProps = { className: isFocused ? undefined : "transparent-input", @@ -324,7 +334,7 @@ function MetadataTable({ } }; - const getKeyInput = (record: APIMetadata) => { + const getKeyInput = (record: APIMetadataWithIndex) => { const isFocused = record.index === focusedRow; return ( <> diff --git a/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts b/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts index c8bf55720a3..82083b0672d 100644 --- a/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts +++ b/frontend/javascripts/test/backend-snapshot-tests/folders.e2e.ts @@ -71,7 +71,7 @@ test("addAllowedTeamToFolder", async (t) => { id: subFolderId, allowedTeams: [teamId], name: "A subfolder!", - metadata: [{ type: APIMetadataType.STRING, key: "foo", value: "bar", index: 0 }], + metadata: [{ type: APIMetadataType.STRING, key: "foo", value: "bar" }], }); t.snapshot(updatedFolderWithTeam, { diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 1ae2feee992..262d7feebda 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -178,7 +178,6 @@ export type APIMetadata = { type: APIMetadataType; key: string; value: string | number | string[]; - index: number; }; export type APIMetadataEntries = APIMetadata[]; From e9fcb242eb1723c82534a9e2da61addf300b7faa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 24 Jul 2024 11:49:02 +0200 Subject: [PATCH 51/84] move metadata table to own file --- .../dashboard/folders/details_sidebar.tsx | 336 +----------------- .../dashboard/folders/metadata_table.tsx | 330 +++++++++++++++++ 2 files changed, 335 insertions(+), 331 deletions(-) create mode 100644 frontend/javascripts/dashboard/folders/metadata_table.tsx diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index c0bc0eb7b27..84ade2f79c1 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -4,26 +4,8 @@ import { SearchOutlined, EditOutlined, LoadingOutlined, - DeleteOutlined, - PlusOutlined, - TagsOutlined, - FieldNumberOutlined, - FieldStringOutlined, } from "@ant-design/icons"; -import { - Typography, - Input, - Result, - Spin, - Tag, - Tooltip, - Dropdown, - MenuProps, - InputNumber, - Select, - Button, - InputNumberProps, -} from "antd"; +import { Result, Spin, Tag, Tooltip } from "antd"; import { stringToColor, formatCountToDataAmountUnit } from "libs/format_utils"; import { pluralize } from "libs/utils"; import _ from "lodash"; @@ -32,50 +14,16 @@ import { OwningOrganizationRow, VoxelSizeRow, } from "oxalis/view/right-border-tabs/dataset_info_tab_view"; -import React, { useEffect, useState } from "react"; -import { - APIDataset, - APIDatasetCompact, - APIMetadata, - APIMetadataEntries, - APIMetadataType, - Folder, -} from "types/api_flow_types"; +import React, { useEffect } from "react"; +import { APIDatasetCompact, Folder } from "types/api_flow_types"; import { DatasetLayerTags, TeamTags } from "../advanced_dataset/dataset_table"; -import { - DatasetCollectionContextValue, - useDatasetCollectionContext, -} from "../dataset/dataset_collection_context"; +import { useDatasetCollectionContext } from "../dataset/dataset_collection_context"; import { SEARCH_RESULTS_LIMIT, useDatasetQuery, useFolderQuery } from "../dataset/queries"; import { useSelector } from "react-redux"; import { OxalisState } from "oxalis/store"; import { getOrganization } from "admin/admin_rest_api"; import { useQuery } from "@tanstack/react-query"; -import { useEffectOnUpdate } from "libs/react_hooks"; -import { useWillUnmount } from "beautiful-react-hooks"; - -function metadataTypeToString(type: APIMetadata["type"]) { - switch (type) { - case "string": - return ( - - Text - - ); - case "number": - return ( - - Number - - ); - case "string[]": - return ( - - Multi-Item Text - - ); - } -} +import MetadataTable from "./metadata_table"; export function DetailsSidebar({ selectedDatasets, @@ -139,280 +87,6 @@ function getMaybeSelectMessage(datasetCount: number) { return datasetCount > 0 ? "Select one to see details." : ""; } -let isDatasetUpdatePending = false; -const updateCachedDatasetOrFolderDebounced = _.debounce( - async ( - context: DatasetCollectionContextValue, - selectedDatasetOrFolder: APIDataset | Folder, - metadata: APIMetadataEntries, - ) => { - isDatasetUpdatePending = false; - if ("folderId" in selectedDatasetOrFolder) { - // In case of a dataset, update the dataset's metadata. - await context.updateCachedDataset(selectedDatasetOrFolder, { metadata: metadata }); - } else { - // Else update the folders metadata. - const folder = selectedDatasetOrFolder; - await context.queries.updateFolderMutation.mutateAsync({ - ...folder, - allowedTeams: folder.allowedTeams?.map((t) => t.id) || [], - metadata, - }); - } - }, - 3000, -); -const originalFlush = updateCachedDatasetOrFolderDebounced.flush; -// Overwrite the debounce flush function to avoid flushing when no update is pending. -updateCachedDatasetOrFolderDebounced.flush = async () => { - if (!isDatasetUpdatePending) return; - isDatasetUpdatePending = false; - originalFlush(); -}; -function updateCachedDatasetOrFolderDebouncedTracked( - context: DatasetCollectionContextValue, - selectedDatasetOrFolder: APIDataset | Folder, - metadata: APIMetadataEntries, -) { - isDatasetUpdatePending = true; - updateCachedDatasetOrFolderDebounced(context, selectedDatasetOrFolder, metadata); - return updateCachedDatasetOrFolderDebounced; -} - -type APIMetadataWithIndex = APIMetadata & { index: number }; -type IndexedMetadataEntries = APIMetadataWithIndex[]; - -function MetadataTable({ - selectedDatasetOrFolder, -}: { selectedDatasetOrFolder: APIDataset | Folder }) { - const context = useDatasetCollectionContext(); - const [metadata, setMetadata] = useState( - selectedDatasetOrFolder?.metadata?.map((entry, index) => ({ ...entry, index })) || [], - ); - const [error, setError] = useState<[number, string] | null>(null); // [index, error message] - const [focusedRow, setFocusedRow] = useState(null); - - useEffect(() => { - if (isDatasetUpdatePending) { - // Flush pending updates and wait for the next update to update this components metadata. - // Otherwise, a cyclic update race between the selectedDatasetOrFolder.metadata and the flushed version might occur. - updateCachedDatasetOrFolderDebounced.flush(); - } else { - // Update state to newest metadata from selectedDatasetOrFolder. - setMetadata( - selectedDatasetOrFolder.metadata?.map((entry, index) => ({ ...entry, index })) || [], - ); - } - }, [selectedDatasetOrFolder.metadata]); - - useEffectOnUpdate(() => { - if (error == null) { - const metadataWithoutIndex = metadata.map(({ index: _ignored, ...rest }) => rest); - updateCachedDatasetOrFolderDebouncedTracked( - context, - selectedDatasetOrFolder, - metadataWithoutIndex, - ); - } - }, [metadata, error]); - - // On component unmount flush pending updates to avoid potential data loss. - useWillUnmount(() => { - updateCachedDatasetOrFolderDebounced.flush(); - }); - - const updatePropName = (index: number, newPropName: string) => { - setMetadata((prev) => { - const entry = prev.find((prop) => prop.index === index); - if (!entry) { - return prev; - } - const maybeAlreadyExistingEntry = prev.find((prop) => prop.key === newPropName); - if (maybeAlreadyExistingEntry) { - setError([entry?.index || -1, `Property ${newPropName} already exists.`]); - } else { - setError(null); - } - const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); - return [ - ...detailsWithoutEditedEntry, - { - ...entry, - key: newPropName, - }, - ]; - }); - }; - - const updateValue = (index: number, newValue: number | string | string[]) => { - setMetadata((prev) => { - const entry = prev.find((prop) => prop.index === index); - if (!entry) { - return prev; - } - const updatedEntry = { ...entry, value: newValue }; - const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); - return [...detailsWithoutEditedEntry, updatedEntry]; - }); - }; - - const addType = (type: APIMetadata["type"]) => { - setMetadata((prev) => { - const highestIndex = prev.reduce((acc, curr) => Math.max(acc, curr.index), 0); - const newEntry: APIMetadataWithIndex = { - key: "", - value: - type === APIMetadataType.STRING_ARRAY ? [] : type === APIMetadataType.NUMBER ? 0 : "", - index: highestIndex + 1, - type, - }; - return [...prev, newEntry]; - }); - }; - - const deleteKey = (index: number) => { - setMetadata((prev) => { - return prev.filter((prop) => prop.index !== index); - }); - }; - - const sortedDetails = metadata.sort((a, b) => a.index - b.index); - - const availableStrArrayTagOptions = _.uniq( - sortedDetails.flatMap((detail) => (detail.type === "string[]" ? detail.value : [])), - ).map((tag) => ({ value: tag, label: tag })); - - const getTypeSelectDropdownMenu: () => MenuProps = () => ({ - items: Object.values(APIMetadataType).map((type) => { - return { - key: type, - label: metadataTypeToString(type as APIMetadata["type"]), - onClick: () => addType(type as APIMetadata["type"]), - }; - }), - }); - - const getValueInput = (record: APIMetadataWithIndex) => { - const isFocused = record.index === focusedRow; - const sharedProps = { - className: isFocused ? undefined : "transparent-input", - onFocus: () => setFocusedRow(record.index), - onBlur: () => setFocusedRow(null), - placeholder: "Value", - size: "small" as InputNumberProps["size"], - }; - switch (record.type) { - case "number": - return ( - updateValue(record.index, newNum || 0)} - {...sharedProps} - /> - ); - case "string": - return ( - updateValue(record.index, evt.target.value)} - {...sharedProps} - /> - ); - case "string[]": - return ( - setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} - value={record.key} - onChange={(evt) => updatePropName(record.index, evt.target.value)} - placeholder="Property" - size="small" - /> - {error != null && error[0] === record.index ? ( - <> -
- {error[1]} - - ) : null} - - ); - }; - - return ( -
-
Metadata
-
- {/* Not using AntD Table to have more control over the styling. */} - - - - - - - - - {sortedDetails.map((record) => { - return ( - - - - - - - ); - })} - - - - -
Property - Value -
{getKeyInput(record)}:{getValueInput(record)} - deleteKey(record.index)} - style={{ - color: "var(--ant-color-text-tertiary)", - width: 16, - }} - /> -
-
- - - -
-
-
-
- ); -} - function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompact }) { // exactDatasetId is needed to prevent refetching when some dataset property of selectedDataset was changed. const exactDatasetId = { diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx new file mode 100644 index 00000000000..56440d36c43 --- /dev/null +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -0,0 +1,330 @@ +import { + DeleteOutlined, + FieldNumberOutlined, + FieldStringOutlined, + PlusOutlined, + TagsOutlined, +} from "@ant-design/icons"; +import { + MenuProps, + InputNumberProps, + InputNumber, + Input, + Select, + Typography, + Dropdown, + Button, +} from "antd"; +import { useWillUnmount } from "beautiful-react-hooks"; +import { + DatasetCollectionContextValue, + useDatasetCollectionContext, +} from "dashboard/dataset/dataset_collection_context"; +import { useEffectOnUpdate } from "libs/react_hooks"; +import _ from "lodash"; +import React from "react"; +import { useState, useEffect } from "react"; +import { + APIDataset, + Folder, + APIMetadataEntries, + APIMetadata, + APIMetadataType, +} from "types/api_flow_types"; + +function metadataTypeToString(type: APIMetadata["type"]) { + switch (type) { + case "string": + return ( + + Text + + ); + case "number": + return ( + + Number + + ); + case "string[]": + return ( + + Multi-Item Text + + ); + } +} + +let isDatasetUpdatePending = false; +const updateCachedDatasetOrFolderDebounced = _.debounce( + async ( + context: DatasetCollectionContextValue, + selectedDatasetOrFolder: APIDataset | Folder, + metadata: APIMetadataEntries, + ) => { + isDatasetUpdatePending = false; + if ("folderId" in selectedDatasetOrFolder) { + // In case of a dataset, update the dataset's metadata. + await context.updateCachedDataset(selectedDatasetOrFolder, { metadata: metadata }); + } else { + // Else update the folders metadata. + const folder = selectedDatasetOrFolder; + await context.queries.updateFolderMutation.mutateAsync({ + ...folder, + allowedTeams: folder.allowedTeams?.map((t) => t.id) || [], + metadata, + }); + } + }, + 3000, +); +const originalFlush = updateCachedDatasetOrFolderDebounced.flush; +// Overwrite the debounce flush function to avoid flushing when no update is pending. +updateCachedDatasetOrFolderDebounced.flush = async () => { + if (!isDatasetUpdatePending) return; + isDatasetUpdatePending = false; + originalFlush(); +}; +function updateCachedDatasetOrFolderDebouncedTracked( + context: DatasetCollectionContextValue, + selectedDatasetOrFolder: APIDataset | Folder, + metadata: APIMetadataEntries, +) { + isDatasetUpdatePending = true; + updateCachedDatasetOrFolderDebounced(context, selectedDatasetOrFolder, metadata); + return updateCachedDatasetOrFolderDebounced; +} + +type APIMetadataWithIndex = APIMetadata & { index: number }; +type IndexedMetadataEntries = APIMetadataWithIndex[]; + +export default function MetadataTable({ + selectedDatasetOrFolder, +}: { selectedDatasetOrFolder: APIDataset | Folder }) { + const context = useDatasetCollectionContext(); + const [metadata, setMetadata] = useState( + selectedDatasetOrFolder?.metadata?.map((entry, index) => ({ ...entry, index })) || [], + ); + const [error, setError] = useState<[number, string] | null>(null); // [index, error message] + const [focusedRow, setFocusedRow] = useState(null); + + useEffect(() => { + if (isDatasetUpdatePending) { + // Flush pending updates and wait for the next update to update this components metadata. + // Otherwise, a cyclic update race between the selectedDatasetOrFolder.metadata and the flushed version might occur. + updateCachedDatasetOrFolderDebounced.flush(); + } else { + // Update state to newest metadata from selectedDatasetOrFolder. + setMetadata( + selectedDatasetOrFolder.metadata?.map((entry, index) => ({ ...entry, index })) || [], + ); + } + }, [selectedDatasetOrFolder.metadata]); + + useEffectOnUpdate(() => { + if (error == null) { + const metadataWithoutIndex = metadata.map(({ index: _ignored, ...rest }) => rest); + updateCachedDatasetOrFolderDebouncedTracked( + context, + selectedDatasetOrFolder, + metadataWithoutIndex, + ); + } + }, [metadata, error]); + + // On component unmount flush pending updates to avoid potential data loss. + useWillUnmount(() => { + updateCachedDatasetOrFolderDebounced.flush(); + }); + + const updatePropName = (index: number, newPropName: string) => { + setMetadata((prev) => { + const entry = prev.find((prop) => prop.index === index); + if (!entry) { + return prev; + } + const maybeAlreadyExistingEntry = prev.find((prop) => prop.key === newPropName); + if (maybeAlreadyExistingEntry) { + setError([entry?.index || -1, `Property ${newPropName} already exists.`]); + } else { + setError(null); + } + const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); + return [ + ...detailsWithoutEditedEntry, + { + ...entry, + key: newPropName, + }, + ]; + }); + }; + + const updateValue = (index: number, newValue: number | string | string[]) => { + setMetadata((prev) => { + const entry = prev.find((prop) => prop.index === index); + if (!entry) { + return prev; + } + const updatedEntry = { ...entry, value: newValue }; + const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); + return [...detailsWithoutEditedEntry, updatedEntry]; + }); + }; + + const addType = (type: APIMetadata["type"]) => { + setMetadata((prev) => { + const highestIndex = prev.reduce((acc, curr) => Math.max(acc, curr.index), 0); + const newEntry: APIMetadataWithIndex = { + key: "", + value: + type === APIMetadataType.STRING_ARRAY ? [] : type === APIMetadataType.NUMBER ? 0 : "", + index: highestIndex + 1, + type, + }; + return [...prev, newEntry]; + }); + }; + + const deleteKey = (index: number) => { + setMetadata((prev) => { + return prev.filter((prop) => prop.index !== index); + }); + }; + + const sortedDetails = metadata.sort((a, b) => a.index - b.index); + + const availableStrArrayTagOptions = _.uniq( + sortedDetails.flatMap((detail) => (detail.type === "string[]" ? detail.value : [])), + ).map((tag) => ({ value: tag, label: tag })); + + const getTypeSelectDropdownMenu: () => MenuProps = () => ({ + items: Object.values(APIMetadataType).map((type) => { + return { + key: type, + label: metadataTypeToString(type as APIMetadata["type"]), + onClick: () => addType(type as APIMetadata["type"]), + }; + }), + }); + + const getValueInput = (record: APIMetadataWithIndex) => { + const isFocused = record.index === focusedRow; + const sharedProps = { + className: isFocused ? undefined : "transparent-input", + onFocus: () => setFocusedRow(record.index), + onBlur: () => setFocusedRow(null), + placeholder: "Value", + size: "small" as InputNumberProps["size"], + }; + switch (record.type) { + case "number": + return ( + updateValue(record.index, newNum || 0)} + {...sharedProps} + /> + ); + case "string": + return ( + updateValue(record.index, evt.target.value)} + {...sharedProps} + /> + ); + case "string[]": + return ( + setFocusedRow(record.index)} + onBlur={() => setFocusedRow(null)} + value={record.key} + onChange={(evt) => updatePropName(record.index, evt.target.value)} + placeholder="Property" + size="small" + /> + {error != null && error[0] === record.index ? ( + <> +
+ {error[1]} + + ) : null} + + ); + }; + + return ( +
+
Metadata
+
+ {/* Not using AntD Table to have more control over the styling. */} + + + + + + + + + {sortedDetails.map((record) => { + return ( + + + + + + + ); + })} + + + + +
Property + Value +
{getKeyInput(record)}:{getValueInput(record)} + deleteKey(record.index)} + style={{ + color: "var(--ant-color-text-tertiary)", + width: 16, + }} + /> +
+
+ + + +
+
+
+
+ ); +} From b14a4d900444022ead30dd0a4e94abdfa90e5fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 24 Jul 2024 14:43:37 +0200 Subject: [PATCH 52/84] only update when metadata changed & refactor code --- .../dashboard/folders/metadata_table.tsx | 172 +++++++++++------- 1 file changed, 105 insertions(+), 67 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 56440d36c43..ef4e94a69e1 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -22,7 +22,7 @@ import { } from "dashboard/dataset/dataset_collection_context"; import { useEffectOnUpdate } from "libs/react_hooks"; import _ from "lodash"; -import React from "react"; +import React, { memo } from "react"; import { useState, useEffect } from "react"; import { APIDataset, @@ -55,6 +55,16 @@ function metadataTypeToString(type: APIMetadata["type"]) { } } +function EmptyTablePlaceholder({ isDataset }: { isDataset: boolean }) { + return ( +
+ Add metadata properties to this {isDataset ? "dataset" : "folder"}. +
{getKeyInput(record)}:{getValueInput(record)}{getDeleteEntryButton(record)}
+
+ + + +
+
{getKeyInput(record)}:{getValueInput(record)} - deleteKey(record.index)} - style={{ - color: "var(--ant-color-text-tertiary)", - width: 16, - }} - /> -
-
- - - -
-
From ebf16b7d9aa6108d643883460a6973b1f8415f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 24 Jul 2024 15:26:10 +0200 Subject: [PATCH 53/84] fix updating the wrong dataset or folder with the newest metadata version of the table --- .../dashboard/folders/details_sidebar.tsx | 4 +- .../dashboard/folders/metadata_table.tsx | 64 +++++++++++-------- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index 84ade2f79c1..d6a59f409a0 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -177,7 +177,7 @@ function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompac )}
- {fullDataset && } + {fullDataset && } {fullDataset?.usedStorageBytes && fullDataset.usedStorageBytes > 10000 ? ( @@ -282,7 +282,7 @@ function FolderDetails({
- +
) : error ? ( "Could not load folder." diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index ef4e94a69e1..37122836205 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -23,7 +23,7 @@ import { import { useEffectOnUpdate } from "libs/react_hooks"; import _ from "lodash"; import React, { memo } from "react"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { APIDataset, Folder, @@ -65,14 +65,14 @@ function EmptyTablePlaceholder({ isDataset }: { isDataset: boolean }) { ); } -let isDatasetUpdatePending = false; +let isUpdatePending = false; const updateCachedDatasetOrFolderDebounced = _.debounce( async ( context: DatasetCollectionContextValue, selectedDatasetOrFolder: APIDataset | Folder, metadata: APIMetadataEntries, ) => { - isDatasetUpdatePending = false; + isUpdatePending = false; if ("folderId" in selectedDatasetOrFolder) { // In case of a dataset, update the dataset's metadata. await context.updateCachedDataset(selectedDatasetOrFolder, { metadata: metadata }); @@ -91,8 +91,8 @@ const updateCachedDatasetOrFolderDebounced = _.debounce( const originalFlush = updateCachedDatasetOrFolderDebounced.flush; // Overwrite the debounce flush function to avoid flushing when no update is pending. updateCachedDatasetOrFolderDebounced.flush = async () => { - if (!isDatasetUpdatePending) return; - isDatasetUpdatePending = false; + if (!isUpdatePending) return; + isUpdatePending = false; originalFlush(); }; function updateCachedDatasetOrFolderDebouncedTracked( @@ -100,7 +100,7 @@ function updateCachedDatasetOrFolderDebouncedTracked( selectedDatasetOrFolder: APIDataset | Folder, metadata: APIMetadataEntries, ) { - isDatasetUpdatePending = true; + isUpdatePending = true; updateCachedDatasetOrFolderDebounced(context, selectedDatasetOrFolder, metadata); return updateCachedDatasetOrFolderDebounced; } @@ -108,39 +108,51 @@ function updateCachedDatasetOrFolderDebouncedTracked( type APIMetadataWithIndex = APIMetadata & { index: number }; type IndexedMetadataEntries = APIMetadataWithIndex[]; +const isDataset = (datasetOrFolder: APIDataset | Folder): datasetOrFolder is APIDataset => + "folderId" in datasetOrFolder; + export default function MetadataTable({ - selectedDatasetOrFolder, -}: { selectedDatasetOrFolder: APIDataset | Folder }) { + datasetOrFolder, +}: { datasetOrFolder: APIDataset | Folder }) { const context = useDatasetCollectionContext(); const [metadata, setMetadata] = useState( - selectedDatasetOrFolder?.metadata?.map((entry, index) => ({ ...entry, index })) || [], + datasetOrFolder?.metadata?.map((entry, index) => ({ ...entry, index })) || [], ); const [error, setError] = useState<[number, string] | null>(null); // [index, error message] const [focusedRow, setFocusedRow] = useState(null); + // Save the current dataset or folder name and the type of it to be able to determine whether the passed datasetOrFolder to this component changed. + const [currentDatasetOrFolderName, setCurrentDatasetOrFolderName] = useState( + datasetOrFolder.name, + ); + const [isCurrentEntityADataset, setIsCurrentEntityADataset] = useState( + isDataset(datasetOrFolder), + ); - useEffect(() => { - if (isDatasetUpdatePending) { - // Flush pending updates and wait for the next update to update this components metadata. - // Otherwise, a cyclic update race between the selectedDatasetOrFolder.metadata and the flushed version might occur. + // Always update local state when folder / dataset changes or the result of the update request + // to the backend is resolved shown by a changed value in selectedDatasetOrFolder.metadata. + useEffectOnUpdate(() => { + const isSameName = datasetOrFolder.name === currentDatasetOrFolderName; + const isSameType = isDataset(datasetOrFolder) === isCurrentEntityADataset; + // In case an update is pending and the dataset or folder is the same as before, flush the pending update and ignore the changes to datasetOrFolder.metadata. + // Otherwise, a cyclic update between the version of the selectedDatasetOrFolder.metadata and the flushed version might occur. + const ignoreLocalMetadataUpdate = isUpdatePending && isSameName && isSameType; + if (isUpdatePending) { updateCachedDatasetOrFolderDebounced.flush(); - } else { + } + if (!ignoreLocalMetadataUpdate) { // Update state to newest metadata from selectedDatasetOrFolder. - setMetadata( - selectedDatasetOrFolder.metadata?.map((entry, index) => ({ ...entry, index })) || [], - ); + setMetadata(datasetOrFolder.metadata?.map((entry, index) => ({ ...entry, index })) || []); } - }, [selectedDatasetOrFolder.metadata]); + setCurrentDatasetOrFolderName(datasetOrFolder.name); + setIsCurrentEntityADataset(isDataset(datasetOrFolder)); + }, [datasetOrFolder.name, datasetOrFolder.metadata]); useEffectOnUpdate(() => { if (error == null) { const metadataWithoutIndex = metadata.map(({ index: _ignored, ...rest }) => rest); - const didMetadataChange = !_.isEqual(metadataWithoutIndex, selectedDatasetOrFolder.metadata); + const didMetadataChange = !_.isEqual(metadataWithoutIndex, datasetOrFolder.metadata); if (didMetadataChange) { - updateCachedDatasetOrFolderDebouncedTracked( - context, - selectedDatasetOrFolder, - metadataWithoutIndex, - ); + updateCachedDatasetOrFolderDebouncedTracked(context, datasetOrFolder, metadataWithoutIndex); } } }, [metadata, error]); @@ -340,7 +352,7 @@ export default function MetadataTable({ return (
- Name: {selectedDatasetOrFolder.name} + Name: {datasetOrFolder.name}
Metadata
@@ -357,7 +369,7 @@ export default function MetadataTable({ {sortedDetails.map(renderMetadataRow)} {sortedDetails.length === 0 && ( - + )} From d998e7f38b5f42b0d005891208a9d815a2141147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 25 Jul 2024 12:38:04 +0200 Subject: [PATCH 54/84] allow multiple error rows & only update local metadata set if new folder/dataset or update failed - Try forcing dropdown menu to stay open during update -> does not work atm :/ --- .../dataset/dataset_collection_context.tsx | 5 +- .../dashboard/folders/metadata_table.tsx | 227 +++++++++++------- frontend/stylesheets/_dashboard.less | 10 +- 3 files changed, 155 insertions(+), 87 deletions(-) diff --git a/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx b/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx index 95905f9f02d..3c8bf0a80b1 100644 --- a/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx +++ b/frontend/javascripts/dashboard/dataset/dataset_collection_context.tsx @@ -4,6 +4,7 @@ import type { APIDatasetCompact, APIDatasetCompactWithoutStatusAndLayerNames, FolderItem, + APIDataset, } from "types/api_flow_types"; import { DatasetUpdater, getDatastores, triggerDatasetCheck } from "admin/admin_rest_api"; import UserLocalStorage from "libs/user_local_storage"; @@ -32,7 +33,7 @@ export type DatasetCollectionContextValue = { datasetId: APIDatasetId, datasetsToUpdate?: Array, ) => Promise; - updateCachedDataset: (id: APIDatasetId, updater: DatasetUpdater) => Promise; + updateCachedDataset: (id: APIDatasetId, updater: DatasetUpdater) => Promise; activeFolderId: string | null; setActiveFolderId: (id: string | null) => void; mostRecentlyUsedActiveFolderId: string | null; @@ -160,7 +161,7 @@ export default function DatasetCollectionContextProvider({ } async function updateCachedDataset(id: APIDatasetId, updater: DatasetUpdater) { - await updateDatasetMutation.mutateAsync([id, updater]); + return await updateDatasetMutation.mutateAsync([id, updater]); } const getBreadcrumbs = (dataset: APIDatasetCompactWithoutStatusAndLayerNames) => { diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 37122836205..1640b2e3c2f 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -55,58 +55,47 @@ function metadataTypeToString(type: APIMetadata["type"]) { } } -function EmptyTablePlaceholder({ isDataset }: { isDataset: boolean }) { - return ( - - - Add metadata properties to this {isDataset ? "dataset" : "folder"}. - - - ); -} - -let isUpdatePending = false; -const updateCachedDatasetOrFolderDebounced = _.debounce( +const updateMetadataDebounced = _.debounce( async ( context: DatasetCollectionContextValue, selectedDatasetOrFolder: APIDataset | Folder, metadata: APIMetadataEntries, + setIfUpdatePending: (value: boolean) => void, + setDidUpdateSucceed: (value: boolean) => void, ) => { - isUpdatePending = false; + setIfUpdatePending(false); if ("folderId" in selectedDatasetOrFolder) { // In case of a dataset, update the dataset's metadata. - await context.updateCachedDataset(selectedDatasetOrFolder, { metadata: metadata }); + try { + const updatedDataset = await context.updateCachedDataset(selectedDatasetOrFolder, { + metadata: metadata, + }); + setDidUpdateSucceed(_.isEqual(updatedDataset.metadata, metadata)); + } catch (error) { + console.error(error); + setDidUpdateSucceed(false); + } } else { // Else update the folders metadata. const folder = selectedDatasetOrFolder; - await context.queries.updateFolderMutation.mutateAsync({ - ...folder, - allowedTeams: folder.allowedTeams?.map((t) => t.id) || [], - metadata, - }); + try { + const updatedFolder = await context.queries.updateFolderMutation.mutateAsync({ + ...folder, + allowedTeams: folder.allowedTeams?.map((t) => t.id) || [], + metadata, + }); + setDidUpdateSucceed(_.isEqual(updatedFolder.metadata, metadata)); + } catch (error) { + console.error(error); + setDidUpdateSucceed(false); + } } }, 3000, ); -const originalFlush = updateCachedDatasetOrFolderDebounced.flush; -// Overwrite the debounce flush function to avoid flushing when no update is pending. -updateCachedDatasetOrFolderDebounced.flush = async () => { - if (!isUpdatePending) return; - isUpdatePending = false; - originalFlush(); -}; -function updateCachedDatasetOrFolderDebouncedTracked( - context: DatasetCollectionContextValue, - selectedDatasetOrFolder: APIDataset | Folder, - metadata: APIMetadataEntries, -) { - isUpdatePending = true; - updateCachedDatasetOrFolderDebounced(context, selectedDatasetOrFolder, metadata); - return updateCachedDatasetOrFolderDebounced; -} -type APIMetadataWithIndex = APIMetadata & { index: number }; -type IndexedMetadataEntries = APIMetadataWithIndex[]; +type APIMetadataWithIndexAndError = APIMetadata & { index: number; error?: string | null }; +type IndexedMetadataEntries = APIMetadataWithIndexAndError[]; const isDataset = (datasetOrFolder: APIDataset | Folder): datasetOrFolder is APIDataset => "folderId" in datasetOrFolder; @@ -116,9 +105,8 @@ export default function MetadataTable({ }: { datasetOrFolder: APIDataset | Folder }) { const context = useDatasetCollectionContext(); const [metadata, setMetadata] = useState( - datasetOrFolder?.metadata?.map((entry, index) => ({ ...entry, index })) || [], + datasetOrFolder?.metadata?.map((entry, index) => ({ ...entry, index, error: null })) || [], ); - const [error, setError] = useState<[number, string] | null>(null); // [index, error message] const [focusedRow, setFocusedRow] = useState(null); // Save the current dataset or folder name and the type of it to be able to determine whether the passed datasetOrFolder to this component changed. const [currentDatasetOrFolderName, setCurrentDatasetOrFolderName] = useState( @@ -128,17 +116,42 @@ export default function MetadataTable({ isDataset(datasetOrFolder), ); + const [isAddEntryDropdownOpen, setIsAddEntryDropdownOpen] = useState(false); + + const [isUpdatePending, setIfUpdatePending] = useState(false); + const [didUpdateSucceed, setDidUpdateSucceed] = useState(true); + const flushPendingUpdates = async () => { + if (!isUpdatePending) return; + setIfUpdatePending(false); + updateMetadataDebounced.flush(); + }; + function updateMetadataDebouncedTracked( + context: DatasetCollectionContextValue, + selectedDatasetOrFolder: APIDataset | Folder, + metadata: APIMetadataEntries, + ) { + setIfUpdatePending(true); + updateMetadataDebounced( + context, + selectedDatasetOrFolder, + metadata, + setIfUpdatePending, + setDidUpdateSucceed, + ); + } + // Always update local state when folder / dataset changes or the result of the update request // to the backend is resolved shown by a changed value in selectedDatasetOrFolder.metadata. useEffectOnUpdate(() => { const isSameName = datasetOrFolder.name === currentDatasetOrFolderName; const isSameType = isDataset(datasetOrFolder) === isCurrentEntityADataset; - // In case an update is pending and the dataset or folder is the same as before, flush the pending update and ignore the changes to datasetOrFolder.metadata. - // Otherwise, a cyclic update between the version of the selectedDatasetOrFolder.metadata and the flushed version might occur. - const ignoreLocalMetadataUpdate = isUpdatePending && isSameName && isSameType; - if (isUpdatePending) { - updateCachedDatasetOrFolderDebounced.flush(); + const isSameDatasetOrFolder = isSameName && isSameType; + if (isUpdatePending && !isSameDatasetOrFolder) { + // Flush pending updates as the dataset or folder changed and its metadata should be shown now. + flushPendingUpdates(); } + // Ignore updates in case they weres successful and the dataset or folder is the same as before to avoid undoing changes made by the user during the debounce time. + const ignoreLocalMetadataUpdate = didUpdateSucceed && isSameDatasetOrFolder; if (!ignoreLocalMetadataUpdate) { // Update state to newest metadata from selectedDatasetOrFolder. setMetadata(datasetOrFolder.metadata?.map((entry, index) => ({ ...entry, index })) || []); @@ -148,31 +161,37 @@ export default function MetadataTable({ }, [datasetOrFolder.name, datasetOrFolder.metadata]); useEffectOnUpdate(() => { - if (error == null) { - const metadataWithoutIndex = metadata.map(({ index: _ignored, ...rest }) => rest); - const didMetadataChange = !_.isEqual(metadataWithoutIndex, datasetOrFolder.metadata); - if (didMetadataChange) { - updateCachedDatasetOrFolderDebouncedTracked(context, datasetOrFolder, metadataWithoutIndex); - } + const hasErrors = metadata.some((entry) => entry.error != null); + if (hasErrors) { + return; } - }, [metadata, error]); + const metadataWithoutIndexAndError = metadata.map( + ({ index: _ignored, error: _ignored2, ...rest }) => rest, + ); + const didMetadataChange = !_.isEqual(metadataWithoutIndexAndError, datasetOrFolder.metadata); + if (didMetadataChange) { + updateMetadataDebouncedTracked(context, datasetOrFolder, metadataWithoutIndexAndError); + } + }, [metadata]); // On component unmount flush pending updates to avoid potential data loss. useWillUnmount(() => { - updateCachedDatasetOrFolderDebounced.flush(); + flushPendingUpdates(); }); const updateMetadataKey = (index: number, newPropName: string) => { setMetadata((prev) => { + let error = null; const entry = prev.find((prop) => prop.index === index); if (!entry) { return prev; } const maybeAlreadyExistingEntry = prev.find((prop) => prop.key === newPropName); if (maybeAlreadyExistingEntry) { - setError([entry?.index || -1, `Property ${newPropName} already exists.`]); - } else { - setError(null); + error = `Property ${newPropName} already exists.`; + } + if (newPropName === "") { + error = "Property name cannot be empty."; } const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); return [ @@ -180,6 +199,7 @@ export default function MetadataTable({ { ...entry, key: newPropName, + error, }, ]; }); @@ -200,12 +220,13 @@ export default function MetadataTable({ const addNewEntryWithType = (type: APIMetadata["type"]) => { setMetadata((prev) => { const highestIndex = prev.reduce((acc, curr) => Math.max(acc, curr.index), 0); - const newEntry: APIMetadataWithIndex = { + const newEntry: APIMetadataWithIndexAndError = { key: "", value: type === APIMetadataType.STRING_ARRAY ? [] : type === APIMetadataType.NUMBER ? 0 : "", index: highestIndex + 1, type, + error: "Enter a property name.", }; // Auto focus the key input of the new entry. setTimeout(() => document.getElementById(getKeyInputId(newEntry))?.focus(), 50); @@ -230,14 +251,18 @@ export default function MetadataTable({ return { key: type, label: metadataTypeToString(type as APIMetadata["type"]), - onClick: () => addNewEntryWithType(type as APIMetadata["type"]), + onClick: () => { + setIsAddEntryDropdownOpen(false); + addNewEntryWithType(type as APIMetadata["type"]); + }, }; }), }); - const getKeyInputId = (record: APIMetadataWithIndex) => `metadata-key-input-id-${record.index}`; + const getKeyInputId = (record: APIMetadataWithIndexAndError) => + `metadata-key-input-id-${record.index}`; - const getKeyInput = (record: APIMetadataWithIndex) => { + const getKeyInput = (record: APIMetadataWithIndexAndError) => { const isFocused = record.index === focusedRow; return ( <> @@ -251,17 +276,17 @@ export default function MetadataTable({ size="small" id={getKeyInputId(record)} /> - {error != null && error[0] === record.index ? ( + {record.error != null ? ( <>
- {error[1]} + {record.error} ) : null} ); }; - const getValueInput = (record: APIMetadataWithIndex) => { + const getValueInput = (record: APIMetadataWithIndexAndError) => { const isFocused = record.index === focusedRow; const sharedProps = { className: isFocused ? undefined : "transparent-input", @@ -303,7 +328,7 @@ export default function MetadataTable({ } }; - const getDeleteEntryButton = (record: APIMetadataWithIndex) => ( + const getDeleteEntryButton = (record: APIMetadataWithIndexAndError) => ( + + +
+ ); + } + return (
-
- Name: {datasetOrFolder.name} -
Metadata
{/* Not using AntD Table to have more control over the styling. */} - - - - - - - - - {sortedDetails.map(renderMetadataRow)} - {sortedDetails.length === 0 && ( - - )} - - -
Property - Value -
+ {sortedDetails.length > 0 ? ( + + + + + + + + + {sortedDetails.map(renderMetadataRow)} + + +
Property + Value +
+ ) : ( + + )}
); diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index 9602ec8bdae..baa284b63d5 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -288,16 +288,24 @@ pre.dataset-import-folder-structure-hint { // Due to the border around the input elements, the width of the td has to be increased by 2px. width: 118.5px; } + tbody tr td:nth-child(3) { + display: flex; + } thead tr th:nth-child(2), tbody tr td:nth-child(2) { width: 5px; // Center the : text-align: center; - vertical-align: middle; + vertical-align: top; } thead tr th:nth-child(4), tbody tr td:nth-child(4) { width: 16px; } } + + .empty-metadata-placeholder { + padding: 16px; + text-align: center; + } } From 4a75782129e7f0acc1bc2d2332db7575e6ca0c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 25 Jul 2024 12:54:30 +0200 Subject: [PATCH 55/84] update preview image --- .../dashboard/folders/metadata_table.tsx | 2 +- public/images/metadata-teaser.svg | 179 ++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 public/images/metadata-teaser.svg diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 1640b2e3c2f..904c15ed5a3 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -395,7 +395,7 @@ export default function MetadataTable({ return (
Metadata preview diff --git a/public/images/metadata-teaser.svg b/public/images/metadata-teaser.svg new file mode 100644 index 00000000000..a27a2b12479 --- /dev/null +++ b/public/images/metadata-teaser.svg @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From a748164701caaecedd8e1e3f42959e96d04f467f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 25 Jul 2024 16:16:55 +0200 Subject: [PATCH 56/84] fix color layer / segmentation layer switchero bug --- frontend/javascripts/types/api_flow_types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 262d7feebda..4dd5b2b04c9 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -241,7 +241,7 @@ export type APIDatasetCompact = APIDatasetCompactWithoutStatusAndLayerNames & { }; export function convertDatasetToCompact(dataset: APIDataset): APIDatasetCompact { - const [colorLayerNames, segmentationLayerNames] = _.partition( + const [segmentationLayerNames, colorLayerNames] = _.partition( dataset.dataSource.dataLayers, (layer) => layer.category === "segmentation", ).map((layers) => layers.map((layer) => layer.name).sort()); From 23cab358c1e29e2ca6e7b131f26d5292f3cd0215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Thu, 25 Jul 2024 17:54:11 +0200 Subject: [PATCH 57/84] remove periodic autosave an replace with explicit save via button or autosave when changing focused dataset / folder --- .../dashboard/folders/metadata_table.tsx | 243 ++++++++---------- 1 file changed, 107 insertions(+), 136 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 904c15ed5a3..292515ff014 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -3,6 +3,7 @@ import { FieldNumberOutlined, FieldStringOutlined, PlusOutlined, + SaveOutlined, TagsOutlined, } from "@ant-design/icons"; import { @@ -14,23 +15,15 @@ import { Typography, Dropdown, Button, + Tooltip, } from "antd"; -import { useWillUnmount } from "beautiful-react-hooks"; -import { - DatasetCollectionContextValue, - useDatasetCollectionContext, -} from "dashboard/dataset/dataset_collection_context"; -import { useEffectOnUpdate } from "libs/react_hooks"; +import { usePreviousValue, useWillUnmount } from "beautiful-react-hooks"; +import { useDatasetCollectionContext } from "dashboard/dataset/dataset_collection_context"; +import Toast from "libs/toast"; import _ from "lodash"; -import React, { memo } from "react"; +import React, { useEffect } from "react"; import { useState } from "react"; -import { - APIDataset, - Folder, - APIMetadataEntries, - APIMetadata, - APIMetadataType, -} from "types/api_flow_types"; +import { APIDataset, Folder, APIMetadata, APIMetadataType } from "types/api_flow_types"; function metadataTypeToString(type: APIMetadata["type"]) { switch (type) { @@ -55,47 +48,9 @@ function metadataTypeToString(type: APIMetadata["type"]) { } } -const updateMetadataDebounced = _.debounce( - async ( - context: DatasetCollectionContextValue, - selectedDatasetOrFolder: APIDataset | Folder, - metadata: APIMetadataEntries, - setIfUpdatePending: (value: boolean) => void, - setDidUpdateSucceed: (value: boolean) => void, - ) => { - setIfUpdatePending(false); - if ("folderId" in selectedDatasetOrFolder) { - // In case of a dataset, update the dataset's metadata. - try { - const updatedDataset = await context.updateCachedDataset(selectedDatasetOrFolder, { - metadata: metadata, - }); - setDidUpdateSucceed(_.isEqual(updatedDataset.metadata, metadata)); - } catch (error) { - console.error(error); - setDidUpdateSucceed(false); - } - } else { - // Else update the folders metadata. - const folder = selectedDatasetOrFolder; - try { - const updatedFolder = await context.queries.updateFolderMutation.mutateAsync({ - ...folder, - allowedTeams: folder.allowedTeams?.map((t) => t.id) || [], - metadata, - }); - setDidUpdateSucceed(_.isEqual(updatedFolder.metadata, metadata)); - } catch (error) { - console.error(error); - setDidUpdateSucceed(false); - } - } - }, - 3000, -); - type APIMetadataWithIndexAndError = APIMetadata & { index: number; error?: string | null }; type IndexedMetadataEntries = APIMetadataWithIndexAndError[]; +type EffectCancelSignal = { dontUpdateState: boolean }; const isDataset = (datasetOrFolder: APIDataset | Folder): datasetOrFolder is APIDataset => "folderId" in datasetOrFolder; @@ -104,79 +59,93 @@ export default function MetadataTable({ datasetOrFolder, }: { datasetOrFolder: APIDataset | Folder }) { const context = useDatasetCollectionContext(); + const previousDatasetOrFolder: APIDataset | Folder | undefined = + usePreviousValue(datasetOrFolder); const [metadata, setMetadata] = useState( datasetOrFolder?.metadata?.map((entry, index) => ({ ...entry, index, error: null })) || [], ); const [focusedRow, setFocusedRow] = useState(null); - // Save the current dataset or folder name and the type of it to be able to determine whether the passed datasetOrFolder to this component changed. - const [currentDatasetOrFolderName, setCurrentDatasetOrFolderName] = useState( - datasetOrFolder.name, - ); - const [isCurrentEntityADataset, setIsCurrentEntityADataset] = useState( - isDataset(datasetOrFolder), - ); - - const [isAddEntryDropdownOpen, setIsAddEntryDropdownOpen] = useState(false); - const [isUpdatePending, setIfUpdatePending] = useState(false); - const [didUpdateSucceed, setDidUpdateSucceed] = useState(true); - const flushPendingUpdates = async () => { - if (!isUpdatePending) return; - setIfUpdatePending(false); - updateMetadataDebounced.flush(); - }; - function updateMetadataDebouncedTracked( - context: DatasetCollectionContextValue, - selectedDatasetOrFolder: APIDataset | Folder, - metadata: APIMetadataEntries, - ) { - setIfUpdatePending(true); - updateMetadataDebounced( - context, - selectedDatasetOrFolder, - metadata, - setIfUpdatePending, - setDidUpdateSucceed, - ); - } + const [isSaving, setIsSaving] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - // Always update local state when folder / dataset changes or the result of the update request - // to the backend is resolved shown by a changed value in selectedDatasetOrFolder.metadata. - useEffectOnUpdate(() => { - const isSameName = datasetOrFolder.name === currentDatasetOrFolderName; - const isSameType = isDataset(datasetOrFolder) === isCurrentEntityADataset; - const isSameDatasetOrFolder = isSameName && isSameType; - if (isUpdatePending && !isSameDatasetOrFolder) { - // Flush pending updates as the dataset or folder changed and its metadata should be shown now. - flushPendingUpdates(); - } - // Ignore updates in case they weres successful and the dataset or folder is the same as before to avoid undoing changes made by the user during the debounce time. - const ignoreLocalMetadataUpdate = didUpdateSucceed && isSameDatasetOrFolder; - if (!ignoreLocalMetadataUpdate) { - // Update state to newest metadata from selectedDatasetOrFolder. - setMetadata(datasetOrFolder.metadata?.map((entry, index) => ({ ...entry, index })) || []); - } - setCurrentDatasetOrFolderName(datasetOrFolder.name); - setIsCurrentEntityADataset(isDataset(datasetOrFolder)); - }, [datasetOrFolder.name, datasetOrFolder.metadata]); + const hasAnyErrors = metadata.some((entry) => entry.error != null); - useEffectOnUpdate(() => { - const hasErrors = metadata.some((entry) => entry.error != null); - if (hasErrors) { + const saveCurrentMetadata = async ( + datasetOrFolderToUpdate: APIDataset | Folder, + metadata: IndexedMetadataEntries, + { dontUpdateState }: EffectCancelSignal = { dontUpdateState: false }, + ) => { + if (hasAnyErrors) { return; } + !dontUpdateState && setIsSaving(true); const metadataWithoutIndexAndError = metadata.map( ({ index: _ignored, error: _ignored2, ...rest }) => rest, ); - const didMetadataChange = !_.isEqual(metadataWithoutIndexAndError, datasetOrFolder.metadata); - if (didMetadataChange) { - updateMetadataDebouncedTracked(context, datasetOrFolder, metadataWithoutIndexAndError); + let serverResponse: APIDataset | Folder; + const isADataset = isDataset(datasetOrFolderToUpdate); + const datasetOrFolderString = isADataset ? "dataset" : "folder"; + try { + if (isADataset) { + // In case of a dataset, update the dataset's metadata. + serverResponse = await context.updateCachedDataset(datasetOrFolderToUpdate, { + metadata: metadataWithoutIndexAndError, + }); + } else { + // Else update the folders metadata. + const folder: Folder = datasetOrFolderToUpdate; + serverResponse = await context.queries.updateFolderMutation.mutateAsync({ + ...folder, + allowedTeams: folder.allowedTeams?.map((t) => t.id) || [], + metadata: metadataWithoutIndexAndError, + }); + } + if (!_.isEqual(serverResponse.metadata, metadataWithoutIndexAndError)) { + Toast.error( + `Failed to save metadata changes for ${datasetOrFolderString} ${datasetOrFolder.name}.`, + ); + } else { + !dontUpdateState && setHasUnsavedChanges(false); + } + } catch (error) { + Toast.error( + `Failed to save metadata changes for ${datasetOrFolderString} ${datasetOrFolder.name}. Please look in the console for more details.`, + ); + console.error(error); + } finally { + !dontUpdateState && setIsSaving(false); } - }, [metadata]); + }; - // On component unmount flush pending updates to avoid potential data loss. + // Always update local state when folder / dataset changes. Prior to that send pending updates. + // biome-ignore lint/correctness/useExhaustiveDependencies: Only execute this hook when underlying datasetOrFolder changes. + useEffect(() => { + if (!previousDatasetOrFolder) { + // Skip first rendering cycle. + return; + } + const isSameName = datasetOrFolder.name === previousDatasetOrFolder.name; + const isSameType = isDataset(datasetOrFolder) === isDataset(previousDatasetOrFolder); + const isSameDatasetOrFolder = isSameName && isSameType; + const effectCancelSignal = { dontUpdateState: false }; + if (!isSameDatasetOrFolder) { + if (hasUnsavedChanges) { + // Flush pending updates as the dataset or folder changed and its metadata should be shown now. + saveCurrentMetadata(previousDatasetOrFolder, metadata, effectCancelSignal); + } + setMetadata( + datasetOrFolder.metadata?.map((entry, index) => ({ ...entry, index, error: null })) || [], + ); + } + return () => { + effectCancelSignal.dontUpdateState = true; + }; + }, [datasetOrFolder.name, isDataset(datasetOrFolder)]); + + // On component unmount update pending updates to avoid potential data loss. useWillUnmount(() => { - flushPendingUpdates(); + saveCurrentMetadata(datasetOrFolder, metadata, { dontUpdateState: true }); }); const updateMetadataKey = (index: number, newPropName: string) => { @@ -194,6 +163,7 @@ export default function MetadataTable({ error = "Property name cannot be empty."; } const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); + setHasUnsavedChanges(true); return [ ...detailsWithoutEditedEntry, { @@ -213,6 +183,7 @@ export default function MetadataTable({ } const updatedEntry = { ...entry, value: newValue }; const detailsWithoutEditedEntry = prev.filter((prop) => prop.index !== index); + setHasUnsavedChanges(true); return [...detailsWithoutEditedEntry, updatedEntry]; }); }; @@ -230,12 +201,14 @@ export default function MetadataTable({ }; // Auto focus the key input of the new entry. setTimeout(() => document.getElementById(getKeyInputId(newEntry))?.focus(), 50); + setHasUnsavedChanges(true); return [...prev, newEntry]; }); }; const deleteKey = (index: number) => { setMetadata((prev) => { + setHasUnsavedChanges(true); return prev.filter((prop) => prop.index !== index); }); }; @@ -251,10 +224,7 @@ export default function MetadataTable({ return { key: type, label: metadataTypeToString(type as APIMetadata["type"]), - onClick: () => { - setIsAddEntryDropdownOpen(false); - addNewEntryWithType(type as APIMetadata["type"]); - }, + onClick: () => addNewEntryWithType(type as APIMetadata["type"]), }; }), }); @@ -269,11 +239,11 @@ export default function MetadataTable({ setFocusedRow(record.index)} - onBlur={() => setFocusedRow(null)} value={record.key} onChange={(evt) => updateMetadataKey(record.index, evt.target.value)} placeholder="Property" size="small" + disabled={isSaving} id={getKeyInputId(record)} /> {record.error != null ? ( @@ -291,9 +261,10 @@ export default function MetadataTable({ const sharedProps = { className: isFocused ? undefined : "transparent-input", onFocus: () => setFocusedRow(record.index), - onBlur: () => setFocusedRow(null), + // onBlur: () => setFocusedRow(null), placeholder: "Value", size: "small" as InputNumberProps["size"], + disabled: isSaving, }; switch (record.type) { case "number": @@ -331,6 +302,7 @@ export default function MetadataTable({ const getDeleteEntryButton = (record: APIMetadataWithIndexAndError) => ( - +
+ + {hasUnsavedChanges && ( + + - +
- - {hasUnsavedChanges && ( - - - +
); @@ -398,11 +425,11 @@ export default function MetadataTable({ {sortedDetails.map(renderMetadataRow)} - + {getAddNewMetadataEntryRow()} ) : ( - + getEmptyTablePlaceholder() )} diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index baa284b63d5..485e289e62d 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -271,6 +271,7 @@ pre.dataset-import-folder-structure-hint { th { padding: 4px !important; font-weight: normal; + text-align: left; } .ant-table-cell { padding: 4px 4px 4px 0px !important; From 9fd95dddb2b5016eafa92b3cd9195e038bdfd805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 26 Jul 2024 13:18:17 +0200 Subject: [PATCH 59/84] update snapshots --- .../backend-snapshot-tests/folders.e2e.js.md | 2 -- .../backend-snapshot-tests/folders.e2e.js.snap | Bin 929 -> 909 bytes 2 files changed, 2 deletions(-) diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.md index 540fc252e69..e94c4b3cdd5 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.md @@ -84,7 +84,6 @@ Generated by [AVA](https://avajs.dev). isEditable: true, metadata: [ { - index: 0, key: 'foo', type: 'string', value: 'bar', @@ -114,7 +113,6 @@ Generated by [AVA](https://avajs.dev). isEditable: false, metadata: [ { - index: 0, key: 'foo', type: 'string', value: 'bar', diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/folders.e2e.js.snap index c3468fbfa18b5e9cb080dfdc99578211cb057d38..e20ceadd1cf8d0b93061cb88a20cb4d547f58dbf 100644 GIT binary patch literal 909 zcmV;819JR9RzVv%{UH>kQ0>C-rt_+!Nv2LFt z5Es&giXeU$Ru>8if{2Ja7wSU%EEGXRai@i9rl!Uh8#~rNW|R9lciy}2zH^2-4nRX} z)8d}WYmd|;t5)>yE9;LDvcFvfq-VaWdoyY2nQ1SLdwtJWzb7{uRe`-T*Plxd1gcL? ze6sauW>*Wj(eg3CsirxzuljEHSKWHoJGZK}hurAPAaLpa*$aDic7~&mdk-}|4BQ|$ z+ExO*-ci@bs@hr_dzR0;xnwuUjlKv03jFSFC_w&Y09FC$0B``nbpTHR^aH3Mt&Fs0 zeXD49h=$D4(o)cPkOnFtnnl0_Qb1}M1$jJfbqk|K@I=bZHs)m~ zWPr50AK?!O2r{6G0V^5M#iTM{LD@M5^fBNW1N;gwT>;iAQkhn8Z&Xhck4EaNYvi#F zGjUV#IPqXZIF;5rthgcU=@L-u5)j(;PXX$v1#}n!4t6!)e$c^+P`J`9mLH1<00000000B+ zS4(IVNf`eAs_vdqlgz|7dss(NR5UV)i5V4kjWO#3As`W7;82~eXzR>$=;?_WbBG>< zJqd!a5OPovWY_g#+&u^if{2JW59&dDfbLVH-Le|`0R zO`if_Ai4Xe-UXNM>wO!38T_?+Fi9x~LCo!V;cPO0|qg%c4=$>h2L4`=Ib_CzIR`3J)ED-sHL>Ppr6#L?UX8mx*a5 zv67?L>H(|*uns_L@wngC(0lt$MxnzgL_3Fyrw|=0{FA@DURbiimc}apdf_$^u!Dds z0sW*P$=*jYOvbG@>30dJU_cE6)-#}sDP`J-vU3a=V89awgg7vl1Dm;0W{kKudLYCT z@y6OZ6}C|jw|PLsE1F{2oY~=|Ea}cw5{*vb%8WOL%lK(CnPZ#jNYI(4Hf0=duXMl4 zWI_0OGU#L@_&MrSyfO$u^)j*0hsisB-CR&sH#dD|-3-DaC#Y^JED94lPCy@_oO}{H zO~4;YlBOnIAmE~sWT@geq=Q%eckDa+xnj>_MdjVffL#nY%7F8#&T&=ew8sqimjOR= zV6Ccex|*)%IMBm^-#G9$2kxpRp%GVte;iD+PBga1<#IC~Pgr7wY%hmM$de+S++M5X zR5&FB8kJQOupdn+)a{HVyM0T8665=2et6yCIBH*BzH_)QBkQ?#W{sa zq!oKd_b>T*G=803UG}uMeZ?=+Y)}m5xw2qdu~OQ^x?`^6cyICe5^%f*1No%I%Mbtn DtJ%a0 From 93d9b6f77e3da21fe0148fd51095dc7593e510a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Mon, 29 Jul 2024 13:32:31 +0200 Subject: [PATCH 60/84] refactor code & remove `useWillUnmount` which sent outdated metadata update to the backend breaking the whole metadata feature. --- .../dashboard/folders/metadata_table.tsx | 133 ++++++++---------- 1 file changed, 55 insertions(+), 78 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 5c634d423fb..c5e6868aa03 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -15,7 +15,7 @@ import { Dropdown, Button, } from "antd"; -import { usePreviousValue, useWillUnmount } from "beautiful-react-hooks"; +import { usePreviousValue } from "beautiful-react-hooks"; import { DatasetCollectionContextValue, useDatasetCollectionContext, @@ -26,7 +26,7 @@ import React, { useEffect } from "react"; import { useState } from "react"; import { APIDataset, Folder, APIMetadata, APIMetadataType } from "types/api_flow_types"; -function metadataTypeToString(type: APIMetadata["type"]) { +function getMetadataTypeLabel(type: APIMetadata["type"]) { switch (type) { case "string": return ( @@ -53,7 +53,7 @@ const saveCurrentMetadata = async ( datasetOrFolderToUpdate: APIDataset | Folder, metadata: IndexedMetadataEntries, context: DatasetCollectionContextValue, - { dontUpdateState }: EffectCancelSignal = { dontUpdateState: false }, + { dontUpdateState }: EffectCancelSignal = { dontUpdateState: false }, // equals whether the component is unmounted setIsSaving: (isSaving: boolean) => void, setHasUnsavedChanges: (hasUnsavedChanges: boolean) => void, ) => { @@ -102,6 +102,9 @@ const saveCurrentMetadata = async ( const saveCurrentMetadataDebounced = _.debounce(saveCurrentMetadata, 3000); +const getKeyInputId = (record: APIMetadataWithIndexAndError) => + `metadata-key-input-id-${record.index}`; + type APIMetadataWithIndexAndError = APIMetadata & { index: number; error?: string | null }; type IndexedMetadataEntries = APIMetadataWithIndexAndError[]; type EffectCancelSignal = { dontUpdateState: boolean }; @@ -119,7 +122,6 @@ export default function MetadataTable({ datasetOrFolder?.metadata?.map((entry, index) => ({ ...entry, index, error: null })) || [], ); const [focusedRow, setFocusedRow] = useState(null); - const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); @@ -155,6 +157,7 @@ export default function MetadataTable({ }; }, [datasetOrFolder.name, isDataset(datasetOrFolder), datasetOrFolder.metadata]); + // Sent automatic debounced updates to the server when metadata changes. // biome-ignore lint/correctness/useExhaustiveDependencies: Only update upon pending changes. useEffect(() => { const effectCancelSignal = { dontUpdateState: false }; @@ -173,20 +176,8 @@ export default function MetadataTable({ }; }, [metadata]); - // On component unmount update pending updates to avoid potential data loss. - useWillUnmount(() => { - saveCurrentMetadata( - datasetOrFolder, - metadata, - context, - { dontUpdateState: true }, - setIsSaving, - setHasUnsavedChanges, - ); - }); - const updateMetadataKey = (index: number, newPropName: string) => { - setMetadata((prev) => { + setMetadata((prev: IndexedMetadataEntries) => { let error = null; const entry = prev.find((prop) => prop.index === index); if (!entry) { @@ -260,15 +251,12 @@ export default function MetadataTable({ items: Object.values(APIMetadataType).map((type) => { return { key: type, - label: metadataTypeToString(type as APIMetadata["type"]), + label: getMetadataTypeLabel(type as APIMetadata["type"]), onClick: () => addNewEntryWithType(type as APIMetadata["type"]), }; }), }); - const getKeyInputId = (record: APIMetadataWithIndexAndError) => - `metadata-key-input-id-${record.index}`; - const getKeyInput = (record: APIMetadataWithIndexAndError) => { const isFocused = record.index === focusedRow; return ( @@ -354,60 +342,6 @@ export default function MetadataTable({ /> ); - const renderMetadataRow = (record: APIMetadataWithIndexAndError) => ( - - {getKeyInput(record)} - : - {getValueInput(record)} - {getDeleteEntryButton(record)} - - ); - - function getAddNewMetadataEntryRow() { - return ( - - -
- - - -
- - - ); - } - - function getEmptyTablePlaceholder() { - return ( -
- Metadata preview - - - - - -
- ); - } - return (
Metadata
@@ -424,12 +358,55 @@ export default function MetadataTable({ - {sortedDetails.map(renderMetadataRow)} - {getAddNewMetadataEntryRow()} + {sortedDetails.map((record) => ( + + {getKeyInput(record)} + : + {getValueInput(record)} + {getDeleteEntryButton(record)} + + ))} + + +
+ + + +
+ + ) : ( - getEmptyTablePlaceholder() +
+ Metadata preview + + + + + +
)}
From 19adc1b71d5e449954d6867c5eabcd2bfe3605b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Mon, 29 Jul 2024 13:54:13 +0200 Subject: [PATCH 61/84] remove unnecessary dependency from useEffect accidentally added in a different pr --- .../oxalis/view/right-border-tabs/bounding_box_tab.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.tsx index c3ba70bc774..6393bbd5ea5 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.tsx @@ -103,12 +103,11 @@ export default function BoundingBoxTab() { APIJobType.EXPORT_TIFF, ); - // biome-ignore lint/correctness/useExhaustiveDependencies: Always try to scroll the active bounding box into view. useEffect(() => { if (bboxTableRef.current != null && activeBoundingBoxId != null) { bboxTableRef.current.scrollTo({ key: activeBoundingBoxId }); } - }, [activeBoundingBoxId, bboxTableRef.current]); + }, [activeBoundingBoxId]); const boundingBoxWrapperTableColumns = [ { From c0827bd25092aace45c262b15d5a9bc57260b12b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Tue, 6 Aug 2024 15:28:33 +0200 Subject: [PATCH 62/84] include metadata in full dataset update route --- app/controllers/DatasetController.scala | 19 ++++++++++++------- app/models/dataset/Dataset.scala | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index ea4d2db801a..a490d96e3a4 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -80,6 +80,7 @@ class DatasetController @Inject()(userService: UserService, (__ \ "sortingKey").readNullable[Instant] and (__ \ "isPublic").read[Boolean] and (__ \ "tags").read[List[String]] and + (__ \ "metadata").readNullable[JsArray] and (__ \ "folderId").readNullable[ObjectId]).tupled def removeFromThumbnailCache(organizationName: String, datasetName: String): Action[AnyContent] = @@ -299,17 +300,21 @@ class DatasetController @Inject()(userService: UserService, def update(organizationName: String, datasetName: String): Action[JsValue] = sil.SecuredAction.async(parse.json) { implicit request => withJsonBodyUsing(datasetPublicReads) { - case (description, displayName, sortingKey, isPublic, tags, folderId) => + case (description, displayName, sortingKey, isPublic, tags, metadata, folderId) => for { dataset <- datasetDAO.findOneByNameAndOrganization(datasetName, request.identity._organization) ?~> notFoundMessage( datasetName) ~> NOT_FOUND + maybeUpdatedMetadata = if (metadata.isDefined) metadata else dataset.metadata _ <- Fox.assertTrue(datasetService.isEditableBy(dataset, Some(request.identity))) ?~> "notAllowed" ~> FORBIDDEN - _ <- datasetDAO.updateFields(dataset._id, - description, - displayName, - sortingKey.getOrElse(dataset.created), - isPublic, - folderId.getOrElse(dataset._folder)) + _ <- datasetDAO.updateFields( + dataset._id, + description, + displayName, + sortingKey.getOrElse(dataset.created), + isPublic, + maybeUpdatedMetadata, + folderId.getOrElse(dataset._folder) + ) _ <- datasetDAO.updateTags(dataset._id, tags) updated <- datasetDAO.findOneByNameAndOrganization(datasetName, request.identity._organization) _ = analyticsService.track(ChangeDatasetSettingsEvent(request.identity, updated)) diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index 0b51fca4dc8..bf09dd483bb 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -499,12 +499,21 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA displayName: Option[String], sortingKey: Instant, isPublic: Boolean, + metadata: Option[JsArray], folderId: ObjectId)(implicit ctx: DBAccessContext): Fox[Unit] = { - val query = for { row <- Datasets if notdel(row) && row._Id === _id.id } yield - (row.description, row.displayname, row.sortingkey, row.ispublic, row._Folder) + val query = for { row <- Datasets if notdel(row) && row._Id === _id.id } yield { + (row.description, row.displayname, row.sortingkey, row.ispublic, row.metadata, row._Folder) + } for { _ <- assertUpdateAccess(_id) - _ <- run(query.update(description, displayName, sortingKey.toSql, isPublic, folderId.toString)) + _ <- run( + query.update( + (description, + displayName, + sortingKey.toSql, + isPublic, + Some(metadata.getOrElse("[]").toString), + folderId.toString))) } yield () } From d624a273f7ab44921367d80b8b12d4f6030e73ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 7 Aug 2024 11:52:56 +0200 Subject: [PATCH 63/84] fix full update dataset route for metadata support --- app/models/dataset/Dataset.scala | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index bf09dd483bb..dfe631d8a55 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -475,7 +475,6 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA params.displayName.map(v => q"displayName = $v"), params.sortingKey.map(v => q"sortingKey = $v"), params.isPublic.map(v => q"isPublic = $v"), - params.tags.map(v => q"tags = $v"), params.folderId.map(v => q"_folder = $v"), params.metadata.map(v => q"metadata = $v"), ).flatten @@ -494,27 +493,22 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA } } - def updateFields(_id: ObjectId, + def updateFields(datasetId: ObjectId, description: Option[String], displayName: Option[String], sortingKey: Instant, isPublic: Boolean, metadata: Option[JsArray], folderId: ObjectId)(implicit ctx: DBAccessContext): Fox[Unit] = { - val query = for { row <- Datasets if notdel(row) && row._Id === _id.id } yield { - (row.description, row.displayname, row.sortingkey, row.ispublic, row.metadata, row._Folder) - } - for { - _ <- assertUpdateAccess(_id) - _ <- run( - query.update( - (description, - displayName, - sortingKey.toSql, - isPublic, - Some(metadata.getOrElse("[]").toString), - folderId.toString))) - } yield () + val updateParameters = new DatasetUpdateParameters( + description = Some(description), + displayName = Some(displayName), + sortingKey = Some(sortingKey), + isPublic = Some(isPublic), + metadata = metadata, + folderId = Some(folderId) + ) + updatePartial(datasetId, updateParameters) } def updateTags(id: ObjectId, tags: List[String])(implicit ctx: DBAccessContext): Fox[Unit] = From dd3c3dabd3acccf731f35ff26321ebd493268070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 7 Aug 2024 12:16:10 +0200 Subject: [PATCH 64/84] remove option to update tags (as they will no longer be rendered in the frontend) --- app/controllers/DatasetController.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index a490d96e3a4..3fba8122cba 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -34,7 +34,6 @@ case class DatasetUpdateParameters( displayName: Option[Option[String]] = Some(None), sortingKey: Option[Instant], isPublic: Option[Boolean], - tags: Option[List[String]], metadata: Option[JsArray], folderId: Option[ObjectId] ) From 0a607e852b020fed5b6941c0d6b92b540af2667a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 9 Aug 2024 18:49:00 +0200 Subject: [PATCH 65/84] remove index from initial metdata added to datasets with publication --- app/models/dataset/DatasetService.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/dataset/DatasetService.scala b/app/models/dataset/DatasetService.scala index d351c3c120e..6f82fca8f14 100644 --- a/app/models/dataset/DatasetService.scala +++ b/app/models/dataset/DatasetService.scala @@ -80,9 +80,9 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, val metadata = if (publication.isDefined) Json.arr( - Json.obj("type" -> "string", "key" -> "species", "value" -> "species name", "index" -> 0), - Json.obj("type" -> "string", "key" -> "brainRegion", "value" -> "brain region", "index" -> 1), - Json.obj("type" -> "string", "key" -> "acquisition", "value" -> "acquisition method", "index" -> 2) + Json.obj("type" -> "string", "key" -> "species", "value" -> "species name"), + Json.obj("type" -> "string", "key" -> "brainRegion", "value" -> "brain region"), + Json.obj("type" -> "string", "key" -> "acquisition", "value" -> "acquisition method") ) else Json.arr() From c0c8233e531527fae790d83d8e6ef0aeb99bf94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Fri, 9 Aug 2024 18:55:51 +0200 Subject: [PATCH 66/84] apply pr feedback (testing pending) --- .../javascripts/dashboard/dataset/queries.tsx | 1 - .../dashboard/folders/details_sidebar.tsx | 9 +- .../dashboard/folders/metadata_table.tsx | 332 ++++++++++-------- frontend/javascripts/libs/react_hooks.ts | 15 - frontend/stylesheets/_dashboard.less | 15 +- test/db/folders.csv | 2 +- 6 files changed, 192 insertions(+), 182 deletions(-) diff --git a/frontend/javascripts/dashboard/dataset/queries.tsx b/frontend/javascripts/dashboard/dataset/queries.tsx index 9a7f926f528..0285151fa44 100644 --- a/frontend/javascripts/dashboard/dataset/queries.tsx +++ b/frontend/javascripts/dashboard/dataset/queries.tsx @@ -324,7 +324,6 @@ export function useUpdateFolderMutation() { ? { ...updatedFolder, parent: oldFolder.parent, - metadata: oldFolder.metadata, } : oldFolder, ), diff --git a/frontend/javascripts/dashboard/folders/details_sidebar.tsx b/frontend/javascripts/dashboard/folders/details_sidebar.tsx index d6a59f409a0..a55e9950dd5 100644 --- a/frontend/javascripts/dashboard/folders/details_sidebar.tsx +++ b/frontend/javascripts/dashboard/folders/details_sidebar.tsx @@ -177,7 +177,12 @@ function DatasetDetails({ selectedDataset }: { selectedDataset: APIDatasetCompac )} - {fullDataset && } + {fullDataset && ( + + )} {fullDataset?.usedStorageBytes && fullDataset.usedStorageBytes > 10000 ? ( @@ -282,7 +287,7 @@ function FolderDetails({
- + ) : error ? ( "Could not load folder." diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index c5e6868aa03..5476f537e47 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -15,17 +15,20 @@ import { Dropdown, Button, } from "antd"; -import { usePreviousValue } from "beautiful-react-hooks"; +import { useWillUnmount } from "beautiful-react-hooks"; import { DatasetCollectionContextValue, useDatasetCollectionContext, } from "dashboard/dataset/dataset_collection_context"; import Toast from "libs/toast"; -import _ from "lodash"; +import _, { set } from "lodash"; import React, { useEffect } from "react"; import { useState } from "react"; import { APIDataset, Folder, APIMetadata, APIMetadataType } from "types/api_flow_types"; +type APIMetadataWithError = APIMetadata & { error?: string | null }; +type IndexedMetadataEntries = APIMetadataWithError[]; + function getMetadataTypeLabel(type: APIMetadata["type"]) { switch (type) { case "string": @@ -49,11 +52,97 @@ function getMetadataTypeLabel(type: APIMetadata["type"]) { } } +type EmptyMetadataPlaceholderProps = { + addNewEntryMenuItems: MenuProps; +}; +const EmptyMetadataPlaceholder: React.FC = ({ + addNewEntryMenuItems, +}) => { + return ( +
+ Metadata preview + + + + + +
+ ); +}; + +interface MetadataValueInputProps { + record: APIMetadataWithError; + index: number; + focusedRow: number | null; + setFocusedRow: (row: number | null) => void; + updateMetadataValue: (index: number, newValue: number | string | string[]) => void; + isSaving: boolean; + availableStrArrayTagOptions: { value: string; label: string }[]; +} + +const MetadataValueInput: React.FC = ({ + record, + index, + focusedRow, + setFocusedRow, + updateMetadataValue, + isSaving, + availableStrArrayTagOptions, +}) => { + const isFocused = index === focusedRow; + const sharedProps = { + className: isFocused ? undefined : "transparent-input", + onFocus: () => setFocusedRow(index), + onBlur: () => setFocusedRow(null), + placeholder: "Value", + size: "small" as InputNumberProps["size"], + disabled: isSaving, + }; + + switch (record.type) { + case APIMetadataType.NUMBER: + return ( + updateMetadataValue(index, newNum || 0)} + {...sharedProps} + /> + ); + case APIMetadataType.STRING: + return ( + updateMetadataValue(index, evt.target.value)} + {...sharedProps} + /> + ); + case APIMetadataType.STRING_ARRAY: + return ( + setFocusedRow(record.index)} + onFocus={() => setFocusedRow(index)} onBlur={() => setFocusedRow(null)} value={record.key} - onChange={(evt) => updateMetadataKey(record.index, evt.target.value)} + onChange={(evt) => updateMetadataKey(index, evt.target.value)} placeholder="Property" size="small" disabled={isSaving} - id={getKeyInputId(record)} + id={getKeyInputIdForIndex(index)} /> {record.error != null ? ( <>
- {record.error} + + {record.error} + ) : null} ); }; - const getValueInput = (record: APIMetadataWithIndexAndError) => { - const isFocused = record.index === focusedRow; - const sharedProps = { - className: isFocused ? undefined : "transparent-input", - onFocus: () => setFocusedRow(record.index), - onBlur: () => setFocusedRow(null), - placeholder: "Value", - size: "small" as InputNumberProps["size"], - disabled: isSaving, - }; - switch (record.type) { - case "number": - return ( - updateMetadataValue(record.index, newNum || 0)} - {...sharedProps} - /> - ); - case "string": - return ( - updateMetadataValue(record.index, evt.target.value)} - {...sharedProps} - /> - ); - case "string[]": - return ( - = ({ {...sharedProps} /> ); - case APIMetadataType.STRING_ARRAY: + case APIMetadataEnum.STRING_ARRAY: return (