Introduce account preferences to remember user's previous location (#4126)

This commit is contained in:
Apoorv Mishra
2022-09-18 18:31:47 +05:30
committed by GitHub
parent b68e58fad5
commit 6502b108e3
17 changed files with 156 additions and 12 deletions

View File

@ -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,

View File

@ -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",

View 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];
}

View File

@ -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,
];

View File

@ -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;

View File

@ -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);

View File

@ -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();

View File

@ -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}`} />;
}

View 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);

View File

@ -221,6 +221,7 @@ export default class AuthStore {
name?: string;
avatarUrl?: string | null;
language?: string;
preferences?: Record<string, boolean>;
}) => {
this.isSaving = true;

View File

@ -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");
});
});

View File

@ -34,6 +34,10 @@ export function profileSettingsPath(): string {
return "/settings";
}
export function accountPreferencesPath(): string {
return "/settings/preferences";
}
export function groupSettingsPath(): string {
return "/settings/groups";
}

View File

@ -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: [

View File

@ -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;

View File

@ -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,

View File

@ -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",

View File

@ -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 };