Compare commits

..

4 Commits

Author SHA1 Message Date
Tom Moor bff6a80b67 styles 2022-06-25 19:07:18 +02:00
Tom Moor 07ad87f65f fix: Don't send email notification with empty diff 2022-06-24 09:57:53 +02:00
Tom Moor dd471328db fix: Collection name missing in notification email
fix: Email styles
2022-06-24 09:50:39 +02:00
Tom Moor 04f0983e20 Bringing across still relevant work from email-diff branch 2022-06-23 10:54:11 +02:00
546 changed files with 10076 additions and 20025 deletions
-8
View File
@@ -35,14 +35,6 @@
"displayName": false
}
]
],
"ignore": [
"**/*.test.ts"
]
},
"development": {
"ignore": [
"**/*.test.ts"
]
}
}
+1 -14
View File
@@ -70,15 +70,6 @@ jobs:
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
steps:
@@ -90,7 +81,7 @@ jobs:
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: test
command: yarn test:server --forceExit
command: yarn test:server
bundle-size:
<<: *defaults
steps:
@@ -149,9 +140,6 @@ workflows:
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
@@ -161,7 +149,6 @@ workflows:
- bundle-size:
requires:
- test-app
- test-shared
- test-server
build-docker:
-9
View File
@@ -1,7 +1,5 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
NODE_ENV=production
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
@@ -170,10 +168,3 @@ SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# Optionally enable rate limiter at application web server
RATE_LIMITER_ENABLED=true
# Configure default throttling parameters for rate limiter
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
+2 -1
View File
@@ -12,6 +12,7 @@
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended"
],
"plugins": [
@@ -20,12 +21,12 @@
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"eslint-plugin-react-hooks",
"import"
],
"rules": {
"eqeqeq": 2,
"curly": 2,
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"es/no-regexp-lookbehind-assertions": "error",
-11
View File
@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
-1
View File
@@ -3,7 +3,6 @@ build
node_modules/*
.env
.log
.vscode/*
npm-debug.log
stats.json
.DS_Store
-88
View File
@@ -1,88 +0,0 @@
{
"projects": [
{
"displayName": "server",
"verbose": false,
"roots": [
"<rootDir>/server"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/env.ts"
],
"setupFilesAfterEnv": [
"<rootDir>/server/test/setup.ts"
],
"testEnvironment": "node",
"runner": "@getoutline/jest-runner-serial"
},
{
"displayName": "app",
"verbose": false,
"roots": [
"<rootDir>/app"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"<rootDir>/app/test/setup.ts"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
},
{
"displayName": "shared-node",
"verbose": false,
"roots": [
"<rootDir>/shared"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js"
],
"setupFilesAfterEnv": [
"<rootDir>/shared/test/setup.ts"
],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"verbose": false,
"roots": [
"<rootDir>/shared"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
}
]
}
+17
View File
@@ -0,0 +1,17 @@
export default class Queue {
name;
constructor(name) {
this.name = name;
}
process = (fn) => {
console.log(`Registered function ${this.name}`);
this.processFn = fn;
};
add = (data) => {
console.log(`Running ${this.name}`);
return this.processFn({ data });
};
}
+1
View File
@@ -1 +1,2 @@
// Mock for node-uuid
global.console.warn = () => {};
+1 -5
View File
@@ -1,10 +1,6 @@
{
"extends": [
"../.eslintrc",
"plugin:react-hooks/recommended",
],
"plugins": [
"eslint-plugin-react-hooks",
"../.eslintrc"
],
"env": {
"jest": true,
+27
View File
@@ -0,0 +1,27 @@
{
"testURL": "http://localhost",
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/app",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.ts"
],
"testEnvironment": "jsdom"
}
+32
View File
@@ -0,0 +1,32 @@
import { ToolsIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { createAction } from "~/actions";
import { DebugSection } from "~/actions/sections";
import env from "~/env";
import { deleteAllDatabases } from "~/utils/developer";
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DebugSection,
perform: async ({ t }) => {
await deleteAllDatabases();
stores.toasts.showToast(t("IndexedDB cache deleted"));
},
});
export const development = createAction({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DebugSection,
visible: ({ event }) =>
env.ENVIRONMENT === "development" ||
(event instanceof KeyboardEvent && event.altKey),
children: [clearIndexedDB],
});
export const rootDebugActions = [development];
-50
View File
@@ -1,50 +0,0 @@
import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import { deleteAllDatabases } from "~/utils/developer";
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DeveloperSection,
perform: async ({ t }) => {
await deleteAllDatabases();
stores.toasts.showToast(t("IndexedDB cache deleted"));
},
});
export const createTestUsers = createAction({
name: "Create test users",
icon: <UserIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: async () => {
const count = 10;
try {
await client.post("/developer.create_test_users", { count });
stores.toasts.showToast(`${count} test users created`);
} catch (err) {
stores.toasts.showToast(err.message, { type: "error" });
}
},
});
export const developer = createAction({
name: ({ t }) => t("Developer"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DeveloperSection,
visible: ({ event }) =>
env.ENVIRONMENT === "development" ||
(event instanceof KeyboardEvent && event.altKey),
children: [clearIndexedDB, createTestUsers],
});
export const rootDeveloperActions = [developer];
+15 -245
View File
@@ -11,18 +11,9 @@ import {
ImportIcon,
PinIcon,
SearchIcon,
UnsubscribeIcon,
SubscribeIcon,
MoveIcon,
TrashIcon,
CrossIcon,
ArchiveIcon,
} from "outline-icons";
import * as React from "react";
import { getEventFiles } from "@shared/utils/files";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
@@ -117,111 +108,22 @@ export const unstarDocument = createAction({
},
});
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
section: DocumentSection,
icon: <SubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
},
perform: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.subscribe();
stores.toasts.showToast(t("Subscribed to document notifications"), {
type: "success",
});
},
});
export const unsubscribeDocument = createAction({
name: ({ t }) => t("Unsubscribe"),
section: DocumentSection,
icon: <UnsubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
);
},
perform: ({ activeDocumentId, stores, currentUserId, t }) => {
if (!activeDocumentId || !currentUserId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.unsubscribe(currentUserId);
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
type: "success",
});
},
});
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("HTML"),
section: DocumentSection,
keywords: "html export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.download("text/html");
},
});
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Markdown"),
section: DocumentSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.download("text/markdown");
},
});
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
section: DocumentSection,
icon: <DownloadIcon />,
keywords: "export",
children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown],
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.download();
},
});
export const duplicateDocument = createAction({
@@ -358,8 +260,8 @@ export const importDocument = createAction({
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev) => {
const files = getEventFiles(ev);
input.onchange = async (ev: Event) => {
const files = getDataTransferFiles(ev);
try {
const file = files[0];
@@ -394,11 +296,10 @@ export const createTemplate = createAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
return !!(
return (
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate &&
!document?.isDeleted
!document?.isTemplate
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -427,146 +328,15 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Move {{ documentName }}", {
documentName: document.noun,
}),
content: (
<DocumentMove
document={document}
onRequestClose={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const archiveDocument = createAction({
name: ({ t }) => t("Archive"),
section: DocumentSection,
icon: <ArchiveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).archive;
},
perform: async ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.archive();
stores.toasts.showToast(t("Document archived"), {
type: "success",
});
}
},
});
export const deleteDocument = createAction({
name: ({ t }) => t("Delete"),
section: DocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).delete;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const permanentlyDeleteDocument = createAction({
name: ({ t }) => t("Permanently delete"),
section: DocumentSection,
icon: <CrossIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).permanentDelete;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Permanently delete {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: (
<DocumentPermanentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createTemplate,
deleteDocument,
importDocument,
downloadDocument,
starDocument,
unstarDocument,
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
moveDocument,
permanentlyDeleteDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
-40
View File
@@ -1,40 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import { createAction } from "~/actions";
import { loadSessionsFromCookie } from "~/hooks/useSessions";
export const changeTeam = createAction({
name: ({ t }) => t("Switch team"),
placeholder: ({ t }) => t("Select a team"),
keywords: "change workspace organization",
section: "Account",
visible: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.length > 0;
},
children: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.map((session) => ({
id: session.url,
name: session.name,
section: "Account",
icon: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
}));
},
});
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export const rootTeamActions = [changeTeam];
-1
View File
@@ -56,7 +56,6 @@ export function actionToMenuItem(
title,
icon,
visible,
dangerous: action.dangerous,
onClick: () => action.perform && action.perform(context),
selected: action.selected ? action.selected(context) : undefined,
};
+2 -4
View File
@@ -1,9 +1,8 @@
import { rootCollectionActions } from "./definitions/collections";
import { rootDeveloperActions } from "./definitions/developer";
import { rootDebugActions } from "./definitions/debug";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootUserActions } from "./definitions/users";
export default [
@@ -12,6 +11,5 @@ export default [
...rootUserActions,
...rootNavigationActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
...rootDebugActions,
];
+1 -1
View File
@@ -2,7 +2,7 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DebugSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
+1 -1
View File
@@ -37,7 +37,7 @@ function Breadcrumb({
return (
<Flex justify="flex-start" align="center">
{topLevelItems.map((item, index) => (
<React.Fragment key={String(item.to) || index}>
<React.Fragment key={item.to || index}>
{item.icon}
{item.to ? (
<Item
+1 -2
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import { ExpandedIcon } from "outline-icons";
import { darken, lighten } from "polished";
import * as React from "react";
@@ -156,7 +155,7 @@ export type Props<T> = {
primary?: boolean;
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
to?: string;
borderOnHover?: boolean;
href?: string;
"data-on"?: string;
+12 -1
View File
@@ -1,6 +1,17 @@
import * as React from "react";
import styled from "styled-components";
const ButtonLink = styled.button`
type Props = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
const ButtonLink: React.FC<Props> = React.forwardRef(
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
return <Button {...props} ref={ref} />;
}
);
const Button = styled.button`
margin: 0;
padding: 0;
border: 0;
+1 -1
View File
@@ -25,7 +25,7 @@ function CollectionDescription({ collection }: Props) {
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection);
const can = usePolicy(collection.id);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
+1 -2
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
@@ -11,7 +10,7 @@ type Props = {
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: LocationDescriptor;
to?: string;
href?: string;
target?: "_blank";
as?: string | React.ComponentType<any>;
+4 -4
View File
@@ -49,8 +49,8 @@ function DocumentListItem(
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const user = useCurrentUser();
const team = useCurrentTeam();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const {
@@ -70,7 +70,7 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = usePolicy(team);
const can = usePolicy(currentTeam.id);
const canCollection = usePolicy(document.collectionId);
return (
@@ -96,7 +96,7 @@ function DocumentListItem(
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy.id !== user.id && (
{document.isBadgedNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
+1 -2
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -36,7 +35,7 @@ type Props = {
showLastViewed?: boolean;
showParentDocuments?: boolean;
document: Document;
to?: LocationDescriptor;
to?: string;
};
const DocumentMeta: React.FC<Props> = ({
+1 -2
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import { observer, useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -13,7 +12,7 @@ import useStores from "~/hooks/useStores";
type Props = {
document: Document;
isDraft: boolean;
to?: LocationDescriptor;
to?: string;
rtl?: boolean;
};
+11 -34
View File
@@ -1,24 +1,22 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import mergeRefs from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import embeds from "@shared/editor/embeds";
import { Heading } from "@shared/editor/lib/getHeadings";
import { getDataTransferFiles } from "@shared/utils/files";
import { supportedImageMimeTypes } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { NotFoundError } from "~/utils/errors";
@@ -59,7 +57,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { documents } = useStores();
const { showToast } = useToasts();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const [
activeLinkEvent,
setActiveLinkEvent,
@@ -180,41 +177,21 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
event.preventDefault();
event.stopPropagation();
const files = getDataTransferFiles(event);
const view = ref?.current?.view;
if (!view) {
return;
}
// Find a valid position at the end of the document to insert our content
// Insert all files as attachments if any of the files are not images.
const isAttachment = files.some(
(file) => !supportedImageMimeTypes.includes(file.type)
);
// Find a valid position at the end of the document
const pos = TextSelection.near(
view.state.doc.resolve(view.state.doc.nodeSize - 2)
).from;
// If there are no files in the drop event attempt to parse the html
// as a fragment and insert it at the end of the document
if (files.length === 0) {
const text =
event.dataTransfer.getData("text/html") ||
event.dataTransfer.getData("text/plain");
const dom = new DOMParser().parseFromString(text, "text/html");
view.dispatch(
view.state.tr.insert(
pos,
ProsemirrorDOMParser.fromSchema(view.state.schema).parse(dom)
)
);
return;
}
// Insert all files as attachments if any of the files are not images.
const isAttachment = files.some(
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
);
insertFiles(view, event, pos, files, {
uploadFile: onUploadFile,
onFileUploadStart: props.onFileUploadStart,
@@ -312,4 +289,4 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
);
}
export default observer(React.forwardRef(Editor));
export default React.forwardRef(Editor);
-1
View File
@@ -2,7 +2,6 @@ import styled from "styled-components";
const Empty = styled.p`
color: ${(props) => props.theme.textTertiary};
user-select: none;
`;
export default Empty;
+1 -1
View File
@@ -33,7 +33,7 @@ type Props = {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const location = useLocation();
const can = usePolicy(document);
const can = usePolicy(document.id);
const opts = {
userName: event.actor.name,
};
+13 -3
View File
@@ -40,7 +40,6 @@ import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { colorPalette } from "@shared/utils/collections";
import ContextMenu from "~/components/ContextMenu";
import Flex from "~/components/Flex";
import { LabelText } from "~/components/Input";
@@ -201,7 +200,18 @@ export const icons = {
keywords: "warning alert error",
},
};
const colors = [
"#4E5C6E",
"#0366d6",
"#9E5CF7",
"#FF825C",
"#FF5C80",
"#FFBE0B",
"#42DED1",
"#00D084",
"#FF4DFA",
"#2F362F",
];
type Props = {
onOpen?: () => void;
onClose?: () => void;
@@ -262,7 +272,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
colors={colors}
triangle="hide"
styles={{
default: {
+30 -29
View File
@@ -104,17 +104,31 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = React.InputHTMLAttributes<
HTMLInputElement | HTMLTextAreaElement
> & {
export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
labelHidden?: boolean;
value?: string;
label?: string;
className?: string;
labelHidden?: boolean;
flex?: boolean;
short?: boolean;
margin?: string | number;
icon?: React.ReactNode;
innerRef?: React.Ref<any>;
name?: string;
pattern?: string;
minLength?: number;
maxLength?: number;
autoFocus?: boolean;
autoComplete?: boolean | string;
readOnly?: boolean;
required?: boolean;
disabled?: boolean;
placeholder?: string;
onChange?: (
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
};
@@ -157,6 +171,8 @@ class Input extends React.Component<Props> {
...rest
} = this.props;
const InputComponent: React.ComponentType =
type === "textarea" ? RealTextarea : RealInput;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
@@ -170,24 +186,15 @@ class Input extends React.Component<Props> {
))}
<Outline focused={this.focused} margin={margin}>
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
<RealTextarea
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
{...rest}
/>
) : (
<RealInput
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type}
{...rest}
/>
)}
<InputComponent
// @ts-expect-error no idea why this is not working
ref={this.input}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type === "textarea" ? undefined : type}
{...rest}
/>
</Outline>
</label>
</Wrapper>
@@ -195,10 +202,4 @@ class Input extends React.Component<Props> {
}
}
export const ReactHookWrappedInput = React.forwardRef(
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
return <Input {...{ ...props, innerRef: ref }} />;
}
);
export default Input;
+2 -3
View File
@@ -1,7 +1,6 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { $Diff } from "utility-types";
import { CollectionPermission } from "@shared/types";
import InputSelect, { Props, Option } from "./InputSelect";
export default function InputSelectPermission(
@@ -32,11 +31,11 @@ export default function InputSelectPermission(
options={[
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
value: "read_write",
},
{
label: t("View only"),
value: CollectionPermission.Read,
value: "read",
},
{
label: t("No access"),
-53
View File
@@ -1,53 +0,0 @@
import { DisconnectedIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Empty from "~/components/Empty";
import useEventListener from "~/hooks/useEventListener";
import { OfflineError } from "~/utils/errors";
import ButtonLink from "../ButtonLink";
import Flex from "../Flex";
type Props = {
error: Error;
retry: () => void;
};
export default function LoadingError({ error, retry, ...rest }: Props) {
const { t } = useTranslation();
useEventListener("online", retry);
const message =
error instanceof OfflineError ? (
<>
<DisconnectedIcon color="currentColor" /> {t("Youre offline.")}
</>
) : (
<>
<WarningIcon color="currentColor" /> {t("Sorry, an error occurred.")}
</>
);
return (
<Content {...rest}>
<Flex align="center" gap={4}>
{message}{" "}
<ButtonLink onClick={() => retry()}>{t("Click to retry")}</ButtonLink>
</Flex>
</Content>
);
}
const Content = styled(Empty)`
padding: 8px 0;
white-space: nowrap;
${ButtonLink} {
color: ${(props) => props.theme.textTertiary};
&:hover {
color: ${(props) => props.theme.textSecondary};
text-decoration: underline;
}
}
`;
+1 -5
View File
@@ -69,11 +69,7 @@ const ListItem = (
);
};
const Wrapper = styled.a<{
$small?: boolean;
$border?: boolean;
to?: string;
}>`
const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) =>
-2
View File
@@ -2,7 +2,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
type Props = {
@@ -41,7 +40,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
heading={heading}
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index, compositeProps) => (
<DocumentListItem
key={item.id}
+16 -34
View File
@@ -34,19 +34,12 @@ type Props<T> = WithTranslation &
index: number,
compositeProps: CompositeStateReturn
) => React.ReactNode;
renderError?: (options: {
error: Error;
retry: () => void;
}) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
@observer
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
@observable
error?: Error;
@observable
isFetchingMore = false;
@@ -87,7 +80,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.isFetchingMore = false;
};
@action
fetchResults = async () => {
if (!this.props.fetch) {
return;
@@ -95,30 +87,25 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = DEFAULT_PAGINATION_LIMIT;
this.error = undefined;
try {
const results = await this.props.fetch({
limit,
offset: this.offset,
...this.props.options,
});
const results = await this.props.fetch({
limit,
offset: this.offset,
...this.props.options,
});
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
this.renderCount += limit;
} catch (err) {
this.error = err;
} finally {
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
}
this.renderCount += limit;
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
}
};
@@ -151,7 +138,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
auth,
empty = null,
renderHeading,
renderError,
onEscape,
} = this.props;
@@ -171,10 +157,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
}
if (items?.length === 0) {
if (this.error && renderError) {
return renderError({ error: this.error, retry: this.fetchResults });
}
return empty;
}
+4 -8
View File
@@ -10,7 +10,6 @@ import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
@@ -35,15 +34,12 @@ function AppSidebar() {
const { t } = useTranslation();
const { documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team);
const can = usePolicy(team.id);
React.useEffect(() => {
if (!user.isViewer) {
documents.fetchDrafts();
documents.fetchTemplates();
}
}, [documents, user.isViewer]);
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
@@ -44,7 +44,7 @@ const CollectionLink: React.FC<Props> = ({
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const canUpdate = usePolicy(collection).update;
const canUpdate = usePolicy(collection.id).update;
const { t } = useTranslation();
const history = useHistory();
const inStarredSection = useStarredContext();
@@ -25,7 +25,7 @@ function CollectionLinkChildren({
expanded,
prefetchDocument,
}: Props) {
const can = usePolicy(collection);
const can = usePolicy(collection.id);
const { showToast } = useToasts();
const manualSort = collection.sort.field === "index";
const { documents } = useStores();
@@ -3,13 +3,12 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
import { createCollection } from "~/actions/definitions/collections";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import DraggableCollectionLink from "./DraggableCollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
@@ -19,10 +18,39 @@ import SidebarAction from "./SidebarAction";
import { DragObject } from "./SidebarLink";
function Collections() {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { documents, collections } = useStores();
const { showToast } = useToasts();
const isPreloaded = !!collections.orderedData.length;
const { t } = useTranslation();
const orderedCollections = collections.orderedData;
React.useEffect(() => {
async function load() {
if (!collections.isLoaded && !isFetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
showToast(
t("Collections could not be loaded, please reload the app"),
{
type: "error",
}
);
setFetchError(error);
} finally {
setFetching(false);
}
}
}
load();
}, [collections, isFetching, showToast, fetchError, t]);
const [
{ isCollectionDropping, isDraggingAnyCollection },
dropToReorderCollection,
@@ -43,46 +71,45 @@ function Collections() {
}),
});
const content = (
<>
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
)}
{orderedCollections.map((collection: Collection, index: number) => (
<DraggableCollectionLink
key={collection.id}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
))}
<SidebarAction action={createCollection} depth={0} />
</>
);
if (!collections.isLoaded || fetchError) {
return (
<Flex column>
<Header id="collections" title={t("Collections")}>
<PlaceholderCollections />
</Header>
</Flex>
);
}
return (
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
aria-label={t("Collections")}
items={collections.orderedData}
fetch={collections.fetchPage}
options={{ limit: 100 }}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
)}
/>
<SidebarAction action={createCollection} depth={0} />
</Relative>
<Relative>{isPreloaded ? content : <Fade>{content}</Fade>}</Relative>
</Header>
</Flex>
);
}
const StyledError = styled(Error)`
font-size: 15px;
padding: 0 8px;
`;
export default observer(Collections);
@@ -6,8 +6,8 @@ import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
@@ -319,7 +319,7 @@ function InnerDocumentLink(
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
maxLength={MAX_TITLE_LENGTH}
/>
}
isActive={(match, location: Location<{ starred?: boolean }>) =>
@@ -39,7 +39,7 @@ function DraggableCollectionLink({
const [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId && !locationStateStarred
);
const can = usePolicy(collection);
const can = usePolicy(collection.id);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Drop to reorder collection
@@ -2,7 +2,7 @@
// This file is pulled almost 100% from react-router with the addition of one
// thing, automatic scroll to the active link. It's worth the copy paste because
// it avoids recalculating the link match again.
import { Location, createLocation, LocationDescriptor } from "history";
import { Location, createLocation } from "history";
import * as React from "react";
import {
__RouterContext as RouterContext,
@@ -13,12 +13,12 @@ import { Link } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
const resolveToLocation = (
to: LocationDescriptor | ((location: Location) => LocationDescriptor),
to: string | Record<string, any>,
currentLocation: Location
) => (typeof to === "function" ? to(currentLocation) : to);
const normalizeToLocation = (
to: LocationDescriptor,
to: string | Record<string, any>,
currentLocation: Location
) => {
return typeof to === "string"
@@ -30,15 +30,17 @@ const joinClassnames = (...classnames: (string | undefined)[]) => {
return classnames.filter((i) => i).join(" ");
};
export type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
export type Props = React.HTMLAttributes<HTMLAnchorElement> & {
activeClassName?: string;
activeStyle?: React.CSSProperties;
className?: string;
scrollIntoViewIfNeeded?: boolean;
exact?: boolean;
isActive?: (match: match | null, location: Location) => boolean;
location?: Location;
strict?: boolean;
to: LocationDescriptor;
style?: React.CSSProperties;
to: string | Record<string, any>;
};
/**
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -15,7 +14,8 @@ export type DragObject = NavigationNode & {
};
type Props = Omit<NavLinkProps, "to"> & {
to?: LocationDescriptor;
to?: string | Record<string, any>;
href?: string | Record<string, any>;
innerRef?: (ref: HTMLElement | null | undefined) => void;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
@@ -32,7 +32,6 @@ export default function Version() {
return (
<SidebarLink
target="_blank"
href="https://github.com/outline/outline/releases"
label={
<>
+387
View File
@@ -0,0 +1,387 @@
import invariant from "invariant";
import { find } from "lodash";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import io from "socket.io-client";
import RootStore from "~/stores/RootStore";
import withStores from "~/components/withStores";
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
type SocketWithAuthentication = {
authenticated?: boolean;
disconnected: boolean;
disconnect: () => void;
close: () => void;
on: (event: string, callback: (data: any) => void) => void;
emit: (event: string, data: any) => void;
io: any;
};
export const SocketContext: any = React.createContext<SocketWithAuthentication | null>(
null
);
type Props = RootStore;
@observer
class SocketProvider extends React.Component<Props> {
@observable
socket: SocketWithAuthentication | null;
componentDidMount() {
this.createConnection();
document.addEventListener(getVisibilityListener(), this.checkConnection);
}
componentWillUnmount() {
if (this.socket) {
this.socket.authenticated = false;
this.socket.disconnect();
}
document.removeEventListener(getVisibilityListener(), this.checkConnection);
}
checkConnection = () => {
if (this.socket?.disconnected && getPageVisible()) {
// null-ifying this reference is important, do not remove. Without it
// references to old sockets are potentially held in context
this.socket.close();
this.socket = null;
this.createConnection();
}
};
createConnection = () => {
this.socket = io(window.location.origin, {
path: "/realtime",
transports: ["websocket"],
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
});
invariant(this.socket, "Socket should be defined");
this.socket.authenticated = false;
const {
auth,
toasts,
documents,
collections,
groups,
pins,
stars,
memberships,
policies,
presence,
views,
fileOperations,
} = this.props;
if (!auth.token) {
return;
}
this.socket.on("connect", () => {
// immediately send current users token to the websocket backend where it
// is verified, if all goes well an 'authenticated' message will be
// received in response
this.socket?.emit("authentication", {
token: auth.token,
});
});
this.socket.on("disconnect", () => {
// when the socket is disconnected we need to clear all presence state as
// it's no longer reliable.
presence.clear();
});
// on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.on("reconnect_attempt", () => {
if (this.socket) {
this.socket.io.opts.transports = auth?.team?.domain
? ["websocket"]
: ["websocket", "polling"];
}
});
this.socket.on("authenticated", () => {
if (this.socket) {
this.socket.authenticated = true;
}
});
this.socket.on("unauthorized", (err: Error) => {
if (this.socket) {
this.socket.authenticated = false;
}
toasts.showToast(err.message, {
type: "error",
});
throw err;
});
this.socket.on("entities", async (event: any) => {
if (event.documentIds) {
for (const documentDescriptor of event.documentIds) {
const documentId = documentDescriptor.id;
let document = documents.get(documentId) || {};
if (event.event === "documents.delete") {
const document = documents.get(documentId);
if (document) {
document.deletedAt = documentDescriptor.updatedAt;
}
policies.remove(documentId);
continue;
}
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
// @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'.
const { title, updatedAt } = document;
if (updatedAt === documentDescriptor.updatedAt) {
continue;
}
// otherwise, grab the latest version of the document
try {
document = await documents.fetch(documentId, {
force: true,
});
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
documents.remove(documentId);
return;
}
}
// if the title changed then we need to update the collection also
// @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'.
if (title !== document.title) {
if (!event.collectionIds) {
event.collectionIds = [];
}
const existing = find(event.collectionIds, {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message
id: document.collectionId,
});
if (!existing) {
event.collectionIds.push({
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message
id: document.collectionId,
});
}
}
}
}
if (event.collectionIds) {
for (const collectionDescriptor of event.collectionIds) {
const collectionId = collectionDescriptor.id;
const collection = collections.get(collectionId);
if (event.event === "collections.delete") {
if (collection) {
collection.deletedAt = collectionDescriptor.updatedAt;
}
const deletedDocuments = documents.inCollection(collectionId);
deletedDocuments.forEach((doc) => {
doc.deletedAt = collectionDescriptor.updatedAt;
policies.remove(doc.id);
});
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
continue;
}
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
try {
await collections.fetch(collectionId, {
force: true,
});
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
return;
}
}
}
}
if (event.groupIds) {
for (const groupDescriptor of event.groupIds) {
const groupId = groupDescriptor.id;
const group = groups.get(groupId) || {};
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type '{}'.
const { updatedAt } = group;
if (updatedAt === groupDescriptor.updatedAt) {
continue;
}
try {
await groups.fetch(groupId, {
force: true,
});
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
groups.remove(groupId);
}
}
}
}
if (event.teamIds) {
await auth.fetch();
}
});
this.socket.on("pins.create", (event: any) => {
pins.add(event);
});
this.socket.on("pins.update", (event: any) => {
pins.add(event);
});
this.socket.on("pins.delete", (event: any) => {
pins.remove(event.modelId);
});
this.socket.on("stars.create", (event: any) => {
stars.add(event);
});
this.socket.on("stars.update", (event: any) => {
stars.add(event);
});
this.socket.on("stars.delete", (event: any) => {
stars.remove(event.modelId);
});
this.socket.on("documents.permanent_delete", (event: any) => {
documents.remove(event.documentId);
});
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on("collections.add_user", (event: any) => {
if (auth.user && event.userId === auth.user.id) {
collections.fetch(event.collectionId, {
force: true,
});
}
// Document policies might need updating as the permission changes
documents.inCollection(event.collectionId).forEach((document) => {
policies.remove(document.id);
});
});
// received when a user is removed from having access to a collection
// to keep state in sync we must update our UI if the user is us,
// or otherwise just remove any membership state we have for that user.
this.socket.on("collections.remove_user", (event: any) => {
if (auth.user && event.userId === auth.user.id) {
collections.remove(event.collectionId);
memberships.removeCollectionMemberships(event.collectionId);
documents.removeCollectionDocuments(event.collectionId);
} else {
memberships.remove(`${event.userId}-${event.collectionId}`);
}
});
this.socket.on("collections.update_index", (event: any) => {
const collection = collections.get(event.collectionId);
if (collection) {
collection.updateIndex(event.index);
}
});
this.socket.on("fileOperations.create", async (event: any) => {
const user = auth.user;
if (user) {
fileOperations.add({ ...event, user });
}
});
this.socket.on("fileOperations.update", async (event: any) => {
const user = auth.user;
if (user) {
fileOperations.add({ ...event, user });
}
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event: any) => {
this.socket?.emit("join", event);
});
// received a message from the API server that we should request
// to leave a specific room. Forward that to the ws server.
this.socket.on("leave", (event: any) => {
this.socket?.emit("leave", event);
});
// received whenever we join a document room, the payload includes
// userIds that are present/viewing and those that are editing.
this.socket.on("document.presence", (event: any) => {
presence.init(event.documentId, event.userIds, event.editingIds);
});
// received whenever a new user joins a document room, aka they
// navigate to / start viewing a document
this.socket.on("user.join", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
views.touch(event.documentId, event.userId);
});
// received whenever a new user leaves a document room, aka they
// navigate away / stop viewing a document
this.socket.on("user.leave", (event: any) => {
presence.leave(event.documentId, event.userId);
views.touch(event.documentId, event.userId);
});
// received when another client in a document room wants to change
// or update it's presence. Currently the only property is whether
// the client is in editing state or not.
this.socket.on("user.presence", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
});
};
render() {
return (
<SocketContext.Provider value={this.socket}>
{this.props.children}
</SocketContext.Provider>
);
}
}
export default withStores(SocketProvider);
+6 -3
View File
@@ -1,14 +1,17 @@
import { m } from "framer-motion";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import NavLink from "~/components/NavLink";
import NavLinkWithChildrenFunc from "~/components/NavLink";
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
type Props = Omit<
React.ComponentProps<typeof NavLinkWithChildrenFunc>,
"children"
> & {
to: string;
exact?: boolean;
};
const TabLink = styled(NavLink)`
const TabLink = styled(NavLinkWithChildrenFunc)`
position: relative;
display: inline-flex;
align-items: center;
+1 -2
View File
@@ -1,4 +1,3 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
@@ -73,4 +72,4 @@ const TableFromParams = (
);
};
export default observer(TableFromParams);
export default TableFromParams;
+1 -1
View File
@@ -2,10 +2,10 @@ import { observer } from "mobx-react";
import * as React from "react";
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 useMediaQuery from "~/hooks/useMediaQuery";
import useStores from "~/hooks/useStores";
import GlobalStyles from "~/styles/globals";
const Theme: React.FC = ({ children }) => {
const { ui } = useStores();
-448
View File
@@ -1,448 +0,0 @@
import invariant from "invariant";
import { find } from "lodash";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { io, Socket } from "socket.io-client";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
import Pin from "~/models/Pin";
import Star from "~/models/Star";
import Subscription from "~/models/Subscription";
import Team from "~/models/Team";
import withStores from "~/components/withStores";
import {
PartialWithId,
WebsocketCollectionUpdateIndexEvent,
WebsocketCollectionUserEvent,
WebsocketEntitiesEvent,
WebsocketEntityDeletedEvent,
} from "~/types";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
type SocketWithAuthentication = Socket & {
authenticated?: boolean;
};
export const WebsocketContext = React.createContext<SocketWithAuthentication | null>(
null
);
type Props = RootStore;
@observer
class WebsocketProvider extends React.Component<Props> {
@observable
socket: SocketWithAuthentication | null;
componentDidMount() {
this.createConnection();
document.addEventListener(getVisibilityListener(), this.checkConnection);
}
componentWillUnmount() {
if (this.socket) {
this.socket.authenticated = false;
this.socket.disconnect();
}
document.removeEventListener(getVisibilityListener(), this.checkConnection);
}
checkConnection = () => {
if (this.socket?.disconnected && getPageVisible()) {
// null-ifying this reference is important, do not remove. Without it
// references to old sockets are potentially held in context
this.socket.close();
this.socket = null;
this.createConnection();
}
};
createConnection = () => {
this.socket = io(window.location.origin, {
path: "/realtime",
transports: ["websocket"],
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
});
invariant(this.socket, "Socket should be defined");
this.socket.authenticated = false;
const {
auth,
toasts,
documents,
collections,
groups,
pins,
stars,
memberships,
policies,
presence,
views,
subscriptions,
fileOperations,
} = this.props;
if (!auth.token) {
return;
}
this.socket.on("connect", () => {
// immediately send current users token to the websocket backend where it
// is verified, if all goes well an 'authenticated' message will be
// received in response
this.socket?.emit("authentication", {
token: auth.token,
});
});
this.socket.on("disconnect", () => {
// when the socket is disconnected we need to clear all presence state as
// it's no longer reliable.
presence.clear();
});
// on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.io.on("reconnect_attempt", () => {
if (this.socket) {
this.socket.io.opts.transports = auth?.team?.domain
? ["websocket"]
: ["websocket", "polling"];
}
});
this.socket.on("authenticated", () => {
if (this.socket) {
this.socket.authenticated = true;
}
});
this.socket.on("unauthorized", (err: Error) => {
if (this.socket) {
this.socket.authenticated = false;
}
toasts.showToast(err.message, {
type: "error",
});
throw err;
});
this.socket.on(
"entities",
action(async (event: WebsocketEntitiesEvent) => {
if (event.documentIds) {
for (const documentDescriptor of event.documentIds) {
const documentId = documentDescriptor.id;
let document = documents.get(documentId);
const previousTitle = document?.title;
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
if (document?.updatedAt === documentDescriptor.updatedAt) {
continue;
}
// otherwise, grab the latest version of the document
try {
document = await documents.fetch(documentId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.remove(documentId);
return;
}
}
// if the title changed then we need to update the collection also
if (document && previousTitle !== document.title) {
if (!event.collectionIds) {
event.collectionIds = [];
}
const existing = find(event.collectionIds, {
id: document.collectionId,
});
if (!existing) {
event.collectionIds.push({
id: document.collectionId,
});
}
}
}
}
if (event.collectionIds) {
for (const collectionDescriptor of event.collectionIds) {
const collectionId = collectionDescriptor.id;
const collection = collections.get(collectionId);
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
try {
await collections.fetch(collectionId, {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
return;
}
}
}
}
})
);
this.socket.on(
"documents.update",
action(
(event: PartialWithId<Document> & { title: string; url: string }) => {
documents.add(event);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.updateDocument(event);
}
}
)
);
this.socket.on(
"documents.archive",
action((event: PartialWithId<Document>) => {
documents.add(event);
policies.remove(event.id);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.removeDocument(event.id);
}
})
);
this.socket.on(
"documents.delete",
action((event: PartialWithId<Document>) => {
documents.add(event);
policies.remove(event.id);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.removeDocument(event.id);
}
})
);
this.socket.on(
"documents.permanent_delete",
(event: WebsocketEntityDeletedEvent) => {
documents.remove(event.modelId);
}
);
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
groups.add(event);
});
this.socket.on("groups.update", (event: PartialWithId<Group>) => {
groups.add(event);
});
this.socket.on("groups.delete", (event: WebsocketEntityDeletedEvent) => {
groups.remove(event.modelId);
});
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {
collections.add(event);
});
this.socket.on(
"collections.delete",
action((event: WebsocketEntityDeletedEvent) => {
const collectionId = event.modelId;
const deletedAt = new Date().toISOString();
const deletedDocuments = documents.inCollection(collectionId);
deletedDocuments.forEach((doc) => {
doc.deletedAt = deletedAt;
policies.remove(doc.id);
});
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
})
);
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
auth.updateTeam(event);
});
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
pins.add(event);
});
this.socket.on("pins.update", (event: PartialWithId<Pin>) => {
pins.add(event);
});
this.socket.on("pins.delete", (event: WebsocketEntityDeletedEvent) => {
pins.remove(event.modelId);
});
this.socket.on("stars.create", (event: PartialWithId<Star>) => {
stars.add(event);
});
this.socket.on("stars.update", (event: PartialWithId<Star>) => {
stars.add(event);
});
this.socket.on("stars.delete", (event: WebsocketEntityDeletedEvent) => {
stars.remove(event.modelId);
});
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on(
"collections.add_user",
action((event: WebsocketCollectionUserEvent) => {
if (auth.user && event.userId === auth.user.id) {
collections.fetch(event.collectionId, {
force: true,
});
}
// Document policies might need updating as the permission changes
documents.inCollection(event.collectionId).forEach((document) => {
policies.remove(document.id);
});
})
);
// received when a user is removed from having access to a collection
// to keep state in sync we must update our UI if the user is us,
// or otherwise just remove any membership state we have for that user.
this.socket.on(
"collections.remove_user",
action((event: WebsocketCollectionUserEvent) => {
if (auth.user && event.userId === auth.user.id) {
collections.remove(event.collectionId);
memberships.removeCollectionMemberships(event.collectionId);
documents.removeCollectionDocuments(event.collectionId);
} else {
memberships.remove(`${event.userId}-${event.collectionId}`);
}
})
);
this.socket.on(
"collections.update_index",
action((event: WebsocketCollectionUpdateIndexEvent) => {
const collection = collections.get(event.collectionId);
if (collection) {
collection.updateIndex(event.index);
}
})
);
this.socket.on(
"fileOperations.create",
(event: PartialWithId<FileOperation>) => {
fileOperations.add(event);
}
);
this.socket.on(
"fileOperations.update",
(event: PartialWithId<FileOperation>) => {
fileOperations.add(event);
}
);
this.socket.on(
"subscriptions.create",
(event: PartialWithId<Subscription>) => {
subscriptions.add(event);
}
);
this.socket.on(
"subscriptions.delete",
(event: WebsocketEntityDeletedEvent) => {
subscriptions.remove(event.modelId);
}
);
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event: any) => {
this.socket?.emit("join", event);
});
// received a message from the API server that we should request
// to leave a specific room. Forward that to the ws server.
this.socket.on("leave", (event: any) => {
this.socket?.emit("leave", event);
});
// received whenever we join a document room, the payload includes
// userIds that are present/viewing and those that are editing.
this.socket.on("document.presence", (event: any) => {
presence.init(event.documentId, event.userIds, event.editingIds);
});
// received whenever a new user joins a document room, aka they
// navigate to / start viewing a document
this.socket.on("user.join", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
views.touch(event.documentId, event.userId);
});
// received whenever a new user leaves a document room, aka they
// navigate away / stop viewing a document
this.socket.on("user.leave", (event: any) => {
presence.leave(event.documentId, event.userId);
views.touch(event.documentId, event.userId);
});
// received when another client in a document room wants to change
// or update it's presence. Currently the only property is whether
// the client is in editing state or not.
this.socket.on("user.presence", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
});
};
render() {
return (
<WebsocketContext.Provider value={this.socket}>
{this.props.children}
</WebsocketContext.Provider>
);
}
}
export default withStores(WebsocketProvider);
+5 -3
View File
@@ -6,13 +6,15 @@ import useStores from "~/hooks/useStores";
type StoreProps = keyof RootStore;
function withStores<
P extends React.ComponentType<ResolvedProps & RootStore>,
P extends React.ComponentType<React.ComponentProps<P> & RootStore>,
ResolvedProps = JSX.LibraryManagedAttributes<
P,
Omit<React.ComponentProps<P>, StoreProps>
>
>(WrappedComponent: P): React.FC<ResolvedProps> {
const ComponentWithStore = (props: ResolvedProps) => {
>(WrappedComponent: P): React.FC<Omit<ResolvedProps, StoreProps>> {
const ComponentWithStore = (
props: Omit<React.ComponentProps<P>, StoreProps>
) => {
const stores = useStores();
return <WrappedComponent {...(props as any)} {...stores} />;
};
+12 -17
View File
@@ -7,13 +7,12 @@ import { Portal } from "react-portal";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { MenuItem } from "@shared/editor/types";
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
import { depths } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { supportedImageMimeTypes } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input";
@@ -37,7 +36,7 @@ export type Props<T extends MenuItem = MenuItem> = {
onFileUploadStop?: () => void;
onShowToast: (message: string) => void;
onLinkToolbarOpen?: () => void;
onClose: (insertNewLine?: boolean) => void;
onClose: () => void;
onClearSearch: () => void;
embeds?: EmbedDescriptor[];
renderMenuItem: (
@@ -124,7 +123,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
if (item) {
this.insertItem(item);
} else {
this.props.onClose(true);
this.props.onClose();
}
}
@@ -183,9 +182,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
insertItem = (item: any) => {
switch (item.name) {
case "image":
return this.triggerFilePick(
AttachmentValidation.imageContentTypes.join(", ")
);
return this.triggerFilePick(supportedImageMimeTypes.join(", "));
case "attachment":
return this.triggerFilePick("*");
case "embed":
@@ -278,7 +275,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
};
handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(event);
const files = getDataTransferFiles(event);
const {
view,
@@ -427,13 +424,11 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const embedItems: EmbedDescriptor[] = [];
for (const embed of embeds) {
if (embed.title) {
embedItems.push(
new EmbedDescriptor({
...embed,
name: "embed",
})
);
if (embed.title && embed.icon) {
embedItems.push({
...embed,
name: "embed",
});
}
}
+16 -10
View File
@@ -11,7 +11,8 @@ import { setTextSelection } from "prosemirror-utils";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import isUrl from "@shared/editor/lib/isUrl";
import { isInternalUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import { ToastOptions } from "~/types";
@@ -44,7 +45,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast: (message: string, options?: ToastOptions) => void;
onShowToast: (message: string, options: ToastOptions) => void;
view: EditorView;
};
@@ -70,7 +71,7 @@ class LinkEditor extends React.Component<Props, State> {
};
get href(): string {
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
return this.props.mark ? this.props.mark.attrs.href : "";
}
get suggestedLinkTitle(): string {
@@ -113,7 +114,17 @@ class LinkEditor extends React.Component<Props, State> {
this.discardInputValue = true;
const { from, to } = this.props;
href = sanitizeUrl(href) ?? "";
// Make sure a protocol is added to the beginning of the input if it's
// likely an absolute URL that was entered without one.
if (
!isUrl(href) &&
!href.startsWith("/") &&
!href.startsWith("#") &&
!href.startsWith("mailto:")
) {
href = `https://${href}`;
}
this.props.onSelectLink({ href, title, from, to });
};
@@ -229,12 +240,7 @@ class LinkEditor extends React.Component<Props, State> {
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
try {
this.props.onClickLink(this.href, event);
} catch (err) {
this.props.onShowToast(this.props.dictionary.openLinkError);
}
this.props.onClickLink(this.href, event);
};
handleCreateLink = async (value: string) => {
File diff suppressed because it is too large Load Diff
+4 -16
View File
@@ -16,8 +16,6 @@ import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
import { Decoration, EditorView } from "prosemirror-view";
import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components";
import EditorContainer from "@shared/editor/components/Styles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import getHeadings from "@shared/editor/lib/getHeadings";
@@ -27,10 +25,8 @@ import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import fullExtensionsPackage from "@shared/editor/packages/full";
import { EventType } from "@shared/editor/types";
import { IntegrationType } from "@shared/types";
import { EmbedDescriptor, EventType } from "@shared/editor/types";
import EventEmitter from "@shared/utils/events";
import Integration from "~/models/Integration";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
@@ -41,6 +37,7 @@ import EmojiMenu from "./components/EmojiMenu";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import SelectionToolbar from "./components/SelectionToolbar";
import EditorContainer from "./components/Styles";
import WithTheme from "./components/WithTheme";
export { default as Extension } from "@shared/editor/lib/Extension";
@@ -113,8 +110,6 @@ export type Props = {
onShowToast: (message: string) => void;
className?: string;
style?: React.CSSProperties;
embedIntegrations?: Integration<IntegrationType.Embed>[];
};
type State = {
@@ -435,7 +430,7 @@ export class Editor extends React.PureComponent<
state: this.createState(this.props.value),
editable: () => !this.props.readOnly,
nodeViews: this.nodeViews,
dispatchTransaction(transaction) {
dispatchTransaction: function (transaction) {
// callback is bound to have the view instance as its this binding
const { state, transactions } = this.state.applyTransaction(
transaction
@@ -559,14 +554,7 @@ export class Editor extends React.PureComponent<
this.setState({ blockMenuOpen: true, blockMenuSearch: search });
};
private handleCloseBlockMenu = (insertNewLine?: boolean) => {
if (insertNewLine) {
const transaction = this.view.state.tr.split(
this.view.state.selection.to
);
this.view.dispatch(transaction);
this.view.focus();
}
private handleCloseBlockMenu = () => {
if (!this.state.blockMenuOpen) {
return;
}
+9 -15
View File
@@ -14,7 +14,6 @@ import {
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import isInCode from "@shared/editor/queries/isInCode";
import isInList from "@shared/editor/queries/isInList";
import isMarkActive from "@shared/editor/queries/isMarkActive";
import isNodeActive from "@shared/editor/queries/isNodeActive";
@@ -29,7 +28,6 @@ export default function formattingMenuItems(
const { schema } = state;
const isTable = isInTable(state);
const isList = isInList(state);
const isCode = isInCode(state);
const allowBlocks = !isTable && !isList;
return [
@@ -49,21 +47,19 @@ export default function formattingMenuItems(
tooltip: dictionary.strong,
icon: BoldIcon,
active: isMarkActive(schema.marks.strong),
visible: !isCode,
},
{
name: "strikethrough",
tooltip: dictionary.strikethrough,
icon: StrikethroughIcon,
active: isMarkActive(schema.marks.strikethrough),
visible: !isCode,
},
{
name: "highlight",
tooltip: dictionary.mark,
icon: HighlightIcon,
active: isMarkActive(schema.marks.highlight),
visible: !isTemplate && !isCode,
visible: !isTemplate,
},
{
name: "code_inline",
@@ -73,7 +69,7 @@ export default function formattingMenuItems(
},
{
name: "separator",
visible: allowBlocks && !isCode,
visible: allowBlocks,
},
{
name: "heading",
@@ -81,7 +77,7 @@ export default function formattingMenuItems(
icon: Heading1Icon,
active: isNodeActive(schema.nodes.heading, { level: 1 }),
attrs: { level: 1 },
visible: allowBlocks && !isCode,
visible: allowBlocks,
},
{
name: "heading",
@@ -89,7 +85,7 @@ export default function formattingMenuItems(
icon: Heading2Icon,
active: isNodeActive(schema.nodes.heading, { level: 2 }),
attrs: { level: 2 },
visible: allowBlocks && !isCode,
visible: allowBlocks,
},
{
name: "blockquote",
@@ -97,11 +93,11 @@ export default function formattingMenuItems(
icon: BlockQuoteIcon,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
visible: allowBlocks && !isCode,
visible: allowBlocks,
},
{
name: "separator",
visible: (allowBlocks || isList) && !isCode,
visible: allowBlocks || isList,
},
{
name: "checkbox_list",
@@ -109,25 +105,24 @@ export default function formattingMenuItems(
icon: TodoListIcon,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: (allowBlocks || isList) && !isCode,
visible: allowBlocks || isList,
},
{
name: "bullet_list",
tooltip: dictionary.bulletList,
icon: BulletedListIcon,
active: isNodeActive(schema.nodes.bullet_list),
visible: (allowBlocks || isList) && !isCode,
visible: allowBlocks || isList,
},
{
name: "ordered_list",
tooltip: dictionary.orderedList,
icon: OrderedListIcon,
active: isNodeActive(schema.nodes.ordered_list),
visible: (allowBlocks || isList) && !isCode,
visible: allowBlocks || isList,
},
{
name: "separator",
visible: !isCode,
},
{
name: "link",
@@ -135,7 +130,6 @@ export default function formattingMenuItems(
icon: LinkIcon,
active: isMarkActive(schema.marks.link),
attrs: { href: "" },
visible: !isCode,
},
];
}
+5 -33
View File
@@ -9,14 +9,11 @@ import {
LinkIcon,
TeamIcon,
BeakerIcon,
BuildingBlocksIcon,
DownloadIcon,
WebhooksIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import Details from "~/scenes/Settings/Details";
import Drawio from "~/scenes/Settings/Drawio";
import Export from "~/scenes/Settings/Export";
import Features from "~/scenes/Settings/Features";
import Groups from "~/scenes/Settings/Groups";
@@ -28,7 +25,6 @@ import Security from "~/scenes/Settings/Security";
import Shares from "~/scenes/Settings/Shares";
import Slack from "~/scenes/Settings/Slack";
import Tokens from "~/scenes/Settings/Tokens";
import Webhooks from "~/scenes/Settings/Webhooks";
import Zapier from "~/scenes/Settings/Zapier";
import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
@@ -50,7 +46,6 @@ type SettingsPage =
| "Shares"
| "Import"
| "Export"
| "Webhooks"
| "Slack"
| "Zapier";
@@ -69,7 +64,7 @@ type ConfigType = {
const useAuthorizedSettingsConfig = () => {
const team = useCurrentTeam();
const can = usePolicy(team);
const can = usePolicy(team.id);
const { t } = useTranslation();
const config: ConfigType = React.useMemo(
@@ -151,7 +146,7 @@ const useAuthorizedSettingsConfig = () => {
name: t("Import"),
path: "/settings/import",
component: Import,
enabled: can.createImport,
enabled: can.manage,
group: t("Team"),
icon: NewDocumentIcon,
},
@@ -159,27 +154,11 @@ const useAuthorizedSettingsConfig = () => {
name: t("Export"),
path: "/settings/export",
component: Export,
enabled: can.createExport,
enabled: can.export,
group: t("Team"),
icon: DownloadIcon,
},
// Integrations
Webhooks: {
name: t("Webhooks"),
path: "/settings/webhooks",
component: Webhooks,
enabled: can.createWebhookSubscription,
group: t("Integrations"),
icon: WebhooksIcon,
},
Drawio: {
name: t("Draw.io"),
path: "/settings/integrations/drawio",
component: Drawio,
enabled: can.update,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
// Intergrations
Slack: {
name: "Slack",
path: "/settings/integrations/slack",
@@ -197,14 +176,7 @@ const useAuthorizedSettingsConfig = () => {
icon: ZapierIcon,
},
}),
[
can.createApiKey,
can.createWebhookSubscription,
can.createExport,
can.createImport,
can.update,
t,
]
[can.createApiKey, can.export, can.manage, can.update, t]
);
const enabledConfigs = React.useMemo(
-4
View File
@@ -18,7 +18,6 @@ export default function useDictionary() {
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
copy: t("Copy"),
createLink: t("Create link"),
createLinkError: t("Sorry, an error occurred creating the link"),
createNewDoc: t("Create a new doc"),
@@ -53,7 +52,6 @@ export default function useDictionary() {
noResults: t("No results"),
openLink: t("Open link"),
goToLink: t("Go to link"),
openLinkError: t("Sorry, that type of link is not supported"),
orderedList: t("Ordered list"),
pageBreak: t("Page break"),
pasteLink: `${t("Paste a link")}`,
@@ -71,8 +69,6 @@ export default function useDictionary() {
table: t("Table"),
tip: t("Tip"),
tipNotice: t("Tip notice"),
showDiagram: t("Show diagram"),
showSource: t("Show source"),
warning: t("Warning"),
warningNotice: t("Warning notice"),
insertDate: t("Current date"),
-48
View File
@@ -1,48 +0,0 @@
import { find } from "lodash";
import * as React from "react";
import embeds, { EmbedDescriptor } from "@shared/editor/embeds";
import { IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Logger from "~/utils/Logger";
import useStores from "./useStores";
/**
* Hook to get all embed configuration for the current team
*
* @param loadIfMissing Should we load integration settings if they are not
* locally available
* @returns A list of embed descriptors
*/
export default function useEmbeds(loadIfMissing = false) {
const { integrations } = useStores();
React.useEffect(() => {
async function fetchEmbedIntegrations() {
try {
await integrations.fetchPage({
limit: 100,
type: IntegrationType.Embed,
});
} catch (err) {
Logger.error("Failed to fetch embed integrations", err);
}
}
!integrations.isLoaded && loadIfMissing && fetchEmbedIntegrations();
}, [integrations, loadIfMissing]);
return React.useMemo(
() =>
embeds.map((e) => {
const em: Integration<IntegrationType.Embed> | undefined = find(
integrations.orderedData,
(i) => i.service === e.component.name.toLowerCase()
);
return new EmbedDescriptor({
...e,
settings: em?.settings,
});
}),
[integrations.orderedData]
);
}
+4 -26
View File
@@ -1,34 +1,12 @@
import * as React from "react";
import BaseModel from "~/models/BaseModel";
import useStores from "./useStores";
/**
* Retrieve the abilities of a policy for a given entity, if the policy is not
* located in the store, it will be fetched from the server.
* Quick access to retrieve the abilities of a policy for a given entity
*
* @param entity The model or model id
* @returns The policy for the model
* @param entityId The entity id
* @returns The available abilities
*/
export default function usePolicy(entity: string | BaseModel | undefined) {
export default function usePolicy(entityId: string) {
const { policies } = useStores();
const triggered = React.useRef(false);
const entityId = entity
? typeof entity === "string"
? entity
: entity.id
: "";
React.useEffect(() => {
if (entity && typeof entity !== "string") {
// The policy for this model is missing and we haven't tried to fetch it
// yet, go ahead and do that now. The force flag is needed otherwise the
// network request will be skipped due to the model existing in the store
if (!policies.get(entity.id) && !triggered.current) {
triggered.current = true;
void entity.store.fetch(entity.id, { force: true });
}
}
}, [policies, entity]);
return policies.abilities(entityId);
}
+1 -1
View File
@@ -8,7 +8,7 @@ type Session = {
teamId: string;
};
export function loadSessionsFromCookie(): Session[] {
function loadSessionsFromCookie(): Session[] {
const sessions = JSON.parse(getCookie("sessions") || "{}");
return Object.keys(sessions).map((teamId) => ({
teamId,
+7 -7
View File
@@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { getEventFiles } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionExport from "~/scenes/CollectionExport";
@@ -117,8 +117,8 @@ function CollectionMenu({
);
const handleFilePicked = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
async (ev: React.FormEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(ev);
// Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty
@@ -187,8 +187,8 @@ function CollectionMenu({
);
const alphabeticalSort = collection.sort.field === "title";
const can = usePolicy(collection);
const canUserInTeam = usePolicy(team);
const can = usePolicy(collection.id);
const canUserInTeam = usePolicy(team.id);
const items: MenuItem[] = React.useMemo(
() => [
{
@@ -266,7 +266,7 @@ function CollectionMenu({
{
type: "button",
title: `${t("Export")}`,
visible: !!(collection && canUserInTeam.createExport),
visible: !!(collection && canUserInTeam.export),
onClick: handleExport,
icon: <ExportIcon />,
},
@@ -296,7 +296,7 @@ function CollectionMenu({
alphabeticalSort,
handleEdit,
handlePermissions,
canUserInTeam.createExport,
canUserInTeam.export,
handleExport,
handleDelete,
handleChangeSort,
+198 -36
View File
@@ -1,11 +1,20 @@
import { observer } from "mobx-react";
import {
EditIcon,
StarredIcon,
UnstarredIcon,
DuplicateIcon,
ArchiveIcon,
TrashIcon,
MoveIcon,
HistoryIcon,
UnpublishIcon,
PrintIcon,
ImportIcon,
NewDocumentIcon,
DownloadIcon,
RestoreIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -14,31 +23,21 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { getEventFiles } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import Document from "~/models/Document";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import CollectionIcon from "~/components/CollectionIcon";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import Template from "~/components/ContextMenu/Template";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Switch from "~/components/Switch";
import { actionToMenuItem } from "~/actions";
import {
pinDocument,
createTemplate,
subscribeDocument,
unsubscribeDocument,
moveDocument,
deleteDocument,
permanentlyDeleteDocument,
downloadDocument,
importDocument,
starDocument,
unstarDocument,
duplicateDocument,
archiveDocument,
} from "~/actions/definitions/documents";
import { pinDocument, createTemplate } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
@@ -95,8 +94,39 @@ function DocumentMenu({
});
const { t } = useTranslation();
const isMobile = useMobile();
const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [
showPermanentDeleteModal,
setShowPermanentDeleteModal,
] = React.useState(false);
const [showMoveModal, setShowMoveModal] = React.useState(false);
const file = React.useRef<HTMLInputElement>(null);
const handleOpen = React.useCallback(() => {
setRenderModals(true);
if (onOpen) {
onOpen();
}
}, [onOpen]);
const handleDuplicate = React.useCallback(async () => {
const duped = await document.duplicate();
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
showToast(t("Document duplicated"), {
type: "success",
});
}, [t, history, showToast, document]);
const handleArchive = React.useCallback(async () => {
await document.archive();
showToast(t("Document archived"), {
type: "success",
});
}, [showToast, t, document]);
const handleRestore = React.useCallback(
async (
ev: React.SyntheticEvent,
@@ -124,8 +154,26 @@ function DocumentMenu({
window.print();
}, [menu]);
const handleStar = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
document.star();
},
[document]
);
const handleUnstar = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
document.unstar();
},
[document]
);
const collection = collections.get(document.collectionId);
const can = usePolicy(document);
const can = usePolicy(document.id);
const canViewHistory = can.read && !can.restore;
const restoreItems = React.useMemo(
() => [
@@ -157,9 +205,22 @@ function DocumentMenu({
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
// simulate a click on the file upload input element
if (file.current) {
file.current.click();
}
},
[file]
);
const handleFilePicked = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
async (ev: React.FormEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(ev);
// Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty
@@ -219,7 +280,7 @@ function DocumentMenu({
<ContextMenu
{...menu}
aria-label={t("Document options")}
onOpen={onOpen}
onOpen={handleOpen}
onClose={onClose}
>
<Template
@@ -252,10 +313,22 @@ function DocumentMenu({
...restoreItems,
],
},
actionToMenuItem(starDocument, context),
actionToMenuItem(unstarDocument, context),
actionToMenuItem(subscribeDocument, context),
actionToMenuItem(unsubscribeDocument, context),
{
type: "button",
title: t("Unstar"),
onClick: handleUnstar,
visible: document.isStarred && !!can.unstar,
icon: <UnstarredIcon />,
},
{
type: "button",
title: t("Star"),
onClick: handleStar,
visible: !document.isStarred && !!can.star,
icon: <StarredIcon />,
},
// Pin document
actionToMenuItem(pinDocument, context),
{
type: "separator",
},
@@ -275,9 +348,22 @@ function DocumentMenu({
visible: !!can.createChildDocument,
icon: <NewDocumentIcon />,
},
actionToMenuItem(importDocument, context),
{
type: "button",
title: t("Import document"),
visible: can.createChildDocument,
onClick: handleImportDocument,
icon: <ImportIcon />,
},
// Templatize document
actionToMenuItem(createTemplate, context),
actionToMenuItem(duplicateDocument, context),
{
type: "button",
title: t("Duplicate"),
onClick: handleDuplicate,
visible: !!can.update,
icon: <DuplicateIcon />,
},
{
type: "button",
title: t("Unpublish"),
@@ -285,18 +371,39 @@ function DocumentMenu({
visible: !!can.unpublish,
icon: <UnpublishIcon />,
},
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(pinDocument, context),
{
type: "button",
title: t("Archive"),
onClick: handleArchive,
visible: !!can.archive,
icon: <ArchiveIcon />,
},
{
type: "button",
title: `${t("Move")}`,
onClick: () => setShowMoveModal(true),
visible: !!can.move,
icon: <MoveIcon />,
},
{
type: "button",
title: `${t("Delete")}`,
dangerous: true,
onClick: () => setShowDeleteModal(true),
visible: !!can.delete,
icon: <TrashIcon />,
},
{
type: "button",
title: `${t("Permanently delete")}`,
dangerous: true,
onClick: () => setShowPermanentDeleteModal(true),
visible: can.permanentDelete,
icon: <CrossIcon />,
},
{
type: "separator",
},
actionToMenuItem(deleteDocument, context),
actionToMenuItem(permanentlyDeleteDocument, context),
{
type: "separator",
},
actionToMenuItem(downloadDocument, context),
{
type: "route",
title: t("History"),
@@ -306,6 +413,13 @@ function DocumentMenu({
visible: canViewHistory,
icon: <HistoryIcon />,
},
{
type: "button",
title: t("Download"),
onClick: document.download,
visible: !!can.download,
icon: <DownloadIcon />,
},
{
type: "button",
title: t("Print"),
@@ -333,7 +447,7 @@ function DocumentMenu({
/>
</Style>
)}
{showDisplayOptions && !isMobile && can.update && (
{showDisplayOptions && !isMobile && (
<Style>
<ToggleMenuItem
width={26}
@@ -350,6 +464,54 @@ function DocumentMenu({
</>
)}
</ContextMenu>
{renderModals && (
<>
{can.move && (
<Modal
title={t("Move {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowMoveModal(false)}
isOpen={showMoveModal}
>
<DocumentMove
document={document}
onRequestClose={() => setShowMoveModal(false)}
/>
</Modal>
)}
{can.delete && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowDeleteModal(false)}
isOpen={showDeleteModal}
isCentered
>
<DocumentDelete
document={document}
onSubmit={() => setShowDeleteModal(false)}
/>
</Modal>
)}
{can.permanentDelete && (
<Modal
title={t("Permanently delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowPermanentDeleteModal(false)}
isOpen={showPermanentDeleteModal}
isCentered
>
<DocumentPermanentDelete
document={document}
onSubmit={() => setShowPermanentDeleteModal(false)}
/>
</Modal>
)}
</>
)}
</>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ function GroupMenu({ group, onMembers }: Props) {
});
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const can = usePolicy(group);
const can = usePolicy(group.id);
return (
<>
+1 -1
View File
@@ -27,7 +27,7 @@ function NewDocumentMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
const can = usePolicy(team.id);
const items = React.useMemo(
() =>
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
+1 -1
View File
@@ -22,7 +22,7 @@ function NewTemplateMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
const can = usePolicy(team.id);
const items = React.useMemo(
() =>
+33 -5
View File
@@ -2,10 +2,11 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { createAction } from "~/actions";
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
import { changeTeam } from "~/actions/definitions/teams";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePrevious from "~/hooks/usePrevious";
import useSessions from "~/hooks/useSessions";
@@ -31,11 +32,32 @@ const OrganizationMenu: React.FC = ({ children }) => {
}
}, [menu, theme, previousTheme]);
// NOTE: it's useful to memoize on the team id and session because the action
// menu is not cached at all.
const actions = React.useMemo(() => {
return [navigateToSettings, separator(), changeTeam, logout];
}, [team.id, sessions]);
const otherSessions = sessions.filter(
(session) => session.teamId !== team.id && session.url !== team.url
);
return [
navigateToSettings,
separator(),
...(otherSessions.length
? [
createAction({
name: t("Switch team"),
section: "account",
children: otherSessions.map((session) => ({
id: session.url,
name: session.name,
section: "account",
icon: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
})),
}),
]
: []),
logout,
];
}, [team.id, team.url, sessions, t]);
return (
<>
@@ -47,4 +69,10 @@ const OrganizationMenu: React.FC = ({ children }) => {
);
};
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export default observer(OrganizationMenu);
+3 -31
View File
@@ -1,6 +1,5 @@
import { trim } from "lodash";
import { action, computed, observable } from "mobx";
import { CollectionPermission } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import CollectionsStore from "~/stores/CollectionsStore";
import Document from "~/models/Document";
@@ -40,7 +39,7 @@ export default class Collection extends ParanoidModel {
@Field
@observable
permission: CollectionPermission | void;
permission: "read" | "read_write" | void;
@Field
@observable
@@ -102,14 +101,8 @@ export default class Collection extends ParanoidModel {
return sortNavigationNodes(this.documents, this.sort);
}
/**
* Updates the document identified by the given id in the collection in memory.
* Does not update the document in the database.
*
* @param document The document properties stored in the collection
*/
@action
updateDocument(document: Pick<Document, "id" | "title" | "url">) {
updateDocument(document: Document) {
const travelNodes = (nodes: NavigationNode[]) =>
nodes.forEach((node) => {
if (node.id === document.id) {
@@ -123,27 +116,6 @@ export default class Collection extends ParanoidModel {
travelNodes(this.documents);
}
/**
* Removes the document identified by the given id from the collection in
* memory. Does not remove the document from the database.
*
* @param documentId The id of the document to remove.
*/
@action
removeDocument(documentId: string) {
this.documents = this.documents.filter(function f(node): boolean {
if (node.id === documentId) {
return false;
}
if (node.children) {
node.children = node.children.filter(f);
}
return true;
});
}
@action
updateIndex(index: string) {
this.index = index;
@@ -216,7 +188,7 @@ export default class Collection extends ParanoidModel {
};
export = () => {
return client.post("/collections.export", {
return client.get("/collections.export", {
id: this.id,
});
};
+7 -3
View File
@@ -1,5 +1,4 @@
import { computed } from "mobx";
import { CollectionPermission } from "@shared/types";
import BaseModel from "./BaseModel";
class CollectionGroupMembership extends BaseModel {
@@ -9,11 +8,16 @@ class CollectionGroupMembership extends BaseModel {
collectionId: string;
permission: CollectionPermission;
permission: string;
@computed
get isEditor(): boolean {
return this.permission === CollectionPermission.ReadWrite;
return this.permission === "read_write";
}
@computed
get isMaintainer(): boolean {
return this.permission === "maintainer";
}
}
+24 -54
View File
@@ -2,10 +2,10 @@ import { addDays, differenceInDays } from "date-fns";
import { floor } from "lodash";
import { action, autorun, computed, observable, set } from "mobx";
import parseTitle from "@shared/utils/parseTitle";
import unescape from "@shared/utils/unescape";
import DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User";
import type { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
import Storage from "~/utils/Storage";
import ParanoidModel from "./ParanoidModel";
import View from "./View";
@@ -155,19 +155,6 @@ export default class Document extends ParanoidModel {
);
}
/**
* Returns whether there is a subscription for this document in the store.
* Does not consider remote state.
*
* @returns True if there is a subscription, false otherwise.
*/
@computed
get isSubscribed(): boolean {
return !!this.store.rootStore.subscriptions.orderedData.find(
(subscription) => subscription.documentId === this.id
);
}
@computed
get isArchived(): boolean {
return !!this.archivedAt;
@@ -268,15 +255,15 @@ export default class Document extends ParanoidModel {
};
@action
pin = (collectionId?: string) => {
return this.store.rootStore.pins.create({
pin = async (collectionId?: string) => {
await this.store.rootStore.pins.create({
documentId: this.id,
...(collectionId ? { collectionId } : {}),
});
};
@action
unpin = (collectionId?: string) => {
unpin = async (collectionId?: string) => {
const pin = this.store.rootStore.pins.orderedData.find(
(pin) =>
pin.documentId === this.id &&
@@ -284,39 +271,19 @@ export default class Document extends ParanoidModel {
(!collectionId && !pin.collectionId))
);
return pin?.delete();
await pin?.delete();
};
@action
star = () => {
star = async () => {
return this.store.star(this);
};
@action
unstar = () => {
unstar = async () => {
return this.store.unstar(this);
};
/**
* Subscribes the current user to this document.
*
* @returns A promise that resolves when the subscription is created.
*/
@action
subscribe = () => {
return this.store.subscribe(this);
};
/**
* Unsubscribes the current user to this document.
*
* @returns A promise that resolves when the subscription is destroyed.
*/
@action
unsubscribe = (userId: string) => {
return this.store.unsubscribe(userId, this);
};
@action
view = () => {
// we don't record views for documents in the trash
@@ -337,7 +304,7 @@ export default class Document extends ParanoidModel {
};
@action
templatize = () => {
templatize = async () => {
return this.store.templatize(this.id);
};
@@ -419,18 +386,21 @@ export default class Document extends ParanoidModel {
};
}
download = async (contentType: "text/html" | "text/markdown") => {
await client.post(
`/documents.export`,
{
id: this.id,
},
{
download: true,
headers: {
accept: contentType,
},
}
);
download = async () => {
// Ensure the document is upto date with latest server contents
await this.fetch();
const body = unescape(this.text);
const blob = new Blob([`# ${this.title}\n\n${body}`], {
type: "text/markdown",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
// Firefox support requires the anchor tag be in the DOM to trigger the dl
if (document.body) {
document.body.appendChild(a);
}
a.href = url;
a.download = `${this.titleWithDefault}.md`;
a.click();
};
}
+8 -3
View File
@@ -1,9 +1,14 @@
import { observable } from "mobx";
import type { IntegrationSettings } from "@shared/types";
import BaseModel from "~/models/BaseModel";
import Field from "./decorators/Field";
class Integration<T = unknown> extends BaseModel {
type Settings = {
url: string;
channel: string;
channelId: string;
};
class Integration extends BaseModel {
id: string;
type: string;
@@ -16,7 +21,7 @@ class Integration<T = unknown> extends BaseModel {
@observable
events: string[];
settings: IntegrationSettings<T>;
settings: Settings;
}
export default Integration;
+7 -3
View File
@@ -1,5 +1,4 @@
import { computed } from "mobx";
import { CollectionPermission } from "@shared/types";
import BaseModel from "./BaseModel";
class Membership extends BaseModel {
@@ -9,11 +8,16 @@ class Membership extends BaseModel {
collectionId: string;
permission: CollectionPermission;
permission: string;
@computed
get isEditor(): boolean {
return this.permission === CollectionPermission.ReadWrite;
return this.permission === "read_write";
}
@computed
get isMaintainer(): boolean {
return this.permission === "maintainer";
}
}
-29
View File
@@ -1,29 +0,0 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
/**
* A subscription represents a request for a user to receive notifications for
* a document.
*/
class Subscription extends BaseModel {
@Field
@observable
id: string;
/** The user subscribing */
userId: string;
/** The document being subscribed to */
documentId: string;
/** The event being subscribed to */
@Field
@observable
event: string;
createdAt: string;
updatedAt: string;
}
export default Subscription;
+1 -1
View File
@@ -1,5 +1,5 @@
import { computed, observable } from "mobx";
import type { Role } from "@shared/types";
import { Role } from "@shared/types";
import ParanoidModel from "./ParanoidModel";
import Field from "./decorators/Field";
-27
View File
@@ -1,27 +0,0 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
class WebhookSubscription extends BaseModel {
@Field
@observable
id: string;
@Field
@observable
name: string;
@Field
@observable
url: string;
@Field
@observable
enabled: boolean;
@Field
@observable
events: string[];
}
export default WebhookSubscription;
+9 -27
View File
@@ -1,4 +1,3 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
import Archive from "~/scenes/Archive";
@@ -11,9 +10,7 @@ import Layout from "~/components/AuthenticatedLayout";
import CenteredContent from "~/components/CenteredContent";
import PlaceholderDocument from "~/components/PlaceholderDocument";
import Route from "~/components/ProfiledRoute";
import WebsocketProvider from "~/components/WebsocketProvider";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import SocketProvider from "~/components/SocketProvider";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
const SettingsRoutes = React.lazy(
@@ -62,12 +59,9 @@ const RedirectDocument = ({
/>
);
function AuthenticatedRoutes() {
const team = useCurrentTeam();
const can = usePolicy(team);
export default function AuthenticatedRoutes() {
return (
<WebsocketProvider>
<SocketProvider>
<Layout>
<React.Suspense
fallback={
@@ -77,24 +71,14 @@ function AuthenticatedRoutes() {
}
>
<Switch>
{can.createDocument && (
<Route exact path="/templates" component={Templates} />
)}
{can.createDocument && (
<Route exact path="/templates/:sort" component={Templates} />
)}
{can.createDocument && (
<Route exact path="/drafts" component={Drafts} />
)}
{can.createDocument && (
<Route exact path="/archive" component={Archive} />
)}
{can.createDocument && (
<Route exact path="/trash" component={Trash} />
)}
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} />
<Route exact path="/templates" component={Templates} />
<Route exact path="/templates/:sort" component={Templates} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} />
<Redirect exact from="/starred" to="/home" />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
@@ -116,8 +100,6 @@ function AuthenticatedRoutes() {
</Switch>
</React.Suspense>
</Layout>
</WebsocketProvider>
</SocketProvider>
);
}
export default observer(AuthenticatedRoutes);
+1 -1
View File
@@ -18,7 +18,7 @@ type Props = {
function Actions({ collection }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection);
const can = usePolicy(collection.id);
return (
<>
+1 -1
View File
@@ -20,7 +20,7 @@ type Props = {
function EmptyCollection({ collection }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection);
const can = usePolicy(collection.id);
const collectionName = collection ? collection.name : "";
const [
-2
View File
@@ -3,7 +3,6 @@ import { observer } from "mobx-react";
import { useState } from "react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { CollectionValidation } from "@shared/validations";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import IconPicker from "~/components/IconPicker";
@@ -94,7 +93,6 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
type="text"
label={t("Name")}
onChange={handleNameChange}
maxLength={CollectionValidation.maxNameLength}
value={name}
required
autoFocus
+1 -3
View File
@@ -25,9 +25,7 @@ function CollectionExport({ collection, onSubmit }: Props) {
setIsLoading(false);
showToast(
t(
"Export started. If you have notifications enabled, you will receive an email when it's complete."
)
t("Export started, you will receive an email when its complete.")
);
onSubmit();
},
+4 -9
View File
@@ -3,10 +3,6 @@ import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, WithTranslation } from "react-i18next";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
@@ -33,13 +29,13 @@ class CollectionNew extends React.Component<Props> {
icon = "";
@observable
color = randomElement(colorPalette);
color = "#4E5C6E";
@observable
sharing = true;
@observable
permission = CollectionPermission.ReadWrite;
permission = "read_write";
@observable
isSaving: boolean;
@@ -101,8 +97,8 @@ class CollectionNew extends React.Component<Props> {
this.hasOpenedIconPicker = true;
};
handlePermissionChange = (permission: CollectionPermission) => {
this.permission = permission;
handlePermissionChange = (newPermission: string) => {
this.permission = newPermission;
};
handleSharingChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
@@ -131,7 +127,6 @@ class CollectionNew extends React.Component<Props> {
type="text"
label={t("Name")}
onChange={this.handleNameChange}
maxLength={CollectionValidation.maxNameLength}
value={this.name}
required
autoFocus
@@ -59,6 +59,7 @@ class AddGroupsToCollection extends React.Component<Props> {
this.props.collectionGroupMemberships.create({
collectionId: this.props.collection.id,
groupId: group.id,
permission: "read_write",
});
this.props.toasts.showToast(
t("{{ groupName }} was added to the collection", {
@@ -57,6 +57,7 @@ class AddPeopleToCollection extends React.Component<Props> {
this.props.memberships.create({
collectionId: this.props.collection.id,
userId: user.id,
permission: "read_write",
});
this.props.toasts.showToast(
t("{{ userName }} was added to the collection", {
@@ -1,7 +1,6 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
import Group from "~/models/Group";
import GroupListItem from "~/components/GroupListItem";
@@ -11,7 +10,7 @@ import CollectionGroupMemberMenu from "~/menus/CollectionGroupMemberMenu";
type Props = {
group: Group;
collectionGroupMembership: CollectionGroupMembership | null | undefined;
onUpdate: (permission: CollectionPermission) => void;
onUpdate: (permission: string) => void;
onRemove: () => void;
};
@@ -22,6 +21,19 @@ const CollectionGroupMemberListItem = ({
onRemove,
}: Props) => {
const { t } = useTranslation();
const PERMISSIONS = React.useMemo(
() => [
{
label: t("View only"),
value: "read",
},
{
label: t("View and edit"),
value: "read_write",
},
],
[t]
);
return (
<GroupListItem
@@ -31,16 +43,7 @@ const CollectionGroupMemberListItem = ({
<>
<Select
label={t("Permissions")}
options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
},
]}
options={PERMISSIONS}
value={
collectionGroupMembership
? collectionGroupMembership.permission
@@ -1,8 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import Membership from "~/models/Membership";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
@@ -20,7 +18,7 @@ type Props = {
canEdit: boolean;
onAdd?: () => void;
onRemove?: () => void;
onUpdate?: (permission: CollectionPermission) => void;
onUpdate?: (permission: string) => void;
};
const MemberListItem = ({
@@ -32,6 +30,19 @@ const MemberListItem = ({
canEdit,
}: Props) => {
const { t } = useTranslation();
const PERMISSIONS = React.useMemo(
() => [
{
label: t("View only"),
value: "read",
},
{
label: t("View and edit"),
value: "read_write",
},
],
[t]
);
return (
<ListItem
@@ -55,16 +66,7 @@ const MemberListItem = ({
{onUpdate && (
<Select
label={t("Permissions")}
options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
},
]}
options={PERMISSIONS}
value={membership ? membership.permission : undefined}
onChange={onUpdate}
disabled={!canEdit}
@@ -101,4 +103,4 @@ const Select = styled(InputSelect)`
}
` as React.ComponentType<SelectProps>;
export default observer(MemberListItem);
export default MemberListItem;
@@ -1,4 +1,3 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -46,4 +45,4 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
);
};
export default observer(UserListItem);
export default UserListItem;
+6 -7
View File
@@ -3,7 +3,6 @@ import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import User from "~/models/User";
@@ -152,7 +151,7 @@ function CollectionPermissions({ collection }: Props) {
);
const handleChangePermission = React.useCallback(
async (permission: CollectionPermission) => {
async (permission: string) => {
try {
await collection.save({
permission,
@@ -219,10 +218,9 @@ function CollectionPermissions({ collection }: Props) {
}}
/>
)}
{collection.permission === CollectionPermission.ReadWrite && (
{collection.permission === "read" && (
<Trans
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
values={{
collectionName,
}}
@@ -231,9 +229,10 @@ function CollectionPermissions({ collection }: Props) {
}}
/>
)}
{collection.permission === CollectionPermission.Read && (
{collection.permission === "read_write" && (
<Trans
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
values={{
collectionName,
}}
+3 -36
View File
@@ -2,21 +2,16 @@ import { Location } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { RouteComponentProps, useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { setCookie } from "tiny-cookie";
import { useTheme } from "styled-components";
import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import Layout from "~/components/Layout";
import Sidebar from "~/components/Sidebar/Shared";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import { AuthorizationError, OfflineError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import Login from "../Login";
import { OfflineError } from "~/utils/errors";
import Document from "./components/Document";
import Loading from "./components/Loading";
@@ -78,7 +73,6 @@ function SharedDocumentScene(props: Props) {
const { ui } = useStores();
const theme = useTheme();
const location = useLocation();
const { t } = useTranslation();
const [response, setResponse] = React.useState<Response>();
const [error, setError] = React.useState<Error | null | undefined>();
const { documents } = useStores();
@@ -111,29 +105,7 @@ function SharedDocumentScene(props: Props) {
}, [documents, documentSlug, shareId, ui]);
if (error) {
if (error instanceof OfflineError) {
return <ErrorOffline />;
} else if (error instanceof AuthorizationError) {
setCookie("postLoginRedirectPath", props.location.pathname);
return (
<Login>
{(config) =>
config?.name && isCloudHosted ? (
<GetStarted>
{t(
"{{ teamName }} is using Outline to share documents, please login to continue.",
{
teamName: config.name,
}
)}
</GetStarted>
) : null
}
</Login>
);
} else {
return <Error404 />;
}
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
}
if (!response) {
@@ -165,9 +137,4 @@ function SharedDocumentScene(props: Props) {
);
}
const GetStarted = styled(Text)`
text-align: center;
margin-top: -8px;
`;
export default observer(SharedDocumentScene);
+9 -11
View File
@@ -57,17 +57,15 @@ export default function Contents({ headings, isFullWidth }: Props) {
<Heading>{t("Contents")}</Heading>
{headings.length ? (
<List>
{headings
.filter((heading) => heading.level < 4)
.map((heading) => (
<ListItem
key={heading.id}
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>{heading.title}</Link>
</ListItem>
))}
{headings.map((heading) => (
<ListItem
key={heading.id}
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>{heading.title}</Link>
</ListItem>
))}
</List>
) : (
<Empty>
+195 -147
View File
@@ -1,134 +1,161 @@
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
import { RouteComponentProps, StaticContext } from "react-router";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import withStores from "~/components/withStores";
import { NavigationNode } from "~/types";
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 = {
documentSlug: string;
revisionId?: string;
shareId?: string;
};
type LocationState = {
title?: string;
restore?: boolean;
revisionId?: string;
};
type Children = (options: {
document: Document;
revision: Revision | undefined;
abilities: Record<string, boolean>;
isEditing: boolean;
readOnly: boolean;
onCreateLink: (title: string) => Promise<string>;
sharedTree: NavigationNode | undefined;
}) => React.ReactNode;
type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
children: Children;
};
function DataLoader({ match, children }: Props) {
const { ui, shares, documents, auth, revisions, subscriptions } = useStores();
const { team } = auth;
const [error, setError] = React.useState<Error | null>(null);
const { revisionId, shareId, documentSlug } = match.params;
// Allows loading by /doc/slug-<urlId> or /doc/<id>
const document =
documents.getByUrl(match.params.documentSlug) ??
documents.get(match.params.documentSlug);
const revision = revisionId ? revisions.get(revisionId) : undefined;
const sharedTree = document
? documents.getSharedTree(document.id)
: undefined;
const isEditRoute = match.path === matchDocumentEdit;
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
const can = usePolicy(document?.id);
const location = useLocation<LocationState>();
React.useEffect(() => {
async function fetchDocument() {
try {
await documents.fetchWithSharedTree(documentSlug, {
shareId,
});
} catch (err) {
setError(err);
}
}
fetchDocument();
}, [ui, documents, document, shareId, documentSlug]);
React.useEffect(() => {
async function fetchRevision() {
if (revisionId && revisionId !== "latest") {
try {
await revisions.fetch(revisionId);
} catch (err) {
setError(err);
}
}
}
fetchRevision();
}, [revisions, revisionId]);
React.useEffect(() => {
async function fetchSubscription() {
if (document?.id) {
try {
await subscriptions.fetchPage({
documentId: document.id,
event: "documents.update",
});
} catch (err) {
Logger.error("Failed to fetch subscriptions", err);
}
}
}
fetchSubscription();
}, [document?.id, subscriptions]);
const onCreateLink = React.useCallback(
async (title: string) => {
if (!document) {
throw new Error("Document not loaded yet");
}
const newDocument = await documents.create({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
title,
text: "",
});
return newDocument.url;
type Props = RootStore &
RouteComponentProps<
{
documentSlug: string;
revisionId?: string;
shareId?: string;
title?: string;
},
[document, documents]
);
StaticContext,
{
title?: string;
}
> & {
children: (arg0: any) => React.ReactNode;
};
@observer
class DataLoader extends React.Component<Props> {
sharedTree: NavigationNode | null | undefined;
@observable
document: Document | null | undefined;
@observable
revision: Revision | null | undefined;
@observable
shapshot: Blob | null | undefined;
@observable
error: Error | null | undefined;
componentDidMount() {
const { documents, match } = this.props;
this.document = documents.getByUrl(match.params.documentSlug);
this.sharedTree = this.document
? documents.getSharedTree(this.document.id)
: undefined;
this.loadDocument();
}
componentDidUpdate(prevProps: Props) {
// If we have the document in the store, but not it's policy then we need to
// reload from the server otherwise the UI will not know which authorizations
// the user has
if (this.document) {
const document = this.document;
const policy = this.props.policies.get(document.id);
if (
!policy &&
!this.error &&
this.props.auth.user &&
this.props.auth.user.id
) {
this.loadDocument();
}
}
// Also need to load the revision if it changes
const { revisionId } = this.props.match.params;
if (
prevProps.match.params.revisionId !== revisionId &&
revisionId &&
revisionId !== "latest"
) {
this.loadRevision();
}
}
get isEditRoute() {
return this.props.match.path === matchDocumentEdit;
}
get isEditing() {
return this.isEditRoute || this.props.auth?.team?.collaborativeEditing;
}
onCreateLink = async (title: string) => {
const document = this.document;
invariant(document, "document must be loaded to create link");
const newDocument = await this.props.documents.create({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
title,
text: "",
});
return newDocument.url;
};
loadRevision = async () => {
const { revisionId } = this.props.match.params;
if (revisionId) {
this.revision = await this.props.revisions.fetch(revisionId);
}
};
loadDocument = async () => {
const { shareId, documentSlug, revisionId } = this.props.match.params;
// sets the document as active in the sidebar if we already have it loaded
if (this.document) {
this.props.ui.setActiveDocument(this.document);
}
try {
const response = await this.props.documents.fetchWithSharedTree(
documentSlug,
{
shareId,
}
);
this.sharedTree = response.sharedTree;
this.document = response.document;
if (revisionId && revisionId !== "latest") {
await this.loadRevision();
} else {
this.revision = undefined;
}
} catch (err) {
this.error = err;
return;
}
const document = this.document;
React.useEffect(() => {
if (document) {
// sets the current document as active in the sidebar
ui.setActiveDocument(document);
const can = this.props.policies.abilities(document.id);
// sets the document as active in the sidebar, ideally in the future this
// will be route driven.
this.props.ui.setActiveDocument(document);
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!can.update && isEditRoute) {
if (!can.update && this.isEditRoute) {
history.push(document.url);
return;
}
@@ -136,51 +163,72 @@ function DataLoader({ match, children }: Props) {
// Prevents unauthorized request to load share information for the document
// when viewing a public share link
if (can.read) {
shares.fetch(document.id).catch((err) => {
this.props.shares.fetch(document.id).catch((err) => {
if (!(err instanceof NotFoundError)) {
throw err;
}
});
}
}
}, [can.read, can.update, document, isEditRoute, shares, ui]);
};
if (error) {
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
}
render() {
const { location, policies, auth, match, ui } = this.props;
const { revisionId } = match.params;
if (this.error) {
return this.error instanceof OfflineError ? (
<ErrorOffline />
) : (
<Error404 />
);
}
const team = auth.team;
const document = this.document;
const revision = this.revision;
if (!document || !team || (revisionId && !revision)) {
return (
<>
<Loading location={location} />
{this.isEditing && !team?.collaborativeEditing && (
<HideSidebar ui={ui} />
)}
</>
);
}
const abilities = policies.abilities(document.id);
// We do not want to remount the document when changing from view->edit
// on the multiplayer flag as the doc is guaranteed to be upto date.
const key = team.collaborativeEditing
? ""
: this.isEditing
? "editing"
: "read-only";
if (!document || !team || (revisionId && !revision)) {
return (
<>
<Loading location={location} />
{isEditing && !team?.collaborativeEditing && <HideSidebar ui={ui} />}
</>
<React.Fragment key={key}>
{this.isEditing && !team.collaborativeEditing && (
<HideSidebar ui={ui} />
)}
{this.props.children({
document,
revision,
abilities,
isEditing: this.isEditing,
readOnly:
!this.isEditing ||
!abilities.update ||
document.isArchived ||
!!revisionId,
onCreateLink: this.onCreateLink,
sharedTree: this.sharedTree,
})}
</React.Fragment>
);
}
// We do not want to remount the document when changing from view->edit
// on the multiplayer flag as the doc is guaranteed to be upto date.
const key = team.collaborativeEditing
? ""
: isEditing
? "editing"
: "read-only";
return (
<React.Fragment key={key}>
{isEditing && !team.collaborativeEditing && <HideSidebar ui={ui} />}
{children({
document,
revision,
abilities: can,
isEditing,
readOnly:
!isEditing || !can.update || document.isArchived || !!revisionId,
onCreateLink,
sharedTree,
})}
</React.Fragment>
);
}
export default observer(DataLoader);
export default withStores(DataLoader);
+5 -13
View File
@@ -55,21 +55,13 @@ import References from "./References";
const AUTOSAVE_DELAY = 3000;
type Params = {
documentSlug: string;
revisionId?: string;
shareId?: string;
};
type LocationState = {
title?: string;
restore?: boolean;
revisionId?: string;
};
type Props = WithTranslation &
RootStore &
RouteComponentProps<Params, StaticContext, LocationState> & {
RouteComponentProps<
Record<string, string>,
StaticContext,
{ restore?: boolean; revisionId?: string }
> & {
sharedTree?: NavigationNode;
abilities: Record<string, any>;
document: Document;
@@ -2,13 +2,13 @@ import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { light } from "@shared/styles/theme";
import {
getCurrentDateAsString,
getCurrentDateTimeAsString,
getCurrentTimeAsString,
} from "@shared/utils/date";
import { DocumentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
import Star, { AnimatedStar } from "~/components/Star";
@@ -132,7 +132,7 @@ const EditableTitle = React.forwardRef(
$emojiWidth={emojiWidth}
$isStarred={document.isStarred}
autoFocus={!value}
maxLength={DocumentValidation.maxTitleLength}
maxLength={MAX_TITLE_LENGTH}
readOnly={readOnly}
dir="auto"
ref={ref}
+1 -3
View File
@@ -57,10 +57,8 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
}
}, [ref]);
// Save document when blurring title, but delay so that if clicking on a
// button this is allowed to execute first.
const handleBlur = React.useCallback(() => {
setTimeout(() => props.onSave({ autosave: true }), 250);
props.onSave({ autosave: true });
}, [props]);
const handleGoToNextInput = React.useCallback(
+1 -1
View File
@@ -100,7 +100,7 @@ function DocumentHeader({
}, [onSave]);
const { isDeleted, isTemplate } = document;
const can = usePolicy(document?.id);
const can = usePolicy(document.id);
const canToggleEmbeds = team?.documentEmbeds;
const canEdit = can.update && !isEditing;
const toc = (
+5 -1
View File
@@ -1,5 +1,6 @@
import { Location } from "history";
import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "~/components/CenteredContent";
import PageTitle from "~/components/PageTitle";
import PlaceholderDocument from "~/components/PlaceholderDocument";
@@ -10,9 +11,12 @@ type Props = {
};
export default function Loading({ location }: Props) {
const { t } = useTranslation();
return (
<Container column auto>
{location.state?.title && <PageTitle title={location.state.title} />}
<PageTitle
title={location.state ? location.state.title : t("Untitled")}
/>
<CenteredContent>
<PlaceholderDocument />
</CenteredContent>
@@ -139,9 +139,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
});
if (debug) {
provider.on("close", (ev: MessageEvent) =>
Logger.debug("collaboration", "close", ev)
);
provider.on("message", (ev: MessageEvent) =>
Logger.debug("collaboration", "incoming", {
message: ev.message,
@@ -7,7 +7,6 @@ import Document from "~/models/Document";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import Tooltip from "~/components/Tooltip";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import SharePopover from "./SharePopover";
@@ -18,12 +17,10 @@ type Props = {
function ShareButton({ document }: Props) {
const { t } = useTranslation();
const { shares } = useStores();
const team = useCurrentTeam();
const share = shares.getByDocumentId(document.id);
const sharedParent = shares.getByDocumentParents(document.id);
const isPubliclyShared =
team.sharing &&
(share?.published || (sharedParent?.published && !document.isDraft));
share?.published || (sharedParent?.published && !document.isDraft);
const popover = usePopoverState({
gutter: 0,
+10 -16
View File
@@ -14,7 +14,6 @@ import Input from "~/components/Input";
import Notice from "~/components/Notice";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -37,35 +36,33 @@ function SharePopover({
onRequestClose,
visible,
}: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const { shares } = useStores();
const { shares, auth } = useStores();
const { showToast } = useToasts();
const [isCopied, setIsCopied] = React.useState(false);
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const buttonRef = React.useRef<HTMLButtonElement>(null);
const can = usePolicy(share ? share.id : "");
const documentAbilities = usePolicy(document);
const documentAbilities = usePolicy(document.id);
const canPublish =
can.update &&
!document.isTemplate &&
team.sharing &&
auth.team?.sharing &&
documentAbilities.share;
const isPubliclyShared =
team.sharing &&
((share && share.published) ||
(sharedParent && sharedParent.published && !document.isDraft));
(share && share.published) ||
(sharedParent && sharedParent.published && !document.isDraft);
useKeyDown("Escape", onRequestClose);
React.useEffect(() => {
if (visible && team.sharing) {
if (visible) {
document.share();
buttonRef.current?.focus();
}
return () => (timeout.current ? clearTimeout(timeout.current) : undefined);
}, [document, visible, team.sharing]);
}, [document, visible]);
const handlePublishedChange = React.useCallback(
async (event) => {
@@ -116,9 +113,6 @@ function SharePopover({
const userLocale = useUserLocale();
const locale = userLocale ? dateLocale(userLocale) : undefined;
const shareUrl = team.sharing
? share?.url ?? ""
: `${team.url}${document.url}`;
return (
<>
@@ -205,14 +199,14 @@ function SharePopover({
type="text"
label={t("Link")}
placeholder={`${t("Loading")}`}
value={shareUrl}
value={share ? share.url : ""}
labelHidden
readOnly
/>
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
<CopyToClipboard text={share ? share.url : ""} onCopy={handleCopied}>
<Button
type="submit"
disabled={isCopied || (!share && team.sharing)}
disabled={isCopied || !share}
ref={buttonRef}
primary
>
@@ -1,6 +1,6 @@
import * as React from "react";
import { USER_PRESENCE_INTERVAL } from "@shared/constants";
import { WebsocketContext } from "~/components/WebsocketProvider";
import { SocketContext } from "~/components/SocketProvider";
type Props = {
documentId: string;
@@ -8,9 +8,9 @@ type Props = {
};
export default class SocketPresence extends React.Component<Props> {
static contextType = WebsocketContext;
static contextType = SocketContext;
previousContext: typeof WebsocketContext;
previousContext: typeof SocketContext;
editingInterval: ReturnType<typeof setInterval>;
+9 -17
View File
@@ -7,21 +7,13 @@ import DataLoader from "./components/DataLoader";
import Document from "./components/Document";
import SocketPresence from "./components/SocketPresence";
type Params = {
documentSlug: string;
revisionId?: string;
shareId?: string;
};
type LocationState = {
title?: string;
restore?: boolean;
revisionId?: string;
};
type Props = RouteComponentProps<Params, StaticContext, LocationState>;
export default function DocumentScene(props: Props) {
export default function DocumentScene(
props: RouteComponentProps<
{ documentSlug: string; revisionId: string },
StaticContext,
{ title?: string }
>
) {
const { ui } = useStores();
const team = useCurrentTeam();
const { documentSlug, revisionId } = props.match.params;
@@ -55,12 +47,12 @@ export default function DocumentScene(props: Props) {
if (isActive && !isMultiplayer) {
return (
<SocketPresence documentId={document.id} isEditing={isEditing}>
<Document document={document} {...rest} />
<Document document={document} match={props.match} {...rest} />
</SocketPresence>
);
}
return <Document document={document} {...rest} />;
return <Document document={document} match={props.match} {...rest} />;
}}
</DataLoader>
);

Some files were not shown because too many files have changed in this diff Show More