diff --git a/frontend/javascripts/admin/account/account_password_view.tsx b/frontend/javascripts/admin/account/account_password_view.tsx index 9d74a6e6546..76692325199 100644 --- a/frontend/javascripts/admin/account/account_password_view.tsx +++ b/frontend/javascripts/admin/account/account_password_view.tsx @@ -4,7 +4,7 @@ import { Alert, Button, Col, Form, Input, Row, Space } from "antd"; import Toast from "libs/toast"; import messages from "messages"; import { useState } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { logoutUserAction } from "viewer/model/actions/user_actions"; import Store from "viewer/store"; import { SettingsCard } from "./helpers/settings_card"; @@ -15,7 +15,7 @@ const { Password } = Input; const MIN_PASSWORD_LENGTH = 8; function AccountPasswordView() { - const history = useHistory(); + const navigate = useNavigate(); const [form] = Form.useForm(); const [isResetPasswordVisible, setResetPasswordVisible] = useState(false); @@ -25,7 +25,7 @@ function AccountPasswordView() { Toast.success(messages["auth.reset_pw_confirmation"]); await logoutUser(); Store.dispatch(logoutUserAction()); - history.push("/auth/login"); + navigate("/auth/login"); }) .catch((error) => { console.error("Password change failed:", error); diff --git a/frontend/javascripts/admin/account/account_settings_view.tsx b/frontend/javascripts/admin/account/account_settings_view.tsx index 75d0730b01e..bfb0dcdaa16 100644 --- a/frontend/javascripts/admin/account/account_settings_view.tsx +++ b/frontend/javascripts/admin/account/account_settings_view.tsx @@ -1,10 +1,7 @@ import { SafetyOutlined, SettingOutlined, UserOutlined } from "@ant-design/icons"; import { Breadcrumb, Layout, Menu } from "antd"; import type { MenuItemGroupType } from "antd/es/menu/interface"; -import { Redirect, Route, Switch, useHistory, useLocation } from "react-router-dom"; -import AccountAuthTokenView from "./account_auth_token_view"; -import AccountPasswordView from "./account_password_view"; -import AccountProfileView from "./account_profile_view"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; const { Sider, Content } = Layout; @@ -46,7 +43,7 @@ const MENU_ITEMS: MenuItemGroupType[] = [ function AccountSettingsView() { const location = useLocation(); - const history = useHistory(); + const navigate = useNavigate(); const selectedKey = location.pathname.split("/").filter(Boolean).pop() || "profile"; const breadcrumbItems = [ @@ -68,17 +65,12 @@ function AccountSettingsView() { selectedKeys={[selectedKey]} style={{ height: "100%", padding: 24 }} items={MENU_ITEMS} - onClick={({ key }) => history.push(`/account/${key}`)} + onClick={({ key }) => navigate(`/account/${key}`)} /> - - - - - } /> - + ); diff --git a/frontend/javascripts/admin/auth/accept_invite_view.tsx b/frontend/javascripts/admin/auth/accept_invite_view.tsx index 958c34a16d7..366d92699c9 100644 --- a/frontend/javascripts/admin/auth/accept_invite_view.tsx +++ b/frontend/javascripts/admin/auth/accept_invite_view.tsx @@ -4,22 +4,19 @@ import { getOrganizationByInvite, joinOrganization, switchToOrganization } from import { Button, Layout, Result, Spin } from "antd"; import { AsyncButton } from "components/async_clickables"; import { useFetch } from "libs/react_helpers"; +import { useWkSelector } from "libs/react_hooks"; import Toast from "libs/toast"; import { location } from "libs/window"; import { useState } from "react"; -import { useHistory } from "react-router-dom"; -import type { APIUser } from "types/api_types"; +import { useNavigate, useParams } from "react-router-dom"; const { Content } = Layout; -export default function AcceptInviteView({ - token, - activeUser, -}: { - token: string; - activeUser: APIUser | null | undefined; -}) { - const history = useHistory(); +export default function AcceptInviteView() { + const activeUser = useWkSelector((state) => state.activeUser); + const { token = "" } = useParams(); + const navigate = useNavigate(); + const [isAuthenticationModalOpen, setIsAuthenticationModalOpen] = useState(false); const [targetOrganization, exception] = useFetch( async () => { @@ -46,7 +43,7 @@ export default function AcceptInviteView({ targetOrganization != null ? targetOrganization.name || targetOrganization.id : "unknown"; const onSuccessfulJoin = (userJustRegistered: boolean = false) => { - history.push("/dashboard"); + navigate("/dashboard"); if (userJustRegistered) { // Since the user just registered, the organization is already active. diff --git a/frontend/javascripts/admin/auth/finish_reset_password_view.tsx b/frontend/javascripts/admin/auth/finish_reset_password_view.tsx index f8c88ec8d2d..2e767c3710c 100644 --- a/frontend/javascripts/admin/auth/finish_reset_password_view.tsx +++ b/frontend/javascripts/admin/auth/finish_reset_password_view.tsx @@ -2,32 +2,34 @@ import { LockOutlined } from "@ant-design/icons"; import { Button, Card, Col, Form, Input, Row } from "antd"; import Request from "libs/request"; import Toast from "libs/toast"; +import { getUrlParamsObjectFromString } from "libs/utils"; import messages from "messages"; -import { useHistory } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; + const FormItem = Form.Item; const { Password } = Input; -type Props = { - resetToken: string; -}; -function FinishResetPasswordView(props: Props) { +function FinishResetPasswordView() { + const location = useLocation(); + const { token } = getUrlParamsObjectFromString(location.search); + const [form] = Form.useForm(); - const history = useHistory(); + const navigate = useNavigate(); function onFinish(formValues: Record) { const data = formValues; - if (props.resetToken === "") { + if (token == null) { Toast.error(messages["auth.reset_token_not_supplied"]); return; } - data.token = props.resetToken; + data.token = token; Request.sendJSONReceiveJSON("/api/auth/resetPassword", { data, }).then(() => { Toast.success(messages["auth.reset_pw_confirmation"]); - history.push("/auth/login"); + navigate("/auth/login"); }); } diff --git a/frontend/javascripts/admin/auth/login_view.tsx b/frontend/javascripts/admin/auth/login_view.tsx index 6f50060d055..ca010b7ab7a 100644 --- a/frontend/javascripts/admin/auth/login_view.tsx +++ b/frontend/javascripts/admin/auth/login_view.tsx @@ -1,7 +1,7 @@ import { Card, Col, Row } from "antd"; import * as Utils from "libs/utils"; import window from "libs/window"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import LoginForm from "./login_form"; type Props = { @@ -9,14 +9,14 @@ type Props = { }; function LoginView({ redirect }: Props) { - const history = useHistory(); + const navigate = useNavigate(); const onLoggedIn = () => { if (!Utils.hasUrlParam("redirectPage")) { if (redirect) { // Use "redirect" prop for internal redirects, e.g. for SecuredRoutes - history.push(redirect); + navigate(redirect); } else { - history.push("/dashboard"); + navigate("/dashboard"); } } else { // Use "redirectPage" URL parameter to cause a full page reload and redirecting to external sites diff --git a/frontend/javascripts/admin/auth/registration_view.tsx b/frontend/javascripts/admin/auth/registration_view.tsx index 591e0ab619d..a48c461ccf9 100644 --- a/frontend/javascripts/admin/auth/registration_view.tsx +++ b/frontend/javascripts/admin/auth/registration_view.tsx @@ -6,11 +6,11 @@ import features from "features"; import Toast from "libs/toast"; import messages from "messages"; import { useEffect, useState } from "react"; -import { Link, useHistory } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import type { APIOrganization } from "types/api_types"; function RegistrationViewGeneric() { - const history = useHistory(); + const navigate = useNavigate(); const [organization, setOrganization] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -55,10 +55,10 @@ function RegistrationViewGeneric() { targetOrganization={organization} onRegistered={(isUserLoggedIn?: boolean) => { if (isUserLoggedIn) { - history.goBack(); + navigate(-1); } else { Toast.success(messages["auth.account_created"]); - history.push("/auth/login"); + navigate("/auth/login"); } }} /> @@ -95,7 +95,7 @@ function RegistrationViewGeneric() { } function RegistrationViewWkOrg() { - const history = useHistory(); + const navigate = useNavigate(); return ( @@ -103,7 +103,7 @@ function RegistrationViewWkOrg() {

Sign Up

{ - history.push("/dashboard"); + navigate("/dashboard"); }} />

) => { Request.sendJSONReceiveJSON("/api/auth/startResetPassword", { data: formValues, }).then(() => { Toast.success(messages["auth.reset_email_notification"]); - history.push("/"); + navigate("/"); }); }; diff --git a/frontend/javascripts/admin/auth/verify_email_view.tsx b/frontend/javascripts/admin/auth/verify_email_view.tsx index 0979872f791..d95e5e3dc0a 100644 --- a/frontend/javascripts/admin/auth/verify_email_view.tsx +++ b/frontend/javascripts/admin/auth/verify_email_view.tsx @@ -4,7 +4,7 @@ import { useFetch } from "libs/react_helpers"; import type { ServerErrorMessage } from "libs/request"; import Toast from "libs/toast"; import { useEffect } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { Store } from "viewer/singletons"; export const VERIFICATION_ERROR_TOAST_KEY = "verificationError"; @@ -44,8 +44,9 @@ export function showVerificationReminderToast() { ); } -export default function VerifyEmailView({ token }: { token: string }) { - const history = useHistory(); +export default function VerifyEmailView() { + const { token = "" } = useParams(); + const navigate = useNavigate(); const [result, exception] = useFetch( async () => { try { @@ -62,7 +63,7 @@ export default function VerifyEmailView({ token }: { token: string }) { Toast.close(VERIFICATION_ERROR_TOAST_KEY); }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: history.push is not needed as a dependency. + // biome-ignore lint/correctness/useExhaustiveDependencies: navigate is not needed as a dependency. useEffect(() => { if (result) { Toast.success("Successfully verified your email."); @@ -80,7 +81,7 @@ export default function VerifyEmailView({ token }: { token: string }) { } if (result || exception) { - history.push("/"); + navigate("/"); } }, [result, exception]); return ( diff --git a/frontend/javascripts/admin/dataset/dataset_add_remote_view.tsx b/frontend/javascripts/admin/dataset/dataset_add_remote_view.tsx index 6a53102aaae..44ef85777ad 100644 --- a/frontend/javascripts/admin/dataset/dataset_add_remote_view.tsx +++ b/frontend/javascripts/admin/dataset/dataset_add_remote_view.tsx @@ -25,6 +25,7 @@ import DatasetSettingsDataTab, { import { FormItemWithInfo, Hideable } from "dashboard/dataset/helper_components"; import FolderSelection from "dashboard/folders/folder_selection"; import { formatScale } from "libs/format_utils"; +import { useWkSelector } from "libs/react_hooks"; import { readFileAsText } from "libs/read_file"; import Toast from "libs/toast"; import { jsonStringify } from "libs/utils"; @@ -32,13 +33,11 @@ import * as Utils from "libs/utils"; import _ from "lodash"; import messages from "messages"; import React, { useEffect, useState } from "react"; -import { connect } from "react-redux"; -import { useHistory } from "react-router-dom"; -import type { APIDataStore, APIUser } from "types/api_types"; +import { useNavigate } from "react-router-dom"; +import type { APIDataStore } from "types/api_types"; import type { ArbitraryObject } from "types/globals"; import type { DataLayer, DatasourceConfiguration } from "types/schemas/datasource.types"; import { Unicode } from "viewer/constants"; -import type { WebknossosState } from "viewer/store"; import { Hint } from "viewer/view/action-bar/download_modal_view"; import { dataPrivacyInfo } from "./dataset_upload_view"; @@ -48,7 +47,7 @@ const { Password } = Input; type FileList = UploadFile[]; -type OwnProps = { +type Props = { onAdded: ( uploadedDatasetId: string, updatedDatasetName: string, @@ -61,11 +60,6 @@ type OwnProps = { // the exploration and import. defaultDatasetUrl?: string | null | undefined; }; -type StateProps = { - activeUser: APIUser | null | undefined; -}; -type Props = OwnProps & StateProps; - function ensureLargestSegmentIdsInPlace(datasource: DatasourceConfiguration) { for (const layer of datasource.dataLayers) { if (layer.category === "color" || layer.largestSegmentId != null) { @@ -180,7 +174,10 @@ export function GoogleAuthFormItem({ } function DatasetAddRemoteView(props: Props) { - const { activeUser, onAdded, datastores, defaultDatasetUrl } = props; + const { activeUser } = useWkSelector((state) => ({ + activeUser: state.activeUser, + })); + const { onAdded, datastores, defaultDatasetUrl } = props; const uploadableDatastores = datastores.filter((datastore) => datastore.allowsUpload); const hasOnlyOneDatastoreOrNone = uploadableDatastores.length <= 1; @@ -192,7 +189,7 @@ function DatasetAddRemoteView(props: Props) { const [targetFolderId, setTargetFolderId] = useState(null); const isDatasourceConfigStrFalsy = Form.useWatch("dataSourceJson", form) == null; const maybeDataLayers = Form.useWatch(["dataSource", "dataLayers"], form); - const history = useHistory(); + const navigate = useNavigate(); useEffect(() => { const params = new URLSearchParams(location.search); @@ -213,7 +210,7 @@ function DatasetAddRemoteView(props: Props) { .getFieldError("datasetName") .filter((error) => error === messages["dataset.name.already_taken"]); if (maybeDSNameError == null) return; - history.push( + navigate( `/datasets/${activeUser?.organization}/${form.getFieldValue(["dataSource", "id", "name"])}`, ); }; @@ -729,9 +726,4 @@ function AddRemoteLayer({ ); } -const mapStateToProps = (state: WebknossosState): StateProps => ({ - activeUser: state.activeUser, -}); - -const connector = connect(mapStateToProps); -export default connector(DatasetAddRemoteView); +export default DatasetAddRemoteView; diff --git a/frontend/javascripts/admin/dataset/dataset_add_view.tsx b/frontend/javascripts/admin/dataset/dataset_add_view.tsx index 6e7c13d2f70..573927d22ba 100644 --- a/frontend/javascripts/admin/dataset/dataset_add_view.tsx +++ b/frontend/javascripts/admin/dataset/dataset_add_view.tsx @@ -7,12 +7,9 @@ import features from "features"; import { useFetch } from "libs/react_helpers"; import { useWkSelector } from "libs/react_hooks"; import React, { useState } from "react"; -import { connect } from "react-redux"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import type { APIDataStore } from "types/api_types"; import { getReadableURLPart } from "viewer/model/accessors/dataset_accessor"; -import { enforceActiveUser } from "viewer/model/accessors/user_accessor"; -import type { WebknossosState } from "viewer/store"; import DatasetAddComposeView from "./dataset_add_compose_view"; const { Content, Sider } = Layout; @@ -32,7 +29,7 @@ const addTypeToVerb: Record = { }; function DatasetAddView() { - const history = useHistory(); + const navigate = useNavigate(); const datastores = useFetch(getDatastores, [], []); const [datasetId, setDatasetId] = useState(""); const [uploadedDatasetName, setUploadedDatasetName] = useState(""); @@ -64,7 +61,7 @@ function DatasetAddView() { datasetId, uploadedDatasetName, setDatasetId, - history, + navigate, ); }; @@ -261,12 +258,7 @@ function VoxelyticsBanner() { ); } -const mapStateToProps = (state: WebknossosState) => ({ - activeUser: enforceActiveUser(state.activeUser), -}); - -const connector = connect(mapStateToProps); -export default connector(DatasetAddView); +export default DatasetAddView; const getPostUploadModal = ( datasetNeedsConversion: boolean, @@ -274,7 +266,7 @@ const getPostUploadModal = ( datasetId: string, uploadedDatasetName: string, setDatasetId: (arg0: string) => void, - history: ReturnType, + navigate: ReturnType, ) => { return ( {datasetNeedsConversion ? ( - - + ) : ( - + )} diff --git a/frontend/javascripts/admin/dataset/dataset_upload_view.tsx b/frontend/javascripts/admin/dataset/dataset_upload_view.tsx index f8db7a30d70..d2658817e25 100644 --- a/frontend/javascripts/admin/dataset/dataset_upload_view.tsx +++ b/frontend/javascripts/admin/dataset/dataset_upload_view.tsx @@ -23,7 +23,6 @@ import { Tooltip, } from "antd"; import dayjs from "dayjs"; -import type { Action as HistoryAction, Location as HistoryLocation } from "history"; import React from "react"; import { connect } from "react-redux"; @@ -56,12 +55,13 @@ import ErrorHandling from "libs/error_handling"; import Toast from "libs/toast"; import * as Utils from "libs/utils"; import { Vector3Input } from "libs/vector_input"; +import { type WithBlockerProps, withBlocker } from "libs/with_blocker_hoc"; +import { type RouteComponentProps, withRouter } from "libs/with_router_hoc"; import Zip from "libs/zipjs_wrapper"; import _ from "lodash"; import messages from "messages"; import { type FileWithPath, useDropzone } from "react-dropzone"; -import { Link, type RouteComponentProps } from "react-router-dom"; -import { withRouter } from "react-router-dom"; +import { type BlockerFunction, Link } from "react-router-dom"; import { type APIDataStore, APIJobType, @@ -95,10 +95,7 @@ type StateProps = { activeUser: APIUser | null | undefined; organization: APIOrganization; }; -type Props = OwnProps & StateProps; -type PropsWithFormAndRouter = Props & { - history: RouteComponentProps["history"]; -}; +type PropsWithFormAndRouter = OwnProps & StateProps & RouteComponentProps & WithBlockerProps; type State = { isUploading: boolean; isFinishing: boolean; @@ -218,7 +215,6 @@ class DatasetUploadView extends React.Component { unfinishedUploadToContinue: null, }; - unblock: ((...args: Array) => any) | null | undefined; blockTimeoutId: number | null = null; formRef: React.RefObject> = React.createRef(); @@ -268,15 +264,12 @@ class DatasetUploadView extends React.Component { unblockHistory() { window.onbeforeunload = null; + this.props.blocker.reset ? this.props.blocker.reset() : void 0; if (this.blockTimeoutId != null) { clearTimeout(this.blockTimeoutId); this.blockTimeoutId = null; } - - if (this.unblock != null) { - this.unblock(); - } } getDatastoreForUrl(url: string): APIDataStore | null | undefined { @@ -298,35 +291,33 @@ class DatasetUploadView extends React.Component { uploadProgress: 0, }); - 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 { isUploading } = this.state; - - if (isUploading) { - 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_during_upload"]; - } + const beforeUnload = (args: BeforeUnloadEvent | BlockerFunction): boolean => { + // Navigation blocking can be triggered by two sources: + // 1. The browser's native beforeunload event + // 2. The React-Router block function (useBlocker or withBlocker HOC) + + if (this.state.isUploading) { + 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 + window.onbeforeunload = beforeUnload; + }, 500); + // The native event requires a truthy return value to show a generic message + // The React Router blocker accepts a boolean + return "preventDefault" in args ? true : !confirm(messages["save.leave_page_unfinished"]); } - return; + return false; }; + const { unfinishedUploadToContinue } = this.state; - this.unblock = this.props.history.block(beforeUnload); - // @ts-ignore window.onbeforeunload = beforeUnload; + this.props.setBlocking({ + // @ts-ignore beforeUnload signature is overloaded + shouldBlock: beforeUnload, + }); const getRandomString = () => { const randomBytes = window.crypto.getRandomValues(new Uint8Array(6)); @@ -1376,4 +1367,4 @@ const mapStateToProps = (state: WebknossosState): StateProps => ({ }); const connector = connect(mapStateToProps); -export default connector(withRouter(DatasetUploadView)); +export default connector(withBlocker(withRouter(DatasetUploadView))); diff --git a/frontend/javascripts/admin/dataset/dataset_url_import.tsx b/frontend/javascripts/admin/dataset/dataset_url_import.tsx index 49f318f605a..244d01cd63c 100644 --- a/frontend/javascripts/admin/dataset/dataset_url_import.tsx +++ b/frontend/javascripts/admin/dataset/dataset_url_import.tsx @@ -3,15 +3,15 @@ import { getDatastores } from "admin/rest_api"; import { useFetch } from "libs/react_helpers"; import * as Utils from "libs/utils"; import _ from "lodash"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; export function DatasetURLImport() { - const history = useHistory(); + const navigate = useNavigate(); const datastores = useFetch(async () => await getDatastores(), null, []); const params = Utils.getUrlParamsObject(); const datasetUri = _.has(params, "url") ? params.url : null; const handleDatasetAdded = async (addedDatasetId: string): Promise => { - history.push(`/datasets/${addedDatasetId}/view`); + navigate(`/datasets/${addedDatasetId}/view`); }; return datastores != null ? ( diff --git a/frontend/javascripts/admin/onboarding.tsx b/frontend/javascripts/admin/onboarding.tsx index 91786313fa4..86779c93a47 100644 --- a/frontend/javascripts/admin/onboarding.tsx +++ b/frontend/javascripts/admin/onboarding.tsx @@ -22,9 +22,10 @@ import LinkButton from "components/link_button"; import DatasetSettingsView from "dashboard/dataset/dataset_settings_view"; import features from "features"; import Toast from "libs/toast"; +import { type RouteComponentProps, withRouter } from "libs/with_router_hoc"; import React, { useState } from "react"; import { connect } from "react-redux"; -import { Link, type RouteComponentProps, withRouter } from "react-router-dom"; +import { Link } from "react-router-dom"; import type { APIDataStore, APIUser } from "types/api_types"; import type { WebknossosState } from "viewer/store"; import Store from "viewer/store"; @@ -408,7 +409,7 @@ class OnboardingView extends React.PureComponent { componentDidMount() { if (this.props.activeUser != null) { - this.props.history.push("/dashboard"); + this.props.navigate("/dashboard"); } } @@ -733,4 +734,4 @@ const mapStateToProps = (state: WebknossosState): StateProps => ({ }); const connector = connect(mapStateToProps); -export default connector(withRouter(OnboardingView)); +export default connector(withRouter(OnboardingView)); diff --git a/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx b/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx index 3f3646e3f2f..530dba36466 100644 --- a/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx +++ b/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx @@ -3,10 +3,14 @@ import { SettingsTitle } from "admin/account/helpers/settings_title"; import { deleteOrganization } from "admin/rest_api"; import { Button, Typography } from "antd"; import { confirmAsync } from "dashboard/dataset/helper_components"; +import { useWkSelector } from "libs/react_hooks"; import { useState } from "react"; -import type { APIOrganization } from "types/api_types"; +import { enforceActiveOrganization } from "viewer/model/accessors/organization_accessors"; -export function OrganizationDangerZoneView({ organization }: { organization: APIOrganization }) { +export function OrganizationDangerZoneView() { + const organization = useWkSelector((state) => + enforceActiveOrganization(state.activeOrganization), + ); const [isDeleting, setIsDeleting] = useState(false); async function handleDeleteButtonClicked(): Promise { diff --git a/frontend/javascripts/admin/organization/organization_notifications_view.tsx b/frontend/javascripts/admin/organization/organization_notifications_view.tsx index f903c7e29f2..50ef078a47f 100644 --- a/frontend/javascripts/admin/organization/organization_notifications_view.tsx +++ b/frontend/javascripts/admin/organization/organization_notifications_view.tsx @@ -4,9 +4,10 @@ import { SettingsTitle } from "admin/account/helpers/settings_title"; import { updateOrganization } from "admin/rest_api"; import { getUsers } from "admin/rest_api"; import { Button, Col, Form, Input, Row } from "antd"; +import { useWkSelector } from "libs/react_hooks"; import Toast from "libs/toast"; import { useEffect, useState } from "react"; -import type { APIOrganization } from "types/api_types"; +import { enforceActiveOrganization } from "viewer/model/accessors/organization_accessors"; import { setActiveOrganizationAction } from "viewer/model/actions/organization_actions"; import { Store } from "viewer/singletons"; @@ -17,7 +18,10 @@ type FormValues = { newUserMailingList: string; }; -export function OrganizationNotificationsView({ organization }: { organization: APIOrganization }) { +export function OrganizationNotificationsView() { + const organization = useWkSelector((state) => + enforceActiveOrganization(state.activeOrganization), + ); const [form] = Form.useForm(); const [ownerEmail, setOwnerEmail] = useState(""); diff --git a/frontend/javascripts/admin/organization/organization_overview_view.tsx b/frontend/javascripts/admin/organization/organization_overview_view.tsx index 8f3013555c4..94b9d92bee5 100644 --- a/frontend/javascripts/admin/organization/organization_overview_view.tsx +++ b/frontend/javascripts/admin/organization/organization_overview_view.tsx @@ -3,9 +3,11 @@ import { SettingsTitle } from "admin/account/helpers/settings_title"; import { getPricingPlanStatus, getUsers, updateOrganization } from "admin/rest_api"; import { Button, Col, Row, Spin, Tooltip, Typography } from "antd"; import { formatCountToDataAmountUnit } from "libs/format_utils"; +import { useWkSelector } from "libs/react_hooks"; import Toast from "libs/toast"; import { useEffect, useState } from "react"; -import type { APIOrganization, APIPricingPlanStatus } from "types/api_types"; +import type { APIPricingPlanStatus } from "types/api_types"; +import { enforceActiveOrganization } from "viewer/model/accessors/organization_accessors"; import { setActiveOrganizationAction } from "viewer/model/actions/organization_actions"; import { Store } from "viewer/singletons"; import { SettingsCard } from "../account/helpers/settings_card"; @@ -20,7 +22,10 @@ import UpgradePricingPlanModal from "./upgrade_plan_modal"; const ORGA_NAME_REGEX_PATTERN = /^[A-Za-z0-9\-_. ß]+$/; -export function OrganizationOverviewView({ organization }: { organization: APIOrganization }) { +export function OrganizationOverviewView() { + const organization = useWkSelector((state) => + enforceActiveOrganization(state.activeOrganization), + ); const [isFetchingData, setIsFetchingData] = useState(true); const [activeUsersCount, setActiveUsersCount] = useState(1); const [pricingPlanStatus, setPricingPlanStatus] = useState(null); diff --git a/frontend/javascripts/admin/organization/organization_view.tsx b/frontend/javascripts/admin/organization/organization_view.tsx index 31c53572f0e..8ceb02deb47 100644 --- a/frontend/javascripts/admin/organization/organization_view.tsx +++ b/frontend/javascripts/admin/organization/organization_view.tsx @@ -1,14 +1,8 @@ import { DeleteOutlined, MailOutlined, UserOutlined } from "@ant-design/icons"; import { Breadcrumb, Layout, Menu } from "antd"; import type { MenuItemGroupType } from "antd/es/menu/interface"; -import { useWkSelector } from "libs/react_hooks"; -import { Route, Switch, useHistory, useLocation } from "react-router-dom"; -import { Redirect } from "react-router-dom"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; import constants from "viewer/constants"; -import { enforceActiveOrganization } from "viewer/model/accessors/organization_accessors"; -import { OrganizationDangerZoneView } from "./organization_danger_zone_view"; -import { OrganizationNotificationsView } from "./organization_notifications_view"; -import { OrganizationOverviewView } from "./organization_overview_view"; const { Sider, Content } = Layout; @@ -43,11 +37,8 @@ const MENU_ITEMS: MenuItemGroupType[] = [ ]; const OrganizationView = () => { - const organization = useWkSelector((state) => - enforceActiveOrganization(state.activeOrganization), - ); const location = useLocation(); - const history = useHistory(); + const navigate = useNavigate(); const selectedKey = location.pathname.split("/").filter(Boolean).pop() || "overview"; const breadcrumbItems = [ @@ -72,26 +63,12 @@ const OrganizationView = () => { selectedKeys={[selectedKey]} style={{ height: "100%", padding: 24 }} items={MENU_ITEMS} - onClick={({ key }) => history.push(`/organization/${key}`)} + onClick={({ key }) => navigate(`/organization/${key}`)} /> - - } - /> - } - /> - } - /> - } /> - + ); diff --git a/frontend/javascripts/admin/project/project_create_view.tsx b/frontend/javascripts/admin/project/project_create_view.tsx index 8fdbb69f40b..42be517587d 100644 --- a/frontend/javascripts/admin/project/project_create_view.tsx +++ b/frontend/javascripts/admin/project/project_create_view.tsx @@ -8,22 +8,21 @@ import { import { Button, Card, Checkbox, Form, Input, InputNumber, Select } from "antd"; import { useWkSelector } from "libs/react_hooks"; import { useEffect, useState } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import type { APITeam, APIUser } from "types/api_types"; import { enforceActiveUser } from "viewer/model/accessors/user_accessor"; import { FormItemWithInfo } from "../../dashboard/dataset/helper_components"; const FormItem = Form.Item; -type Props = { - projectId?: string | null | undefined; -}; -function ProjectCreateView({ projectId }: Props) { +function ProjectCreateView() { + const { projectId } = useParams(); + const [teams, setTeams] = useState([]); const [users, setUsers] = useState([]); const [isFetchingData, setIsFetchingData] = useState(false); const [form] = Form.useForm(); - const history = useHistory(); + const navigate = useNavigate(); const activeUser = useWkSelector((state) => enforceActiveUser(state.activeUser)); useEffect(() => { fetchData(); @@ -59,7 +58,7 @@ function ProjectCreateView({ projectId }: Props) { await createProject(formValues); } - history.push("/projects"); + navigate("/projects"); }; const isEditMode = projectId != null; diff --git a/frontend/javascripts/admin/project/project_list_view.tsx b/frontend/javascripts/admin/project/project_list_view.tsx index be6467dcb8d..a12b51f0f4e 100644 --- a/frontend/javascripts/admin/project/project_list_view.tsx +++ b/frontend/javascripts/admin/project/project_list_view.tsx @@ -28,36 +28,24 @@ import { AsyncLink } from "components/async_clickables"; import FormattedDate from "components/formatted_date"; import { handleGenericError } from "libs/error_handling"; import Persistence from "libs/persistence"; -import { useEffectOnlyOnce } from "libs/react_hooks"; +import { useEffectOnlyOnce, useWkSelector } from "libs/react_hooks"; import Toast from "libs/toast"; import * as Utils from "libs/utils"; import _ from "lodash"; import messages from "messages"; import React, { useEffect, useState } from "react"; -import { connect } from "react-redux"; -import { Link } from "react-router-dom"; +import { Link, useLocation, useParams } from "react-router-dom"; import { type APIProject, type APIProjectWithStatus, - type APIUser, type APIUserBase, TracingTypeEnum, } from "types/api_types"; import { enforceActiveUser } from "viewer/model/accessors/user_accessor"; -import type { WebknossosState } from "viewer/store"; const { Column } = Table; const { Search } = Input; -type OwnProps = { - initialSearchValue?: string; - taskTypeId?: string; -}; -type StateProps = { - activeUser: APIUser; -}; -type Props = OwnProps & StateProps; - const persistence = new Persistence<{ searchQuery: string }>( { searchQuery: PropTypes.string, @@ -65,8 +53,13 @@ const persistence = new Persistence<{ searchQuery: string }>( "projectList", ); -function ProjectListView({ initialSearchValue, taskTypeId, activeUser }: Props) { +function ProjectListView() { + const { taskTypeId } = useParams(); + const { modal } = App.useApp(); + const location = useLocation(); + const initialSearchValue = location.hash.slice(1); + const activeUser = useWkSelector((state) => enforceActiveUser(state.activeUser)); const [isLoading, setIsLoading] = useState(true); const [projects, setProjects] = useState([]); @@ -95,10 +88,6 @@ function ProjectListView({ initialSearchValue, taskTypeId, activeUser }: Props) persistence.persist({ searchQuery }); }, [searchQuery]); - useEffect(() => { - fetchData(taskTypeId); - }, [taskTypeId]); - async function fetchData(taskTypeId?: string): Promise { let projects; let taskTypeName; @@ -475,9 +464,4 @@ function ProjectListView({ initialSearchValue, taskTypeId, activeUser }: Props) ); } -const mapStateToProps = (state: WebknossosState): StateProps => ({ - activeUser: enforceActiveUser(state.activeUser), -}); - -const connector = connect(mapStateToProps); -export default connector(ProjectListView); +export default ProjectListView; diff --git a/frontend/javascripts/admin/scripts/script_create_view.tsx b/frontend/javascripts/admin/scripts/script_create_view.tsx index 24ddbe8ac56..5b86d5afc27 100644 --- a/frontend/javascripts/admin/scripts/script_create_view.tsx +++ b/frontend/javascripts/admin/scripts/script_create_view.tsx @@ -1,23 +1,18 @@ import { createScript, getScript, getTeamManagerOrAdminUsers, updateScript } from "admin/rest_api"; import { Button, Card, Form, Input, Select } from "antd"; +import { useWkSelector } from "libs/react_hooks"; import { useEffect, useState } from "react"; -import { connect } from "react-redux"; -import { useHistory } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import type { APIUser } from "types/api_types"; import { enforceActiveUser } from "viewer/model/accessors/user_accessor"; -import type { WebknossosState } from "viewer/store"; const FormItem = Form.Item; -type OwnProps = { - scriptId?: string | null | undefined; -}; -type StateProps = { - activeUser: APIUser; -}; -type Props = OwnProps & StateProps; -function ScriptCreateView({ scriptId, activeUser }: Props) { - const history = useHistory(); +function ScriptCreateView() { + const { scriptId } = useParams(); + + const navigate = useNavigate(); + const activeUser = useWkSelector((state) => enforceActiveUser(state.activeUser)); const [users, setUsers] = useState([]); const [isFetchingData, setIsFetchingData] = useState(false); const [form] = Form.useForm(); @@ -51,7 +46,7 @@ function ScriptCreateView({ scriptId, activeUser }: Props) { await createScript(formValues); } - history.push("/scripts"); + navigate("/scripts"); }; const titlePrefix = scriptId ? "Update" : "Create"; @@ -127,9 +122,4 @@ function ScriptCreateView({ scriptId, activeUser }: Props) { ); } -const mapStateToProps = (state: WebknossosState): StateProps => ({ - activeUser: enforceActiveUser(state.activeUser), -}); - -const connector = connect(mapStateToProps); -export default connector(ScriptCreateView); +export default ScriptCreateView; diff --git a/frontend/javascripts/admin/task/task_create_form_view.tsx b/frontend/javascripts/admin/task/task_create_form_view.tsx index 2c7dacc0cd2..d5ce5133826 100644 --- a/frontend/javascripts/admin/task/task_create_form_view.tsx +++ b/frontend/javascripts/admin/task/task_create_form_view.tsx @@ -42,7 +42,7 @@ import { Vector3Input, Vector6Input } from "libs/vector_input"; import _ from "lodash"; import messages from "messages"; import React, { useEffect, useState } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import type { APIDataset, APIProject, APIScript, APITask, APITaskType } from "types/api_types"; import type { Vector3, Vector6 } from "viewer/constants"; import type { BoundingBoxObject } from "viewer/store"; @@ -288,10 +288,6 @@ export function ReloadResourceButton({ ); } -type Props = { - taskId: string | null | undefined; -}; - type FormValues = { baseAnnotation: NewTask["baseAnnotation"]; boundingBox: Vector6 | null; @@ -307,8 +303,9 @@ type FormValues = { neededExperience: NewTask["neededExperience"]; }; -function TaskCreateFormView({ taskId }: Props) { - const history = useHistory(); +function TaskCreateFormView() { + const { taskId } = useParams(); + const navigate = useNavigate(); const { modal } = App.useApp(); const [form] = Form.useForm(); @@ -381,7 +378,7 @@ function TaskCreateFormView({ taskId }: Props) { boundingBox, }; const confirmedTask = await updateTask(taskId, newTask); - history.push(`/tasks/${confirmedTask.id}`); + navigate(`/tasks/${confirmedTask.id}`); } else { setIsUploading(true); // or create a new one either from the form values or with an NML file diff --git a/frontend/javascripts/admin/task/task_create_view.tsx b/frontend/javascripts/admin/task/task_create_view.tsx index eb489cb2372..3be53a227c3 100644 --- a/frontend/javascripts/admin/task/task_create_view.tsx +++ b/frontend/javascripts/admin/task/task_create_view.tsx @@ -9,7 +9,7 @@ const TaskCreateView = () => { icon: , label: "Create Task", key: "1", - children: , + children: , }, { icon: , diff --git a/frontend/javascripts/admin/task/task_list_view.tsx b/frontend/javascripts/admin/task/task_list_view.tsx index 0919c06386a..bfbbcda32ea 100644 --- a/frontend/javascripts/admin/task/task_list_view.tsx +++ b/frontend/javascripts/admin/task/task_list_view.tsx @@ -38,7 +38,7 @@ import _ from "lodash"; import messages from "messages"; import type React from "react"; import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import type { APITask, APITaskType, TaskStatus } from "types/api_types"; const { Search, TextArea } = Input; @@ -56,6 +56,14 @@ const persistence = new Persistence<{ searchQuery: string }>( function TaskListView({ initialFieldValues }: Props) { const { modal } = App.useApp(); + const { taskId, projectId, taskTypeId } = useParams(); + + initialFieldValues = { + ...initialFieldValues, + taskId, + projectId, + taskTypeId, + }; const [isLoading, setIsLoading] = useState(false); const [tasks, setTasks] = useState([]); diff --git a/frontend/javascripts/admin/tasktype/task_type_create_view.tsx b/frontend/javascripts/admin/tasktype/task_type_create_view.tsx index 2abd94f7dc9..8f171bc54a3 100644 --- a/frontend/javascripts/admin/tasktype/task_type_create_view.tsx +++ b/frontend/javascripts/admin/tasktype/task_type_create_view.tsx @@ -9,7 +9,7 @@ import { useFetch } from "libs/react_helpers"; import { jsonStringify } from "libs/utils"; import _ from "lodash"; import { useEffect, useState } from "react"; -import { useHistory } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { type APIAllowedMode, type APIMagRestrictions, @@ -24,10 +24,6 @@ const RadioGroup = Radio.Group; const FormItem = Form.Item; const { TextArea } = Input; -type Props = { - taskTypeId?: string | null | undefined; -}; - type FormValues = { isMagRestricted: boolean; summary: string; @@ -73,8 +69,10 @@ function isMaximumMagnificationSmallerThenMinRule(value: number | undefined, min ); } -function TaskTypeCreateView({ taskTypeId }: Props) { - const history = useHistory(); +function TaskTypeCreateView() { + const { taskTypeId } = useParams(); + + const navigate = useNavigate(); const [useRecommendedConfiguration, setUseRecommendedConfiguration] = useState(false); const [isFetchingData, setIsFetchingData] = useState(true); const [form] = Form.useForm(); @@ -165,7 +163,7 @@ function TaskTypeCreateView({ taskTypeId }: Props) { await createTaskType(newTaskType); } - history.push("/taskTypes"); + navigate("/taskTypes"); } function onChangeUseRecommendedConfiguration(useRecommendedConfiguration: boolean) { diff --git a/frontend/javascripts/admin/tasktype/task_type_list_view.tsx b/frontend/javascripts/admin/tasktype/task_type_list_view.tsx index a9791d65bce..73a63157914 100644 --- a/frontend/javascripts/admin/tasktype/task_type_list_view.tsx +++ b/frontend/javascripts/admin/tasktype/task_type_list_view.tsx @@ -23,16 +23,12 @@ import _ from "lodash"; import messages from "messages"; import * as React from "react"; import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import type { APITaskType } from "types/api_types"; const { Column } = Table; const { Search } = Input; -type Props = { - initialSearchValue?: string; -}; - const persistence = new Persistence<{ searchQuery: string }>( { searchQuery: PropTypes.string, @@ -40,7 +36,10 @@ const persistence = new Persistence<{ searchQuery: string }>( "taskTypeList", ); -function TaskTypeListView({ initialSearchValue }: Props) { +function TaskTypeListView() { + const location = useLocation(); + const initialSearchValue = location.hash.slice(1); + const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); const [taskTypes, setTaskTypes] = useState([]); diff --git a/frontend/javascripts/admin/user/user_list_view.tsx b/frontend/javascripts/admin/user/user_list_view.tsx index e617fab0d7f..41c7a3af69d 100644 --- a/frontend/javascripts/admin/user/user_list_view.tsx +++ b/frontend/javascripts/admin/user/user_list_view.tsx @@ -24,11 +24,11 @@ import Persistence from "libs/persistence"; import Toast from "libs/toast"; import * as Utils from "libs/utils"; import { location } from "libs/window"; +import { type RouteComponentProps, withRouter } from "libs/with_router_hoc"; import _ from "lodash"; import messages from "messages"; import React, { type Key, useEffect, useState } from "react"; import { connect } from "react-redux"; -import type { RouteComponentProps } from "react-router-dom"; import { Link } from "react-router-dom"; import type { APIOrganization, APITeamMembership, APIUser, ExperienceMap } from "types/api_types"; import { enforceActiveOrganization } from "viewer/model/accessors/organization_accessors"; @@ -656,4 +656,4 @@ const mapStateToProps = (state: WebknossosState): StateProps => ({ }); const connector = connect(mapStateToProps); -export default connector(UserListView); +export default connector(withRouter(UserListView)); diff --git a/frontend/javascripts/admin/voxelytics/task_list_view.tsx b/frontend/javascripts/admin/voxelytics/task_list_view.tsx index 075ae0f3796..348c677b116 100644 --- a/frontend/javascripts/admin/voxelytics/task_list_view.tsx +++ b/frontend/javascripts/admin/voxelytics/task_list_view.tsx @@ -38,7 +38,7 @@ import { } from "libs/format_utils"; import { useSearchParams, useUpdateEvery, useWkSelector } from "libs/react_hooks"; import { notEmpty } from "libs/utils"; -import { Link, useHistory, useLocation, useParams } from "react-router-dom"; +import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { VoxelyticsRunState, type VoxelyticsTaskConfig, @@ -264,7 +264,7 @@ export default function TaskListView({ const { modal } = App.useApp(); const [searchQuery, setSearchQuery] = useState(""); const { runId } = useSearchParams(); - const history = useHistory(); + const navigate = useNavigate(); // expandedTask = state of the collapsible list const [expandedTasks, setExpandedTasks] = useState>([]); @@ -433,7 +433,7 @@ export default function TaskListView({ onOk: async () => { try { await deleteWorkflow(report.workflow.hash); - history.push("/workflows"); + navigate("/workflows"); message.success("Workflow report deleted."); } catch (error) { console.error(error); @@ -678,10 +678,10 @@ export default function TaskListView({