feat: Option for separate edit mode (#4203)

* stash

* wip

* cleanup

* Remove collaborativeEditing toggle, it will always be on in next release.
Flip separateEdit -> seamlessEdit

* Clarify language, hide toggle when collaborative editing is disabled

* Flip boolean to match, easier to reason about
This commit is contained in:
Tom Moor
2022-10-02 17:58:33 +02:00
committed by GitHub
parent b9bf2e58cb
commit 933fbb2578
20 changed files with 172 additions and 124 deletions

View File

@@ -4,6 +4,7 @@ import { ThemeProvider } from "styled-components";
import { breakpoints } from "@shared/styles";
import GlobalStyles from "@shared/styles/globals";
import { dark, light, lightMobile, darkMobile } from "@shared/styles/theme";
import { UserPreference } from "@shared/types";
import useMediaQuery from "~/hooks/useMediaQuery";
import useStores from "~/hooks/useStores";
@@ -28,7 +29,10 @@ const Theme: React.FC = ({ children }) => {
<ThemeProvider theme={theme}>
<>
<GlobalStyles
useCursorPointer={auth.user?.preferences?.useCursorPointer !== false}
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer,
true
)}
/>
{children}
</>

View File

@@ -296,7 +296,7 @@ class WebsocketProvider extends React.Component<Props> {
);
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
auth.updateTeam(event);
auth.team?.updateFromJson(event);
});
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {

View File

@@ -130,7 +130,7 @@ const useAuthorizedSettingsConfig = () => {
name: t("Features"),
path: "/settings/features",
component: Features,
enabled: can.update,
enabled: can.update && team.collaborativeEditing,
group: t("Team"),
icon: BeakerIcon,
},

View File

@@ -263,7 +263,7 @@ function DocumentMenu({
type: "route",
title: t("Edit"),
to: editDocumentUrl(document),
visible: !!can.update && !team.collaborativeEditing,
visible: !!can.update && !team.seamlessEditing,
icon: <EditIcon />,
},
{

View File

@@ -1,4 +1,5 @@
import { computed, observable } from "mobx";
import { TeamPreference, TeamPreferences } from "@shared/types";
import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
@@ -51,6 +52,10 @@ class Team extends BaseModel {
@observable
defaultUserRole: string;
@Field
@observable
preferences: TeamPreferences | null;
domain: string | null | undefined;
url: string;
@@ -63,6 +68,45 @@ class Team extends BaseModel {
get signinMethods(): string {
return "SSO";
}
/**
* Returns whether this team is using a separate editing mode behind an "Edit"
* button rather than seamless always-editing.
*
* @returns True if editing mode is seamless (no button)
*/
@computed
get seamlessEditing(): boolean {
return (
this.collaborativeEditing &&
this.getPreference(TeamPreference.SeamlessEdit, true)
);
}
/**
* Get the value for a specific preference key, or return the fallback if
* none is set.
*
* @param key The TeamPreference key to retrieve
* @param fallback An optional fallback value, defaults to false.
* @returns The value
*/
getPreference(key: TeamPreference, fallback = false): boolean {
return this.preferences?.[key] ?? fallback;
}
/**
* Set the value for a specific preference key.
*
* @param key The TeamPreference key to retrieve
* @param value The value to set
*/
setPreference(key: TeamPreference, value: boolean) {
this.preferences = {
...this.preferences,
[key]: value,
};
}
}
export default Team;

View File

@@ -1,7 +1,7 @@
import { subMinutes } from "date-fns";
import { computed, observable } from "mobx";
import { now } from "mobx-utils";
import type { Role, UserPreferences } from "@shared/types";
import type { Role, UserPreference, UserPreferences } from "@shared/types";
import ParanoidModel from "./ParanoidModel";
import Field from "./decorators/Field";
@@ -66,6 +66,31 @@ class User extends ParanoidModel {
return "member";
}
}
/**
* Get the value for a specific preference key, or return the fallback if
* none is set.
*
* @param key The UserPreference key to retrieve
* @param fallback An optional fallback value, defaults to false.
* @returns The value
*/
getPreference(key: UserPreference, fallback = false): boolean {
return this.preferences?.[key] ?? fallback;
}
/**
* Set the value for a specific preference key.
*
* @param key The UserPreference key to retrieve
* @param value The value to set
*/
setPreference(key: UserPreference, value: boolean) {
this.preferences = {
...this.preferences,
[key]: value,
};
}
}
export default User;

View File

@@ -12,7 +12,6 @@ import Logger from "~/utils/Logger";
import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history";
import { matchDocumentEdit } from "~/utils/routeHelpers";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
type Params = {
@@ -65,7 +64,7 @@ function DataLoader({ match, children }: Props) {
? documents.getSharedTree(document.id)
: undefined;
const isEditRoute = match.path === matchDocumentEdit;
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
const isEditing = isEditRoute || !!auth.team?.seamlessEditing;
const can = usePolicy(document?.id);
const location = useLocation<LocationState>();
@@ -176,7 +175,6 @@ function DataLoader({ match, children }: Props) {
return (
<>
<Loading location={location} />
{isEditing && !team?.collaborativeEditing && <HideSidebar ui={ui} />}
</>
);
}
@@ -191,7 +189,6 @@ function DataLoader({ match, children }: Props) {
return (
<React.Fragment key={key}>
{isEditing && !team.collaborativeEditing && <HideSidebar ui={ui} />}
{children({
document,
revision,

View File

@@ -540,7 +540,7 @@ class DocumentScene extends React.Component<Props> {
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly && !team?.collaborativeEditing}
isEditing={!readOnly && !team?.seamlessEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
publishingIsDisabled={

View File

@@ -220,11 +220,11 @@ function DocumentHeader({
<>
<ObservingBanner />
{!isPublishing && isSaving && !team?.collaborativeEditing && (
{!isPublishing && isSaving && !team?.seamlessEditing && (
<Status>{t("Saving")}</Status>
)}
{!isDeleted && !isRevision && <Collaborators document={document} />}
{(isEditing || team?.collaborativeEditing) && !isTemplate && isNew && (
{(isEditing || team?.seamlessEditing) && !isTemplate && isNew && (
<Action>
<TemplatesMenu
document={document}
@@ -260,10 +260,7 @@ function DocumentHeader({
</Action>
</>
)}
{canEdit &&
!team?.collaborativeEditing &&
!isRevision &&
editAction}
{canEdit && !team?.seamlessEditing && !isRevision && editAction}
{canEdit && can.createChildDocument && !isRevision && !isMobile && (
<Action>
<NewChildDocumentMenu

View File

@@ -1,22 +0,0 @@
import * as React from "react";
import UiStore from "~/stores/UiStore";
type Props = {
ui: UiStore;
};
class HideSidebar extends React.Component<Props> {
componentDidMount() {
this.props.ui.enableEditMode();
}
componentWillUnmount() {
this.props.ui.disableEditMode();
}
render() {
return this.props.children || null;
}
}
export default HideSidebar;

View File

@@ -44,7 +44,6 @@ export default function DocumentScene(props: Props) {
const urlParts = documentSlug ? documentSlug.split("-") : [];
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
const key = [urlId, revisionId].join("/");
const isMultiplayer = team.collaborativeEditing;
return (
<DataLoader
@@ -59,7 +58,7 @@ export default function DocumentScene(props: Props) {
// TODO: Remove once multiplayer is 100% rollout, SocketPresence will
// no longer be required
if (isActive && !isMultiplayer) {
if (isActive && !team.collaborativeEditing) {
return (
<SocketPresence documentId={document.id} isEditing={isEditing}>
<Document document={document} {...rest} />

View File

@@ -1,9 +1,8 @@
import { observer } from "mobx-react";
import { BeakerIcon } from "outline-icons";
import { useState } from "react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { TeamPreference } from "@shared/types";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
@@ -11,48 +10,28 @@ import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import isCloudHosted from "~/utils/isCloudHosted";
import SettingRow from "./components/SettingRow";
function Features() {
const { auth, dialogs } = useStores();
const { auth } = useStores();
const team = useCurrentTeam();
const { t } = useTranslation();
const { showToast } = useToasts();
const [data, setData] = useState({
collaborativeEditing: team.collaborativeEditing,
});
const handleChange = async (ev: React.ChangeEvent<HTMLInputElement>) => {
const newData = { ...data, [ev.target.name]: ev.target.checked };
setData(newData);
const handlePreferenceChange = async (
ev: React.ChangeEvent<HTMLInputElement>
) => {
const preferences = {
...team.preferences,
[ev.target.name]: ev.target.checked,
};
await auth.updateTeam(newData);
await auth.updateTeam({ preferences });
showToast(t("Settings saved"), {
type: "success",
});
};
const handleCollabDisable = async () => {
const newData = { ...data, collaborativeEditing: false };
setData(newData);
await auth.updateTeam(newData);
showToast(t("Settings saved"), {
type: "success",
});
};
const handleCollabDisableConfirm = () => {
dialogs.openModal({
isCentered: true,
title: t("Are you sure you want to disable collaborative editing?"),
content: (
<DisableCollaborativeEditingDialog onSubmit={handleCollabDisable} />
),
});
};
return (
<Scene title={t("Features")} icon={<BeakerIcon color="currentColor" />}>
<Heading>{t("Features")}</Heading>
@@ -62,53 +41,24 @@ function Features() {
the experience for all team members.
</Trans>
</Text>
<SettingRow
name="collaborativeEditing"
label={t("Collaborative editing")}
description={t(
"When enabled multiple people can edit documents at the same time with shared presence and live cursors."
)}
>
<Switch
id="collaborativeEditing"
name="collaborativeEditing"
checked={data.collaborativeEditing}
disabled={data.collaborativeEditing && isCloudHosted}
onChange={
data.collaborativeEditing
? handleCollabDisableConfirm
: handleChange
}
/>
</SettingRow>
{team.collaborativeEditing && (
<SettingRow
name="seamlessEdit"
label={t("Seamless editing")}
description={t(
`When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.`
)}
>
<Switch
id="seamlessEdit"
name="seamlessEdit"
checked={team.getPreference(TeamPreference.SeamlessEdit, true)}
onChange={handlePreferenceChange}
/>
</SettingRow>
)}
</Scene>
);
}
function DisableCollaborativeEditingDialog({
onSubmit,
}: {
onSubmit: () => void;
}) {
const { t } = useTranslation();
return (
<ConfirmationDialog
onSubmit={onSubmit}
submitText={t("Im sure Disable")}
danger
>
<>
<Text type="secondary">
<Trans>
Enabling collaborative editing again in the future may cause some
documents to revert to this point in time. It is not advised to
disable this feature.
</Trans>
</Text>
</>
</ConfirmationDialog>
);
}
export default observer(Features);

View File

@@ -3,6 +3,7 @@ import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { languageOptions } from "@shared/i18n";
import { UserPreference } from "@shared/types";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import InputSelect from "~/components/InputSelect";
@@ -93,7 +94,7 @@ function Preferences() {
<Switch
id="useCursorPointer"
name="useCursorPointer"
checked={user.preferences?.useCursorPointer !== false}
checked={user.getPreference(UserPreference.UseCursorPointer, true)}
onChange={handlePreferenceChange}
/>
</SettingRow>

View File

@@ -2,6 +2,7 @@ import * as Sentry from "@sentry/react";
import invariant from "invariant";
import { observable, action, computed, autorun, runInAction } from "mobx";
import { getCookie, setCookie, removeCookie } from "tiny-cookie";
import { TeamPreferences, UserPreferences } from "@shared/types";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import RootStore from "~/stores/RootStore";
import Policy from "~/models/Policy";
@@ -221,7 +222,7 @@ export default class AuthStore {
name?: string;
avatarUrl?: string | null;
language?: string;
preferences?: Record<string, boolean>;
preferences?: UserPreferences;
}) => {
this.isSaving = true;
@@ -246,6 +247,7 @@ export default class AuthStore {
defaultCollectionId?: string | null;
subdomain?: string | null | undefined;
allowedDomains?: string[] | null | undefined;
preferences?: TeamPreferences;
}) => {
this.isSaving = true;