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 { import {
organizationSettingsPath, organizationSettingsPath,
profileSettingsPath, profileSettingsPath,
accountPreferencesPath,
homePath, homePath,
searchPath, searchPath,
draftsPath, draftsPath,
@@ -104,6 +105,14 @@ export const navigateToProfileSettings = createAction({
perform: () => history.push(profileSettingsPath()), 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({ export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"), name: ({ t }) => t("API documentation"),
section: NavigationSection, section: NavigationSection,

View File

@@ -12,6 +12,7 @@ import {
BuildingBlocksIcon, BuildingBlocksIcon,
DownloadIcon, DownloadIcon,
WebhooksIcon, WebhooksIcon,
SettingsIcon,
} from "outline-icons"; } from "outline-icons";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -23,6 +24,7 @@ import Groups from "~/scenes/Settings/Groups";
import Import from "~/scenes/Settings/Import"; import Import from "~/scenes/Settings/Import";
import Members from "~/scenes/Settings/Members"; import Members from "~/scenes/Settings/Members";
import Notifications from "~/scenes/Settings/Notifications"; import Notifications from "~/scenes/Settings/Notifications";
import Preferences from "~/scenes/Settings/Preferences";
import Profile from "~/scenes/Settings/Profile"; import Profile from "~/scenes/Settings/Profile";
import Security from "~/scenes/Settings/Security"; import Security from "~/scenes/Settings/Security";
import Shares from "~/scenes/Settings/Shares"; import Shares from "~/scenes/Settings/Shares";
@@ -34,6 +36,7 @@ import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon"; import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env"; import env from "~/env";
import isCloudHosted from "~/utils/isCloudHosted"; import isCloudHosted from "~/utils/isCloudHosted";
import { accountPreferencesPath } from "~/utils/routeHelpers";
import useCurrentTeam from "./useCurrentTeam"; import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy"; import usePolicy from "./usePolicy";
@@ -82,6 +85,14 @@ const useAuthorizedSettingsConfig = () => {
group: t("Account"), group: t("Account"),
icon: ProfileIcon, icon: ProfileIcon,
}, },
Preferences: {
name: t("Preferences"),
path: accountPreferencesPath(),
component: Preferences,
enabled: true,
group: t("Account"),
icon: SettingsIcon,
},
Notifications: { Notifications: {
name: t("Notifications"), name: t("Notifications"),
path: "/settings/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 Template from "~/components/ContextMenu/Template";
import { import {
navigateToProfileSettings, navigateToProfileSettings,
navigateToAccountPreferences,
openKeyboardShortcuts, openKeyboardShortcuts,
openChangelog, openChangelog,
openAPIDocumentation, openAPIDocumentation,
@@ -44,6 +45,7 @@ const AccountMenu: React.FC = ({ children }) => {
openBugReportUrl, openBugReportUrl,
changeTheme, changeTheme,
navigateToProfileSettings, navigateToProfileSettings,
navigateToAccountPreferences,
separator(), separator(),
logout, logout,
]; ];

View File

@@ -1,7 +1,7 @@
import { subMinutes } from "date-fns"; import { subMinutes } from "date-fns";
import { computed, observable } from "mobx"; import { computed, observable } from "mobx";
import { now } from "mobx-utils"; import { now } from "mobx-utils";
import type { Role } from "@shared/types"; import type { Role, UserPreferences } from "@shared/types";
import ParanoidModel from "./ParanoidModel"; import ParanoidModel from "./ParanoidModel";
import Field from "./decorators/Field"; import Field from "./decorators/Field";
@@ -26,6 +26,8 @@ class User extends ParanoidModel {
@observable @observable
language: string; language: string;
preferences: UserPreferences | null | undefined;
email: string; email: string;
isAdmin: boolean; isAdmin: boolean;

View File

@@ -8,6 +8,7 @@ import {
Route, Route,
useHistory, useHistory,
useRouteMatch, useRouteMatch,
useLocation,
} from "react-router-dom"; } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
@@ -30,6 +31,7 @@ import Tabs from "~/components/Tabs";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
import { editCollection } from "~/actions/definitions/collections"; import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions"; import useCommandBarActions from "~/hooks/useCommandBarActions";
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers"; import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers";
@@ -42,16 +44,23 @@ function CollectionScene() {
const params = useParams<{ id?: string }>(); const params = useParams<{ id?: string }>();
const history = useHistory(); const history = useHistory();
const match = useRouteMatch(); const match = useRouteMatch();
const location = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const { documents, pins, collections, ui } = useStores(); const { documents, pins, collections, ui } = useStores();
const [isFetching, setFetching] = React.useState(false); const [isFetching, setFetching] = React.useState(false);
const [error, setError] = React.useState<Error | undefined>(); const [error, setError] = React.useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
const id = params.id || ""; const id = params.id || "";
const collection: Collection | null | undefined = const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id); collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection?.id || ""); const can = usePolicy(collection?.id || "");
React.useEffect(() => {
setLastVisitedPath(currentPath);
}, [currentPath, setLastVisitedPath]);
React.useEffect(() => { React.useEffect(() => {
if (collection?.name) { if (collection?.name) {
const canonicalUrl = updateCollectionUrl(match.url, collection); const canonicalUrl = updateCollectionUrl(match.url, collection);

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { StaticContext } from "react-router"; import { StaticContext } from "react-router";
import { RouteComponentProps } from "react-router-dom"; import { RouteComponentProps } from "react-router-dom";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentTeam from "~/hooks/useCurrentTeam";
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import DataLoader from "./components/DataLoader"; import DataLoader from "./components/DataLoader";
import Document from "./components/Document"; import Document from "./components/Document";
@@ -25,6 +26,12 @@ export default function DocumentScene(props: Props) {
const { ui } = useStores(); const { ui } = useStores();
const team = useCurrentTeam(); const team = useCurrentTeam();
const { documentSlug, revisionId } = props.match.params; const { documentSlug, revisionId } = props.match.params;
const currentPath = props.location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
React.useEffect(() => {
setLastVisitedPath(currentPath);
}, [currentPath, setLastVisitedPath]);
React.useEffect(() => { React.useEffect(() => {
return () => ui.clearActiveDocument(); return () => ui.clearActiveDocument();

View File

@@ -19,6 +19,7 @@ import PageTitle from "~/components/PageTitle";
import TeamLogo from "~/components/TeamLogo"; import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text"; import Text from "~/components/Text";
import env from "~/env"; import env from "~/env";
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import useQuery from "~/hooks/useQuery"; import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted"; import isCloudHosted from "~/utils/isCloudHosted";
@@ -62,6 +63,9 @@ function Login({ children }: Props) {
const [error, setError] = React.useState(null); const [error, setError] = React.useState(null);
const [emailLinkSentTo, setEmailLinkSentTo] = React.useState(""); const [emailLinkSentTo, setEmailLinkSentTo] = React.useState("");
const isCreate = location.pathname === "/create"; const isCreate = location.pathname === "/create";
const rememberLastPath = !!auth.user?.preferences?.rememberLastPath;
const [lastVisitedPath] = useLastVisitedPath();
const handleReset = React.useCallback(() => { const handleReset = React.useCallback(() => {
setEmailLinkSentTo(""); setEmailLinkSentTo("");
}, []); }, []);
@@ -91,6 +95,14 @@ function Login({ children }: Props) {
} }
}, [query]); }, [query]);
if (
auth.authenticated &&
rememberLastPath &&
lastVisitedPath !== location.pathname
) {
return <Redirect to={lastVisitedPath} />;
}
if (auth.authenticated && auth.team?.defaultCollectionId) { if (auth.authenticated && auth.team?.defaultCollectionId) {
return <Redirect to={`/collection/${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; name?: string;
avatarUrl?: string | null; avatarUrl?: string | null;
language?: string; language?: string;
preferences?: Record<string, boolean>;
}) => { }) => {
this.isSaving = true; this.isSaving = true;

View File

@@ -1,4 +1,4 @@
import { sharedDocumentPath } from "./routeHelpers"; import { sharedDocumentPath, accountPreferencesPath } from "./routeHelpers";
describe("#sharedDocumentPath", () => { describe("#sharedDocumentPath", () => {
test("should return share path for a document", () => { 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"; return "/settings";
} }
export function accountPreferencesPath(): string {
return "/settings/preferences";
}
export function groupSettingsPath(): string { export function groupSettingsPath(): string {
return "/settings/groups"; return "/settings/groups";
} }

View File

@@ -22,7 +22,11 @@ import {
AllowNull, AllowNull,
} from "sequelize-typescript"; } from "sequelize-typescript";
import { languages } from "@shared/i18n"; import { languages } from "@shared/i18n";
import { CollectionPermission } from "@shared/types"; import {
CollectionPermission,
UserPreference,
UserPreferences,
} from "@shared/types";
import { stringToColor } from "@shared/utils/color"; import { stringToColor } from "@shared/utils/color";
import env from "@server/env"; import env from "@server/env";
import { ValidationError } from "../errors"; import { ValidationError } from "../errors";
@@ -54,12 +58,6 @@ export enum UserRole {
Viewer = "viewer", Viewer = "viewer",
} }
export enum UserPreference {
RememberLastPath = "rememberLastPath",
}
export type UserPreferences = { [key in UserPreference]?: boolean };
@Scopes(() => ({ @Scopes(() => ({
withAuthentications: { withAuthentications: {
include: [ include: [

View File

@@ -1,6 +1,6 @@
import { UserPreferences } from "@shared/types";
import env from "@server/env"; import env from "@server/env";
import { User } from "@server/models"; import { User } from "@server/models";
import { UserPreferences } from "@server/models/User";
type Options = { type Options = {
includeDetails?: boolean; includeDetails?: boolean;

View File

@@ -1,6 +1,8 @@
import crypto from "crypto"; import crypto from "crypto";
import Router from "koa-router"; import Router from "koa-router";
import { has } from "lodash";
import { Op, WhereOptions } from "sequelize"; import { Op, WhereOptions } from "sequelize";
import { UserPreference } from "@shared/types";
import { UserValidation } from "@shared/validations"; import { UserValidation } from "@shared/validations";
import { RateLimiterStrategy } from "@server/RateLimiter"; import { RateLimiterStrategy } from "@server/RateLimiter";
import userDemoter from "@server/commands/userDemoter"; import userDemoter from "@server/commands/userDemoter";
@@ -17,7 +19,7 @@ import logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter"; import { rateLimiter } from "@server/middlewares/rateLimiter";
import { Event, User, Team } from "@server/models"; 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 { can, authorize } from "@server/policies";
import { presentUser, presentPolicies } from "@server/presenters"; import { presentUser, presentPolicies } from "@server/presenters";
import { import {
@@ -188,7 +190,7 @@ router.post("users.update", auth(), async (ctx) => {
} }
if (preferences) { if (preferences) {
assertKeysIn(preferences, UserPreference); assertKeysIn(preferences, UserPreference);
if (preferences.rememberLastPath) { if (has(preferences, UserPreference.RememberLastPath)) {
assertBoolean(preferences.rememberLastPath); assertBoolean(preferences.rememberLastPath);
user.setPreference( user.setPreference(
UserPreference.RememberLastPath, UserPreference.RememberLastPath,

View File

@@ -50,6 +50,7 @@
"Trash": "Trash", "Trash": "Trash",
"Settings": "Settings", "Settings": "Settings",
"Profile": "Profile", "Profile": "Profile",
"Preferences": "Preferences",
"API documentation": "API documentation", "API documentation": "API documentation",
"Send us feedback": "Send us feedback", "Send us feedback": "Send us feedback",
"Report a bug": "Report a bug", "Report a bug": "Report a bug",
@@ -683,6 +684,9 @@
"Email address": "Email address", "Email address": "Email address",
"Your email address should be updated in your SSO provider.": "Your email address should be updated in your SSO provider.", "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.", "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 saved": "Profile saved",
"Profile picture updated": "Profile picture updated", "Profile picture updated": "Profile picture updated",
"Unable to upload new profile picture": "Unable to upload new profile picture", "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 }
| { url: string; channel: string; channelId: string } | { url: string; channel: string; channelId: string }
| { serviceTeamId: string }; | { serviceTeamId: string };
export enum UserPreference {
RememberLastPath = "rememberLastPath",
}
export type UserPreferences = { [key in UserPreference]?: boolean };