diff --git a/frontend/javascripts/admin/account/account_auth_token_view.tsx b/frontend/javascripts/admin/account/account_auth_token_view.tsx
index f7e7d6b538b..2058fe3642f 100644
--- a/frontend/javascripts/admin/account/account_auth_token_view.tsx
+++ b/frontend/javascripts/admin/account/account_auth_token_view.tsx
@@ -4,7 +4,7 @@ import { Button, Col, Row, Spin, Typography } from "antd";
import { useWkSelector } from "libs/react_hooks";
import Toast from "libs/toast";
import { useEffect, useState } from "react";
-import { SettingsCard } from "./helpers/settings_card";
+import { SettingsCard, type SettingsCardProps } from "./helpers/settings_card";
import { SettingsTitle } from "./helpers/settings_title";
const { Text } = Typography;
@@ -41,10 +41,10 @@ function AccountAuthTokenView() {
}
};
- const APIitems = [
+ const APIitems: SettingsCardProps[] = [
{
title: "Auth Token",
- value: (
+ content: (
{currentToken}
@@ -52,9 +52,9 @@ function AccountAuthTokenView() {
},
{
title: "Token Revocation",
- explanation:
+ tooltip:
"Revoke your token if it has been compromised or if you suspect someone else has gained access to it. This will invalidate all active sessions.",
- value: (
+ content: (
} type="primary" ghost onClick={handleRevokeToken}>
Revoke and Generate New Token
@@ -64,7 +64,7 @@ function AccountAuthTokenView() {
? [
{
title: "Organization ID",
- value: (
+ content: (
{activeUser.organization}
@@ -74,7 +74,7 @@ function AccountAuthTokenView() {
: []),
{
title: "API Documentation",
- value: (
+ content: (
Read the docs
@@ -92,11 +92,7 @@ function AccountAuthTokenView() {
{APIitems.map((item) => (
-
+
))}
diff --git a/frontend/javascripts/admin/account/account_password_view.tsx b/frontend/javascripts/admin/account/account_password_view.tsx
index 76692325199..3118bf6291c 100644
--- a/frontend/javascripts/admin/account/account_password_view.tsx
+++ b/frontend/javascripts/admin/account/account_password_view.tsx
@@ -7,7 +7,7 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { logoutUserAction } from "viewer/model/actions/user_actions";
import Store from "viewer/store";
-import { SettingsCard } from "./helpers/settings_card";
+import { SettingsCard, type SettingsCardProps } from "./helpers/settings_card";
import { SettingsTitle } from "./helpers/settings_title";
const FormItem = Form.Item;
const { Password } = Input;
@@ -155,10 +155,10 @@ function AccountPasswordView() {
setResetPasswordVisible(true);
}
- const passKeyList = [
+ const passKeyList: SettingsCardProps[] = [
{
title: "Coming soon",
- value: "Passwordless login with passkeys is coming soon",
+ content: "Passwordless login with passkeys is coming soon",
// action: } size="small" />,
action: undefined,
},
@@ -171,7 +171,7 @@ function AccountPasswordView() {
{passKeyList.map((item) => (
-
+
))}
diff --git a/frontend/javascripts/admin/account/account_profile_view.tsx b/frontend/javascripts/admin/account/account_profile_view.tsx
index 7967bcd00a2..c5eaa4d7797 100644
--- a/frontend/javascripts/admin/account/account_profile_view.tsx
+++ b/frontend/javascripts/admin/account/account_profile_view.tsx
@@ -9,7 +9,7 @@ import { formatUserName } from "viewer/model/accessors/user_accessor";
import { setThemeAction } from "viewer/model/actions/ui_actions";
import { setActiveUserAction } from "viewer/model/actions/user_actions";
import Store from "viewer/store";
-import { SettingsCard } from "./helpers/settings_card";
+import { SettingsCard, type SettingsCardProps } from "./helpers/settings_card";
import { SettingsTitle } from "./helpers/settings_title";
function AccountProfileView() {
@@ -56,29 +56,29 @@ function AccountProfileView() {
},
];
- const profileItems = [
+ const profileItems: SettingsCardProps[] = [
{
title: "Name",
- value: formatUserName(activeUser, activeUser),
+ content: formatUserName(activeUser, activeUser),
},
{
title: "Email",
- value: activeUser.email,
+ content: activeUser.email,
},
{
title: "Organization",
- value: activeOrganization?.name || activeUser.organization,
+ content: activeOrganization?.name || activeUser.organization,
},
{
title: "Role",
- value: role,
- explanation: (
+ content: role,
+ tooltip: (
Learn More
),
},
{
title: "Theme",
- value: (
+ content: (
}>
{themeItems.find((item) => item.key === selectedTheme)?.label}
@@ -95,11 +95,7 @@ function AccountProfileView() {
{profileItems.map((item) => (
-
+
))}
diff --git a/frontend/javascripts/admin/account/account_settings_view.tsx b/frontend/javascripts/admin/account/account_settings_view.tsx
index bfb0dcdaa16..e526e9c97ce 100644
--- a/frontend/javascripts/admin/account/account_settings_view.tsx
+++ b/frontend/javascripts/admin/account/account_settings_view.tsx
@@ -44,7 +44,11 @@ const MENU_ITEMS: MenuItemGroupType[] = [
function AccountSettingsView() {
const location = useLocation();
const navigate = useNavigate();
- const selectedKey = location.pathname.split("/").filter(Boolean).pop() || "profile";
+ const selectedKey =
+ location.pathname
+ .split("/")
+ .filter((p) => p.length > 0)
+ .pop() || "profile";
const breadcrumbItems = [
{
diff --git a/frontend/javascripts/admin/account/helpers/settings_card.tsx b/frontend/javascripts/admin/account/helpers/settings_card.tsx
index 62966cd5dad..130b9f1c74f 100644
--- a/frontend/javascripts/admin/account/helpers/settings_card.tsx
+++ b/frontend/javascripts/admin/account/helpers/settings_card.tsx
@@ -1,31 +1,32 @@
import { InfoCircleOutlined } from "@ant-design/icons";
-import { Card, Flex, Popover, Typography } from "antd";
+import { Card, Flex, Tooltip, Typography } from "antd";
-interface SettingsCardProps {
+export type SettingsCardProps = {
title: string;
- description: React.ReactNode;
- explanation?: React.ReactNode;
+ content: React.ReactNode;
+ tooltip?: React.ReactNode;
action?: React.ReactNode;
-}
+ style?: React.CSSProperties;
+};
-export function SettingsCard({ title, description, explanation, action }: SettingsCardProps) {
+export function SettingsCard({ title, content, tooltip, action, style }: SettingsCardProps) {
return (
-
+
{title}
- {explanation != null ? (
-
-
-
+ {tooltip != null ? (
+
+
+
) : null}
{action}
- {description}
+ {content}
);
}
diff --git a/frontend/javascripts/admin/dataset/dataset_add_remote_view.tsx b/frontend/javascripts/admin/dataset/dataset_add_remote_view.tsx
index 895f1e35950..baa72b6e703 100644
--- a/frontend/javascripts/admin/dataset/dataset_add_remote_view.tsx
+++ b/frontend/javascripts/admin/dataset/dataset_add_remote_view.tsx
@@ -1,51 +1,25 @@
-import { UnlockOutlined } from "@ant-design/icons";
import { CardContainer, DatastoreFormItem } from "admin/dataset/dataset_components";
-import { exploreRemoteDataset, isDatasetNameValid, storeRemoteDataset } from "admin/rest_api";
-import {
- Button,
- Col,
- Collapse,
- Divider,
- Form,
- type FormInstance,
- Input,
- List,
- Modal,
- Radio,
- Row,
- Upload,
-} from "antd";
-import type { RcFile, UploadChangeParam, UploadFile } from "antd/lib/upload";
-import { AsyncButton } from "components/async_clickables";
+import { isDatasetNameValid, storeRemoteDataset } from "admin/rest_api";
+import { Button, Col, Divider, Form, type FormInstance, List, Modal, Row } from "antd";
import BrainSpinner from "components/brain_spinner";
-import DatasetSettingsDataTab, {
- // Sync simple with advanced and get newest datasourceJson
+import DatasetSettingsDataTab from "dashboard/dataset/dataset_settings_data_tab";
+import {
+ DatasetSettingsProvider, // Sync simple with advanced and get newest datasourceJson
syncDataSourceFields,
-} from "dashboard/dataset/dataset_settings_data_tab";
+} from "dashboard/dataset/dataset_settings_provider";
import { FormItemWithInfo, Hideable } from "dashboard/dataset/helper_components";
import FolderSelection from "dashboard/folders/folder_selection";
-import { formatScale } from "libs/format_utils";
import { useWkSelector } from "libs/react_hooks";
-import { readFileAsText } from "libs/read_file";
import Toast from "libs/toast";
-import { jsonStringify } from "libs/utils";
import * as Utils from "libs/utils";
-import _ from "lodash";
import messages from "messages";
-import React, { useEffect, useState } from "react";
+import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { APIDataStore } from "types/api_types";
-import type { ArbitraryObject } from "types/globals";
-import type { DataLayer, DatasourceConfiguration } from "types/schemas/datasource.types";
-import { Unicode } from "viewer/constants";
-import { Hint } from "viewer/view/action-bar/download_modal_view";
import { dataPrivacyInfo } from "./dataset_upload_view";
+import { AddRemoteLayer } from "./remote/add_remote_layer";
const FormItem = Form.Item;
-const RadioGroup = Radio.Group;
-const { Password } = Input;
-
-type FileList = UploadFile[];
type Props = {
onAdded: (
@@ -61,119 +35,6 @@ type Props = {
defaultDatasetUrl?: string | null | undefined;
};
-function ensureLargestSegmentIdsInPlace(datasource: DatasourceConfiguration) {
- for (const layer of datasource.dataLayers) {
- if (layer.category === "color" || layer.largestSegmentId != null) {
- continue;
- }
- // Make sure the property exists. Otherwise, a field would not be
- // rendered in the form.
- layer.largestSegmentId = null;
- }
-}
-
-function mergeNewLayers(
- existingDatasource: DatasourceConfiguration | null,
- newDatasource: DatasourceConfiguration,
-): DatasourceConfiguration {
- if (existingDatasource?.dataLayers == null) {
- return newDatasource;
- }
- const allLayers = newDatasource.dataLayers.concat(existingDatasource.dataLayers);
- const groupedLayers: Record = _.groupBy(
- allLayers,
- (layer: DataLayer) => layer.name,
- );
- const uniqueLayers: DataLayer[] = [];
- for (const entry of _.entries(groupedLayers)) {
- const [name, layerGroup] = entry;
- if (layerGroup.length === 1) {
- uniqueLayers.push(layerGroup[0]);
- } else {
- let idx = 1;
- for (const layer of layerGroup) {
- if (idx === 1) {
- uniqueLayers.push(layer);
- } else {
- uniqueLayers.push({ ...layer, name: `${name}_${idx}` });
- }
- idx++;
- }
- }
- }
- return {
- ...existingDatasource,
- dataLayers: uniqueLayers,
- };
-}
-
-export const parseCredentials = async (
- file: RcFile | undefined,
-): Promise => {
- if (!file) {
- return null;
- }
- const jsonString = await readFileAsText(file);
- try {
- return JSON.parse(jsonString);
- } catch (_exception) {
- Toast.error("Cannot parse credentials as valid JSON. Ignoring credentials file.");
- return null;
- }
-};
-
-export function GoogleAuthFormItem({
- fileList,
- handleChange,
- showOptionalHint,
-}: {
- fileList: FileList;
- handleChange: (arg: UploadChangeParam>) => void;
- showOptionalHint?: boolean;
-}) {
- return (
-
- Google{Unicode.NonBreakingSpace}
-
- Service Account
-
- {Unicode.NonBreakingSpace}Key {showOptionalHint && "(Optional)"}
-
- }
- hasFeedback
- >
- false}
- >
-
-
-
-
- Click or Drag your Google Cloud Authentication File to this Area to Upload
-
-
- This is only needed if the dataset is located in a non-public Google Cloud Storage bucket
-
-
-
- );
-}
-
function DatasetAddRemoteView(props: Props) {
const { onAdded, datastores, defaultDatasetUrl } = props;
const activeUser = useWkSelector((state) => state.activeUser);
@@ -183,7 +44,6 @@ function DatasetAddRemoteView(props: Props) {
const [showAddLayerModal, setShowAddLayerModal] = useState(false);
const [showLoadingOverlay, setShowLoadingOverlay] = useState(defaultDatasetUrl != null);
- const [dataSourceEditMode, setDataSourceEditMode] = useState<"simple" | "advanced">("simple");
const [form] = Form.useForm();
const [targetFolderId, setTargetFolderId] = useState(null);
const isDatasourceConfigStrFalsy = Form.useWatch("dataSourceJson", form) == null;
@@ -254,8 +114,6 @@ function DatasetAddRemoteView(props: Props) {
};
async function handleStoreDataset() {
- // Sync simple with advanced and get newest datasourceJson
- syncDataSourceFields(form, dataSourceEditMode === "simple" ? "advanced" : "simple", true);
try {
await form.validateFields();
} catch (_e) {
@@ -279,23 +137,23 @@ function DatasetAddRemoteView(props: Props) {
return;
}
+ // The dataset name is not synced with the datasource.id.name in the advanced settings: See DatasetSettingsDataTab.
+ const datasetName = form.getFieldValue(["dataset", "name"]);
const dataSourceJsonStr = form.getFieldValue("dataSourceJson");
if (dataSourceJsonStr && activeUser) {
- let configJSON;
try {
- configJSON = JSON.parse(dataSourceJsonStr);
- const nameValidationResult = await isDatasetNameValid(configJSON.id.name);
+ const nameValidationResult = await isDatasetNameValid(datasetName);
if (nameValidationResult) {
throw new Error(nameValidationResult);
}
const { newDatasetId } = await storeRemoteDataset(
datastoreToUse.url,
- configJSON.id.name,
+ datasetName,
activeUser.organization,
dataSourceJsonStr,
targetFolderId,
);
- onAdded(newDatasetId, configJSON.id.name);
+ onAdded(newDatasetId, datasetName);
} catch (e) {
setShowLoadingOverlay(false);
Toast.error(`The datasource config could not be stored. ${e}`);
@@ -327,7 +185,6 @@ function DatasetAddRemoteView(props: Props) {
uploadableDatastores={uploadableDatastores}
setDatasourceConfigStr={setDatasourceConfigStr}
onSuccess={() => setShowAddLayerModal(false)}
- dataSourceEditMode={dataSourceEditMode}
/>
@@ -336,7 +193,6 @@ function DatasetAddRemoteView(props: Props) {
form={form}
uploadableDatastores={uploadableDatastores}
setDatasourceConfigStr={setDatasourceConfigStr}
- dataSourceEditMode={dataSourceEditMode}
defaultUrl={defaultDatasetUrl}
onError={() => setShowLoadingOverlay(false)}
onSuccess={(defaultDatasetUrl: string) => onSuccesfulExplore(defaultDatasetUrl)}
@@ -372,15 +228,9 @@ function DatasetAddRemoteView(props: Props) {
{/* Only the component's visibility is changed, so that the form is always rendered.
This is necessary so that the form's structure is always populated. */}
- {
- syncDataSourceFields(form, activeEditMode, true);
- form.validateFields();
- setDataSourceEditMode(activeEditMode);
- }}
- />
+
+
+
{!hideDatasetUI && (
<>
@@ -434,295 +284,4 @@ function DatasetAddRemoteView(props: Props) {
);
}
-function AddRemoteLayer({
- form,
- uploadableDatastores,
- setDatasourceConfigStr,
- onSuccess,
- onError,
- dataSourceEditMode,
- defaultUrl,
-}: {
- form: FormInstance;
- uploadableDatastores: APIDataStore[];
- setDatasourceConfigStr: (dataSourceJson: string) => void;
- onSuccess?: (datasetUrl: string) => Promise | void;
- onError?: () => void;
- dataSourceEditMode: "simple" | "advanced";
- defaultUrl?: string | null | undefined;
-}) {
- const isDatasourceConfigStrFalsy = Form.useWatch("dataSourceJson", form) != null;
- const datasourceUrl: string | null = Form.useWatch("url", form);
- const [exploreLog, setExploreLog] = useState(null);
- const [showCredentialsFields, setShowCredentialsFields] = useState(false);
- const [usernameOrAccessKey, setUsernameOrAccessKey] = useState("");
- const [passwordOrSecretKey, setPasswordOrSecretKey] = useState("");
- const [selectedProtocol, setSelectedProtocol] = useState<"s3" | "https" | "gs" | "file">("https");
- const [fileList, setFileList] = useState([]);
-
- useEffect(() => {
- if (defaultUrl != null) {
- // only set datasourceUrl in the first render
- if (datasourceUrl == null) {
- form.setFieldValue("url", defaultUrl);
- form.validateFields(["url"]);
- } else {
- handleExplore();
- }
- }
- }, [defaultUrl, datasourceUrl, form.setFieldValue, form.validateFields]);
-
- const handleChange = (info: UploadChangeParam>) => {
- // Restrict the upload list to the latest file
- const newFileList = info.fileList.slice(-1);
- setFileList(newFileList);
- };
-
- function validateUrls(userInput: string) {
- const removePrefix = (value: string, prefix: string) =>
- value.startsWith(prefix) ? value.slice(prefix.length) : value;
-
- // If pasted from neuroglancer, uris have these prefixes even before the protocol. The backend ignores them.
- userInput = removePrefix(userInput, "zarr://");
- userInput = removePrefix(userInput, "zarr3://");
- userInput = removePrefix(userInput, "n5://");
- userInput = removePrefix(userInput, "precomputed://");
-
- if (userInput.startsWith("https://") || userInput.startsWith("http://")) {
- setSelectedProtocol("https");
- } else if (userInput.startsWith("s3://")) {
- setSelectedProtocol("s3");
- } else if (userInput.startsWith("gs://")) {
- setSelectedProtocol("gs");
- } else if (userInput.startsWith("file://")) {
- setSelectedProtocol("file"); // Unused
- } else {
- throw new Error(
- "Dataset URL must employ one of the following protocols: https://, http://, s3://, gs:// or file://",
- );
- }
- }
-
- const handleFailure = () => {
- if (onError) onError();
- };
-
- async function handleExplore() {
- if (!datasourceUrl) {
- handleFailure();
- Toast.error("Please provide a valid URL for exploration.");
- return;
- }
-
- // Sync simple with advanced and get newest datasourceJson
- syncDataSourceFields(form, dataSourceEditMode === "simple" ? "advanced" : "simple", true);
- const datasourceConfigStr = form.getFieldValue("dataSourceJson");
- const datastoreToUse = uploadableDatastores.find(
- (datastore) => form.getFieldValue("datastoreUrl") === datastore.url,
- );
- if (!datastoreToUse) {
- handleFailure();
- Toast.error("Could not find datastore that allows uploading.");
- return;
- }
-
- const { dataSource: newDataSource, report } = await (async () => {
- // @ts-ignore
- const preferredVoxelSize = Utils.parseMaybe(datasourceConfigStr)?.scale;
-
- if (showCredentialsFields) {
- if (selectedProtocol === "gs") {
- const credentials =
- fileList.length > 0 ? await parseCredentials(fileList[0]?.originFileObj) : null;
- if (credentials) {
- return exploreRemoteDataset(
- [datasourceUrl],
- datastoreToUse.name,
- {
- username: "",
- pass: JSON.stringify(credentials),
- },
- preferredVoxelSize,
- );
- } else {
- // Fall through to exploreRemoteDataset without parameters
- }
- } else if (usernameOrAccessKey && passwordOrSecretKey) {
- return exploreRemoteDataset(
- [datasourceUrl],
- datastoreToUse.name,
- {
- username: usernameOrAccessKey,
- pass: passwordOrSecretKey,
- },
- preferredVoxelSize,
- );
- }
- }
- return exploreRemoteDataset([datasourceUrl], datastoreToUse.name, null, preferredVoxelSize);
- })();
- setExploreLog(report);
- if (!newDataSource) {
- handleFailure();
- Toast.error(
- "Exploring this remote dataset did not return a datasource. Please check the Log.",
- );
- return;
- }
- ensureLargestSegmentIdsInPlace(newDataSource);
- if (!datasourceConfigStr) {
- setDatasourceConfigStr(jsonStringify(newDataSource));
- if (onSuccess) {
- onSuccess(datasourceUrl);
- }
- return;
- }
- let existingDatasource: DatasourceConfiguration;
- try {
- existingDatasource = JSON.parse(datasourceConfigStr);
- } catch (_e) {
- handleFailure();
- Toast.error(
- "The current datasource config contains invalid JSON. Cannot add the new Zarr/N5 data.",
- );
- return;
- }
- if (
- existingDatasource?.scale != null &&
- !_.isEqual(existingDatasource.scale, newDataSource.scale)
- ) {
- Toast.warning(
- `${messages["dataset.add_zarr_different_scale_warning"]}\n${formatScale(
- newDataSource.scale,
- )}`,
- { timeout: 10000 },
- );
- }
- setDatasourceConfigStr(jsonStringify(mergeNewLayers(existingDatasource, newDataSource)));
- if (onSuccess) {
- onSuccess(datasourceUrl);
- }
- }
-
- return (
- <>
- Please enter a URL that points to the Zarr, Neuroglancer Precomputed or N5 data you would like
- to import. If necessary, specify the credentials for the dataset.{" "}
- {defaultUrl == null
- ? "For datasets with multiple \
- layers, e.g. raw microscopy and segmentation data, please add them separately with the ”Add \
- Layer” button below. Once you have approved of the resulting datasource you can import it."
- : "If the provided URL is valid, the datasource will be imported and you will be redirected to the dataset."}
- {
- try {
- validateUrls(value);
- return Promise.resolve();
- } catch (e) {
- handleFailure();
- return Promise.reject(e);
- }
- },
- },
- ]}
- validateFirst
- >
-
-
-
- setShowCredentialsFields(e.target.value === "show")}
- >
- {selectedProtocol === "https" ? "None" : "Anonymous"}
-
- {selectedProtocol === "https" ? "Basic authentication" : "With credentials"}
-
-
-
- {showCredentialsFields ? (
- selectedProtocol === "gs" ? (
-
- ) : (
-
-
-
- setUsernameOrAccessKey(e.target.value)}
- />
-
-
-
-
- setPasswordOrSecretKey(e.target.value)}
- />
-
-
-
- )
- ) : null}
- {exploreLog ? (
-
-
-
- {exploreLog}
-
- ),
- },
- ]}
- />
-
-
- ) : null}
-
-
-
-
-
- {defaultUrl == null ? "Add Layer" : "Validate URL and Continue"}
-
-
-
-
- >
- );
-}
-
export default DatasetAddRemoteView;
diff --git a/frontend/javascripts/admin/dataset/remote/add_remote_layer.tsx b/frontend/javascripts/admin/dataset/remote/add_remote_layer.tsx
new file mode 100644
index 00000000000..7576454edcc
--- /dev/null
+++ b/frontend/javascripts/admin/dataset/remote/add_remote_layer.tsx
@@ -0,0 +1,371 @@
+import { exploreRemoteDataset } from "admin/rest_api";
+import { Col, Collapse, Form, type FormInstance, Input, Radio, Row } from "antd";
+import type { RcFile, UploadChangeParam, UploadFile } from "antd/lib/upload";
+import { AsyncButton } from "components/async_clickables";
+import { formatScale } from "libs/format_utils";
+import { readFileAsText } from "libs/read_file";
+import Toast from "libs/toast";
+import { jsonStringify } from "libs/utils";
+import * as Utils from "libs/utils";
+import _ from "lodash";
+import messages from "messages";
+import { useEffect, useState } from "react";
+import type { APIDataStore } from "types/api_types";
+import type { ArbitraryObject } from "types/globals";
+import type { DataLayer, DatasourceConfiguration } from "types/schemas/datasource.types";
+import { Hint } from "viewer/view/action-bar/download_modal_view";
+import { GoogleAuthFormItem } from "./google_auth_form_item";
+
+const FormItem = Form.Item;
+const RadioGroup = Radio.Group;
+const { Password } = Input;
+
+type FileList = UploadFile[];
+
+function ensureLargestSegmentIdsInPlace(datasource: DatasourceConfiguration) {
+ for (const layer of datasource.dataLayers) {
+ if (layer.category === "color" || layer.largestSegmentId != null) {
+ continue;
+ }
+ // Make sure the property exists. Otherwise, a field would not be
+ // rendered in the form.
+ layer.largestSegmentId = null;
+ }
+}
+
+function mergeNewLayers(
+ existingDatasource: DatasourceConfiguration | null,
+ newDatasource: DatasourceConfiguration,
+): DatasourceConfiguration {
+ if (existingDatasource?.dataLayers == null) {
+ return newDatasource;
+ }
+ const allLayers = newDatasource.dataLayers.concat(existingDatasource.dataLayers);
+ const groupedLayers: Record = _.groupBy(
+ allLayers,
+ (layer: DataLayer) => layer.name,
+ );
+ const uniqueLayers: DataLayer[] = [];
+ for (const entry of _.entries(groupedLayers)) {
+ const [name, layerGroup] = entry;
+ if (layerGroup.length === 1) {
+ uniqueLayers.push(layerGroup[0]);
+ } else {
+ let idx = 1;
+ for (const layer of layerGroup) {
+ if (idx === 1) {
+ uniqueLayers.push(layer);
+ } else {
+ uniqueLayers.push({ ...layer, name: `${name}_${idx}` });
+ }
+ idx++;
+ }
+ }
+ }
+ return {
+ ...existingDatasource,
+ dataLayers: uniqueLayers,
+ };
+}
+
+export const parseCredentials = async (
+ file: RcFile | undefined,
+): Promise => {
+ if (!file) {
+ return null;
+ }
+ const jsonString = await readFileAsText(file);
+ try {
+ return JSON.parse(jsonString);
+ } catch (_exception) {
+ Toast.error("Cannot parse credentials as valid JSON. Ignoring credentials file.");
+ return null;
+ }
+};
+
+export function AddRemoteLayer({
+ form,
+ uploadableDatastores,
+ setDatasourceConfigStr,
+ onSuccess,
+ onError,
+ defaultUrl,
+}: {
+ form: FormInstance;
+ uploadableDatastores: APIDataStore[];
+ setDatasourceConfigStr: (dataSourceJson: string) => void;
+ onSuccess?: (datasetUrl: string) => Promise | void;
+ onError?: () => void;
+ defaultUrl?: string | null | undefined;
+}) {
+ const isDatasourceConfigStrFalsy = Form.useWatch("dataSourceJson", form) != null;
+ const datasourceUrl: string | null = Form.useWatch("url", form);
+ const [exploreLog, setExploreLog] = useState(null);
+ const [showCredentialsFields, setShowCredentialsFields] = useState(false);
+ const [usernameOrAccessKey, setUsernameOrAccessKey] = useState("");
+ const [passwordOrSecretKey, setPasswordOrSecretKey] = useState("");
+ const [selectedProtocol, setSelectedProtocol] = useState<"s3" | "https" | "gs" | "file">("https");
+ const [fileList, setFileList] = useState([]);
+
+ useEffect(() => {
+ if (defaultUrl != null) {
+ // only set datasourceUrl in the first render
+ if (datasourceUrl == null) {
+ form.setFieldValue("url", defaultUrl);
+ form.validateFields(["url"]);
+ } else {
+ handleExplore();
+ }
+ }
+ }, [defaultUrl, datasourceUrl, form.setFieldValue, form.validateFields]);
+
+ const handleChange = (info: UploadChangeParam>) => {
+ // Restrict the upload list to the latest file
+ const newFileList = info.fileList.slice(-1);
+ setFileList(newFileList);
+ };
+
+ function validateUrls(userInput: string) {
+ const removePrefix = (value: string, prefix: string) =>
+ value.startsWith(prefix) ? value.slice(prefix.length) : value;
+
+ // If pasted from neuroglancer, uris have these prefixes even before the protocol. The backend ignores them.
+ userInput = removePrefix(userInput, "zarr://");
+ userInput = removePrefix(userInput, "zarr3://");
+ userInput = removePrefix(userInput, "n5://");
+ userInput = removePrefix(userInput, "precomputed://");
+
+ if (userInput.startsWith("https://") || userInput.startsWith("http://")) {
+ setSelectedProtocol("https");
+ } else if (userInput.startsWith("s3://")) {
+ setSelectedProtocol("s3");
+ } else if (userInput.startsWith("gs://")) {
+ setSelectedProtocol("gs");
+ } else if (userInput.startsWith("file://")) {
+ setSelectedProtocol("file"); // Unused
+ } else {
+ throw new Error(
+ "Dataset URL must employ one of the following protocols: https://, http://, s3://, gs:// or file://",
+ );
+ }
+ }
+
+ const handleFailure = () => {
+ if (onError) onError();
+ };
+
+ async function handleExplore() {
+ if (!datasourceUrl) {
+ handleFailure();
+ Toast.error("Please provide a valid URL for exploration.");
+ return;
+ }
+
+ const datasourceConfigStr = form.getFieldValue("dataSourceJson");
+ const datastoreToUse = uploadableDatastores.find(
+ (datastore) => form.getFieldValue("datastoreUrl") === datastore.url,
+ );
+ if (!datastoreToUse) {
+ handleFailure();
+ Toast.error("Could not find datastore that allows uploading.");
+ return;
+ }
+
+ const { dataSource: newDataSource, report } = await (async () => {
+ // @ts-ignore
+ const preferredVoxelSize = Utils.parseMaybe(datasourceConfigStr)?.scale;
+
+ if (showCredentialsFields) {
+ if (selectedProtocol === "gs") {
+ const credentials =
+ fileList.length > 0 ? await parseCredentials(fileList[0]?.originFileObj) : null;
+ if (credentials) {
+ return exploreRemoteDataset(
+ [datasourceUrl],
+ datastoreToUse.name,
+ {
+ username: "",
+ pass: JSON.stringify(credentials),
+ },
+ preferredVoxelSize,
+ );
+ } else {
+ // Fall through to exploreRemoteDataset without parameters
+ }
+ } else if (usernameOrAccessKey && passwordOrSecretKey) {
+ return exploreRemoteDataset(
+ [datasourceUrl],
+ datastoreToUse.name,
+ {
+ username: usernameOrAccessKey,
+ pass: passwordOrSecretKey,
+ },
+ preferredVoxelSize,
+ );
+ }
+ }
+ return exploreRemoteDataset([datasourceUrl], datastoreToUse.name, null, preferredVoxelSize);
+ })();
+ setExploreLog(report);
+ if (!newDataSource) {
+ handleFailure();
+ Toast.error(
+ "Exploring this remote dataset did not return a datasource. Please check the Log.",
+ );
+ return;
+ }
+ ensureLargestSegmentIdsInPlace(newDataSource);
+ if (!datasourceConfigStr) {
+ setDatasourceConfigStr(jsonStringify(newDataSource));
+ if (onSuccess) {
+ onSuccess(datasourceUrl);
+ }
+ return;
+ }
+ let existingDatasource: DatasourceConfiguration;
+ try {
+ existingDatasource = JSON.parse(datasourceConfigStr);
+ } catch (_e) {
+ handleFailure();
+ Toast.error(
+ "The current datasource config contains invalid JSON. Cannot add the new Zarr/N5 data.",
+ );
+ return;
+ }
+ if (
+ existingDatasource?.scale != null &&
+ !_.isEqual(existingDatasource.scale, newDataSource.scale)
+ ) {
+ Toast.warning(
+ `${messages["dataset.add_zarr_different_scale_warning"]}\n${formatScale(
+ newDataSource.scale,
+ )}`,
+ { timeout: 10000 },
+ );
+ }
+ setDatasourceConfigStr(jsonStringify(mergeNewLayers(existingDatasource, newDataSource)));
+ if (onSuccess) {
+ onSuccess(datasourceUrl);
+ }
+ }
+
+ return (
+ <>
+ Please enter a URL that points to the Zarr, Neuroglancer Precomputed or N5 data you would like
+ to import. If necessary, specify the credentials for the dataset.{" "}
+ {defaultUrl == null
+ ? "For datasets with multiple \
+ layers, e.g. raw microscopy and segmentation data, please add them separately with the ”Add \
+ Layer” button below. Once you have approved of the resulting datasource you can import it."
+ : "If the provided URL is valid, the datasource will be imported and you will be redirected to the dataset."}
+ {
+ try {
+ validateUrls(value);
+ return Promise.resolve();
+ } catch (e) {
+ handleFailure();
+ return Promise.reject(e);
+ }
+ },
+ },
+ ]}
+ validateFirst
+ >
+
+
+
+ setShowCredentialsFields(e.target.value === "show")}
+ >
+ {selectedProtocol === "https" ? "None" : "Anonymous"}
+
+ {selectedProtocol === "https" ? "Basic authentication" : "With credentials"}
+
+
+
+ {showCredentialsFields ? (
+ selectedProtocol === "gs" ? (
+
+ ) : (
+
+
+
+ setUsernameOrAccessKey(e.target.value)}
+ />
+
+
+
+
+ setPasswordOrSecretKey(e.target.value)}
+ />
+
+
+
+ )
+ ) : null}
+ {exploreLog ? (
+
+
+
+ {exploreLog}
+
+ ),
+ },
+ ]}
+ />
+
+
+ ) : null}
+
+
+
+
+
+ {defaultUrl == null ? "Add Layer" : "Validate URL and Continue"}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/javascripts/admin/dataset/remote/google_auth_form_item.tsx b/frontend/javascripts/admin/dataset/remote/google_auth_form_item.tsx
new file mode 100644
index 00000000000..b75931cc624
--- /dev/null
+++ b/frontend/javascripts/admin/dataset/remote/google_auth_form_item.tsx
@@ -0,0 +1,60 @@
+import { UnlockOutlined } from "@ant-design/icons";
+import type { FileList } from "admin/dataset/composition_wizard/common.ts";
+import { Form, Upload } from "antd";
+import { Unicode } from "viewer/constants";
+
+import type { UploadChangeParam, UploadFile } from "antd/lib/upload";
+
+const FormItem = Form.Item;
+
+export function GoogleAuthFormItem({
+ fileList,
+ handleChange,
+ showOptionalHint,
+}: {
+ fileList: FileList;
+ handleChange: (arg: UploadChangeParam>) => void;
+ showOptionalHint?: boolean;
+}) {
+ return (
+
+ Google{Unicode.NonBreakingSpace}
+
+ Service Account
+
+ {Unicode.NonBreakingSpace}Key {showOptionalHint && "(Optional)"}
+ >
+ }
+ hasFeedback
+ >
+ false}
+ >
+
+
+
+
+ Click or Drag your Google Cloud Authentication File to this Area to Upload
+
+
+ This is only needed if the dataset is located in a non-public Google Cloud Storage bucket
+
+
+
+ );
+}
diff --git a/frontend/javascripts/admin/onboarding.tsx b/frontend/javascripts/admin/onboarding.tsx
index 8723dfac56c..7261de1e9c2 100644
--- a/frontend/javascripts/admin/onboarding.tsx
+++ b/frontend/javascripts/admin/onboarding.tsx
@@ -20,6 +20,7 @@ import { getDatastores, sendInvitesForOrganization } from "admin/rest_api";
import { Alert, AutoComplete, Button, Card, Col, Form, Input, Modal, Row, Steps } from "antd";
import CreditsFooter from "components/credits_footer";
import LinkButton from "components/link_button";
+import { DatasetSettingsProvider } from "dashboard/dataset/dataset_settings_provider";
import DatasetSettingsView from "dashboard/dataset/dataset_settings_view";
import features from "features";
import { useWkSelector } from "libs/react_hooks";
@@ -527,12 +528,14 @@ function OnboardingView() {
)}
{datasetIdToImport != null && (
-
+ >
+
+
)}
diff --git a/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx b/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx
index 530dba36466..8890366fe04 100644
--- a/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx
+++ b/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx
@@ -54,7 +54,7 @@ export function OrganizationDangerZoneView() {
/>
diff --git a/frontend/javascripts/admin/organization/organization_overview_view.tsx b/frontend/javascripts/admin/organization/organization_overview_view.tsx
index fdd28193781..f5dfdcd7641 100644
--- a/frontend/javascripts/admin/organization/organization_overview_view.tsx
+++ b/frontend/javascripts/admin/organization/organization_overview_view.tsx
@@ -5,12 +5,12 @@ import { Button, Col, Row, Spin, Tooltip, Typography } from "antd";
import { formatCountToDataAmountUnit, formatCreditsString } from "libs/format_utils";
import { useWkSelector } from "libs/react_hooks";
import Toast from "libs/toast";
-import { useEffect, useState } from "react";
+import { type Key, useEffect, useState } from "react";
import type { APIPricingPlanStatus } from "types/api_types";
import { enforceActiveOrganization } from "viewer/model/accessors/organization_accessors";
import { setActiveOrganizationAction } from "viewer/model/actions/organization_actions";
import { Store } from "viewer/singletons";
-import { SettingsCard } from "../account/helpers/settings_card";
+import { SettingsCard, type SettingsCardProps } from "../account/helpers/settings_card";
import {
PlanAboutToExceedAlert,
PlanExceededAlert,
@@ -119,11 +119,11 @@ export function OrganizationOverviewView() {
);
- const orgaStats = [
+ const orgaStats: (SettingsCardProps & { key: Key })[] = [
{
key: "name",
title: "Name",
- value: (
+ content: (
Compare all plans
@@ -151,20 +151,20 @@ export function OrganizationOverviewView() {
{
key: "users",
title: "Users",
- value: `${activeUsersCount} / ${maxUsersCountLabel}`,
+ content: `${activeUsersCount} / ${maxUsersCountLabel}`,
action: upgradeUsersAction,
},
{
key: "storage",
title: "Storage",
- value: `${usedStorageLabel} / ${includedStorageLabel}`,
+ content: `${usedStorageLabel} / ${includedStorageLabel}`,
action: upgradeStorageAction,
},
{
key: "credits",
title: "WEBKNOSSOS Credits",
- value:
+ content:
organization.creditBalance != null
? formatCreditsString(organization.creditBalance)
: "N/A",
@@ -185,9 +185,9 @@ export function OrganizationOverviewView() {
))}
diff --git a/frontend/javascripts/admin/organization/organization_view.tsx b/frontend/javascripts/admin/organization/organization_view.tsx
index 8ceb02deb47..bc8d96c424a 100644
--- a/frontend/javascripts/admin/organization/organization_view.tsx
+++ b/frontend/javascripts/admin/organization/organization_view.tsx
@@ -39,7 +39,11 @@ const MENU_ITEMS: MenuItemGroupType[] = [
const OrganizationView = () => {
const location = useLocation();
const navigate = useNavigate();
- const selectedKey = location.pathname.split("/").filter(Boolean).pop() || "overview";
+ const selectedKey =
+ location.pathname
+ .split("/")
+ .filter((p) => p.length > 0)
+ .pop() || "overview";
const breadcrumbItems = [
{
diff --git a/frontend/javascripts/components/async_clickables.tsx b/frontend/javascripts/components/async_clickables.tsx
index 043b0f602be..610c149dbdc 100644
--- a/frontend/javascripts/components/async_clickables.tsx
+++ b/frontend/javascripts/components/async_clickables.tsx
@@ -4,11 +4,26 @@ import * as React from "react";
import FastTooltip from "./fast_tooltip";
const { useState, useEffect, useRef } = React;
+/**
+ * Props for the AsyncButton component.
+ */
export type AsyncButtonProps = Omit & {
+ /**
+ * If true, the button's content will be hidden when it is in the loading state.
+ */
hideContentWhenLoading?: boolean;
+ /**
+ * The async function to be called when the button is clicked.
+ * It should return a promise that resolves when the async operation is complete.
+ */
onClick: (event: React.MouseEvent) => Promise;
};
+/**
+ * A React hook that wraps an async onClick handler to manage a loading state.
+ * @param originalOnClick The async function to be called when the element is clicked.
+ * @returns A tuple containing a boolean `isLoading` state and the wrapped `onClick` handler.
+ */
function useLoadingClickHandler(
originalOnClick: (event: React.MouseEvent) => Promise,
): [boolean, React.MouseEventHandler] {
@@ -41,6 +56,11 @@ function useLoadingClickHandler(
return [isLoading, onClick];
}
+/**
+ * A button component that handles asynchronous actions.
+ * It displays a loading indicator while the `onClick` promise is pending.
+ * It is a wrapper around the antd Button component.
+ */
export function AsyncButton(props: AsyncButtonProps) {
const [isLoading, onClick] = useLoadingClickHandler(props.onClick);
const { children, hideContentWhenLoading, title, ...rest } = props;
@@ -56,14 +76,25 @@ export function AsyncButton(props: AsyncButtonProps) {
);
}
+
+/**
+ * An icon button component that handles asynchronous actions.
+ * It displays a loading indicator in place of the icon while the `onClick` promise is pending.
+ */
export function AsyncIconButton(
props: Omit & {
+ /** The icon to be displayed on the button. */
icon: React.ReactElement;
},
) {
const [isLoading, onClick] = useLoadingClickHandler(props.onClick);
return React.cloneElement(isLoading ? : props.icon, { ...props, onClick });
}
+
+/**
+ * A link component that handles asynchronous actions.
+ * Prepends a loading icon while the promise is pending.
+ */
export function AsyncLink(props: AsyncButtonProps) {
const [isLoading, onClick] = useLoadingClickHandler(props.onClick);
const icon = isLoading ? (
diff --git a/frontend/javascripts/dashboard/dataset/color_layer_ordering_component.tsx b/frontend/javascripts/dashboard/dataset/color_layer_ordering_component.tsx
index 2f07a49d394..9d377eccac1 100644
--- a/frontend/javascripts/dashboard/dataset/color_layer_ordering_component.tsx
+++ b/frontend/javascripts/dashboard/dataset/color_layer_ordering_component.tsx
@@ -1,9 +1,9 @@
-import { InfoCircleOutlined, MenuOutlined } from "@ant-design/icons";
+import { MenuOutlined } from "@ant-design/icons";
import { DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
-import { Collapse, type CollapseProps, List, Tooltip } from "antd";
-import { settings, settingsTooltips } from "messages";
+import { List } from "antd";
+import { useCallback } from "react";
// Example taken and modified from https://ant.design/components/table/#components-table-demo-drag-sorting-handler.
@@ -34,63 +34,45 @@ export default function ColorLayerOrderingTable({
colorLayerNames?: string[];
onChange?: (newColorLayerNames: string[]) => void;
}) {
- const onSortEnd = (event: DragEndEvent) => {
- const { active, over } = event;
+ const onSortEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
- if (active && over && colorLayerNames) {
- const oldIndex = colorLayerNames.indexOf(active.id as string);
- const newIndex = colorLayerNames.indexOf(over.id as string);
+ if (active && over && colorLayerNames) {
+ const oldIndex = colorLayerNames.indexOf(active.id as string);
+ const newIndex = colorLayerNames.indexOf(over.id as string);
- document.body.classList.remove("is-dragging");
+ document.body.classList.remove("is-dragging");
- if (oldIndex !== newIndex && onChange) {
- const movedElement = colorLayerNames[oldIndex];
- const newColorLayerNames = colorLayerNames.filter((_, index) => index !== oldIndex);
- newColorLayerNames.splice(newIndex, 0, movedElement);
- onChange(newColorLayerNames);
+ if (oldIndex !== newIndex && onChange) {
+ const movedElement = colorLayerNames[oldIndex];
+ const newColorLayerNames = colorLayerNames.filter((_, index) => index !== oldIndex);
+ newColorLayerNames.splice(newIndex, 0, movedElement);
+ onChange(newColorLayerNames);
+ }
}
- }
- };
+ },
+ [colorLayerNames, onChange],
+ );
const isSettingEnabled = colorLayerNames && colorLayerNames.length > 1;
const sortingItems = isSettingEnabled ? colorLayerNames.map((name) => name) : [];
- const collapsibleDisabledExplanation =
+ const settingsIsDisabledExplanation =
"The order of layers can only be configured when the dataset has multiple color layers.";
- const panelTitle = (
-
- {settings.colorLayerOrder}{" "}
-
-
-
-
- );
+ const onDragStart = useCallback(() => {
+ colorLayerNames && colorLayerNames.length > 1 && document.body.classList.add("is-dragging");
+ }, [colorLayerNames]);
- const collapseItems: CollapseProps["items"] = [
- {
- label: panelTitle,
- key: "1",
- children: sortingItems.map((name) => ),
- },
- ];
-
- return (
- {
- colorLayerNames && colorLayerNames.length > 1 && document.body.classList.add("is-dragging");
- }}
- onDragEnd={onSortEnd}
- >
+ return isSettingEnabled ? (
+
-
+ {sortingItems.map((layerName) => (
+
+ ))}
+ ) : (
+ settingsIsDisabledExplanation
);
}
diff --git a/frontend/javascripts/dashboard/dataset/dataset_rotation_form_item.tsx b/frontend/javascripts/dashboard/dataset/dataset_rotation_form_item.tsx
index 38bee1be18a..bbc1e778347 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_rotation_form_item.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_rotation_form_item.tsx
@@ -42,7 +42,10 @@ export const AxisRotationFormItem: React.FC = ({
form,
axis,
}: AxisRotationFormItemProps) => {
- const dataLayers: APIDataLayer[] = Form.useWatch(["dataSource", "dataLayers"], form);
+ const dataLayers: APIDataLayer[] = Form.useWatch(["dataSource", "dataLayers"], {
+ form,
+ preserve: true,
+ });
const datasetBoundingBox = useMemo(
() => getDatasetBoundingBoxFromLayers(dataLayers),
[dataLayers],
diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_context.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_context.tsx
new file mode 100644
index 00000000000..182b7d9115b
--- /dev/null
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_context.tsx
@@ -0,0 +1,44 @@
+import type { FormInstance } from "antd";
+import { createContext, useContext } from "react";
+import type { APIDataSource, APIDataset } from "types/api_types";
+import type { DatasetConfiguration } from "viewer/store";
+import type { DatasetRotationAndMirroringSettings } from "./dataset_rotation_form_item";
+
+export type DataSourceEditMode = "simple" | "advanced";
+
+export type FormData = {
+ dataSource: APIDataSource;
+ dataSourceJson: string;
+ dataset: APIDataset;
+ defaultConfiguration: DatasetConfiguration;
+ defaultConfigurationLayersJson: string;
+ datasetRotation?: DatasetRotationAndMirroringSettings;
+};
+
+export type DatasetSettingsContextValue = {
+ form: FormInstance;
+ isLoading: boolean;
+ dataset: APIDataset | null | undefined;
+ datasetId: string;
+ datasetDefaultConfiguration: DatasetConfiguration | null | undefined;
+ activeDataSourceEditMode: DataSourceEditMode;
+ isEditingMode: boolean;
+ handleSubmit: () => void;
+ handleCancel: () => void;
+ handleDataSourceEditModeChange: (activeEditMode: DataSourceEditMode) => void;
+ onValuesChange: (changedValues: FormData, allValues: FormData) => void;
+ getFormValidationSummary: () => Record;
+ hasFormErrors: boolean;
+};
+
+export const DatasetSettingsContext = createContext(
+ undefined,
+);
+
+export const useDatasetSettingsContext = () => {
+ const context = useContext(DatasetSettingsContext);
+ if (!context) {
+ throw new Error("useDatasetSettingsContext must be used within a DatasetSettingsProvider");
+ }
+ return context;
+};
diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_data_tab.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_data_tab.tsx
index 9fb89dfb133..18d3a1020e9 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_data_tab.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_data_tab.tsx
@@ -1,4 +1,6 @@
import { CopyOutlined, DeleteOutlined } from "@ant-design/icons";
+import { SettingsCard } from "admin/account/helpers/settings_card";
+import { SettingsTitle } from "admin/account/helpers/settings_title";
import { getDatasetNameRules, layerNameRules } from "admin/dataset/dataset_components";
import { useStartAndPollJob } from "admin/job/job_hooks";
import { startFindLargestSegmentIdJob } from "admin/rest_api";
@@ -9,7 +11,6 @@ import {
type FormInstance,
Input,
InputNumber,
- List,
Row,
Select,
Space,
@@ -24,87 +25,41 @@ import {
} from "dashboard/dataset/helper_components";
import { useWkSelector } from "libs/react_hooks";
import Toast from "libs/toast";
-import { jsonStringify, parseMaybe } from "libs/utils";
import { BoundingBoxInput, Vector3Input } from "libs/vector_input";
-import * as React from "react";
+import type React from "react";
+import { cloneElement, useEffect } from "react";
import { type APIDataLayer, type APIDataset, APIJobType } from "types/api_types";
-import type { ArbitraryObject } from "types/globals";
import type { DataLayer } from "types/schemas/datasource.types";
import { isValidJSON, syncValidator, validateDatasourceJSON } from "types/validation";
import { AllUnits, LongUnitToShortUnitMap, type Vector3 } from "viewer/constants";
import { getSupportedValueRangeForElementClass } from "viewer/model/bucket_data_handling/data_rendering_logic";
import type { BoundingBoxObject } from "viewer/store";
import { AxisRotationSettingForDataset } from "./dataset_rotation_form_item";
+import { useDatasetSettingsContext } from "./dataset_settings_context";
+import { syncDataSourceFields } from "./dataset_settings_provider";
const FormItem = Form.Item;
-export const syncDataSourceFields = (
- form: FormInstance,
- syncTargetTabKey: "simple" | "advanced",
- // Syncing the dataset name is optional as this is needed for the add remote view, but not for the edit view.
- // In the edit view, the datasource.id fields should never be changed and the backend will automatically ignore all changes to the id field.
- syncDatasetName = false,
-): void => {
- if (!form) {
- return;
- }
-
- if (syncTargetTabKey === "advanced") {
- // Copy from simple to advanced: update json
- const dataSourceFromSimpleTab = form.getFieldValue("dataSource");
- if (syncDatasetName && dataSourceFromSimpleTab) {
- dataSourceFromSimpleTab.id ??= {};
- dataSourceFromSimpleTab.id.name = form.getFieldValue(["dataset", "name"]);
- }
- form.setFieldsValue({
- dataSourceJson: jsonStringify(dataSourceFromSimpleTab),
- });
- } else {
- const dataSourceFromAdvancedTab = parseMaybe(
- form.getFieldValue("dataSourceJson"),
- ) as ArbitraryObject | null;
- // Copy from advanced to simple: update form values
- if (syncDatasetName && dataSourceFromAdvancedTab?.id?.name) {
- form.setFieldsValue({
- dataset: {
- name: dataSourceFromAdvancedTab.id.name,
- },
- });
- }
- form.setFieldsValue({
- dataSource: dataSourceFromAdvancedTab,
- });
- }
-};
-
-export default function DatasetSettingsDataTab({
- form,
- activeDataSourceEditMode,
- onChange,
- dataset,
-}: {
- form: FormInstance;
- activeDataSourceEditMode: "simple" | "advanced";
- onChange: (arg0: "simple" | "advanced") => void;
- dataset?: APIDataset | null | undefined;
-}) {
+export default function DatasetSettingsDataTab() {
+ const { dataset, form, activeDataSourceEditMode, handleDataSourceEditModeChange } =
+ useDatasetSettingsContext();
// Using the return value of useWatch for the `dataSource` var
// yields outdated values. Therefore, the hook only exists for listening.
- Form.useWatch("dataSource", form);
+ // TODO: The "preserve" option probably fixes this, e.g. useWatch("dataSource", { form, preserve: true });
+ Form.useWatch("dataSource", { form, preserve: true });
// Then, the newest value can be retrieved with getFieldValue
const dataSource = form.getFieldValue("dataSource");
const dataSourceJson = Form.useWatch("dataSourceJson", form);
const datasetStoredLocationInfo = dataset
? ` (as stored on datastore ${dataset?.dataStore.name} at ${dataset?.owningOrganization}/${dataset?.directoryName})`
: "";
-
const isJSONValid = isValidJSON(dataSourceJson);
return (
-
{
const key = bool ? "advanced" : "simple";
- onChange(key);
+ handleDataSourceEditModeChange(key);
}}
/>
-
+
+
@@ -191,149 +147,140 @@ function SimpleDatasetForm({
});
syncDataSourceFields(form, "advanced");
};
+ const marginBottom: React.CSSProperties = {
+ marginBottom: 24,
+ };
+
return (
-
- Dataset
-
- }
- >
-
-
-
-
+
+ copyDatasetID(dataset?.id)} icon={ } />
+
+
+
+
+
+ value?.every((el) => el > 0),
+ "Each component of the voxel size must be greater than 0",
+ ),
+ },
+ ]}
+ >
+
+
+
+
+ ({
+ value: unit,
+ label: (
+
+ {LongUnitToShortUnitMap[unit]}
+
+ ),
+ }))}
+ />
+
+
+
+ }
+ />
-
- Layers
-
+
+
+
+
+
}
- >
- {dataSource?.dataLayers?.map((layer: DataLayer, idx: number) => (
- // the layer name may change in this view, the order does not, so idx is the right key choice here
-
-
+
+ {dataSource?.dataLayers?.map((layer: DataLayer, idx: number) => (
+ // the layer name may change in this view, the order does not, so idx is the right key choice here
+
+
+
+ }
/>
-
- ))}
-
+
+
+ ))}
);
}
@@ -359,17 +306,18 @@ function SimpleLayerForm({
form: FormInstance;
dataset: APIDataset | null | undefined;
}) {
+ const dataLayers = Form.useWatch(["dataSource", "dataLayers"], form);
+ const category = Form.useWatch(["dataSource", "dataLayers", index, "category"], form);
+
const layerCategorySavedOnServer = dataset?.dataSource.dataLayers[index]?.category;
const isStoredAsSegmentationLayer = layerCategorySavedOnServer === "segmentation";
- const dataLayers = Form.useWatch(["dataSource", "dataLayers"]);
- const category = Form.useWatch(["dataSource", "dataLayers", index, "category"]);
const isSegmentation = category === "segmentation";
const valueRange = getSupportedValueRangeForElementClass(layer.elementClass);
const mayLayerBeRemoved = dataLayers?.length > 1;
// biome-ignore lint/correctness/useExhaustiveDependencies: Always revalidate in case the user changes the data layers in the form.
- React.useEffect(() => {
+ useEffect(() => {
// Always validate all fields so that in the case of duplicate layer
// names all relevant fields are properly validated.
// This is a workaround, since shouldUpdate=true on a
@@ -469,11 +417,11 @@ function SimpleLayerForm({
{layer.elementClass}
@@ -683,7 +631,7 @@ function DelegatePropsToFirstChild({ children, ...props }: { children: React.Rea
// even though antd only demands one. We do this for better layouting.
return (
<>
- {React.cloneElement(children[0], props)}
+ {cloneElement(children[0], props)}
{children[1]}
>
);
diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx
index 5ef789317be..1db2ae5610a 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx
@@ -1,25 +1,22 @@
import { useQueryClient } from "@tanstack/react-query";
-import { deleteDatasetOnDisk, getDataset } from "admin/rest_api";
-import { Button } from "antd";
-import { useFetch } from "libs/react_helpers";
+import { SettingsCard } from "admin/account/helpers/settings_card";
+import { SettingsTitle } from "admin/account/helpers/settings_title";
+import { deleteDatasetOnDisk } from "admin/rest_api";
+import { Button, Col, Row } from "antd";
import Toast from "libs/toast";
import messages from "messages";
-import { useState } from "react";
+import { useCallback, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { useDatasetSettingsContext } from "./dataset_settings_context";
import { confirmAsync } from "./helper_components";
-type Props = {
- datasetId: string;
-};
-
-const DatasetSettingsDeleteTab = ({ datasetId }: Props) => {
+const DatasetSettingsDeleteTab = () => {
+ const { dataset } = useDatasetSettingsContext();
const [isDeleting, setIsDeleting] = useState(false);
const queryClient = useQueryClient();
const navigate = useNavigate();
- const dataset = useFetch(() => getDataset(datasetId), null, [datasetId]);
-
- async function handleDeleteButtonClicked(): Promise {
+ const handleDeleteButtonClicked = useCallback(async () => {
if (!dataset) {
return;
}
@@ -52,16 +49,31 @@ const DatasetSettingsDeleteTab = ({ datasetId }: Props) => {
queryClient.invalidateQueries({ queryKey: ["dataset", "search"] });
navigate("/dashboard");
- }
+ }, [dataset, navigate, queryClient]);
return (
-
Deleting a dataset on disk cannot be undone. Please be certain.
-
Note that annotations for the dataset stay downloadable and the name stays reserved.
-
Only admins are allowed to delete datasets.
-
- Delete Dataset on Disk
-
+
+
+
+
+ Deleting a dataset on disk cannot be undone. Please be certain.
+
+ Note that annotations for the dataset stay downloadable and the name stays
+ reserved.
+
+ Only admins are allowed to delete datasets.
+
+ Delete Dataset on Disk
+
+ >
+ }
+ />
+
+
);
};
diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_metadata_tab.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_metadata_tab.tsx
index cf45bd216a3..28c460a1c20 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_metadata_tab.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_metadata_tab.tsx
@@ -1,36 +1,41 @@
-import { Col, DatePicker, Input, Row } from "antd";
-import { FormItemWithInfo } from "./helper_components";
+import { SettingsCard, type SettingsCardProps } from "admin/account/helpers/settings_card";
+import { SettingsTitle } from "admin/account/helpers/settings_title";
+import { Col, DatePicker, Form, Input, Row } from "antd";
export default function DatasetSettingsMetadataTab() {
+ const metadataItems: SettingsCardProps[] = [
+ {
+ title: "Publication Date",
+ tooltip:
+ "Datasets are sorted by date. Specify the date (e.g. publication date) in order to influence the sorting order of the listed datasets in your dashboard.",
+ content: (
+
+
+
+ ),
+ },
+ {
+ title: "Description",
+ tooltip:
+ "Add a description with additional information about your dataset that will be displayed when working with this dataset. Supports Markdown formatting.",
+ content: (
+
+
+
+ ),
+ },
+ ];
+
return (
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {metadataItems.map((item) => (
+
+
+
+ ))}
-
-
-
);
}
diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_provider.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_provider.tsx
new file mode 100644
index 00000000000..ab5100a25f0
--- /dev/null
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_provider.tsx
@@ -0,0 +1,436 @@
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ getDataset,
+ getDatasetDefaultConfiguration,
+ readDatasetDatasource,
+ sendAnalyticsEvent,
+ updateDatasetDatasource,
+ updateDatasetDefaultConfiguration,
+ updateDatasetPartial,
+ updateDatasetTeams,
+} from "admin/rest_api";
+import { Form, type FormInstance } from "antd";
+import dayjs from "dayjs";
+import { handleGenericError } from "libs/error_handling";
+import Toast from "libs/toast";
+import { jsonStringify, parseMaybe } from "libs/utils";
+import _ from "lodash";
+import messages from "messages";
+import { useCallback, useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import type {
+ APIDataSource,
+ APIDataset,
+ MutableAPIDataSource,
+ MutableAPIDataset,
+} from "types/api_types";
+import type { ArbitraryObject } from "types/globals";
+import { enforceValidatedDatasetViewConfiguration } from "types/schemas/dataset_view_configuration_defaults";
+import {
+ EXPECTED_TRANSFORMATION_LENGTH,
+ doAllLayersHaveTheSameRotation,
+ getRotationSettingsFromTransformationIn90DegreeSteps,
+} from "viewer/model/accessors/dataset_layer_transformation_accessor";
+import type { DatasetConfiguration } from "viewer/store";
+import type { DatasetRotationAndMirroringSettings } from "./dataset_rotation_form_item";
+import {
+ DatasetSettingsContext,
+ type DatasetSettingsContextValue,
+} from "./dataset_settings_context";
+import type { DataSourceEditMode, FormData } from "./dataset_settings_context";
+import { hasFormError } from "./helper_components";
+import useBeforeUnload from "./useBeforeUnload_hook";
+
+type DatasetSettingsProviderProps = {
+ children: React.ReactNode;
+ datasetId: string;
+ isEditingMode: boolean;
+ onComplete?: () => void;
+ onCancel?: () => void;
+ form?: FormInstance;
+};
+
+export const syncDataSourceFields = (
+ form: FormInstance, // Keep form as a prop for this utility function
+ syncTargetTabKey: DataSourceEditMode,
+ // Syncing the dataset name is optional as this is needed for the add remote view, but not for the edit view.
+ // In the edit view, the datasource.id fields should never be changed and the backend will automatically ignore all changes to the id field.
+ syncDatasetName = false,
+): void => {
+ if (!form) {
+ return;
+ }
+
+ if (syncTargetTabKey === "advanced") {
+ // Copy from simple to advanced: update json
+ const dataSourceFromSimpleTab = form.getFieldValue("dataSource");
+ if (syncDatasetName && dataSourceFromSimpleTab) {
+ dataSourceFromSimpleTab.id ??= {};
+ dataSourceFromSimpleTab.id.name = form.getFieldValue(["dataset", "name"]);
+ }
+ form.setFieldsValue({
+ dataSourceJson: jsonStringify(dataSourceFromSimpleTab),
+ });
+ } else {
+ const dataSourceFromAdvancedTab = parseMaybe(
+ form.getFieldValue("dataSourceJson"),
+ ) as ArbitraryObject | null;
+ // Copy from advanced to simple: update form values
+ if (syncDatasetName && dataSourceFromAdvancedTab?.id?.name) {
+ form.setFieldsValue({
+ dataset: {
+ name: dataSourceFromAdvancedTab.id.name,
+ },
+ });
+ }
+ form.setFieldsValue({
+ dataSource: dataSourceFromAdvancedTab,
+ });
+ form.setFieldsValue({
+ datasetRotation: getRotationFromCoordinateTransformations(
+ dataSourceFromAdvancedTab as MutableAPIDataSource,
+ ),
+ });
+ }
+};
+
+export function getRotationFromCoordinateTransformations(
+ dataSource: APIDataSource,
+): DatasetRotationAndMirroringSettings | undefined {
+ if (doAllLayersHaveTheSameRotation(dataSource.dataLayers)) {
+ const firstLayerTransformations = dataSource.dataLayers[0].coordinateTransformations;
+ let initialDatasetRotationSettings: DatasetRotationAndMirroringSettings;
+ if (
+ !firstLayerTransformations ||
+ firstLayerTransformations.length !== EXPECTED_TRANSFORMATION_LENGTH
+ ) {
+ const nulledSetting = { rotationInDegrees: 0, isMirrored: false };
+ initialDatasetRotationSettings = { x: nulledSetting, y: nulledSetting, z: nulledSetting };
+ } else {
+ initialDatasetRotationSettings = {
+ x: getRotationSettingsFromTransformationIn90DegreeSteps(firstLayerTransformations[1], "x"),
+ y: getRotationSettingsFromTransformationIn90DegreeSteps(firstLayerTransformations[2], "y"),
+ z: getRotationSettingsFromTransformationIn90DegreeSteps(firstLayerTransformations[3], "z"),
+ };
+ }
+ return initialDatasetRotationSettings;
+ }
+
+ return undefined;
+}
+
+export const DatasetSettingsProvider: React.FC = ({
+ children,
+ datasetId,
+ isEditingMode,
+ onComplete,
+ onCancel,
+ form: formProp, // In case of Remote Dataset Upload, we start with a prefilled form containing the DS information
+}) => {
+ const [form] = Form.useForm(formProp);
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+
+ const [hasFormErrors, setHasFormErrors] = useState(false);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+ const [dataset, setDataset] = useState(null);
+ const [datasetDefaultConfiguration, setDatasetDefaultConfiguration] = useState<
+ DatasetConfiguration | null | undefined
+ >(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [activeDataSourceEditMode, setActiveDataSourceEditMode] = useState<"simple" | "advanced">(
+ "simple",
+ );
+ const [savedDataSourceOnServer, setSavedDataSourceOnServer] = useState<
+ APIDataSource | null | undefined
+ >(null);
+
+ onComplete = onComplete ? onComplete : () => navigate("/dashboard");
+ onCancel = onCancel ? onCancel : () => navigate("/dashboard");
+
+ const fetchData = useCallback(async (): Promise => {
+ try {
+ setIsLoading(true);
+ let fetchedDataset = await getDataset(datasetId);
+ const dataSource = await readDatasetDatasource(fetchedDataset);
+
+ setSavedDataSourceOnServer(dataSource);
+
+ if (dataSource == null) {
+ throw new Error("No datasource received from server.");
+ }
+
+ if (fetchedDataset.dataSource.status?.includes("Error")) {
+ const datasetClone = _.cloneDeep(fetchedDataset) as any as MutableAPIDataset;
+ datasetClone.dataSource.status = fetchedDataset.dataSource.status;
+ fetchedDataset = datasetClone as APIDataset;
+ }
+
+ form.setFieldsValue({
+ dataSourceJson: jsonStringify(dataSource),
+ dataset: {
+ name: fetchedDataset.name,
+ isPublic: fetchedDataset.isPublic || false,
+ description: fetchedDataset.description || undefined,
+ allowedTeams: fetchedDataset.allowedTeams || [],
+ // @ts-ignore: The Antd DatePicker component requires a daysjs date object instead of plain number timestamp
+ sortingKey: dayjs(fetchedDataset.sortingKey as any as Dayjs),
+ },
+ });
+
+ form.setFieldsValue({
+ // @ts-ignore Missmatch between APIDataSource and MutableAPIDataset
+ dataSource,
+ });
+
+ form.setFieldsValue({
+ datasetRotation: getRotationFromCoordinateTransformations(dataSource),
+ });
+
+ const fetchedDatasetDefaultConfiguration = await getDatasetDefaultConfiguration(datasetId);
+ enforceValidatedDatasetViewConfiguration(
+ fetchedDatasetDefaultConfiguration,
+ fetchedDataset,
+ true,
+ );
+ form.setFieldsValue({
+ defaultConfiguration: fetchedDatasetDefaultConfiguration,
+ defaultConfigurationLayersJson: JSON.stringify(
+ fetchedDatasetDefaultConfiguration.layers,
+ null,
+ " ",
+ ),
+ });
+
+ setDatasetDefaultConfiguration(fetchedDatasetDefaultConfiguration);
+ setDataset(fetchedDataset);
+ return fetchedDataset.name;
+ } catch (error) {
+ handleGenericError(error as Error);
+ return undefined;
+ } finally {
+ setIsLoading(false);
+ form.validateFields();
+ }
+ }, [datasetId, form.setFieldsValue, form.validateFields]);
+
+ const getFormValidationSummary = useCallback((): Record<
+ "data" | "general" | "defaultConfig",
+ boolean
+ > => {
+ // Antd's form.getFieldsError only returns errors for mounted form fields, i.e. from the current tab :anger:
+ const err = form.getFieldsError();
+ const formErrors: Record = {};
+
+ if (!err || !dataset) {
+ return formErrors;
+ }
+
+ if (
+ hasFormError(err, "dataSource") ||
+ hasFormError(err, "dataSourceJson") ||
+ hasFormError(err, "datasetRotation")
+ ) {
+ formErrors.data = true;
+ }
+
+ if (hasFormError(err, "dataset")) {
+ formErrors.general = true;
+ }
+
+ if (
+ hasFormError(err, "defaultConfiguration") ||
+ hasFormError(err, "defaultConfigurationLayersJson")
+ ) {
+ formErrors.defaultConfig = true;
+ }
+
+ return formErrors;
+ }, [form, dataset]);
+
+ const didDatasourceChange = useCallback(
+ (dataSource: Record) => {
+ return !_.isEqual(dataSource, savedDataSourceOnServer || {});
+ },
+ [savedDataSourceOnServer],
+ );
+
+ const didDatasourceIdChange = useCallback(
+ (dataSource: Record) => {
+ const savedDatasourceId = savedDataSourceOnServer?.id;
+ if (!savedDatasourceId) {
+ return false;
+ }
+ return (
+ savedDatasourceId.name !== dataSource.id.name ||
+ savedDatasourceId.team !== dataSource.id.team
+ );
+ },
+ [savedDataSourceOnServer],
+ );
+
+ const isOnlyDatasourceIncorrectAndNotEdited = useCallback(() => {
+ const validationSummary = getFormValidationSummary();
+
+ if (_.size(validationSummary) === 1 && validationSummary.data) {
+ try {
+ const dataSource = JSON.parse(form.getFieldValue("dataSourceJson"));
+ const didNotEditDatasource = !didDatasourceChange(dataSource);
+ return didNotEditDatasource;
+ } catch (_e) {
+ return false;
+ }
+ }
+
+ return false;
+ }, [getFormValidationSummary, form, didDatasourceChange]);
+
+ const submitForm = useCallback(async () => {
+ // Call getFieldsValue() with "True" to get all values from form not just those that are visible in the current view
+ const formValues: FormData = form.getFieldsValue(true);
+ const datasetChangeValues = { ...formValues.dataset };
+
+ if (datasetChangeValues.sortingKey != null) {
+ datasetChangeValues.sortingKey = datasetChangeValues.sortingKey.valueOf();
+ }
+
+ const teamIds = formValues.dataset.allowedTeams.map((t) => t.id);
+ await updateDatasetPartial(datasetId, datasetChangeValues);
+
+ if (datasetDefaultConfiguration != null) {
+ await updateDatasetDefaultConfiguration(
+ datasetId,
+ _.extend({}, datasetDefaultConfiguration, formValues.defaultConfiguration, {
+ layers: JSON.parse(formValues.defaultConfigurationLayersJson),
+ }),
+ );
+ }
+
+ await updateDatasetTeams(datasetId, teamIds);
+ const dataSource = JSON.parse(formValues.dataSourceJson);
+
+ if (dataset != null && didDatasourceChange(dataSource)) {
+ if (didDatasourceIdChange(dataSource)) {
+ Toast.warning(messages["dataset.settings.updated_datasource_id_warning"]);
+ }
+ await updateDatasetDatasource(dataset.directoryName, dataset.dataStore.url, dataSource);
+ setSavedDataSourceOnServer(dataSource);
+ }
+
+ const verb = isEditingMode ? "updated" : "imported";
+ Toast.success(`Successfully ${verb} ${datasetChangeValues?.name || datasetId}.`);
+ setHasUnsavedChanges(false);
+
+ if (dataset && queryClient) {
+ queryClient.invalidateQueries({
+ queryKey: ["datasetsByFolder", dataset.folderId],
+ });
+ queryClient.invalidateQueries({ queryKey: ["dataset", "search"] });
+ }
+
+ onComplete();
+ }, [
+ datasetId,
+ datasetDefaultConfiguration,
+ dataset,
+ didDatasourceChange,
+ didDatasourceIdChange,
+ isEditingMode,
+ queryClient,
+ onComplete,
+ form.getFieldsValue,
+ ]);
+
+ const switchToProblematicTab = useCallback(() => {
+ const validationSummary = getFormValidationSummary();
+
+ // Switch to the earliest, problematic tab
+ switch (Object.keys(validationSummary).pop()) {
+ case "data":
+ return navigate("data");
+ case "general":
+ // "general" is very broad and there is no specific tab for it.
+ return;
+ case "defaultConfig":
+ return navigate("defaultConfig");
+ default:
+ return;
+ }
+ }, [getFormValidationSummary, navigate]);
+
+ const handleValidationFailed = useCallback(() => {
+ const isOnlyDatasourceIncorrectAndNotEditedResult = isOnlyDatasourceIncorrectAndNotEdited();
+
+ if (!isOnlyDatasourceIncorrectAndNotEditedResult || !dataset) {
+ switchToProblematicTab();
+ setHasFormErrors(true);
+ Toast.warning(messages["dataset.import.invalid_fields"]);
+ } else {
+ submitForm();
+ }
+ }, [isOnlyDatasourceIncorrectAndNotEdited, dataset, submitForm, switchToProblematicTab]);
+
+ const handleSubmit = useCallback(() => {
+ syncDataSourceFields(form, activeDataSourceEditMode === "simple" ? "advanced" : "simple");
+
+ const afterForceUpdateCallback = () => {
+ setTimeout(() => form.validateFields().then(submitForm).catch(handleValidationFailed), 0);
+ };
+
+ setActiveDataSourceEditMode((prev) => prev);
+ setTimeout(afterForceUpdateCallback, 0);
+ }, [form, activeDataSourceEditMode, submitForm, handleValidationFailed]);
+
+ const onValuesChange = useCallback((_changedValues: FormData, _allValues: FormData) => {
+ setHasUnsavedChanges(true);
+ }, []);
+
+ const handleCancel = useCallback(() => {
+ onCancel();
+ }, [onCancel]);
+
+ const handleDataSourceEditModeChange = useCallback(
+ (activeEditMode: DataSourceEditMode) => {
+ syncDataSourceFields(form, activeEditMode);
+ form.validateFields();
+ setActiveDataSourceEditMode(activeEditMode);
+ },
+ [form],
+ );
+
+ useBeforeUnload(hasUnsavedChanges, messages["dataset.leave_with_unsaved_changes"]);
+
+ useEffect(() => {
+ // In case of Remote Dataset Upload, we start with a prefilled form containing the DS information
+ if (formProp === undefined) {
+ // For all other cases, i.e. editting existing datasets, we fetch the dataset information from the backend
+ fetchData().then((datasetName) => {
+ sendAnalyticsEvent("open_dataset_settings", {
+ datasetName: datasetName ?? "Not found dataset",
+ });
+ });
+ }
+ }, [fetchData, formProp]);
+
+ const contextValue: DatasetSettingsContextValue = {
+ form,
+ isLoading,
+ dataset,
+ datasetId,
+ datasetDefaultConfiguration,
+ activeDataSourceEditMode,
+ isEditingMode,
+ handleSubmit,
+ handleCancel,
+ handleDataSourceEditModeChange,
+ onValuesChange,
+ getFormValidationSummary,
+ hasFormErrors,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_sharing_tab.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_sharing_tab.tsx
index 7a72e941cef..b6a12537bca 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_sharing_tab.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_sharing_tab.tsx
@@ -1,161 +1,149 @@
-import { CopyOutlined, InfoCircleOutlined, RetweetOutlined } from "@ant-design/icons";
+import { RetweetOutlined } from "@ant-design/icons";
+import { useQuery } from "@tanstack/react-query";
+import { SettingsCard, type SettingsCardProps } from "admin/account/helpers/settings_card";
+import { SettingsTitle } from "admin/account/helpers/settings_title";
import { PricingPlanEnum } from "admin/organization/pricing_plan_utils";
import { getDatasetSharingToken, revokeDatasetSharingToken } from "admin/rest_api";
-import { Button, Checkbox, Collapse, type FormInstance, Input, Space, Tooltip } from "antd";
+import { Col, Collapse, Form, Row, Space, Switch, Tooltip, Typography } from "antd";
import { AsyncButton } from "components/async_clickables";
import { PricingEnforcedBlur } from "components/pricing_enforcers";
import DatasetAccessListView from "dashboard/advanced_dataset/dataset_access_list_view";
import TeamSelectionComponent from "dashboard/dataset/team_selection_component";
import { useWkSelector } from "libs/react_hooks";
-import Toast from "libs/toast";
import { isUserAdminOrDatasetManager, isUserAdminOrTeamManager } from "libs/utils";
import window from "libs/window";
-import type React from "react";
-import { useEffect, useState } from "react";
-import type { APIDataset } from "types/api_types";
+import { useCallback, useMemo } from "react";
import { getReadableURLPart } from "viewer/model/accessors/dataset_accessor";
-import { FormItemWithInfo } from "./helper_components";
+import { useDatasetSettingsContext } from "./dataset_settings_context";
-type Props = {
- form: FormInstance | null;
- datasetId: string;
- dataset: APIDataset | null | undefined;
-};
-
-export default function DatasetSettingsSharingTab({ form, datasetId, dataset }: Props) {
- const [sharingToken, setSharingToken] = useState("");
+export default function DatasetSettingsSharingTab() {
+ const { form, datasetId, dataset } = useDatasetSettingsContext();
const activeUser = useWkSelector((state) => state.activeUser);
const isDatasetManagerOrAdmin = isUserAdminOrDatasetManager(activeUser);
- const allowedTeamsComponent = (
-
-
-
-
-
- );
-
- async function fetch() {
- const newSharingToken = await getDatasetSharingToken(datasetId);
- setSharingToken(newSharingToken);
- }
-
- // biome-ignore lint/correctness/useExhaustiveDependencies(fetch):
- useEffect(() => {
- fetch();
- }, []);
-
- function handleSelectCode(event: React.MouseEvent): void {
- event.currentTarget.select();
- }
-
- async function handleCopySharingLink(): Promise {
- const link = getSharingLink();
-
- if (!link) {
- return;
- }
-
- await navigator.clipboard.writeText(link);
- Toast.success("Sharing Link copied to clipboard");
- }
+ const isDatasetPublic = Form.useWatch(["dataset", "isPublic"], form);
+ const { data: sharingToken, refetch } = useQuery({
+ queryKey: ["datasetSharingToken", datasetId],
+ queryFn: () => getDatasetSharingToken(datasetId),
+ });
- async function handleRevokeSharingLink(): Promise {
+ const handleRevokeSharingLink = useCallback(async () => {
await revokeDatasetSharingToken(datasetId);
- const newSharingToken = await getDatasetSharingToken(datasetId);
- setSharingToken(newSharingToken);
- }
+ refetch();
+ }, [datasetId, refetch]);
- function getSharingLink() {
- if (!form) return undefined;
-
- const doesNeedToken = !form.getFieldValue("dataset.isPublic");
+ const sharingLink = useMemo(() => {
const tokenSuffix = `?token=${sharingToken}`;
- return `${window.location.origin}/datasets/${dataset ? getReadableURLPart(dataset) : datasetId}/view${doesNeedToken ? tokenSuffix : ""}`;
- }
+ const sharingLink = `${window.location.origin}/datasets/${dataset ? getReadableURLPart(dataset) : datasetId}/view${isDatasetPublic ? "" : tokenSuffix}`;
+ return (
+
+ {sharingLink}
+
+ );
+ }, [dataset, datasetId, isDatasetPublic, sharingToken]);
- function getUserAccessList() {
+ const userAccessList = useMemo(() => {
if (!activeUser || !dataset) return undefined;
if (!isUserAdminOrTeamManager(activeUser)) return undefined;
- const panelLabel = (
-
- All users with access permission to work with this dataset{" "}
-
-
-
-
- );
-
return (
,
},
]}
/>
);
- }
+ }, [activeUser, dataset]);
+
+ const sharingItems: SettingsCardProps[] = useMemo(
+ () => [
+ {
+ title: "Make dataset publicly accessible",
+ tooltip:
+ "Make your dataset public, for anonymous/unregistered users to access your dataset.",
+ content: (
+
+
+
+ ),
+ },
+ {
+ title: "Additional team access permissions for this dataset",
+ tooltip:
+ "The dataset can be seen by administrators, dataset managers and by teams that have access to the folder in which the dataset is located. If you want to grant additional teams access, define these teams here.",
+ content: (
+
+
+
+
+
+ ),
+ },
+ {
+ title: "Sharing Link",
+ content: (
+ <>
+ {sharingLink}
+
+ {!isDatasetPublic && (
+
+ The URL contains a secret token which enables anybody with this link to view
+ the dataset. Renew the token to make the old link invalid.
+
+ }
+ >
+ }>
+ Renew Authorization Token
+
+
+ )}
+
+ >
+ ),
+ tooltip:
+ "The sharing link can be used to allow unregistered users to view this dataset. If the dataset itself is not public, the link contains a secret token which ensures that the dataset can be opened if you know the special link.",
+ },
+
+ {
+ title: "Users with access permission to work with this dataset",
+ tooltip:
+ "Dataset access is based on the specified team permissions and individual user roles. Any changes will only appear after pressing the Save button.",
+ content: userAccessList,
+ },
+ ],
+ [
+ sharingLink,
+ userAccessList,
+ handleRevokeSharingLink,
+ isDatasetPublic,
+ isDatasetManagerOrAdmin,
+ ],
+ );
- return form ? (
+ return (
-
- Make dataset publicly accessible
-
- {allowedTeamsComponent}
-
- The sharing link can be used to allow unregistered users to view this dataset. If the
- dataset itself is not public, the link contains a secret token which ensures that the
- dataset can be opened if you know the special link.
-
- }
- >
-
-
- }>
- Copy
-
- {!form.getFieldValue("dataset.isPublic") && (
-
- The URL contains a secret token which enables anybody with this link to view the
- dataset. Renew the token to make the old link invalid.
-
- }
- >
- }>
- Renew
-
-
- )}
-
-
- {getUserAccessList()}
+
+
+ {sharingItems.map((item) => (
+
+
+
+ ))}
+
- ) : null;
+ );
}
diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx
index c0f206f13f9..b55a9b7f826 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx
@@ -1,390 +1,57 @@
-import { ExclamationCircleOutlined } from "@ant-design/icons";
-import { useQueryClient } from "@tanstack/react-query";
import {
- getDataset,
- getDatasetDefaultConfiguration,
- readDatasetDatasource,
- sendAnalyticsEvent,
- updateDatasetDatasource,
- updateDatasetDefaultConfiguration,
- updateDatasetPartial,
- updateDatasetTeams,
-} from "admin/rest_api";
-import { Alert, Button, Card, Form, Spin, Tabs, Tooltip } from "antd";
-import dayjs from "dayjs";
+ CodeSandboxOutlined,
+ DeleteOutlined,
+ ExclamationCircleOutlined,
+ ExportOutlined,
+ FileTextOutlined,
+ SettingOutlined,
+ TeamOutlined,
+} from "@ant-design/icons";
+import { Alert, Breadcrumb, Button, Form, Layout, Menu, Tooltip } from "antd";
+import type { ItemType } from "antd/es/menu/interface";
+import { useDatasetSettingsContext } from "dashboard/dataset/dataset_settings_context";
+
import features from "features";
-import { handleGenericError } from "libs/error_handling";
import { useWkSelector } from "libs/react_hooks";
-import Toast from "libs/toast";
-import { jsonStringify } from "libs/utils";
-import _ from "lodash";
import messages from "messages";
-import { useCallback, useEffect, useState } from "react";
-import { Link } from "react-router-dom";
-import type { APIDataSource, APIDataset, MutableAPIDataset } from "types/api_types";
-import { enforceValidatedDatasetViewConfiguration } from "types/schemas/dataset_view_configuration_defaults";
+import type React from "react";
+import { useCallback } from "react";
+import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Unicode } from "viewer/constants";
import { getReadableURLPart } from "viewer/model/accessors/dataset_accessor";
-import {
- EXPECTED_TRANSFORMATION_LENGTH,
- doAllLayersHaveTheSameRotation,
- getRotationSettingsFromTransformationIn90DegreeSteps,
-} from "viewer/model/accessors/dataset_layer_transformation_accessor";
-import type { DatasetConfiguration } from "viewer/store";
-import type { DatasetRotationAndMirroringSettings } from "./dataset_rotation_form_item";
-import DatasetSettingsDataTab, { syncDataSourceFields } from "./dataset_settings_data_tab";
-import DatasetSettingsDeleteTab from "./dataset_settings_delete_tab";
-import DatasetSettingsMetadataTab from "./dataset_settings_metadata_tab";
-import DatasetSettingsSharingTab from "./dataset_settings_sharing_tab";
-import DatasetSettingsViewConfigTab from "./dataset_settings_viewconfig_tab";
-import { Hideable, hasFormError } from "./helper_components";
-import useBeforeUnload from "./useBeforeUnload_hook";
+const { Sider, Content } = Layout;
const FormItem = Form.Item;
const notImportedYetStatus = "Not imported yet.";
-type DatasetSettingsViewProps = {
- datasetId: string;
- isEditingMode: boolean;
- onComplete: () => void;
- onCancel: () => void;
-};
-type TabKey = "data" | "general" | "defaultConfig" | "sharing" | "deleteDataset";
-
-export type FormData = {
- dataSource: APIDataSource;
- dataSourceJson: string;
- dataset: APIDataset;
- defaultConfiguration: DatasetConfiguration;
- defaultConfigurationLayersJson: string;
- datasetRotation?: DatasetRotationAndMirroringSettings;
+const BREADCRUMB_LABELS = {
+ data: "Data Source",
+ sharing: "Sharing & Permissions",
+ metadata: "Metadata",
+ defaultConfig: "View Configuration",
+ delete: "Delete Dataset",
};
-const DatasetSettingsView: React.FC = ({
- datasetId,
- isEditingMode,
- onComplete,
- onCancel,
-}) => {
- const [form] = Form.useForm();
- const queryClient = useQueryClient();
+const DatasetSettingsView: React.FC = () => {
+ const {
+ form,
+ isEditingMode,
+ dataset,
+ datasetId,
+ handleSubmit,
+ handleCancel,
+ onValuesChange,
+ getFormValidationSummary,
+ hasFormErrors,
+ } = useDatasetSettingsContext();
const isUserAdmin = useWkSelector((state) => state.activeUser?.isAdmin || false);
-
- const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
- const [dataset, setDataset] = useState(null);
- const [datasetDefaultConfiguration, setDatasetDefaultConfiguration] = useState<
- DatasetConfiguration | null | undefined
- >(null);
- const [isLoading, setIsLoading] = useState(true);
- const [activeDataSourceEditMode, setActiveDataSourceEditMode] = useState<"simple" | "advanced">(
- "simple",
- );
- const [activeTabKey, setActiveTabKey] = useState("data");
- const [savedDataSourceOnServer, setSavedDataSourceOnServer] = useState<
- APIDataSource | null | undefined
- >(null);
-
- const fetchData = useCallback(async (): Promise => {
- try {
- setIsLoading(true);
- let fetchedDataset = await getDataset(datasetId);
- const dataSource = await readDatasetDatasource(fetchedDataset);
-
- // Ensure that zarr layers (which aren't inferred by the back-end) are still
- // included in the inferred data source
- setSavedDataSourceOnServer(dataSource);
-
- if (dataSource == null) {
- throw new Error("No datasource received from server.");
- }
-
- if (fetchedDataset.dataSource.status?.includes("Error")) {
- // If the datasource-properties.json could not be parsed due to schema errors,
- // we replace it with the version that is at least parsable.
- const datasetClone = _.cloneDeep(fetchedDataset) as any as MutableAPIDataset;
- // We are keeping the error message to display it to the user.
- datasetClone.dataSource.status = fetchedDataset.dataSource.status;
- fetchedDataset = datasetClone as APIDataset;
- }
-
- form.setFieldsValue({
- dataSourceJson: jsonStringify(dataSource),
- dataset: {
- name: fetchedDataset.name,
- isPublic: fetchedDataset.isPublic || false,
- description: fetchedDataset.description || undefined,
- allowedTeams: fetchedDataset.allowedTeams || [],
- sortingKey: dayjs(fetchedDataset.sortingKey),
- },
- });
-
- // This call cannot be combined with the previous setFieldsValue,
- // since the layer values wouldn't be initialized correctly.
- form.setFieldsValue({
- dataSource,
- });
-
- // Retrieve the initial dataset rotation settings from the data source config.
- if (doAllLayersHaveTheSameRotation(dataSource.dataLayers)) {
- const firstLayerTransformations = dataSource.dataLayers[0].coordinateTransformations;
- let initialDatasetRotationSettings: DatasetRotationAndMirroringSettings;
- if (
- !firstLayerTransformations ||
- firstLayerTransformations.length !== EXPECTED_TRANSFORMATION_LENGTH
- ) {
- const nulledSetting = { rotationInDegrees: 0, isMirrored: false };
- initialDatasetRotationSettings = { x: nulledSetting, y: nulledSetting, z: nulledSetting };
- } else {
- initialDatasetRotationSettings = {
- // First transformation is a translation to the coordinate system origin.
- x: getRotationSettingsFromTransformationIn90DegreeSteps(
- firstLayerTransformations[1],
- "x",
- ),
- y: getRotationSettingsFromTransformationIn90DegreeSteps(
- firstLayerTransformations[2],
- "y",
- ),
- z: getRotationSettingsFromTransformationIn90DegreeSteps(
- firstLayerTransformations[3],
- "z",
- ),
- // Fifth transformation is a translation back to the original position.
- };
- }
- form.setFieldsValue({
- datasetRotation: initialDatasetRotationSettings,
- });
- }
-
- const fetchedDatasetDefaultConfiguration = await getDatasetDefaultConfiguration(datasetId);
- enforceValidatedDatasetViewConfiguration(
- fetchedDatasetDefaultConfiguration,
- fetchedDataset,
- true,
- );
- form.setFieldsValue({
- defaultConfiguration: fetchedDatasetDefaultConfiguration,
- defaultConfigurationLayersJson: JSON.stringify(
- fetchedDatasetDefaultConfiguration.layers,
- null,
- " ",
- ),
- });
-
- setDatasetDefaultConfiguration(fetchedDatasetDefaultConfiguration);
- setDataset(fetchedDataset);
- } catch (error) {
- handleGenericError(error as Error);
- } finally {
- setIsLoading(false);
- form.validateFields();
- }
- }, [datasetId, form]);
-
- const getFormValidationSummary = useCallback((): Record => {
- const err = form.getFieldsError();
- const formErrors: Record = {};
-
- if (!err || !dataset) {
- return formErrors;
- }
-
- const hasErr = hasFormError;
-
- if (hasErr(err, "dataSource") || hasErr(err, "dataSourceJson")) {
- formErrors.data = true;
- }
-
- if (hasErr(err, "dataset")) {
- formErrors.general = true;
- }
-
- if (hasErr(err, "defaultConfiguration") || hasErr(err, "defaultConfigurationLayersJson")) {
- formErrors.defaultConfig = true;
- }
-
- return formErrors;
- }, [form, dataset]);
-
- const switchToProblematicTab = useCallback(() => {
- const validationSummary = getFormValidationSummary();
-
- if (validationSummary[activeTabKey]) {
- // Active tab is already problematic
- return;
- }
-
- // Switch to the earliest, problematic tab
- const problematicTab = _.find(
- ["data", "general", "defaultConfig"],
- (key) => validationSummary[key],
- );
-
- if (problematicTab) {
- setActiveTabKey(problematicTab as TabKey);
- }
- }, [getFormValidationSummary, activeTabKey]);
-
- const didDatasourceChange = useCallback(
- (dataSource: Record) => {
- return !_.isEqual(dataSource, savedDataSourceOnServer || {});
- },
- [savedDataSourceOnServer],
- );
-
- const didDatasourceIdChange = useCallback(
- (dataSource: Record) => {
- const savedDatasourceId = savedDataSourceOnServer?.id;
- if (!savedDatasourceId) {
- return false;
- }
- return (
- savedDatasourceId.name !== dataSource.id.name ||
- savedDatasourceId.team !== dataSource.id.team
- );
- },
- [savedDataSourceOnServer],
- );
-
- const isOnlyDatasourceIncorrectAndNotEdited = useCallback(() => {
- const validationSummary = getFormValidationSummary();
-
- if (_.size(validationSummary) === 1 && validationSummary.data) {
- try {
- const dataSource = JSON.parse(form.getFieldValue("dataSourceJson"));
- const didNotEditDatasource = !didDatasourceChange(dataSource);
- return didNotEditDatasource;
- } catch (_e) {
- return false;
- }
- }
-
- return false;
- }, [getFormValidationSummary, form, didDatasourceChange]);
-
- const submit = useCallback(
- async (formValues: FormData) => {
- const datasetChangeValues = { ...formValues.dataset };
-
- if (datasetChangeValues.sortingKey != null) {
- datasetChangeValues.sortingKey = datasetChangeValues.sortingKey.valueOf();
- }
-
- const teamIds = formValues.dataset.allowedTeams.map((t) => t.id);
- await updateDatasetPartial(datasetId, datasetChangeValues);
-
- if (datasetDefaultConfiguration != null) {
- await updateDatasetDefaultConfiguration(
- datasetId,
- _.extend({}, datasetDefaultConfiguration, formValues.defaultConfiguration, {
- layers: JSON.parse(formValues.defaultConfigurationLayersJson),
- }),
- );
- }
-
- await updateDatasetTeams(datasetId, teamIds);
- const dataSource = JSON.parse(formValues.dataSourceJson);
-
- if (dataset != null && didDatasourceChange(dataSource)) {
- if (didDatasourceIdChange(dataSource)) {
- Toast.warning(messages["dataset.settings.updated_datasource_id_warning"]);
- }
- await updateDatasetDatasource(dataset.directoryName, dataset.dataStore.url, dataSource);
- setSavedDataSourceOnServer(dataSource);
- }
-
- const verb = isEditingMode ? "updated" : "imported";
- Toast.success(`Successfully ${verb} ${dataset?.name || datasetId}.`);
- setHasUnsavedChanges(false);
-
- if (dataset && queryClient) {
- // Update new cache
- queryClient.invalidateQueries({
- queryKey: ["datasetsByFolder", dataset.folderId],
- });
- queryClient.invalidateQueries({ queryKey: ["dataset", "search"] });
- }
-
- onComplete();
- },
- [
- datasetId,
- datasetDefaultConfiguration,
- dataset,
- didDatasourceChange,
- didDatasourceIdChange,
- isEditingMode,
- queryClient,
- onComplete,
- ],
- );
-
- const handleValidationFailed = useCallback(
- ({ values }: { values: FormData }) => {
- const isOnlyDatasourceIncorrectAndNotEditedResult = isOnlyDatasourceIncorrectAndNotEdited();
-
- // Check whether the validation error was introduced or existed before
- if (!isOnlyDatasourceIncorrectAndNotEditedResult || !dataset) {
- switchToProblematicTab();
- Toast.warning(messages["dataset.import.invalid_fields"]);
- } else {
- // If the validation error existed before, still attempt to update dataset
- submit(values);
- }
- },
- [isOnlyDatasourceIncorrectAndNotEdited, dataset, switchToProblematicTab, submit],
- );
-
- const handleSubmit = useCallback(() => {
- // Ensure that all form fields are in sync
- syncDataSourceFields(form, activeDataSourceEditMode === "simple" ? "advanced" : "simple");
-
- const afterForceUpdateCallback = () => {
- // Trigger validation manually, because fields may have been updated
- // and defer the validation as it is done asynchronously by antd or so.
- setTimeout(
- () =>
- form
- .validateFields()
- .then((formValues) => submit(formValues))
- .catch((errorInfo) => handleValidationFailed(errorInfo)),
- 0,
- );
- };
-
- // Need to force update of the SimpleAdvancedDataForm as removing a layer in the advanced tab does not update
- // the form items in the simple tab (only the values are updated). The form items automatically update once
- // the simple tab renders, but this is not the case when the user directly submits the changes.
- // In functional components, we can trigger a re-render by updating state
- setActiveDataSourceEditMode((prev) => prev); // Force re-render
- setTimeout(afterForceUpdateCallback, 0);
- }, [form, activeDataSourceEditMode, submit, handleValidationFailed]);
-
- const onValuesChange = useCallback((_changedValues: FormData, _allValues: FormData) => {
- setHasUnsavedChanges(true);
- }, []);
-
- const handleDataSourceEditModeChange = useCallback(
- (activeEditMode: "simple" | "advanced") => {
- syncDataSourceFields(form, activeEditMode);
- form.validateFields();
- setActiveDataSourceEditMode(activeEditMode);
- },
- [form],
- );
-
- // Setup beforeunload handling
- useBeforeUnload(hasUnsavedChanges, messages["dataset.leave_with_unsaved_changes"]);
-
- // Initial data fetch and analytics
- // biome-ignore lint/correctness/useExhaustiveDependencies: dataset dependency removed to avoid infinite loop
- useEffect(() => {
- fetchData();
- sendAnalyticsEvent("open_dataset_settings", {
- datasetName: dataset ? dataset.name : "Not found dataset",
- });
- }, [fetchData]);
+ const location = useLocation();
+ const navigate = useNavigate();
+ const selectedKey =
+ location.pathname
+ .split("/")
+ .filter((p) => p.length > 0)
+ .pop() || "data";
const getMessageComponents = useCallback(() => {
if (dataset == null) {
@@ -446,138 +113,119 @@ const DatasetSettingsView: React.FC = ({
);
}, [dataset, isEditingMode]);
+ const titleString = isEditingMode ? "Dataset Settings" : "Import";
const maybeStoredDatasetName = dataset?.name || datasetId;
- const maybeDataSourceId = dataset
- ? {
- owningOrganization: dataset.owningOrganization,
- directoryName: dataset.directoryName,
- }
- : null;
- const titleString = isEditingMode ? "Settings for" : "Import";
- const datasetLinkOrName = isEditingMode ? (
-
- {maybeStoredDatasetName}
-
- ) : (
- maybeStoredDatasetName
- );
const confirmString =
isEditingMode || (dataset != null && dataset.dataSource.status == null) ? "Save" : "Import";
- const formErrors = getFormValidationSummary();
const errorIcon = (
);
- const tabs = [
+ const formErrors = hasFormErrors ? getFormValidationSummary() : {};
+ const menuItems: ItemType[] = [
{
- label: Data {formErrors.data ? errorIcon : ""} ,
- key: "data",
- forceRender: true,
- children: (
-
- {
- // We use the Hideable component here to avoid that the user can "tab"
- // to hidden form elements.
- }
-
-
- ),
+ label: titleString,
+ type: "group",
+ children: [
+ {
+ key: "data",
+ icon: formErrors.data ? errorIcon : ,
+ label: "Data Source",
+ },
+ {
+ key: "sharing",
+ icon: formErrors.general ? errorIcon : ,
+ label: "Sharing & Permissions",
+ },
+ {
+ key: "metadata",
+ icon: formErrors.general ? errorIcon : ,
+ label: "Metadata",
+ },
+ {
+ key: "defaultConfig",
+ icon: formErrors.defaultConfig ? errorIcon : ,
+ label: "View Configuration",
+ },
+ isUserAdmin && features().allowDeleteDatasets
+ ? {
+ key: "delete",
+ icon: ,
+ label: "Delete",
+ }
+ : null,
+ ],
},
-
+ { type: "divider" },
{
- label: Sharing & Permissions {formErrors.general ? errorIcon : null} ,
- key: "sharing",
- forceRender: true,
- children: (
-
-
-
- ),
+ type: "group",
+ children: isEditingMode
+ ? [
+ {
+ key: "open",
+ icon: ,
+ label: "Open in WEBKNOSSOS",
+ onClick: () =>
+ window.open(
+ `/datasets/${dataset ? getReadableURLPart(dataset) : datasetId}/view`,
+ "_blank",
+ "noopener",
+ ),
+ },
+ ]
+ : [],
},
+ ];
+ const breadcrumbItems = [
{
- label: Metadata ,
- key: "general",
- forceRender: true,
- children: (
-
-
-
- ),
+ title: titleString,
},
-
+ { title: maybeStoredDatasetName },
{
- label: View Configuration {formErrors.defaultConfig ? errorIcon : ""} ,
- key: "defaultConfig",
- forceRender: true,
- children: (
-
- {
- maybeDataSourceId ? (
-
- ) : null /* null case should never be rendered as tabs are only rendered when the dataset is loaded. */
- }
-
- ),
+ title: BREADCRUMB_LABELS[selectedKey as keyof typeof BREADCRUMB_LABELS],
},
];
- if (isUserAdmin && features().allowDeleteDatasets)
- tabs.push({
- label: Delete Dataset ,
- key: "deleteDataset",
- forceRender: true,
- children: (
-
-
-
- ),
- });
+ const navigateToTab = useCallback(
+ ({ key }: { key: string }) => {
+ if (key === selectedKey || key === "open") return;
+ navigate(key);
+ },
+ [navigate, selectedKey],
+ );
return (
-
+
+
+
);
};
diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_viewconfig_tab.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_viewconfig_tab.tsx
index 3ac2277deb6..8c4ab456731 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_viewconfig_tab.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_viewconfig_tab.tsx
@@ -1,47 +1,44 @@
import { InfoCircleOutlined } from "@ant-design/icons";
+import { SettingsCard, type SettingsCardProps } from "admin/account/helpers/settings_card";
+import { SettingsTitle } from "admin/account/helpers/settings_title";
import { getAgglomeratesForDatasetLayer, getMappingsForDatasetLayer } from "admin/rest_api";
-import {
- Alert,
- Checkbox,
- Col,
- Divider,
- Form,
- Input,
- InputNumber,
- Row,
- Select,
- Table,
- Tooltip,
-} from "antd";
+import { Col, Form, Input, InputNumber, Row, Select, Switch, Table, Tooltip } from "antd";
import { Slider } from "components/slider";
import { Vector3Input } from "libs/vector_input";
import _ from "lodash";
-import messages, {
- type RecommendedConfiguration,
- layerViewConfigurations,
- settings,
- settingsTooltips,
-} from "messages";
+import messages, { layerViewConfigurations, settings, settingsTooltips } from "messages";
import { useMemo, useState } from "react";
-import type { APIDataSourceId } from "types/api_types";
+import type { APIDataSourceId, APIDataset } from "types/api_types";
import { getDefaultLayerViewConfiguration } from "types/schemas/dataset_view_configuration.schema";
import { syncValidator, validateLayerViewConfigurationObjectJSON } from "types/validation";
import { BLEND_MODES } from "viewer/constants";
import type { DatasetConfiguration, DatasetLayerConfiguration } from "viewer/store";
import ColorLayerOrderingTable from "./color_layer_ordering_component";
-import { FormItemWithInfo, jsonEditStyle } from "./helper_components";
+import { useDatasetSettingsContext } from "./dataset_settings_context";
+import { jsonEditStyle } from "./helper_components";
const FormItem = Form.Item;
-export default function DatasetSettingsViewConfigTab(props: {
- dataSourceId: APIDataSourceId;
- dataStoreURL: string | undefined;
-}) {
- const { dataSourceId, dataStoreURL } = props;
+export default function DatasetSettingsViewConfigTab() {
+ const { dataset } = useDatasetSettingsContext();
+ if (dataset == null) {
+ return null;
+ }
+ return ;
+}
+
+const DatasetSettingsViewConfigTabWithDataset = ({ dataset }: { dataset: APIDataset }) => {
const [availableMappingsPerLayerCache, setAvailableMappingsPerLayer] = useState<
Record
>({});
+ const dataStoreURL = dataset.dataStore.url;
+ const dataSourceId: APIDataSourceId = {
+ owningOrganization: dataset.owningOrganization,
+ directoryName: dataset.directoryName,
+ };
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: validate on dataset change
const validateDefaultMappings = useMemo(
() => async (configStr: string, dataStoreURL: string, dataSourceId: APIDataSourceId) => {
let config = {} as DatasetConfiguration["layers"];
@@ -92,7 +89,7 @@ export default function DatasetSettingsViewConfigTab(props: {
throw new Error("The following mappings are invalid: " + errors.join("\n"));
}
},
- [availableMappingsPerLayerCache],
+ [availableMappingsPerLayerCache, dataset], // Add dataset to dependencies for dataSourceId
);
const columns = [
@@ -158,178 +155,197 @@ export default function DatasetSettingsViewConfigTab(props: {
};
},
);
- const checkboxSettings = (
- [
- ["interpolation", 6],
- ["fourBit", 6],
- ["renderMissingDataBlack", 6],
- ] as Array<[keyof RecommendedConfiguration, number]>
- ).map(([settingsName, spanWidth]) => (
-
-
-
- {settings[settingsName]}{" "}
-
-
-
-
-
-
- ));
+
+ const viewConfigItems: SettingsCardProps[] = [
+ {
+ title: "Position",
+ tooltip: "The default position is defined in voxel-coordinates (x, y, z).",
+ content: (
+
+
+
+ ),
+ },
+ {
+ title: "Zoom Level",
+ tooltip:
+ "A zoom level of “1” will display the data in its original magnification.",
+ content: (
+ value == null || value > 0,
+ "The zoom value must be greater than 0.",
+ ),
+ },
+ ]}
+ >
+
+
+ ),
+ },
+ {
+ title: "Rotation",
+ tooltip:
+ "The default rotation that will be applied when viewing the dataset for the first time.",
+ content: (
+
+
+
+ ),
+ },
+ {
+ title: settings.interpolation as string,
+ tooltip: settingsTooltips.interpolation,
+ content: (
+
+
+
+ ),
+ },
+ {
+ title: settings.fourBit as string,
+ tooltip: settingsTooltips.fourBit,
+ content: (
+
+
+
+ ),
+ },
+ {
+ title: settings.renderMissingDataBlack as string,
+ tooltip: settingsTooltips.renderMissingDataBlack,
+ content: (
+
+
+
+ ),
+ },
+ {
+ title: settings.segmentationPatternOpacity as string,
+ tooltip: settingsTooltips.segmentationPatternOpacity,
+ content: (
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: settings.blendMode as string,
+ tooltip: settingsTooltips.blendMode,
+ content: (
+
+
+ Additive
+ Cover
+
+
+ ),
+ },
+ {
+ title: settings.loadingStrategy as string,
+ tooltip: settingsTooltips.loadingStrategy,
+ content: (
+
+
+ Best quality first
+ Progressive quality
+
+
+ ),
+ },
+ {
+ title: "Color Layer Order",
+ tooltip:
+ "Set the order in which color layers are rendered. This setting is only relevant if the cover blend mode is active.",
+ content: (
+
+
+
+ ),
+ },
+ ];
return (
-
-
-
-
-
-
-
-
- value == null || value > 0,
- "The zoom value must be greater than 0.",
- ),
- },
- ]}
- >
-
-
-
-
-
-
-
-
-
-
{checkboxSettings}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Additive
- Cover
-
-
-
-
-
-
- Best quality first
- Progressive quality
-
-
-
-
-
-
-
-
-
-
+
+ {viewConfigItems.map((item) => (
+
+
+
+ ))}
-
-
+
+
-
- Promise.all([
- validateLayerViewConfigurationObjectJSON(_rule, config),
- dataStoreURL
- ? validateDefaultMappings(config, dataStoreURL, dataSourceId)
- : Promise.resolve(),
- ]),
- },
- ]}
- >
-
-
+
+ Promise.all([
+ validateLayerViewConfigurationObjectJSON(_rule, config),
+ dataStoreURL
+ ? validateDefaultMappings(config, dataStoreURL, dataSourceId)
+ : Promise.resolve(),
+ ]),
+ },
+ ]}
+ >
+
+
+ }
+ />
- Valid layer view configurations and their default values:
-
-
-
+ }
/>
);
-}
+};
diff --git a/frontend/javascripts/dashboard/dataset/helper_components.tsx b/frontend/javascripts/dashboard/dataset/helper_components.tsx
index 53f4ac04462..3402fc3318d 100644
--- a/frontend/javascripts/dashboard/dataset/helper_components.tsx
+++ b/frontend/javascripts/dashboard/dataset/helper_components.tsx
@@ -2,9 +2,9 @@ import { InfoCircleOutlined } from "@ant-design/icons";
import { Alert, Form, Modal, Tooltip } from "antd";
import type { FormItemProps, Rule } from "antd/lib/form";
import type { NamePath } from "antd/lib/form/interface";
-import _ from "lodash";
+import sum from "lodash/sum";
import type { FieldError } from "rc-field-form/es/interface";
-import * as React from "react";
+import React from "react";
const FormItem = Form.Item;
@@ -125,5 +125,5 @@ export const hasFormError = (formErrors: FieldError[], key: string): boolean =>
const errorsForKey = formErrors.map((errorObj) =>
errorObj.name[0] === key ? errorObj.errors.length : 0,
);
- return _.sum(errorsForKey) > 0;
+ return sum(errorsForKey) > 0;
};
diff --git a/frontend/javascripts/dashboard/dataset/useBeforeUnload_hook.ts b/frontend/javascripts/dashboard/dataset/useBeforeUnload_hook.ts
index 98bc7c22de6..fdbf0fec9f1 100644
--- a/frontend/javascripts/dashboard/dataset/useBeforeUnload_hook.ts
+++ b/frontend/javascripts/dashboard/dataset/useBeforeUnload_hook.ts
@@ -2,6 +2,28 @@ import { useCallback, useEffect, useRef } from "react";
import { type BlockerFunction, useBlocker } from "react-router-dom";
const useBeforeUnload = (hasUnsavedChanges: boolean, message: string) => {
+ const beforeUnload = useCallback(
+ (args: BeforeUnloadEvent | BlockerFunction): boolean | undefined => {
+ // Navigation blocking can be triggered by two sources:
+ // 1. The browser's native beforeunload event
+ // 2. The React-Router block function (useBlocker or withBlocker HOC)
+
+ if (hasUnsavedChanges && !location.pathname.startsWith("/datasets")) {
+ window.onbeforeunload = null; // clear the event handler otherwise it would be called twice. Once from history.block once from the beforeunload event
+ blockTimeoutIdRef.current = window.setTimeout(() => {
+ // restore the event handler in case a user chose to stay on the page
+ window.onbeforeunload = beforeUnload;
+ }, 500);
+ // The native event requires a truthy return value to show a generic message
+ // The React Router blocker accepts a boolean
+ return "preventDefault" in args ? true : !confirm(message);
+ }
+ // The native event requires an empty return value to not show a message
+ return;
+ },
+ [hasUnsavedChanges, message],
+ );
+
// @ts-ignore beforeUnload signature is overloaded
const blocker = useBlocker(beforeUnload);
const blockTimeoutIdRef = useRef(null);
@@ -15,25 +37,6 @@ const useBeforeUnload = (hasUnsavedChanges: boolean, message: string) => {
blocker.reset ? blocker.reset() : void 0;
}, [blocker.reset]);
- function beforeUnload(args: BeforeUnloadEvent | BlockerFunction): boolean | undefined {
- // Navigation blocking can be triggered by two sources:
- // 1. The browser's native beforeunload event
- // 2. The React-Router block function (useBlocker or withBlocker HOC)
-
- if (hasUnsavedChanges) {
- window.onbeforeunload = null; // clear the event handler otherwise it would be called twice. Once from history.block once from the beforeunload event
- blockTimeoutIdRef.current = window.setTimeout(() => {
- // restore the event handler in case a user chose to stay on the page
- window.onbeforeunload = beforeUnload;
- }, 500);
- // The native event requires a truthy return value to show a generic message
- // The React Router blocker accepts a boolean
- return "preventDefault" in args ? true : !confirm(message);
- }
- // The native event requires an empty return value to not show a message
- return;
- }
-
useEffect(() => {
window.onbeforeunload = beforeUnload;
diff --git a/frontend/javascripts/messages.tsx b/frontend/javascripts/messages.tsx
index 0c3276ee6f1..19d31980405 100644
--- a/frontend/javascripts/messages.tsx
+++ b/frontend/javascripts/messages.tsx
@@ -51,6 +51,8 @@ export const settings: Partial> =
colorLayerOrder: "Color Layer Order",
};
export const settingsTooltips: Partial> = {
+ segmentationPatternOpacity:
+ "The opacity of the pattern overlaid on any segmentation layer for improved contrast.",
loadingStrategy: `You can choose between loading the best quality first
(will take longer until you see data) or alternatively,
improving the quality progressively (data will be loaded faster,
@@ -83,6 +85,7 @@ export const settingsTooltips: Partial> = {
color: "Color",
alpha: "Layer opacity",
diff --git a/frontend/javascripts/router/route_wrappers.tsx b/frontend/javascripts/router/route_wrappers.tsx
index 3021c2cebb9..0d87e0433b9 100644
--- a/frontend/javascripts/router/route_wrappers.tsx
+++ b/frontend/javascripts/router/route_wrappers.tsx
@@ -6,6 +6,7 @@ import Onboarding from "admin/onboarding";
import { createExplorational, getShortLink } from "admin/rest_api";
import AsyncRedirect from "components/redirect";
import DashboardView, { urlTokenToTabKeyMap } from "dashboard/dashboard_view";
+import { DatasetSettingsProvider } from "dashboard/dataset/dataset_settings_provider";
import DatasetSettingsView from "dashboard/dataset/dataset_settings_view";
import features from "features";
import { useWkSelector } from "libs/react_hooks";
@@ -99,12 +100,9 @@ export function DatasetSettingsRouteWrapper() {
);
}
return (
- window.history.back()}
- onCancel={() => window.history.back()}
- />
+
+
+
);
}
diff --git a/frontend/javascripts/router/router.tsx b/frontend/javascripts/router/router.tsx
index d573ed86edb..2435e95df65 100644
--- a/frontend/javascripts/router/router.tsx
+++ b/frontend/javascripts/router/router.tsx
@@ -52,6 +52,11 @@ import ChangeEmailView from "admin/auth/change_email_view";
import { OrganizationDangerZoneView } from "admin/organization/organization_danger_zone_view";
import { OrganizationNotificationsView } from "admin/organization/organization_notifications_view";
import { OrganizationOverviewView } from "admin/organization/organization_overview_view";
+import DatasetSettingsDataTab from "dashboard/dataset/dataset_settings_data_tab";
+import DatasetSettingsDeleteTab from "dashboard/dataset/dataset_settings_delete_tab";
+import DatasetSettingsMetadataTab from "dashboard/dataset/dataset_settings_metadata_tab";
+import DatasetSettingsSharingTab from "dashboard/dataset/dataset_settings_sharing_tab";
+import DatasetSettingsViewConfigTab from "dashboard/dataset/dataset_settings_viewconfig_tab";
import { useWkSelector } from "libs/react_hooks";
import { PageNotFoundView } from "./page_not_found_view";
import {
@@ -270,7 +275,14 @@ const routes = createRoutesFromElements(
}
- />
+ >
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+