diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx index c7b45ba87db..a18ab33ad2a 100644 --- a/frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx +++ b/frontend/javascripts/dashboard/dataset/dataset_settings_delete_tab.tsx @@ -1,33 +1,23 @@ import { useQueryClient } from "@tanstack/react-query"; import { deleteDatasetOnDisk, getDataset } from "admin/rest_api"; import { Button } from "antd"; +import { useFetch } from "libs/react_helpers"; import Toast from "libs/toast"; import messages from "messages"; -import { useEffect, useState } from "react"; -import type { RouteComponentProps } from "react-router-dom"; -import { withRouter } from "react-router-dom"; -import type { APIDataset } from "types/api_types"; +import { useState } from "react"; +import { useHistory } from "react-router-dom"; import { confirmAsync } from "./helper_components"; type Props = { datasetId: string; - history: RouteComponentProps["history"]; }; -const DatasetSettingsDeleteTab = ({ datasetId, history }: Props) => { +const DatasetSettingsDeleteTab = ({ datasetId }: Props) => { const [isDeleting, setIsDeleting] = useState(false); - const [dataset, setDataset] = useState(null); const queryClient = useQueryClient(); + const history = useHistory(); - async function fetch() { - const newDataset = await getDataset(datasetId); - setDataset(newDataset); - } - - // biome-ignore lint/correctness/useExhaustiveDependencies(fetch): - useEffect(() => { - fetch(); - }, []); + const dataset = useFetch(() => getDataset(datasetId), null, [datasetId]); async function handleDeleteButtonClicked(): Promise { if (!dataset) { @@ -76,4 +66,4 @@ const DatasetSettingsDeleteTab = ({ datasetId, history }: Props) => { ); }; -export default withRouter(DatasetSettingsDeleteTab); +export default DatasetSettingsDeleteTab; diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_sharing_tab.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_sharing_tab.tsx index 871e99ac7f3..7a72e941cef 100644 --- a/frontend/javascripts/dashboard/dataset/dataset_settings_sharing_tab.tsx +++ b/frontend/javascripts/dashboard/dataset/dataset_settings_sharing_tab.tsx @@ -6,27 +6,25 @@ 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 { connect } from "react-redux"; -import { type RouteComponentProps, withRouter } from "react-router-dom"; -import type { APIDataset, APIUser } from "types/api_types"; +import type { APIDataset } from "types/api_types"; import { getReadableURLPart } from "viewer/model/accessors/dataset_accessor"; -import type { WebknossosState } from "viewer/store"; import { FormItemWithInfo } from "./helper_components"; type Props = { form: FormInstance | null; datasetId: string; dataset: APIDataset | null | undefined; - activeUser: APIUser | null | undefined; }; -function DatasetSettingsSharingTab({ form, datasetId, dataset, activeUser }: Props) { +export default function DatasetSettingsSharingTab({ form, datasetId, dataset }: Props) { const [sharingToken, setSharingToken] = useState(""); + const activeUser = useWkSelector((state) => state.activeUser); const isDatasetManagerOrAdmin = isUserAdminOrDatasetManager(activeUser); const allowedTeamsComponent = ( @@ -161,10 +159,3 @@ function DatasetSettingsSharingTab({ form, datasetId, dataset, activeUser }: Pro ) : null; } - -const mapStateToProps = (state: WebknossosState) => ({ - activeUser: state.activeUser, -}); - -const connector = connect(mapStateToProps); -export default connector(withRouter(DatasetSettingsSharingTab)); diff --git a/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx b/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx index 9099c1ae5f9..9e72fb5d419 100644 --- a/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx +++ b/frontend/javascripts/dashboard/dataset/dataset_settings_view.tsx @@ -1,5 +1,5 @@ import { ExclamationCircleOutlined } from "@ant-design/icons"; -import { defaultContext } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { getDataset, getDatasetDefaultConfiguration, @@ -10,24 +10,18 @@ import { updateDatasetPartial, updateDatasetTeams, } from "admin/rest_api"; -import { Alert, Button, Card, Form, type FormInstance, Spin, Tabs, Tooltip } from "antd"; +import { Alert, Button, Card, Form, Spin, Tabs, Tooltip } from "antd"; import dayjs from "dayjs"; import features from "features"; -import type { - Action as HistoryAction, - Location as HistoryLocation, - UnregisterCallback, -} from "history"; 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 * as React from "react"; -import { connect } from "react-redux"; -import type { RouteComponentProps } from "react-router-dom"; -import { Link, withRouter } from "react-router-dom"; -import type { APIDataSource, APIDataset, APIMessage, MutableAPIDataset } from "types/api_types"; +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 { Unicode } from "viewer/constants"; import { getReadableURLPart } from "viewer/model/accessors/dataset_accessor"; @@ -36,7 +30,7 @@ import { doAllLayersHaveTheSameRotation, getRotationSettingsFromTransformationIn90DegreeSteps, } from "viewer/model/accessors/dataset_layer_transformation_accessor"; -import type { DatasetConfiguration, WebknossosState } from "viewer/store"; +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"; @@ -44,33 +38,20 @@ 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 FormItem = Form.Item; const notImportedYetStatus = "Not imported yet."; -type OwnProps = { + +type DatasetSettingsViewProps = { datasetId: string; isEditingMode: boolean; onComplete: () => void; onCancel: () => void; }; -type StateProps = { - isUserAdmin: boolean; -}; -type Props = OwnProps & StateProps; -type PropsWithFormAndRouter = Props & { - history: RouteComponentProps["history"]; -}; + type TabKey = "data" | "general" | "defaultConfig" | "sharing" | "deleteDataset"; -type State = { - hasUnsavedChanges: boolean; - dataset: APIDataset | null | undefined; - datasetDefaultConfiguration: DatasetConfiguration | null | undefined; - messages: Array; - isLoading: boolean; - activeDataSourceEditMode: "simple" | "advanced"; - activeTabKey: TabKey; - savedDataSourceOnServer: APIDataSource | null | undefined; -}; + export type FormData = { dataSource: APIDataSource; dataSourceJson: string; @@ -80,122 +61,70 @@ export type FormData = { datasetRotation?: DatasetRotationAndMirroringSettings; }; -class DatasetSettingsView extends React.PureComponent { - formRef = React.createRef(); - unblock: UnregisterCallback | null | undefined; - blockTimeoutId: number | null | undefined; - static contextType = defaultContext; - declare context: React.ContextType; - - state: State = { - hasUnsavedChanges: false, - dataset: null, - datasetDefaultConfiguration: null, - isLoading: true, - messages: [], - activeDataSourceEditMode: "simple", - activeTabKey: "data", - savedDataSourceOnServer: null, - }; - - async componentDidMount() { - await this.fetchData(); - sendAnalyticsEvent("open_dataset_settings", { - datasetName: this.state.dataset ? this.state.dataset.name : "Not found dataset", - }); - - const beforeUnload = ( - newLocation: HistoryLocation, - action: HistoryAction, - ): string | false | void => { - // 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) { - const { hasUnsavedChanges } = this.state; - - if (hasUnsavedChanges) { - window.onbeforeunload = null; // clear the event handler otherwise it would be called twice. Once from history.block once from the beforeunload event - - this.blockTimeoutId = window.setTimeout(() => { - // restore the event handler in case a user chose to stay on the page - // @ts-ignore - window.onbeforeunload = beforeUnload; - }, 500); - return messages["dataset.leave_with_unsaved_changes"]; - } - } - return; - }; - - this.unblock = this.props.history.block(beforeUnload); - // @ts-ignore - window.onbeforeunload = beforeUnload; - } - - componentWillUnmount() { - this.unblockHistory(); - } - - unblockHistory() { - window.onbeforeunload = null; - - if (this.blockTimeoutId != null) { - clearTimeout(this.blockTimeoutId); - this.blockTimeoutId = null; - } - - if (this.unblock != null) { - this.unblock(); - } - } - - async fetchData(): Promise { +const DatasetSettingsView: React.FC = ({ + datasetId, + isEditingMode, + onComplete, + onCancel, +}) => { + const [form] = Form.useForm(); + const queryClient = useQueryClient(); + 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 { - this.setState({ - isLoading: true, - }); - let dataset = await getDataset(this.props.datasetId); - const dataSource = await readDatasetDatasource(dataset); + 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 - this.setState({ savedDataSourceOnServer: dataSource }); + setSavedDataSourceOnServer(dataSource); if (dataSource == null) { throw new Error("No datasource received from server."); } - if (dataset.dataSource.status?.includes("Error")) { + 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(dataset) as any as MutableAPIDataset; + const datasetClone = _.cloneDeep(fetchedDataset) as any as MutableAPIDataset; // We are keeping the error message to display it to the user. - datasetClone.dataSource.status = dataset.dataSource.status; - dataset = datasetClone as APIDataset; - } - - const form = this.formRef.current; - - if (!form) { - throw new Error("Form couldn't be initialized."); + datasetClone.dataSource.status = fetchedDataset.dataSource.status; + fetchedDataset = datasetClone as APIDataset; } form.setFieldsValue({ dataSourceJson: jsonStringify(dataSource), dataset: { - name: dataset.name, - isPublic: dataset.isPublic || false, - description: dataset.description || undefined, - allowedTeams: dataset.allowedTeams || [], - sortingKey: dayjs(dataset.sortingKey), + 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; @@ -228,45 +157,34 @@ class DatasetSettingsView extends React.PureComponent => { - const form = this.formRef.current; - - if (!form) { - return {}; + setIsLoading(false); + form.validateFields(); } + }, [datasetId, form]); + const getFormValidationSummary = useCallback((): Record => { const err = form.getFieldsError(); - const { dataset } = this.state; const formErrors: Record = {}; if (!err || !dataset) { @@ -288,12 +206,12 @@ class DatasetSettingsView extends React.PureComponent { + const validationSummary = getFormValidationSummary(); - if (validationSummary[this.state.activeTabKey]) { + if (validationSummary[activeTabKey]) { // Active tab is already problematic return; } @@ -305,39 +223,38 @@ class DatasetSettingsView extends React.PureComponent(callbackfn: (value: s... Remove this comment to see the full error message - activeTabKey: problematicTab, - }); - } - } - - didDatasourceChange(dataSource: Record) { - return !_.isEqual(dataSource, this.state.savedDataSourceOnServer || {}); - } - - didDatasourceIdChange(dataSource: Record) { - const savedDatasourceId = this.state.savedDataSourceOnServer?.id; - if (!savedDatasourceId) { - return false; + setActiveTabKey(problematicTab as TabKey); } - return ( - savedDatasourceId.name !== dataSource.id.name || savedDatasourceId.team !== dataSource.id.team - ); - } - - isOnlyDatasourceIncorrectAndNotEdited() { - const validationSummary = this.getFormValidationSummary(); - const form = this.formRef.current; + }, [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], + ); - if (!form) { - return false; - } + const isOnlyDatasourceIncorrectAndNotEdited = useCallback(() => { + const validationSummary = getFormValidationSummary(); if (_.size(validationSummary) === 1 && validationSummary.data) { try { const dataSource = JSON.parse(form.getFieldValue("dataSourceJson")); - const didNotEditDatasource = !this.didDatasourceChange(dataSource); + const didNotEditDatasource = !didDatasourceChange(dataSource); return didNotEditDatasource; } catch (_e) { return false; @@ -345,33 +262,84 @@ class DatasetSettingsView extends React.PureComponent { - const { dataset } = this.state; - const isOnlyDatasourceIncorrectAndNotEdited = this.isOnlyDatasourceIncorrectAndNotEdited(); - - // Check whether the validation error was introduced or existed before - if (!isOnlyDatasourceIncorrectAndNotEdited || !dataset) { - this.switchToProblematicTab(); - Toast.warning(messages["dataset.import.invalid_fields"]); - } else { - // If the validation error existed before, still attempt to update dataset - this.submit(values); - } - }; + }, [getFormValidationSummary, form, didDatasourceChange]); - handleSubmit = () => { - // Ensure that all form fields are in sync - const form = this.formRef.current; + const submit = useCallback( + async (formValues: FormData) => { + const datasetChangeValues = { ...formValues.dataset }; - if (!form) { - return; - } - syncDataSourceFields( - form, - this.state.activeDataSourceEditMode === "simple" ? "advanced" : "simple", - ); + 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 @@ -380,8 +348,8 @@ class DatasetSettingsView extends React.PureComponent form .validateFields() - .then((formValues) => this.submit(formValues)) - .catch((errorInfo) => this.handleValidationFailed(errorInfo)), + .then((formValues) => submit(formValues)) + .catch((errorInfo) => handleValidationFailed(errorInfo)), 0, ); }; @@ -389,68 +357,42 @@ class DatasetSettingsView extends React.PureComponent { - const { dataset, datasetDefaultConfiguration } = this.state; - const datasetChangeValues = { ...formValues.dataset }; - - if (datasetChangeValues.sortingKey != null) { - datasetChangeValues.sortingKey = datasetChangeValues.sortingKey.valueOf(); - } - - const teamIds = formValues.dataset.allowedTeams.map((t) => t.id); - await updateDatasetPartial(this.props.datasetId, datasetChangeValues); - - if (datasetDefaultConfiguration != null) { - await updateDatasetDefaultConfiguration( - this.props.datasetId, - _.extend({}, datasetDefaultConfiguration, formValues.defaultConfiguration, { - layers: JSON.parse(formValues.defaultConfigurationLayersJson), - }), - ); - } - - await updateDatasetTeams(this.props.datasetId, teamIds); - const dataSource = JSON.parse(formValues.dataSourceJson); - - if (dataset != null && this.didDatasourceChange(dataSource)) { - if (this.didDatasourceIdChange(dataSource)) { - Toast.warning(messages["dataset.settings.updated_datasource_id_warning"]); - } - await updateDatasetDatasource(dataset.directoryName, dataset.dataStore.url, dataSource); - this.setState({ - savedDataSourceOnServer: dataSource, - }); - } - - const verb = this.props.isEditingMode ? "updated" : "imported"; - Toast.success(`Successfully ${verb} ${dataset?.name || this.props.datasetId}.`); - this.setState({ - hasUnsavedChanges: false, + // 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]); - if (dataset) { - // Update new cache - const queryClient = this.context; - if (queryClient) { - queryClient.invalidateQueries({ - queryKey: ["datasetsByFolder", dataset.folderId], - }); - queryClient.invalidateQueries({ queryKey: ["dataset", "search"] }); - } - } - - this.props.onComplete(); - }; - - getMessageComponents() { - if (this.state.dataset == null) { + const getMessageComponents = useCallback(() => { + if (dataset == null) { return null; } - const { status } = this.state.dataset.dataSource; + const { status } = dataset.dataSource; const messageElements = []; if (status != null) { @@ -481,7 +423,7 @@ class DatasetSettingsView extends React.PureComponent ), ); - } else if (!this.props.isEditingMode) { + } else if (!isEditingMode) { // The user just uploaded the dataset, but the import is already complete due to a // valid dataSource.json file messageElements.push( @@ -494,16 +436,6 @@ class DatasetSettingsView extends React.PureComponent ( - - )); - messageElements.push(...restMessages); return (
); - } + }, [dataset, isEditingMode]); - onValuesChange = (_changedValues: FormData, _allValues: FormData) => { - this.setState({ - hasUnsavedChanges: true, + 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 = [ + { + label: Data {formErrors.data ? errorIcon : ""}, + key: "data", + forceRender: true, + children: ( + + ), + }, + + { + label: Sharing & Permissions {formErrors.general ? errorIcon : null}, + key: "sharing", + forceRender: true, + children: ( + + ), + }, + + { + label: Metadata, + key: "general", + forceRender: true, + children: ( + + ), + }, + + { + label: View Configuration {formErrors.defaultConfig ? errorIcon : ""}, + key: "defaultConfig", + forceRender: true, + children: ( + + ), + }, + ]; + + if (isUserAdmin && features().allowDeleteDatasets) + tabs.push({ + label: Delete Dataset , + key: "deleteDataset", + forceRender: true, + children: ( + + ), }); - }; - - onCancel = () => { - this.unblockHistory(); - this.props.onCancel(); - }; - - render() { - const form = this.formRef.current; - const { dataset } = this.state; - - const maybeStoredDatasetName = dataset?.name || this.props.datasetId; - const maybeDataSourceId = dataset - ? { - owningOrganization: dataset.owningOrganization, - directoryName: dataset.directoryName, - } - : null; - const { isUserAdmin } = this.props; - const titleString = this.props.isEditingMode ? "Settings for" : "Import"; - const datasetLinkOrName = this.props.isEditingMode ? ( - + + {titleString} Dataset {datasetLinkOrName} + + } > - {maybeStoredDatasetName} - - ) : ( - maybeStoredDatasetName - ); - const confirmString = - this.props.isEditingMode || - (this.state.dataset != null && this.state.dataset.dataSource.status == null) - ? "Save" - : "Import"; - const formErrors = this.getFormValidationSummary(); - const errorIcon = ( - - - - ); - - const tabs = [ - { - label: Data {formErrors.data ? errorIcon : ""}, - key: "data", - forceRender: true, - children: ( - - ), - }, - - { - label: Sharing & Permissions {formErrors.general ? errorIcon : null}, - key: "sharing", - forceRender: true, - children: ( - - ), - }, - - { - label: Metadata, - key: "general", - forceRender: true, - children: ( - - ), - }, - - { - label: View Configuration {formErrors.defaultConfig ? errorIcon : ""}, - key: "defaultConfig", - forceRender: true, - children: ( - - ), - }, - ]; - - if (isUserAdmin && features().allowDeleteDatasets) - tabs.push({ - label: Delete Dataset , - key: "deleteDataset", - forceRender: true, - children: ( - - ), - }); - - return ( -
- - {titleString} Dataset {datasetLinkOrName} - - } - > - - {this.getMessageComponents()} - - - - this.setState({ - // @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'TabKey'. - activeTabKey, - }) - } - items={tabs} - /> - - - - {Unicode.NonBreakingSpace} - - - - -
- ); - } -} - -const mapStateToProps = (state: WebknossosState): StateProps => ({ - isUserAdmin: state.activeUser?.isAdmin || false, -}); +
+ + + {Unicode.NonBreakingSpace} + + + + + + ); +}; -const connector = connect(mapStateToProps); -export default connector(withRouter(DatasetSettingsView)); +export default DatasetSettingsView; diff --git a/frontend/javascripts/dashboard/dataset/team_selection_component.tsx b/frontend/javascripts/dashboard/dataset/team_selection_component.tsx index e657a4b3a3a..b2d65e04c1f 100644 --- a/frontend/javascripts/dashboard/dataset/team_selection_component.tsx +++ b/frontend/javascripts/dashboard/dataset/team_selection_component.tsx @@ -1,12 +1,14 @@ import { getEditableTeams, getTeams } from "admin/rest_api"; import { Select } from "antd"; +import { useEffectOnlyOnce } from "libs/react_hooks"; +import Toast from "libs/toast"; import _ from "lodash"; -import * as React from "react"; +import { useCallback, useEffect, useState } from "react"; import type { APITeam } from "types/api_types"; const { Option } = Select; -type Props = { +type TeamSelectionComponentProps = { value?: APITeam | Array; onChange?: (value: APITeam | Array) => void; afterFetchedTeams?: (arg0: Array) => void; @@ -14,102 +16,80 @@ type Props = { allowNonEditableTeams?: boolean; disabled?: boolean; }; -type State = { - possibleTeams: Array; - selectedTeams: Array; - isFetchingData: boolean; -}; -class TeamSelectionComponent extends React.PureComponent { - state: State = { - possibleTeams: [], - selectedTeams: this.props.value ? _.flatten([this.props.value]) : [], - isFetchingData: false, - }; +function TeamSelectionComponent({ + value, + onChange, + afterFetchedTeams, + mode, + allowNonEditableTeams, + disabled, +}: TeamSelectionComponentProps) { + const [possibleTeams, setPossibleTeams] = useState([]); + const [selectedTeams, setSelectedTeams] = useState(value ? _.flatten([value]) : []); + const [isFetchingData, setIsFetchingData] = useState(false); - componentDidMount() { - this.fetchData(); - } + // Sync selectedTeams with value + useEffect(() => { + setSelectedTeams(value ? _.flatten([value]) : []); + }, [value]); - componentDidUpdate(prevProps: Props) { - if (prevProps.value !== this.props.value) { - this.setState({ - selectedTeams: this.props.value ? _.flatten([this.props.value]) : [], - }); - } - } + // Fetch teams on mount + useEffectOnlyOnce(() => { + fetchData(); + }); - async fetchData() { - this.setState({ - isFetchingData: true, - }); + async function fetchData() { + setIsFetchingData(true); try { - const possibleTeams = this.props.allowNonEditableTeams - ? await getTeams() - : await getEditableTeams(); - this.setState({ - possibleTeams, - isFetchingData: false, - }); - - if (this.props.afterFetchedTeams != null) { - this.props.afterFetchedTeams(possibleTeams); + const possibleTeams = allowNonEditableTeams ? await getTeams() : await getEditableTeams(); + setPossibleTeams(possibleTeams); + if (afterFetchedTeams) { + afterFetchedTeams(possibleTeams); } } catch (_exception) { - console.error("Could not load teams."); + Toast.error("Could not load teams."); + } finally { + setIsFetchingData(false); } } - onSelectTeams = (selectedTeamIdsOrId: string | Array) => { - // we can't use this.props.mode because of flow + const getAllTeams = useCallback((): APITeam[] => { + return _.unionBy(possibleTeams, selectedTeams, (t) => t.id); + }, [possibleTeams, selectedTeams]); + + const onSelectTeams = (selectedTeamIdsOrId: string | Array) => { const selectedTeamIds = Array.isArray(selectedTeamIdsOrId) ? selectedTeamIdsOrId : [selectedTeamIdsOrId]; - const allTeams = this.getAllTeams(); - + const allTeams = getAllTeams(); const selectedTeams = _.compact(selectedTeamIds.map((id) => allTeams.find((t) => t.id === id))); - - if (this.props.onChange) { - this.props.onChange(Array.isArray(selectedTeamIdsOrId) ? selectedTeams : selectedTeams[0]); + if (onChange) { + onChange(Array.isArray(selectedTeamIdsOrId) ? selectedTeams : selectedTeams[0]); } - - this.setState({ - selectedTeams, - }); + setSelectedTeams(selectedTeams); }; - getAllTeams = (): Array => - _.unionBy(this.state.possibleTeams, this.state.selectedTeams, (t) => t.id); - - render() { - return ( - - ); - } + return ( + + ); } export default TeamSelectionComponent; diff --git a/frontend/javascripts/dashboard/dataset/useBeforeUnload_hook.ts b/frontend/javascripts/dashboard/dataset/useBeforeUnload_hook.ts new file mode 100644 index 00000000000..315afd2e89d --- /dev/null +++ b/frontend/javascripts/dashboard/dataset/useBeforeUnload_hook.ts @@ -0,0 +1,58 @@ +import type { + Action as HistoryAction, + Location as HistoryLocation, + UnregisterCallback, +} from "history"; +import { useCallback, useEffect, useRef } from "react"; +import { useHistory } from "react-router-dom"; + +const useBeforeUnload = (hasUnsavedChanges: boolean, message: string) => { + const history = useHistory(); + const unblockRef = useRef(null); + const blockTimeoutIdRef = useRef(null); + + const unblockHistory = useCallback(() => { + window.onbeforeunload = null; + if (blockTimeoutIdRef.current != null) { + clearTimeout(blockTimeoutIdRef.current); + blockTimeoutIdRef.current = null; + } + if (unblockRef.current != null) { + unblockRef.current(); + unblockRef.current = null; + } + }, []); + + useEffect(() => { + const beforeUnload = ( + newLocation: HistoryLocation, + action: HistoryAction, + ): string | false | void => { + // 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 (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 + // @ts-ignore + window.onbeforeunload = beforeUnload; + }, 500); + return message; + } + } + return; + }; + + unblockRef.current = history.block(beforeUnload); + // @ts-ignore + window.onbeforeunload = beforeUnload; + + return () => { + unblockHistory(); + }; + }, [history, hasUnsavedChanges, message, unblockHistory]); +}; + +export default useBeforeUnload; diff --git a/frontend/javascripts/messages.tsx b/frontend/javascripts/messages.tsx index 468b2297bef..5b709334965 100644 --- a/frontend/javascripts/messages.tsx +++ b/frontend/javascripts/messages.tsx @@ -333,7 +333,7 @@ instead. Only enable this option if you understand its effect. All layers will n "dataset.leave_during_upload": "WARNING: The upload is still in progress and will be aborted when hitting OK. Please click cancel and wait until the upload is finished before leaving the page.", "dataset.leave_with_unsaved_changes": - "There are unsaved changes for the dataset's configuration. Please click “Save” before leaving the page. To discard the changes click “Cancel”.", + "There are unsaved changes for the dataset's configuration. Please click “Save” before leaving the page. To discard the changes click “Ok”.", "dataset.add_success": "The dataset was added successfully.", "dataset.add_error": "Could not reach the datastore.", "dataset.add_zarr_different_scale_warning": diff --git a/unreleased_changes/8719.md b/unreleased_changes/8719.md new file mode 100644 index 00000000000..fc6af65a2c0 --- /dev/null +++ b/unreleased_changes/8719.md @@ -0,0 +1,2 @@ +### Changed +- Refactored dataset settings and child tabs as React functional components. \ No newline at end of file