diff --git a/frontend/javascripts/admin/account/account_auth_token_view.tsx b/frontend/javascripts/admin/account/account_auth_token_view.tsx
index e1bb6f77358..e5cde44f217 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 9d74a6e6546..ec05ecaf633 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 { useHistory } 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/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/organization/organization_danger_zone_view.tsx b/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx
index 3f3646e3f2f..fe63efa0ec9 100644
--- a/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx
+++ b/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx
@@ -50,7 +50,7 @@ export function OrganizationDangerZoneView({ organization }: { organization: API
/>
diff --git a/frontend/javascripts/admin/organization/organization_overview_view.tsx b/frontend/javascripts/admin/organization/organization_overview_view.tsx
index 8f3013555c4..cab0da05223 100644
--- a/frontend/javascripts/admin/organization/organization_overview_view.tsx
+++ b/frontend/javascripts/admin/organization/organization_overview_view.tsx
@@ -4,11 +4,11 @@ import { getPricingPlanStatus, getUsers, updateOrganization } from "admin/rest_a
import { Button, Col, Row, Spin, Tooltip, Typography } from "antd";
import { formatCountToDataAmountUnit } from "libs/format_utils";
import Toast from "libs/toast";
-import { useEffect, useState } from "react";
+import { type Key, useEffect, useState } from "react";
import type { APIOrganization, APIPricingPlanStatus } from "types/api_types";
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,
@@ -114,11 +114,11 @@ export function OrganizationOverviewView({ organization }: { organization: APIOr
);
- const orgaStats = [
+ const orgaStats: (SettingsCardProps & { key: Key })[] = [
{
key: "name",
title: "Name",
- value: (
+ content: (
Compare all plans
@@ -146,20 +146,20 @@ export function OrganizationOverviewView({ organization }: { organization: APIOr
{
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: organization.creditBalance || "N/A",
+ content: organization.creditBalance || "N/A",
action: buyMoreCreditsAction,
},
];
@@ -177,9 +177,9 @@ export function OrganizationOverviewView({ organization }: { organization: APIOr
))}
diff --git a/frontend/javascripts/components/async_clickables.tsx b/frontend/javascripts/components/async_clickables.tsx
index 043b0f602be..1694fd8a160 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.
+ * It displays a loading indicator before the link text while the `onClick` 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..b925dc6567d 100644
--- a/frontend/javascripts/dashboard/dataset/color_layer_ordering_component.tsx
+++ b/frontend/javascripts/dashboard/dataset/color_layer_ordering_component.tsx
@@ -1,9 +1,10 @@
-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 { Collapse, type CollapseProps, List } from "antd";
+import { settingsTooltips } from "messages";
+import { useCallback } from "react";
// Example taken and modified from https://ant.design/components/table/#components-table-demo-drag-sorting-handler.
@@ -34,63 +35,56 @@ 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 collapseItems: CollapseProps["items"] = [
{
- label: panelTitle,
+ label: settingsTooltips.colorLayerOrder,
key: "1",
children: sortingItems.map((name) => ),
},
];
- return (
- {
- colorLayerNames && colorLayerNames.length > 1 && document.body.classList.add("is-dragging");
- }}
- onDragEnd={onSortEnd}
- >
+ const onDragStart = useCallback(() => {
+ colorLayerNames && colorLayerNames.length > 1 && document.body.classList.add("is-dragging");
+ }, [colorLayerNames]);
+
+ return isSettingEnabled ? (
+
+ ) : (
+ 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..92202ba3748
--- /dev/null
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_context.tsx
@@ -0,0 +1,41 @@
+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 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: "simple" | "advanced";
+ isEditingMode: boolean;
+ handleSubmit: () => void;
+ handleCancel: () => void;
+ handleDataSourceEditModeChange: (activeEditMode: "simple" | "advanced") => void;
+ onValuesChange: (changedValues: FormData, allValues: FormData) => void;
+ getFormValidationSummary: () => Record;
+};
+
+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..5369c19bc7d 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,
@@ -26,7 +27,8 @@ 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";
@@ -35,11 +37,12 @@ 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";
const FormItem = Form.Item;
export const syncDataSourceFields = (
- form: FormInstance,
+ form: FormInstance, // Keep form as a prop for this utility function
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.
@@ -77,17 +80,9 @@ export const syncDataSourceFields = (
}
};
-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);
@@ -97,14 +92,13 @@ export default function DatasetSettingsDataTab({
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 +186,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
+
+
+
+ }
/>
-
- ))}
-
+
+
+ ))}
);
}
@@ -361,15 +347,15 @@ function SimpleLayerForm({
}) {
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 dataLayers = Form.useWatch(["dataSource", "dataLayers"], form);
+ const category = Form.useWatch(["dataSource", "dataLayers", index, "category"], form);
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 +455,11 @@ function SimpleLayerForm({
{layer.elementClass}
@@ -683,7 +669,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 a18ab33ad2a..76781012fca 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx
@@ -1,29 +1,27 @@
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 { useHistory } 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 history = useHistory();
- const dataset = useFetch(() => getDataset(datasetId), null, [datasetId]);
-
- async function handleDeleteButtonClicked(): Promise {
+ const handleDeleteButtonClicked = useCallback(async () => {
if (!dataset) {
return;
}
+ // TODO why is this an async confirm? Sync should work too and get proper antd styling
const deleteDataset = await confirmAsync({
title: `Deleting a dataset on disk cannot be undone. Are you certain to delete dataset ${dataset.name}? Note that the name of a dataset is not guaranteed to be free to use afterwards.`,
okText: "Yes, Delete Dataset on Disk now",
@@ -52,16 +50,31 @@ const DatasetSettingsDeleteTab = ({ datasetId }: Props) => {
queryClient.invalidateQueries({ queryKey: ["dataset", "search"] });
history.push("/dashboard");
- }
+ }, [dataset, history, 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..0746e9526ea 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_metadata_tab.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_metadata_tab.tsx
@@ -1,36 +1,51 @@
-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: "Display Name",
+ tooltip: "Add a descriptive name for your dataset instead of the technical name.",
+ content: (
+
+
+
+ ),
+ },
+
+ {
+ 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..452c1b8f898
--- /dev/null
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_provider.tsx
@@ -0,0 +1,355 @@
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ getDataset,
+ getDatasetDefaultConfiguration,
+ readDatasetDatasource,
+ sendAnalyticsEvent,
+ updateDatasetDatasource,
+ updateDatasetDefaultConfiguration,
+ updateDatasetPartial,
+ updateDatasetTeams,
+} from "admin/rest_api";
+import { Form } from "antd";
+import dayjs from "dayjs";
+import { handleGenericError } from "libs/error_handling";
+import Toast from "libs/toast";
+import { jsonStringify } from "libs/utils";
+import _ from "lodash";
+import messages from "messages";
+import { useCallback, useEffect, useState } from "react";
+import { useHistory } from "react-router-dom";
+import type { APIDataSource, APIDataset, MutableAPIDataset } from "types/api_types";
+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 { FormData } from "./dataset_settings_context";
+import { syncDataSourceFields } from "./dataset_settings_data_tab";
+import { hasFormError } from "./helper_components";
+import useBeforeUnload from "./useBeforeUnload_hook";
+
+type DatasetSettingsProviderProps = {
+ children: React.ReactNode;
+ datasetId: string;
+ isEditingMode: boolean;
+ onComplete?: () => void;
+ onCancel?: () => void;
+};
+
+export const DatasetSettingsProvider: React.FC = ({
+ children,
+ datasetId,
+ isEditingMode,
+ onComplete,
+ onCancel,
+}) => {
+ const [form] = Form.useForm();
+ const queryClient = useQueryClient();
+ const history = useHistory();
+
+ 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 : () => history.push("/dashboard");
+ onCancel = onCancel ? onCancel : () => history.push("/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),
+ },
+ });
+
+ form.setFieldsValue({
+ // @ts-ignore Missmatch between APIDataSource and MutableAPIDataset
+ dataSource,
+ });
+
+ if (doAllLayersHaveTheSameRotation(dataSource.dataLayers)) {
+ const firstLayerTransformations = dataSource.dataLayers[0].coordinateTransformations;
+ let initialDatasetRotationSettings: DatasetRotationAndMirroringSettings;
+ if (
+ !firstLayerTransformations ||
+ firstLayerTransformations.length !== EXPECTED_TRANSFORMATION_LENGTH
+ ) {
+ 3;
+ 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",
+ ),
+ };
+ }
+ 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);
+ 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 => {
+ 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 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 handleValidationFailed = useCallback(() => {
+ const isOnlyDatasourceIncorrectAndNotEditedResult = isOnlyDatasourceIncorrectAndNotEdited();
+
+ if (!isOnlyDatasourceIncorrectAndNotEditedResult || !dataset) {
+ // TODO: Add logic to switch to problematic tab
+ console.warn("Validation failed, switching to problematic tab logic needed.");
+ Toast.warning(messages["dataset.import.invalid_fields"]);
+ } else {
+ submitForm();
+ }
+ }, [isOnlyDatasourceIncorrectAndNotEdited, dataset, submitForm]);
+
+ 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: "simple" | "advanced") => {
+ syncDataSourceFields(form, activeEditMode);
+ form.validateFields();
+ setActiveDataSourceEditMode(activeEditMode);
+ },
+ [form],
+ );
+
+ useBeforeUnload(hasUnsavedChanges, messages["dataset.leave_with_unsaved_changes"]);
+
+ useEffect(() => {
+ fetchData().then((datasetName) => {
+ sendAnalyticsEvent("open_dataset_settings", {
+ datasetName: datasetName ?? "Not found dataset",
+ });
+ });
+ }, [fetchData]);
+
+ const contextValue: DatasetSettingsContextValue = {
+ form,
+ isLoading,
+ dataset,
+ datasetId,
+ datasetDefaultConfiguration,
+ activeDataSourceEditMode,
+ isEditingMode,
+ handleSubmit,
+ handleCancel,
+ handleDataSourceEditModeChange,
+ onValuesChange,
+ getFormValidationSummary,
+ };
+
+ 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..21bfa6ea526 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_sharing_tab.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_sharing_tab.tsx
@@ -1,161 +1,150 @@
-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 { useWatch } from "antd/es/form/Form";
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 = 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 9e72fb5d419..cdad574641a 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx
@@ -1,391 +1,81 @@
-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 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 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 {
+ Redirect,
+ Route,
+ type RouteComponentProps,
+ Switch,
+ useHistory,
+ useLocation,
+} 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,
+ } = useDatasetSettingsContext();
const isUserAdmin = useWkSelector((state) => state.activeUser?.isAdmin || false);
+ const location = useLocation();
+ const history = useHistory();
+ const selectedKey = location.pathname.split("/").filter(Boolean).pop() || "data";
- 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 switchToProblematicTab = useCallback(() => {
+ // const validationSummary = getFormValidationSummary();
- const handleDataSourceEditModeChange = useCallback(
- (activeEditMode: "simple" | "advanced") => {
- syncDataSourceFields(form, activeEditMode);
- form.validateFields();
- setActiveDataSourceEditMode(activeEditMode);
- },
- [form],
- );
+ // if (validationSummary[activeTabKey]) {
+ // // Active tab is already problematic
+ // return;
+ // }
- // Setup beforeunload handling
- useBeforeUnload(hasUnsavedChanges, messages["dataset.leave_with_unsaved_changes"]);
+ // // Switch to the earliest, problematic tab
+ // const problematicTab = ["data", "general", "defaultConfig"].find(
+ // (key) => validationSummary[key],
+ // );
- // 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]);
+ // if (problematicTab) {
+ // setActiveTabKey(problematicTab as TabKey);
+ // }
+ // }, [getFormValidationSummary, activeTabKey]);
const getMessageComponents = useCallback(() => {
if (dataset == null) {
@@ -447,22 +137,9 @@ 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();
@@ -477,108 +154,122 @@ const DatasetSettingsView: React.FC = ({
);
- const tabs = [
+ 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.sharing ? errorIcon : ,
+ label: "Sharing & Permissions",
+ },
+ {
+ key: "metadata",
+ icon: formErrors.metadata ? 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: (
-
-
-
- ),
- });
-
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..7fdf1f4a04e 100644
--- a/frontend/javascripts/dashboard/dataset/dataset_settings_viewconfig_tab.tsx
+++ b/frontend/javascripts/dashboard/dataset/dataset_settings_viewconfig_tab.tsx
@@ -1,27 +1,12 @@
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 { getDefaultLayerViewConfiguration } from "types/schemas/dataset_view_configuration.schema";
@@ -29,19 +14,26 @@ import { syncValidator, validateLayerViewConfigurationObjectJSON } from "types/v
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 [availableMappingsPerLayerCache, setAvailableMappingsPerLayer] = useState<
Record
>({});
+ const { dataset } = useDatasetSettingsContext();
+ const dataStoreURL = dataset?.dataStore.url;
+ const dataSourceId: APIDataSourceId | null = dataset
+ ? {
+ owningOrganization: dataset.owningOrganization,
+ directoryName: dataset.directoryName,
+ }
+ : null;
+
+ // 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 +84,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,175 +150,191 @@ 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 - Arbitrary Modes only",
+ tooltip: "The default rotation that will be used in oblique and flight view mode.",
+ 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([
+ // Use dataset.dataSource.id for dataSourceId
+ 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..a41eecca0cd 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";
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 315afd2e89d..66a09282334 100644
--- a/frontend/javascripts/dashboard/dataset/useBeforeUnload_hook.ts
+++ b/frontend/javascripts/dashboard/dataset/useBeforeUnload_hook.ts
@@ -31,7 +31,7 @@ const useBeforeUnload = (hasUnsavedChanges: boolean, message: string) => {
// Only show the prompt if this is a proper beforeUnload event from the browser
// or the pathname changed
// This check has to be done because history.block triggers this function even if only the url hash changed
- if (action === undefined || newLocation.pathname !== window.location.pathname) {
+ if (action === undefined || !newLocation.pathname.includes("/datasets")) {
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(() => {
diff --git a/frontend/javascripts/messages.tsx b/frontend/javascripts/messages.tsx
index 5b709334965..25a3a017fb0 100644
--- a/frontend/javascripts/messages.tsx
+++ b/frontend/javascripts/messages.tsx
@@ -39,7 +39,7 @@ export const settings: Partial> =
sphericalCapRadius: "Sphere Radius",
crosshairSize: "Crosshair Size",
brushSize: "Brush Size",
- segmentationPatternOpacity: "Pattern Opacity",
+ segmentationPatternOpacity: "Segmentation Pattern Opacity",
loadingStrategy: "Loading Strategy",
gpuMemoryFactor: "Hardware Utilization",
overwriteMode: "Volume Annotation Overwrite Mode",
@@ -50,6 +50,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,
@@ -82,6 +84,7 @@ export const settingsTooltips: Partial> = {
color: "Color",
alpha: "Layer opacity",
diff --git a/frontend/javascripts/router.tsx b/frontend/javascripts/router.tsx
index 84dcf22e2a3..3cb69beaefa 100644
--- a/frontend/javascripts/router.tsx
+++ b/frontend/javascripts/router.tsx
@@ -69,6 +69,7 @@ import AiModelListView from "admin/voxelytics/ai_model_list_view";
import { CheckCertificateModal } from "components/check_certificate_modal";
import ErrorBoundary from "components/error_boundary";
import { CheckTermsOfServices } from "components/terms_of_services_check";
+import { DatasetSettingsProvider } from "dashboard/dataset/dataset_settings_provider";
import loadable from "libs/lazy_loader";
import type { EmptyObject } from "types/globals";
import { getDatasetIdOrNameFromReadableURLPart } from "viewer/model/accessors/dataset_accessor";
@@ -545,12 +546,23 @@ class ReactRouter extends React.Component {
);
}
return (
- window.history.back()}
- onCancel={() => window.history.back()}
- />
+
+
+
+ );
+ }}
+ />
+ {
+ const { datasetId } = getDatasetIdOrNameFromReadableURLPart(
+ match.params.datasetNameAndId,
+ );
+ return (
+
+
+
);
}}
/>