mirror of
https://github.com/outline/outline.git
synced 2025-03-28 14:34:35 +00:00
Introduce account preferences to remember user's previous location (#4126)
This commit is contained in:
@ -28,6 +28,7 @@ import history from "~/utils/history";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
profileSettingsPath,
|
||||
accountPreferencesPath,
|
||||
homePath,
|
||||
searchPath,
|
||||
draftsPath,
|
||||
@ -104,6 +105,14 @@ export const navigateToProfileSettings = createAction({
|
||||
perform: () => history.push(profileSettingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createAction({
|
||||
name: ({ t }) => t("Preferences"),
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <SettingsIcon />,
|
||||
perform: () => history.push(accountPreferencesPath()),
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
name: ({ t }) => t("API documentation"),
|
||||
section: NavigationSection,
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
BuildingBlocksIcon,
|
||||
DownloadIcon,
|
||||
WebhooksIcon,
|
||||
SettingsIcon,
|
||||
} from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -23,6 +24,7 @@ import Groups from "~/scenes/Settings/Groups";
|
||||
import Import from "~/scenes/Settings/Import";
|
||||
import Members from "~/scenes/Settings/Members";
|
||||
import Notifications from "~/scenes/Settings/Notifications";
|
||||
import Preferences from "~/scenes/Settings/Preferences";
|
||||
import Profile from "~/scenes/Settings/Profile";
|
||||
import Security from "~/scenes/Settings/Security";
|
||||
import Shares from "~/scenes/Settings/Shares";
|
||||
@ -34,6 +36,7 @@ import SlackIcon from "~/components/SlackIcon";
|
||||
import ZapierIcon from "~/components/ZapierIcon";
|
||||
import env from "~/env";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { accountPreferencesPath } from "~/utils/routeHelpers";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import usePolicy from "./usePolicy";
|
||||
|
||||
@ -82,6 +85,14 @@ const useAuthorizedSettingsConfig = () => {
|
||||
group: t("Account"),
|
||||
icon: ProfileIcon,
|
||||
},
|
||||
Preferences: {
|
||||
name: t("Preferences"),
|
||||
path: accountPreferencesPath(),
|
||||
component: Preferences,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
Notifications: {
|
||||
name: t("Notifications"),
|
||||
path: "/settings/notifications",
|
||||
|
14
app/hooks/useLastVisitedPath.tsx
Normal file
14
app/hooks/useLastVisitedPath.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
|
||||
export default function useLastVisitedPath() {
|
||||
const [lastVisitedPath, setLastVisitedPath] = usePersistedState(
|
||||
"lastVisitedPath",
|
||||
"/"
|
||||
);
|
||||
|
||||
const setPathAsLastVisitedPath = (path: string) => {
|
||||
path !== lastVisitedPath && setLastVisitedPath(path);
|
||||
};
|
||||
|
||||
return [lastVisitedPath, setPathAsLastVisitedPath];
|
||||
}
|
@ -6,6 +6,7 @@ import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import {
|
||||
navigateToProfileSettings,
|
||||
navigateToAccountPreferences,
|
||||
openKeyboardShortcuts,
|
||||
openChangelog,
|
||||
openAPIDocumentation,
|
||||
@ -44,6 +45,7 @@ const AccountMenu: React.FC = ({ children }) => {
|
||||
openBugReportUrl,
|
||||
changeTheme,
|
||||
navigateToProfileSettings,
|
||||
navigateToAccountPreferences,
|
||||
separator(),
|
||||
logout,
|
||||
];
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { subMinutes } from "date-fns";
|
||||
import { computed, observable } from "mobx";
|
||||
import { now } from "mobx-utils";
|
||||
import type { Role } from "@shared/types";
|
||||
import type { Role, UserPreferences } from "@shared/types";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
@ -26,6 +26,8 @@ class User extends ParanoidModel {
|
||||
@observable
|
||||
language: string;
|
||||
|
||||
preferences: UserPreferences | null | undefined;
|
||||
|
||||
email: string;
|
||||
|
||||
isAdmin: boolean;
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
Route,
|
||||
useHistory,
|
||||
useRouteMatch,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@ -30,6 +31,7 @@ import Tabs from "~/components/Tabs";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { editCollection } from "~/actions/definitions/collections";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers";
|
||||
@ -42,16 +44,23 @@ function CollectionScene() {
|
||||
const params = useParams<{ id?: string }>();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { documents, pins, collections, ui } = useStores();
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const currentPath = location.pathname;
|
||||
const [, setLastVisitedPath] = useLastVisitedPath();
|
||||
|
||||
const id = params.id || "";
|
||||
const collection: Collection | null | undefined =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const can = usePolicy(collection?.id || "");
|
||||
|
||||
React.useEffect(() => {
|
||||
setLastVisitedPath(currentPath);
|
||||
}, [currentPath, setLastVisitedPath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection?.name) {
|
||||
const canonicalUrl = updateCollectionUrl(match.url, collection);
|
||||
|
@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { StaticContext } from "react-router";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DataLoader from "./components/DataLoader";
|
||||
import Document from "./components/Document";
|
||||
@ -25,6 +26,12 @@ export default function DocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { documentSlug, revisionId } = props.match.params;
|
||||
const currentPath = props.location.pathname;
|
||||
const [, setLastVisitedPath] = useLastVisitedPath();
|
||||
|
||||
React.useEffect(() => {
|
||||
setLastVisitedPath(currentPath);
|
||||
}, [currentPath, setLastVisitedPath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => ui.clearActiveDocument();
|
||||
|
@ -19,6 +19,7 @@ import PageTitle from "~/components/PageTitle";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
@ -62,6 +63,9 @@ function Login({ children }: Props) {
|
||||
const [error, setError] = React.useState(null);
|
||||
const [emailLinkSentTo, setEmailLinkSentTo] = React.useState("");
|
||||
const isCreate = location.pathname === "/create";
|
||||
const rememberLastPath = !!auth.user?.preferences?.rememberLastPath;
|
||||
const [lastVisitedPath] = useLastVisitedPath();
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
setEmailLinkSentTo("");
|
||||
}, []);
|
||||
@ -91,6 +95,14 @@ function Login({ children }: Props) {
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
if (
|
||||
auth.authenticated &&
|
||||
rememberLastPath &&
|
||||
lastVisitedPath !== location.pathname
|
||||
) {
|
||||
return <Redirect to={lastVisitedPath} />;
|
||||
}
|
||||
|
||||
if (auth.authenticated && auth.team?.defaultCollectionId) {
|
||||
return <Redirect to={`/collection/${auth.team?.defaultCollectionId}`} />;
|
||||
}
|
||||
|
57
app/scenes/Settings/Preferences.tsx
Normal file
57
app/scenes/Settings/Preferences.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SettingsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Heading from "~/components/Heading";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Preferences() {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const { auth } = useStores();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const handleChange = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newPreferences = {
|
||||
...user.preferences,
|
||||
[ev.target.name]: ev.target.checked,
|
||||
};
|
||||
|
||||
await auth.updateUser({
|
||||
preferences: newPreferences,
|
||||
});
|
||||
showToast(t("Preferences saved"), {
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("Preferences")}
|
||||
icon={<SettingsIcon color="currentColor" />}
|
||||
>
|
||||
<Heading>{t("Preferences")}</Heading>
|
||||
<SettingRow
|
||||
name="rememberLastPath"
|
||||
label={t("Remember previous location")}
|
||||
description={t(
|
||||
"Automatically return to the document you were last viewing when the app is re-opened"
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
id="rememberLastPath"
|
||||
name="rememberLastPath"
|
||||
checked={!!user.preferences?.rememberLastPath}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Preferences);
|
@ -221,6 +221,7 @@ export default class AuthStore {
|
||||
name?: string;
|
||||
avatarUrl?: string | null;
|
||||
language?: string;
|
||||
preferences?: Record<string, boolean>;
|
||||
}) => {
|
||||
this.isSaving = true;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { sharedDocumentPath } from "./routeHelpers";
|
||||
import { sharedDocumentPath, accountPreferencesPath } from "./routeHelpers";
|
||||
|
||||
describe("#sharedDocumentPath", () => {
|
||||
test("should return share path for a document", () => {
|
||||
@ -12,3 +12,9 @@ describe("#sharedDocumentPath", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#accountPreferencesPath", () => {
|
||||
test("should return account preferences path", () => {
|
||||
expect(accountPreferencesPath()).toBe("/settings/preferences");
|
||||
});
|
||||
});
|
||||
|
@ -34,6 +34,10 @@ export function profileSettingsPath(): string {
|
||||
return "/settings";
|
||||
}
|
||||
|
||||
export function accountPreferencesPath(): string {
|
||||
return "/settings/preferences";
|
||||
}
|
||||
|
||||
export function groupSettingsPath(): string {
|
||||
return "/settings/groups";
|
||||
}
|
||||
|
@ -22,7 +22,11 @@ import {
|
||||
AllowNull,
|
||||
} from "sequelize-typescript";
|
||||
import { languages } from "@shared/i18n";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import {
|
||||
CollectionPermission,
|
||||
UserPreference,
|
||||
UserPreferences,
|
||||
} from "@shared/types";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import env from "@server/env";
|
||||
import { ValidationError } from "../errors";
|
||||
@ -54,12 +58,6 @@ export enum UserRole {
|
||||
Viewer = "viewer",
|
||||
}
|
||||
|
||||
export enum UserPreference {
|
||||
RememberLastPath = "rememberLastPath",
|
||||
}
|
||||
|
||||
export type UserPreferences = { [key in UserPreference]?: boolean };
|
||||
|
||||
@Scopes(() => ({
|
||||
withAuthentications: {
|
||||
include: [
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { UserPreferences } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import { User } from "@server/models";
|
||||
import { UserPreferences } from "@server/models/User";
|
||||
|
||||
type Options = {
|
||||
includeDetails?: boolean;
|
||||
|
@ -1,6 +1,8 @@
|
||||
import crypto from "crypto";
|
||||
import Router from "koa-router";
|
||||
import { has } from "lodash";
|
||||
import { Op, WhereOptions } from "sequelize";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { UserValidation } from "@shared/validations";
|
||||
import { RateLimiterStrategy } from "@server/RateLimiter";
|
||||
import userDemoter from "@server/commands/userDemoter";
|
||||
@ -17,7 +19,7 @@ import logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { Event, User, Team } from "@server/models";
|
||||
import { UserFlag, UserRole, UserPreference } from "@server/models/User";
|
||||
import { UserFlag, UserRole } from "@server/models/User";
|
||||
import { can, authorize } from "@server/policies";
|
||||
import { presentUser, presentPolicies } from "@server/presenters";
|
||||
import {
|
||||
@ -188,7 +190,7 @@ router.post("users.update", auth(), async (ctx) => {
|
||||
}
|
||||
if (preferences) {
|
||||
assertKeysIn(preferences, UserPreference);
|
||||
if (preferences.rememberLastPath) {
|
||||
if (has(preferences, UserPreference.RememberLastPath)) {
|
||||
assertBoolean(preferences.rememberLastPath);
|
||||
user.setPreference(
|
||||
UserPreference.RememberLastPath,
|
||||
|
@ -50,6 +50,7 @@
|
||||
"Trash": "Trash",
|
||||
"Settings": "Settings",
|
||||
"Profile": "Profile",
|
||||
"Preferences": "Preferences",
|
||||
"API documentation": "API documentation",
|
||||
"Send us feedback": "Send us feedback",
|
||||
"Report a bug": "Report a bug",
|
||||
@ -683,6 +684,9 @@
|
||||
"Email address": "Email address",
|
||||
"Your email address should be updated in your SSO provider.": "Your email address should be updated in your SSO provider.",
|
||||
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
|
||||
"Preferences saved": "Preferences saved",
|
||||
"Remember previous location": "Remember previous location",
|
||||
"Automatically return to the document you were last viewing when the app is re-opened": "Automatically return to the document you were last viewing when the app is re-opened",
|
||||
"Profile saved": "Profile saved",
|
||||
"Profile picture updated": "Profile picture updated",
|
||||
"Unable to upload new profile picture": "Unable to upload new profile picture",
|
||||
|
@ -43,3 +43,9 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
|
||||
| { url: string }
|
||||
| { url: string; channel: string; channelId: string }
|
||||
| { serviceTeamId: string };
|
||||
|
||||
export enum UserPreference {
|
||||
RememberLastPath = "rememberLastPath",
|
||||
}
|
||||
|
||||
export type UserPreferences = { [key in UserPreference]?: boolean };
|
||||
|
Reference in New Issue
Block a user