Compare commits

..

92 Commits

Author SHA1 Message Date
Tom Moor 2c1657c7f2 Merge 2022-07-17 21:55:54 +01:00
Tom Moor 16541560eb performance 2022-07-17 21:50:45 +01:00
Tom Moor 832eb20a10 Merge branch 'main' of github.com:outline/outline into feat/dropdown-pagination 2022-07-17 21:00:37 +01:00
Tom Moor 501221a038 Styling tweaks 2022-07-17 14:22:37 +01:00
CuriousCorrelation 2f4a9378ce Change initial load limit to DEFAULT_PAGINATION_LIMIT 2022-07-17 12:22:25 +01:00
CuriousCorrelation ddab370640 Add ability to search for users 2022-07-17 12:22:25 +01:00
CuriousCorrelation c01312db24 Add user store fn to get unordered data
Getting users via pagination API returns a sorted list.
If we fetch for 3 users, they'll be sorted alphabetically,
next 3 will also be sorted alphabetically.
That means a final list will look like
[a, i, p] + [c, d, f] = [a, c, d, f, i, p]
Final result is unsorted.

At UX level, it looks like jerky dropdown
with users appearing in the middle of the list,
sometimes appearing just as user scrolls past.

`users.unorderedData` fixes that by sending
users consistently, although unsorted.
2022-07-17 12:22:25 +01:00
CuriousCorrelation f2cffc9354 fix: Typo 2022-07-17 12:22:25 +01:00
CuriousCorrelation 6fbbbcbf87 Revert TFilterOption interface changes 2022-07-17 12:22:25 +01:00
CuriousCorrelation dad3ea104b Add docs for else branch 2022-07-17 12:22:25 +01:00
CuriousCorrelation e1f6fbab7f Remove test intermediate paginateFetch proxy fn 2022-07-17 12:22:25 +01:00
CuriousCorrelation efc891d22a Rename Data suffix to Options for readability 2022-07-17 12:22:25 +01:00
CuriousCorrelation 22ca319d25 Move mount useEffect to top, convention 2022-07-17 12:22:25 +01:00
CuriousCorrelation e7d96f9257 fix: Search box not filling all space horizontally 2022-07-17 12:22:25 +01:00
CuriousCorrelation 507e6ea926 Update UserFilter paginateFetch def 2022-07-17 12:22:25 +01:00
CuriousCorrelation 5544521524 Remove generic qualifiers (used for testing) 2022-07-17 12:22:25 +01:00
CuriousCorrelation 7a57d8fbf9 Move filteredData to top level for readability 2022-07-17 12:22:25 +01:00
CuriousCorrelation d119bc65e0 Change options to be generic
Now function expects `options` to impl `PaginatedList`
2022-07-17 12:22:25 +01:00
CuriousCorrelation 175e9ca597 Change paginateFetch type
`paginateFetch` now has concrete types
2022-07-17 12:22:25 +01:00
CuriousCorrelation a7fc3494c8 Fix handleFilter dependency 2022-07-17 12:22:25 +01:00
CuriousCorrelation 384259f662 Change searchable to be optional 2022-07-17 12:22:25 +01:00
CuriousCorrelation b6e5e7ed80 Add filter clear action on blank search
This fixes stale search persisting when
clearing search characters.
2022-07-17 12:22:25 +01:00
CuriousCorrelation a7d5b70440 Update FilterOptions component with new API 2022-07-17 12:22:25 +01:00
CuriousCorrelation fec7695711 Impl PaginatedItem interface for options 2022-07-17 12:22:25 +01:00
CuriousCorrelation da1a8287f0 Reduce initial number of user to load 2022-07-17 12:22:25 +01:00
CuriousCorrelation d3934606e6 Add ability to populate filteredData on mount 2022-07-17 12:22:25 +01:00
CuriousCorrelation 6e9363b551 Fix clicking on menu not clearing previous search 2022-07-17 12:22:25 +01:00
CuriousCorrelation 0926b32957 Add searchable and paginateFetch var 2022-07-17 12:22:25 +01:00
CuriousCorrelation 1b2a5a7ee6 Implement options filter 2022-07-17 12:22:25 +01:00
CuriousCorrelation 6319e54fc0 Add PaginatedItem type req to TFilterOption
For use with `PaginatedList`
2022-07-17 12:22:25 +01:00
CuriousCorrelation b6813272c1 [WIP] Change map with MenuItem to PaginatedList 2022-07-17 12:22:25 +01:00
CuriousCorrelation 0a0946cf12 Add InputSearch styling 2022-07-17 12:22:25 +01:00
CuriousCorrelation f7d7159805 Remove PaginatedDropdown in favor of merge with FilterOptions 2022-07-17 12:22:24 +01:00
CuriousCorrelation 2fca88f621 Revert UserFilter changes 2022-07-17 12:22:24 +01:00
CuriousCorrelation 2b97a5d593 Made default item component dependency
Now parent component can set a default value,
instead of it being hardcoded "Any authors".
2022-07-17 12:22:24 +01:00
CuriousCorrelation b9e013185d Fix dropdown button label not setting 2022-07-17 12:22:24 +01:00
CuriousCorrelation a55b0b3872 Use user.id as PaginatedItem id 2022-07-17 12:22:24 +01:00
CuriousCorrelation 1cf83da73f Remove onFocus
Has no adverse effects so far, fingers crossed
2022-07-17 12:22:24 +01:00
CuriousCorrelation 61d4158d8c Impl Paginated list
Pagination seems to work fine.
`onFocus` isn't useful because
mount is handled already.

TODO: Try removing `onFocus`
2022-07-17 12:22:24 +01:00
CuriousCorrelation 8b70a52114 Add memoized options
TODO: Use `options` to create filtered data
2022-07-17 12:22:24 +01:00
CuriousCorrelation 2f7789088a Add users param to PaginatedDropdown
Not sure what `users` type should be.

TODO: Use `users` to calculate `options`
2022-07-17 12:22:24 +01:00
CuriousCorrelation 7c43d937c4 Change PaginatedDropdown to consume user store
`PaginatedDropdown` should consume user store directly,
so the pagination and refresh can be encapsulated
into one single component.

TODO: Impl memoized options calculation for
`PaginatedDropdown`
2022-07-17 12:22:24 +01:00
CuriousCorrelation 10daeb41e6 Impl author filter and mount set
TODO: Limit user display using pagination.
- Limit user display
- Scroll limited users
- Query for more

Because data would be provided by parent component,
`PaginatedDropdown` might need to use a callback
to fetch additional data.
2022-07-17 12:22:24 +01:00
CuriousCorrelation d20c6c456e Add static positioning for search box
Now search box doesn't scroll away with the content

TODO: Impl `handleOnChange`
2022-07-17 12:22:24 +01:00
CuriousCorrelation 180a41bd59 Replace FilterOptions with PaginatedDropdown
TODO: Make search bar sticky
2022-07-17 12:22:24 +01:00
CuriousCorrelation fada7032be Add PaginatedDropdown component
Based on `FilterOptions.tsx`

TODO: Replace `FilterOptions` with `PaginatedDropdown`
in `UserFilter.tsx`
2022-07-17 12:22:24 +01:00
CuriousCorrelation ba7b555dd4 Change initial load limit to DEFAULT_PAGINATION_LIMIT 2022-07-16 11:18:45 +05:30
CuriousCorrelation d6e0250b2c Merge remote-tracking branch 'origin' into feat/dropdown-pagination 2022-07-16 11:04:44 +05:30
CuriousCorrelation 6ce8eaa910 Add ability to search for users 2022-07-15 21:15:25 +05:30
CuriousCorrelation 23123e1175 Add user store fn to get unordered data
Getting users via pagination API returns a sorted list.
If we fetch for 3 users, they'll be sorted alphabetically,
next 3 will also be sorted alphabetically.
That means a final list will look like
[a, i, p] + [c, d, f] = [a, c, d, f, i, p]
Final result is unsorted.

At UX level, it looks like jerky dropdown
with users appearing in the middle of the list,
sometimes appearing just as user scrolls past.

`users.unorderedData` fixes that by sending
users consistently, although unsorted.
2022-07-15 21:07:54 +05:30
CuriousCorrelation 880d50cc87 fix: Typo 2022-07-15 13:35:24 +05:30
CuriousCorrelation 4573ed6f62 Revert TFilterOption interface changes 2022-07-15 13:02:17 +05:30
CuriousCorrelation 68f04130b8 Add docs for else branch 2022-07-15 12:52:30 +05:30
CuriousCorrelation e4b58d7119 Remove test intermediate paginateFetch proxy fn 2022-07-13 16:09:50 +05:30
CuriousCorrelation 9782f6ea21 Rename Data suffix to Options for readability 2022-07-13 15:52:19 +05:30
CuriousCorrelation 2ad2dbc69e Move mount useEffect to top, convention 2022-07-13 15:51:31 +05:30
CuriousCorrelation 64992aab51 Merge branch 'main' into feat/dropdown-pagination 2022-07-13 14:54:57 +05:30
CuriousCorrelation 2cbe07fc6a fix: Search box not filling all space horizontally 2022-07-12 23:40:59 +05:30
CuriousCorrelation ec51e495a1 Update UserFilter paginateFetch def 2022-07-12 17:35:25 +05:30
CuriousCorrelation 6a0d1de57a Remove generic qualifiers (used for testing) 2022-07-12 17:34:45 +05:30
CuriousCorrelation 4efa4103c3 Move filteredData to top level for readability 2022-07-11 23:34:25 +05:30
CuriousCorrelation 87ac604344 Change options to be generic
Now function expects `options` to impl `PaginatedList`
2022-07-11 23:12:54 +05:30
CuriousCorrelation 25acbe04f5 Change paginateFetch type
`paginateFetch` now has concrete types
2022-07-11 23:11:19 +05:30
CuriousCorrelation 1d39359e36 Fix handleFilter dependency 2022-07-11 22:26:47 +05:30
CuriousCorrelation bbfcc7f96c Change searchable to be optional 2022-07-11 19:30:27 +05:30
CuriousCorrelation 0fe220164e Add filter clear action on blank search
This fixes stale search persisting when
clearing search characters.
2022-07-11 19:20:06 +05:30
CuriousCorrelation 933fb815e7 Update FilterOptions component with new API 2022-07-11 18:16:42 +05:30
CuriousCorrelation 9ecd40ce76 Impl PaginatedItem interface for options 2022-07-11 18:16:17 +05:30
CuriousCorrelation e41aaac07b Reduce initial number of user to load 2022-07-11 18:15:41 +05:30
CuriousCorrelation e51468f50b Add ability to populate filteredData on mount 2022-07-11 18:14:55 +05:30
CuriousCorrelation 11018578b7 Fix clicking on menu not clearing previous search 2022-07-11 18:14:39 +05:30
CuriousCorrelation 63622c2f66 Add searchable and paginateFetch var 2022-07-11 18:14:03 +05:30
CuriousCorrelation 2b790cdc2f Implement options filter 2022-07-11 18:13:30 +05:30
CuriousCorrelation 06360fd7c2 Add PaginatedItem type req to TFilterOption
For use with `PaginatedList`
2022-07-11 18:11:30 +05:30
CuriousCorrelation a72700b408 [WIP] Change map with MenuItem to PaginatedList 2022-07-11 18:10:21 +05:30
CuriousCorrelation 5972eb2232 Add InputSearch styling 2022-07-11 18:07:17 +05:30
CuriousCorrelation 1201bd85a9 Remove PaginatedDropdown in favor of merge with FilterOptions 2022-07-11 17:56:18 +05:30
CuriousCorrelation 10d5941e85 Revert UserFilter changes 2022-07-11 17:55:53 +05:30
CuriousCorrelation 734e752b76 Made default item component dependency
Now parent component can set a default value,
instead of it being hardcoded "Any authors".
2022-07-10 22:17:27 +05:30
CuriousCorrelation 3941474191 Fix dropdown button label not setting 2022-07-10 21:48:01 +05:30
CuriousCorrelation 3becb00102 Use user.id as PaginatedItem id 2022-07-10 21:01:47 +05:30
CuriousCorrelation dac60cd4a0 Remove onFocus
Has no adverse effects so far, fingers crossed
2022-07-10 20:07:21 +05:30
CuriousCorrelation 966f449156 Impl Paginated list
Pagination seems to work fine.
`onFocus` isn't useful because
mount is handled already.

TODO: Try removing `onFocus`
2022-07-10 20:06:01 +05:30
CuriousCorrelation 5e0893ebc9 Add memoized options
TODO: Use `options` to create filtered data
2022-07-10 20:03:56 +05:30
CuriousCorrelation 3ad1187977 Add users param to PaginatedDropdown
Not sure what `users` type should be.

TODO: Use `users` to calculate `options`
2022-07-10 20:01:45 +05:30
CuriousCorrelation 57b03936eb Change PaginatedDropdown to consume user store
`PaginatedDropdown` should consume user store directly,
so the pagination and refresh can be encapsulated
into one single component.

TODO: Impl memoized options calculation for
`PaginatedDropdown`
2022-07-10 19:59:09 +05:30
CuriousCorrelation 324f4b133d Impl author filter and mount set
TODO: Limit user display using pagination.
- Limit user display
- Scroll limited users
- Query for more

Because data would be provided by parent component,
`PaginatedDropdown` might need to use a callback
to fetch additional data.
2022-07-10 18:49:54 +05:30
CuriousCorrelation b0ca6b5567 Add static positioning for search box
Now search box doesn't scroll away with the content

TODO: Impl `handleOnChange`
2022-07-10 17:53:11 +05:30
CuriousCorrelation 28178846fa Replace FilterOptions with PaginatedDropdown
TODO: Make search bar sticky
2022-07-10 16:56:43 +05:30
CuriousCorrelation c80a490340 Add PaginatedDropdown component
Based on `FilterOptions.tsx`

TODO: Replace `FilterOptions` with `PaginatedDropdown`
in `UserFilter.tsx`
2022-07-10 16:55:07 +05:30
CuriousCorrelation c39c907d72 Merge remote-tracking branch 'origin/tom/chore-developer-test-users' into feat/dropdown-pagination 2022-07-10 16:04:30 +05:30
Tom Moor eeff46b6c9 Add ability to quickly create test users in development 2022-07-10 09:13:24 +02:00
435 changed files with 7327 additions and 12666 deletions
+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
-1
View File
@@ -25,7 +25,6 @@
"rules": {
"eqeqeq": 2,
"curly": 2,
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"es/no-regexp-lookbehind-assertions": "error",
-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 = () => {};
+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"
}
+12 -242
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 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({
@@ -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
View File
@@ -3,7 +3,6 @@ import { rootDeveloperActions } from "./definitions/developer";
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 [
@@ -13,5 +12,4 @@ export default [
...rootNavigationActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
];
+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);
+3
View File
@@ -41,6 +41,7 @@ type Props = {
visible?: boolean;
placement?: Placement;
animating?: boolean;
className?: string;
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
onOpen?: () => void;
onClose?: () => void;
@@ -51,6 +52,7 @@ const ContextMenu: React.FC<Props> = ({
children,
onOpen,
onClose,
className,
...rest
}) => {
const previousVisible = usePrevious(rest.visible);
@@ -131,6 +133,7 @@ const ContextMenu: React.FC<Props> = ({
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
className={className}
hiddenScrollbars
style={
maxHeight && topAnchor
+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 && (
+8 -8
View File
@@ -1,24 +1,25 @@
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 {
getDataTransferFiles,
supportedImageMimeTypes,
} from "@shared/utils/files";
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 +60,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,
@@ -212,7 +212,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
// Insert all files as attachments if any of the files are not images.
const isAttachment = files.some(
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
(file) => !supportedImageMimeTypes.includes(file.type)
);
insertFiles(view, event, pos, files, {
@@ -312,4 +312,4 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
);
}
export default observer(React.forwardRef(Editor));
export default React.forwardRef(Editor);
+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,
};
+123 -25
View File
@@ -1,12 +1,16 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import Button, { Inner } from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Text from "~/components/Text";
import { Outline } from "./Input";
import InputSearch from "./InputSearch";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
type TFilterOption = {
type TFilterOption = PaginatedItem & {
key: string;
label: string;
note?: string;
@@ -19,54 +23,121 @@ type Props = {
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
search?: (query: string) => Promise<TFilterOption[]>;
paginateFetch?: (
options: PaginatedItem
) => Promise<PaginatedItem[] | undefined>;
};
const FilterOptions = ({
options,
activeKey = "",
defaultLabel = "Filter options",
defaultLabel,
selectedPrefix = "",
className,
onSelect,
search,
paginateFetch,
}: Props) => {
const { t } = useTranslation();
const tDefaultLabel: string = defaultLabel ?? t("Filter options");
const menu = useMenuState({
modal: true,
});
const [filteredOptions, setFilteredOptions] = React.useState<TFilterOption[]>(
[]
);
React.useEffect(() => {
setFilteredOptions(options);
}, [options]);
const selected =
options.find((option) => option.key === activeKey) || options[0];
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
const clearFilter = React.useCallback(() => {
setFilteredOptions(options);
}, [options]);
// Simple case-insensitive filter to
// check if text appears in any author's name.
const handleFilter = React.useCallback(
async (event) => {
const { value } = event.target;
if (value) {
const res = options.filter((option) =>
option.label.toLowerCase().includes(value.toLowerCase())
);
if (search) {
const more = await search(value);
const missing = more.filter(
(item) => !res.map((r) => r.key).includes(item.key)
);
setFilteredOptions([...missing, ...res]);
} else {
setFilteredOptions(res);
}
} else {
// Clears filter options cache.
// This part fires off when search term is "".
// Either by user clearing it entirely or
// by deleting one character at a time,
// gradually decreasing relevance.
clearFilter();
}
},
[clearFilter, options, search]
);
const renderItem = React.useCallback(
(option: TFilterOption) => (
<MenuItem
key={option.key}
onClick={() => {
onSelect(option.key);
menu.hide();
}}
selected={option.key === activeKey}
{...menu}
>
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
),
[activeKey, activeKey, onSelect]
);
return (
<Wrapper>
<MenuButton {...menu}>
<MenuButton {...menu} onClick={clearFilter}>
{(props) => (
<StyledButton {...props} className={className} neutral disclosure>
{activeKey ? selectedLabel : defaultLabel}
{activeKey ? selectedLabel : tDefaultLabel}
</StyledButton>
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} {...menu}>
{options.map((option) => (
<MenuItem
key={option.key}
onClick={() => {
onSelect(option.key);
menu.hide();
}}
selected={option.key === activeKey}
{...menu}
>
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
))}
<ContextMenu aria-label={tDefaultLabel} {...menu}>
{search && (
<>
<StyledInputSearch onChange={handleFilter} autoFocus />
<br />
</>
)}
<PaginatedList
items={filteredOptions}
fetch={paginateFetch}
renderItem={renderItem}
/>
</ContextMenu>
</Wrapper>
);
@@ -110,4 +181,31 @@ const Wrapper = styled.div`
margin-right: 8px;
`;
// `position: sticky` leaves a bit of space above the search box,
// which shows author names moving past behind it.
const StyledInputSearch = styled(InputSearch)`
position: absolute;
width: 100%;
border: none;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
overflow: hidden;
top: 0;
z-index: 1;
${Outline} {
border-top-style: unset;
border-right-style: unset;
border-left-style: unset;
border-radius: unset;
border-bottom: 1px solid ${(props) => props.theme.divider};
background: ${(props) => props.theme.menuBackground};
font-size: 14px;
input {
margin-left: 8px;
}
}
`;
export default FilterOptions;
+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: {
+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"),
+3 -1
View File
@@ -94,7 +94,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
}
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = DEFAULT_PAGINATION_LIMIT;
const limit = this.props.options?.limit ?? DEFAULT_PAGINATION_LIMIT;
this.error = undefined;
try {
@@ -153,6 +153,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
renderHeading,
renderError,
onEscape,
children,
} = this.props;
const showLoading =
@@ -223,6 +224,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
});
}}
</ArrowKeyNavigation>
{children}
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
+1 -1
View File
@@ -36,7 +36,7 @@ function AppSidebar() {
const { documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team);
const can = usePolicy(team.id);
React.useEffect(() => {
if (!user.isViewer) {
@@ -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();
@@ -8,6 +8,7 @@ import Collection from "~/models/Collection";
import Flex from "~/components/Flex";
import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
import Text from "~/components/Text";
import { createCollection } from "~/actions/definitions/collections";
import useStores from "~/hooks/useStores";
import DraggableCollectionLink from "./DraggableCollectionLink";
@@ -62,6 +63,11 @@ function Collections() {
/>
) : undefined
}
empty={
<Empty type="tertiary" size="small">
{t("Empty")}
</Empty>
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
@@ -72,14 +78,22 @@ function Collections() {
belowCollection={orderedCollections[index + 1]}
/>
)}
/>
<SidebarAction action={createCollection} depth={0} />
>
<SidebarAction action={createCollection} depth={0} />
</PaginatedList>
</Relative>
</Header>
</Flex>
);
}
const Empty = styled(Text)`
margin-left: 36px;
margin-bottom: 0;
line-height: 34px;
font-style: italic;
`;
const StyledError = styled(Error)`
font-size: 15px;
padding: 0 8px;
@@ -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
+391
View File
@@ -0,0 +1,391 @@
import invariant from "invariant";
import { find } from "lodash";
import { 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 withStores from "~/components/withStores";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
type SocketWithAuthentication = Socket & {
authenticated?: boolean;
};
export const SocketContext = 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.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", 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 instanceof AuthorizationError ||
err instanceof NotFoundError
) {
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 instanceof AuthorizationError ||
err instanceof NotFoundError
) {
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 instanceof AuthorizationError ||
err instanceof NotFoundError
) {
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);
+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);
+9 -15
View File
@@ -7,13 +7,11 @@ 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, getEventFiles } from "@shared/utils/files";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input";
@@ -37,7 +35,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 +122,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 +181,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":
@@ -428,12 +424,10 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
for (const embed of embeds) {
if (embed.title) {
embedItems.push(
new EmbedDescriptor({
...embed,
name: "embed",
})
);
embedItems.push({
...embed,
name: "embed",
});
}
}
+5 -10
View File
@@ -11,7 +11,7 @@ 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 { isInternalUrl, sanitizeHref } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import { ToastOptions } from "~/types";
@@ -44,7 +44,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 +70,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 +113,7 @@ class LinkEditor extends React.Component<Props, State> {
this.discardInputValue = true;
const { from, to } = this.props;
href = sanitizeUrl(href) ?? "";
href = sanitizeHref(href) ?? "";
this.props.onSelectLink({ href, title, from, to });
};
@@ -229,12 +229,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;
}
+1 -11
View File
@@ -9,14 +9,12 @@ 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";
@@ -69,7 +67,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(
@@ -172,14 +170,6 @@ const useAuthorizedSettingsConfig = () => {
group: t("Integrations"),
icon: WebhooksIcon,
},
Drawio: {
name: t("Draw.io"),
path: "/settings/integrations/drawio",
component: Drawio,
enabled: can.update,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
Slack: {
name: "Slack",
path: "/settings/integrations/slack",
-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,
+4 -4
View File
@@ -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,
+194 -32
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";
@@ -16,29 +25,19 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { getEventFiles } from "@shared/utils/files";
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,6 +205,19 @@ 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);
@@ -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"),
@@ -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";
+4 -4
View File
@@ -11,7 +11,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 SocketProvider from "~/components/SocketProvider";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
@@ -64,10 +64,10 @@ const RedirectDocument = ({
function AuthenticatedRoutes() {
const team = useCurrentTeam();
const can = usePolicy(team);
const can = usePolicy(team.id);
return (
<WebsocketProvider>
<SocketProvider>
<Layout>
<React.Suspense
fallback={
@@ -116,7 +116,7 @@ function AuthenticatedRoutes() {
</Switch>
</React.Suspense>
</Layout>
</WebsocketProvider>
</SocketProvider>
);
}
+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 -2
View File
@@ -3,7 +3,7 @@ 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 { MAX_TITLE_LENGTH } from "@shared/constants";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import IconPicker from "~/components/IconPicker";
@@ -94,7 +94,7 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
type="text"
label={t("Name")}
onChange={handleNameChange}
maxLength={CollectionValidation.maxNameLength}
maxLength={MAX_TITLE_LENGTH}
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();
},
+6 -9
View File
@@ -3,10 +3,7 @@ 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 { MAX_TITLE_LENGTH } from "@shared/constants";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
@@ -33,13 +30,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 +98,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 +128,7 @@ class CollectionNew extends React.Component<Props> {
type="text"
label={t("Name")}
onChange={this.handleNameChange}
maxLength={CollectionValidation.maxNameLength}
maxLength={MAX_TITLE_LENGTH}
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,
}}
+2 -26
View File
@@ -2,20 +2,17 @@ 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 { useTheme } from "styled-components";
import { setCookie } from "tiny-cookie";
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 Document from "./components/Document";
import Loading from "./components/Loading";
@@ -78,7 +75,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();
@@ -115,22 +111,7 @@ function SharedDocumentScene(props: Props) {
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>
);
return <Login />;
} else {
return <Error404 />;
}
@@ -165,9 +146,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>
+3 -25
View File
@@ -8,7 +8,6 @@ import ErrorOffline from "~/scenes/ErrorOffline";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
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";
@@ -42,23 +41,18 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
};
function DataLoader({ match, children }: Props) {
const { ui, shares, documents, auth, revisions, subscriptions } = useStores();
const { ui, shares, documents, auth, revisions } = 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 document = documents.getByUrl(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 can = usePolicy(document ? document.id : "");
const location = useLocation<LocationState>();
React.useEffect(() => {
@@ -87,22 +81,6 @@ function DataLoader({ match, children }: Props) {
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) {
@@ -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 -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,
@@ -45,7 +45,7 @@ function SharePopover({
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 &&
@@ -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>;
+1 -16
View File
@@ -24,9 +24,6 @@ function DocumentDelete({ document, onSubmit }: Props) {
const { showToast } = useToasts();
const canArchive = !document.isDraft && !document.isArchived;
const collection = collections.get(document.collectionId);
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
@@ -97,23 +94,11 @@ function DocumentDelete({ document, onSubmit }: Props) {
em: <strong />,
}}
/>
) : nestedDocumentsCount < 1 ? (
<Trans
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
values={{
documentTitle: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
) : (
<Trans
count={nestedDocumentsCount}
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>."
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents."
values={{
documentTitle: document.titleWithDefault,
any: nestedDocumentsCount,
}}
components={{
em: <strong />,
+2 -3
View File
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import { useState } from "react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
@@ -39,8 +38,8 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
const { t } = useTranslation();
const prevCollection = collections.get(item.collectionId);
const accessMapping = {
[CollectionPermission.ReadWrite]: t("view and edit access"),
[CollectionPermission.Read]: t("view only access"),
read_write: t("view and edit access"),
read: t("view only access"),
null: t("no access"),
};
+1 -1
View File
@@ -25,7 +25,7 @@ function GroupEdit({ group, onSubmit }: Props) {
try {
await group.save({
name,
name: name,
});
onSubmit();
} catch (err) {
+1 -1
View File
@@ -26,7 +26,7 @@ function GroupMembers({ group }: Props) {
const { users, groupMemberships } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const can = usePolicy(group);
const can = usePolicy(group.id);
const handleAddModal = (state: boolean) => {
setAddModalOpen(state);
@@ -1,6 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge";
@@ -17,22 +15,20 @@ type Props = {
};
const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
const { t } = useTranslation();
return (
<ListItem
title={user.name}
subtitle={
<>
{user.lastActiveAt ? (
<Trans>
<>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
</>
) : (
t("Never signed in")
"Never signed in"
)}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</>
}
image={<Avatar src={user.avatarUrl} size={32} />}
@@ -41,7 +37,7 @@ const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
{onAdd && (
<Button onClick={onAdd} neutral>
{t("Add")}
Add
</Button>
)}
</Flex>
@@ -50,4 +46,4 @@ const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
);
};
export default observer(GroupMemberListItem);
export default GroupMemberListItem;
@@ -1,7 +1,5 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge";
@@ -16,8 +14,6 @@ type Props = {
};
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
const { t } = useTranslation();
return (
<ListItem
title={user.name}
@@ -25,20 +21,20 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
subtitle={
<>
{user.lastActiveAt ? (
<Trans>
<>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
</>
) : (
t("Never signed in")
"Never signed in"
)}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</>
}
actions={
canEdit ? (
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
{t("Add")}
Add
</Button>
) : undefined
}
@@ -46,4 +42,4 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
);
};
export default observer(UserListItem);
export default UserListItem;
+1 -1
View File
@@ -29,7 +29,7 @@ function GroupNew({ onSubmit }: Props) {
const group = new Group(
{
name,
name: name,
},
groups
);
+1 -1
View File
@@ -30,7 +30,7 @@ function Home() {
pins.fetchPage();
}, [pins]);
const canManageTeam = usePolicy(team).manage;
const canManageTeam = usePolicy(team.id).manage;
return (
<Scene
+6 -5
View File
@@ -5,7 +5,6 @@ import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { Role } from "@shared/types";
import { UserValidation } from "@shared/validations";
import Button from "~/components/Button";
import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex";
@@ -20,6 +19,8 @@ import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
const MAX_INVITES = 20;
type Props = {
onSubmit: () => void;
};
@@ -56,7 +57,7 @@ function Invite({ onSubmit }: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const predictedDomain = user.email.split("@")[1];
const can = usePolicy(team);
const can = usePolicy(team.id);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
@@ -96,10 +97,10 @@ function Invite({ onSubmit }: Props) {
}, []);
const handleAdd = React.useCallback(() => {
if (invites.length >= UserValidation.maxInvitesPerRequest) {
if (invites.length >= MAX_INVITES) {
showToast(
t("Sorry, you can only send {{MAX_INVITES}} invites at a time", {
MAX_INVITES: UserValidation.maxInvitesPerRequest,
MAX_INVITES,
}),
{
type: "warning",
@@ -240,7 +241,7 @@ function Invite({ onSubmit }: Props) {
))}
<Flex justify="space-between">
{invites.length <= UserValidation.maxInvitesPerRequest ? (
{invites.length <= MAX_INVITES ? (
<Button type="button" onClick={handleAdd} neutral>
<Trans>Add another</Trans>
</Button>
+5 -9
View File
@@ -89,15 +89,11 @@ function AuthenticationProvider(props: Props) {
);
}
// If we're on a custom domain or a subdomain then the auth must point to the
// apex (env.URL) for authentication so that the state cookie can be set and read.
// We pass the host into the auth URL so that the server can redirect on error
// and keep the user on the same page.
const { custom, teamSubdomain, host } = parseDomain(window.location.origin);
const needsRedirect = custom || teamSubdomain;
const href = needsRedirect
? `${env.URL}${authUrl}?host=${encodeURI(host)}`
: authUrl;
// If we're on a custom domain then the auth must point to the root
// app.getoutline.com for authentication so that the state cookie can be set
// and read.
const isCustomDomain = parseDomain(window.location.origin).custom;
const href = `${isCustomDomain ? env.URL : ""}${authUrl}`;
return (
<Wrapper>
-9
View File
@@ -57,15 +57,6 @@ export default function Notices() {
Please try again.
</NoticeAlert>
))}
{notice === "invalid-authentication" &&
(description ? (
<NoticeAlert>{description}</NoticeAlert>
) : (
<NoticeAlert>
Authentication failed you do not have permission to access this
team.
</NoticeAlert>
))}
{notice === "expired-token" && (
<NoticeAlert>
Sorry, it looks like that sign-in link is no longer valid, please try
+7 -14
View File
@@ -49,11 +49,7 @@ function Header({ config }: { config?: Config | undefined }) {
);
}
type Props = {
children?: (config?: Config) => React.ReactNode;
};
function Login({ children }: Props) {
function Login() {
const location = useLocation();
const query = useQuery();
const { t, i18n } = useTranslation();
@@ -178,14 +174,11 @@ function Login({ children }: Props) {
</GetStarted>
</>
) : (
<>
<StyledHeading centered>
{t("Login to {{ authProviderName }}", {
authProviderName: config.name || "Outline",
})}
</StyledHeading>
{children?.(config)}
</>
<StyledHeading centered>
{t("Login to {{ authProviderName }}", {
authProviderName: config.name || "Outline",
})}
</StyledHeading>
)}
<Notices />
{defaultProvider && (
@@ -243,7 +236,7 @@ const CheckEmailIcon = styled(EmailIcon)`
const Background = styled(Fade)`
width: 100vw;
height: 100%;
height: 100vh;
background: ${(props) => props.theme.background};
display: flex;
`;
+18 -8
View File
@@ -14,25 +14,33 @@ function UserFilter(props: Props) {
const { t } = useTranslation();
const { users } = useStores();
React.useEffect(() => {
users.fetchPage({
limit: 100,
});
}, [users]);
const options = React.useMemo(() => {
const userOptions = users.all.map((user) => ({
const userOptions = users.unorderedData.map((user) => ({
key: user.id,
id: user.id,
label: user.name,
}));
return [
{
key: "",
id: "",
label: t("Any author"),
},
...userOptions,
];
}, [users.all, t]);
}, [users.unorderedData, t]);
const search = React.useCallback(
async (query: string) => {
const res = await users.find(query);
return res.map((user) => ({
key: user.id,
id: user.id,
label: user.name,
}));
},
[users]
);
return (
<FilterOptions
@@ -41,6 +49,8 @@ function UserFilter(props: Props) {
onSelect={onSelect}
defaultLabel={t("Any author")}
selectedPrefix={`${t("Author")}:`}
search={search}
paginateFetch={users.fetchPage}
/>
);
}
-101
View File
@@ -1,101 +0,0 @@
import { head } from "lodash";
import { observer } from "mobx-react";
import { BuildingBlocksIcon } from "outline-icons";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import { ReactHookWrappedInput as Input } from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type FormData = {
url: string;
};
const SERVICE_NAME = "diagrams";
function Drawio() {
const { integrations } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
React.useEffect(() => {
integrations.fetchPage({
service: SERVICE_NAME,
type: IntegrationType.Embed,
});
}, [integrations]);
const integration = head(integrations.orderedData) as
| Integration<IntegrationType.Embed>
| undefined;
const { register, handleSubmit: formHandleSubmit, formState } = useForm<
FormData
>({
mode: "all",
defaultValues: {
url: integration?.settings.url,
},
});
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await integrations.save({
id: integration?.id,
type: IntegrationType.Embed,
service: SERVICE_NAME,
settings: {
url: data.url,
},
});
showToast(t("Settings saved"), {
type: "success",
});
} catch (err) {
showToast(err.message, {
type: "error",
});
}
},
[integrations, integration, t, showToast]
);
return (
<Scene title="Draw.io" icon={<BuildingBlocksIcon color="currentColor" />}>
<Heading>Draw.io</Heading>
<Text type="secondary">
<Trans>
Add your self-hosted draw.io installation url here to enable automatic
embedding of diagrams within documents.
</Trans>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<p>
<Input
label={t("Draw.io deployment")}
placeholder={"https://app.diagrams.net/"}
pattern="https?://.*"
{...register("url", {
required: true,
})}
/>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</p>
</form>
</Text>
</Scene>
);
}
export default observer(Drawio);
+1 -1
View File
@@ -56,7 +56,7 @@ function Export() {
<Heading>{t("Export")}</Heading>
<Text type="secondary">
<Trans
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete."
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started we will email a link to <em>{{ userEmail }}</em> when its complete."
values={{
userEmail: user.email,
}}
+1 -1
View File
@@ -24,7 +24,7 @@ function Groups() {
const { t } = useTranslation();
const { groups } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const can = usePolicy(team.id);
const [
newGroupModalOpen,
handleNewGroupModalOpen,
+1 -1
View File
@@ -40,7 +40,7 @@ function Members() {
const [data, setData] = React.useState<User[]>([]);
const [totalPages, setTotalPages] = React.useState(0);
const [userIds, setUserIds] = React.useState<string[]>([]);
const can = usePolicy(team);
const can = usePolicy(team.id);
const query = params.get("query") || "";
const filter = params.get("filter") || "";
const sort = params.get("sort") || "name";
-7
View File
@@ -51,13 +51,6 @@ function Notifications() {
"Receive a notification when someone you invited creates an account"
),
},
{
event: "emails.export_completed",
title: t("Export completed"),
description: t(
"Receive a notification when an export you requested has been completed"
),
},
{
visible: isCloudHosted,
event: "emails.onboarding",
+1 -1
View File
@@ -21,7 +21,7 @@ function Shares() {
const { t } = useTranslation();
const { shares, auth } = useStores();
const canShareDocuments = auth.team && auth.team.sharing;
const can = usePolicy(team);
const can = usePolicy(team.id);
const [isLoading, setIsLoading] = React.useState(false);
const [data, setData] = React.useState<Share[]>([]);
const [totalPages, setTotalPages] = React.useState(0);
+1 -4
View File
@@ -3,7 +3,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
@@ -125,9 +124,7 @@ function Slack() {
<SlackListItem
key={integration.id}
collection={collection}
integration={
integration as Integration<IntegrationType.Post>
}
integration={integration}
/>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ function Tokens() {
const { t } = useTranslation();
const { apiKeys } = useStores();
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
const can = usePolicy(team);
const can = usePolicy(team.id);
return (
<Scene
+1 -1
View File
@@ -23,7 +23,7 @@ function Webhooks() {
const { t } = useTranslation();
const { webhookSubscriptions } = useStores();
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
const can = usePolicy(team);
const can = usePolicy(team.id);
return (
<Scene
@@ -5,7 +5,6 @@ import * as React from "react";
import AvatarEditor from "react-avatar-editor";
import Dropzone from "react-dropzone";
import styled from "styled-components";
import { AttachmentValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
@@ -135,7 +134,7 @@ class ImageUpload extends React.Component<RootStore & Props> {
return (
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
accept="image/png, image/jpeg"
onDropAccepted={this.onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
@@ -4,7 +4,6 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import { IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
@@ -18,7 +17,7 @@ import Text from "~/components/Text";
import useToasts from "~/hooks/useToasts";
type Props = {
integration: Integration<IntegrationType.Post>;
integration: Integration;
collection: Collection;
};
+28 -26
View File
@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
@@ -14,31 +13,34 @@ type Props = {
showMenu: boolean;
};
const UserListItem = ({ user, showMenu }: Props) => {
const { t } = useTranslation();
@observer
class UserListItem extends React.Component<Props> {
render() {
const { user, showMenu } = this.props;
return (
<ListItem
title={<Title>{user.name}</Title>}
image={<Avatar src={user.avatarUrl} size={32} />}
subtitle={
<>
{user.email ? `${user.email} · ` : undefined}
{user.lastActiveAt ? (
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
) : (
t("Invited")
)}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</>
}
actions={showMenu ? <UserMenu user={user} /> : undefined}
/>
);
};
return (
<ListItem
title={<Title>{user.name}</Title>}
image={<Avatar src={user.avatarUrl} size={32} />}
subtitle={
<>
{user.email ? `${user.email} · ` : undefined}
{user.lastActiveAt ? (
<>
Active <Time dateTime={user.lastActiveAt} /> ago
</>
) : (
"Invited"
)}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
{user.isSuspended && <Badge>Suspended</Badge>}
</>
}
actions={showMenu ? <UserMenu user={user} /> : undefined}
/>
);
}
}
const Title = styled.span`
&:hover {
@@ -47,4 +49,4 @@ const Title = styled.span`
}
`;
export default observer(UserListItem);
export default UserListItem;
@@ -31,6 +31,8 @@ const WEBHOOK_EVENTS = {
"documents.archive",
"documents.unarchive",
"documents.restore",
"documents.star",
"documents.unstar",
"documents.move",
"documents.update",
"documents.update.delayed",
+1 -1
View File
@@ -21,7 +21,7 @@ function Templates(props: RouteComponentProps<{ sort: string }>) {
const team = useCurrentTeam();
const { fetchTemplates, templates, templatesAlphabetical } = documents;
const { sort } = props.match.params;
const can = usePolicy(team);
const can = usePolicy(team.id);
return (
<Scene
+28 -83
View File
@@ -1,120 +1,65 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import { ReactHookWrappedInput as Input } from "~/components/Input";
import Modal from "~/components/Modal";
import Text from "~/components/Text";
import env from "~/env";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type FormData = {
code: string;
};
type Props = {
onRequestClose: () => void;
};
function UserDelete({ onRequestClose }: Props) {
const [isWaitingCode, setWaitingCode] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const { auth } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const { register, handleSubmit: formHandleSubmit, formState } = useForm<
FormData
>();
const handleRequestDelete = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
try {
await auth.requestDelete();
setWaitingCode(true);
} catch (error) {
showToast(error.message, {
type: "error",
});
}
},
[auth, showToast]
);
const handleSubmit = React.useCallback(
async (data: FormData) => {
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsDeleting(true);
try {
await auth.deleteUser(data);
await auth.deleteUser();
auth.logout();
} catch (error) {
showToast(error.message, {
type: "error",
});
} finally {
setIsDeleting(false);
}
},
[auth, showToast]
);
const inputProps = register("code", {
required: true,
});
return (
<Modal isOpen title={t("Delete Account")} onRequestClose={onRequestClose}>
<Flex column>
<form onSubmit={formHandleSubmit(handleSubmit)}>
{isWaitingCode ? (
<>
<Text type="secondary">
<Trans>
A confirmation code has been sent to your email address,
please enter the code below to permanantly destroy your
account.
</Trans>
</Text>
<Text type="secondary">
<Trans
defaults="<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned."
components={{
em: <strong />,
}}
/>
</Text>
<Input
placeholder="CODE"
autoComplete="off"
autoFocus
maxLength={8}
required
{...inputProps}
/>
</>
) : (
<>
<Text type="secondary">
<Trans>
Are you sure? Deleting your account will destroy identifying
data associated with your user and cannot be undone. You will
be immediately logged out of Outline and all your API tokens
will be revoked.
</Trans>
</Text>
</>
)}
{env.EMAIL_ENABLED && !isWaitingCode ? (
<Button type="submit" onClick={handleRequestDelete} neutral>
{t("Continue")}
</Button>
) : (
<Button type="submit" disabled={formState.isSubmitting} danger>
{formState.isSubmitting
? `${t("Deleting")}`
: t("Delete My Account")}
</Button>
)}
<form onSubmit={handleSubmit}>
<Text type="secondary">
<Trans>
Are you sure? Deleting your account will destroy identifying data
associated with your user and cannot be undone. You will be
immediately logged out of Outline and all your API tokens will be
revoked.
</Trans>
</Text>
<Text type="secondary">
<Trans
defaults="<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned."
components={{
em: <strong />,
}}
/>
</Text>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Delete My Account")}
</Button>
</form>
</Flex>
</Modal>
+8 -16
View File
@@ -199,13 +199,8 @@ export default class AuthStore {
};
@action
requestDelete = () => {
return client.post(`/users.requestDelete`);
};
@action
deleteUser = async (data: { code: string }) => {
await client.post(`/users.delete`, data);
deleteUser = async () => {
await client.post(`/users.delete`);
runInAction("AuthStore#updateUser", () => {
this.user = null;
this.team = null;
@@ -260,6 +255,12 @@ export default class AuthStore {
@action
logout = async (savePath = false) => {
if (!this.token) {
return;
}
client.post(`/auth.delete`);
// if this logout was forced from an authenticated route then
// save the current path so we can go back there once signed in
if (savePath) {
@@ -270,19 +271,10 @@ export default class AuthStore {
}
}
// If there is no auth token stored there is nothing else to do
if (!this.token) {
return;
}
// invalidate authentication token on server
client.post(`/auth.delete`);
// remove authentication token itself
removeCookie("accessToken", {
path: "/",
});
// remove session record on apex cookie
const team = this.team;

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