Compare commits

...

134 Commits

Author SHA1 Message Date
Saumya Pandey 8a1e2eb751 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-10-08 09:29:29 +05:30
Alexander Krantz 09a409b494 feat: add changing appearance for guests (#2632)
* Allow changing appearance when guest

* Apply suggestions from code review

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-10-07 18:43:41 -07:00
Saumya Pandey 584377e7de check for collaborativeEditing 2021-10-07 23:14:42 +05:30
Saumya Pandey b90ed11c5b Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-10-07 23:13:36 +05:30
Tom Moor ccd947c6e8 fix: Positioning of input select items when seleted item does not fit in available area
fix: Scroll selected item in input select
2021-10-06 23:31:35 -07:00
Tom Moor 4e05728218 fix: InputSelect disabled state 2021-10-06 22:36:45 -07:00
Saumya Pandey 40e09dd829 fix: Implement custom Select Input (#2571) 2021-10-06 21:48:43 -07:00
Tom Moor 99381d10ff translations 2021-10-06 21:17:17 -07:00
Translate-O-Tron 36c73051b4 New Crowdin updates (#2596) 2021-10-06 21:09:29 -07:00
Saumya Pandey 81718c8ee1 fix: Delete collection exports (#2595) 2021-10-06 21:08:45 -07:00
Tom Moor be905a6993 feat: Add idle detection and disconnect collaboration socket (#2629) 2021-10-06 17:37:21 -07:00
Tom Moor b39d4aade7 Bump editor, minor emoji trigger fixes and adds Perl language support 2021-10-06 08:38:43 -07:00
Tom Moor c5fb5f875f flow 2021-10-04 22:08:16 -07:00
Tom Moor 552755dace feat: Add admin UI for enabling collab editing 2021-10-04 22:00:47 -07:00
Tom Moor e61c71766f Add guard against overwriting text when collaborative editing enabled 2021-10-04 19:20:48 -07:00
Tom Moor df5dc2f691 fix: Improve graceful shutdown 2021-10-04 18:20:42 -07:00
Tom Moor 28097835d0 chore: Remove debounced search (#2625)
* Remove debounced search

* fix hover color on filter options
2021-10-04 08:04:56 -07:00
Tom Moor 3de51c1a67 Bump editor, closes #2620, #2619 2021-10-02 22:21:26 -07:00
Tom Moor 223a47af95 fix: Improve error when email field not returned from OIDC 2021-10-02 22:42:41 -04:00
Tom Moor 7c8675ce17 fix: Creating API token reloads app
fix: API keys unselectable in list
closes #2604
2021-10-02 22:39:37 -04:00
Tom Moor 157c3ce80f fix: Missing cascade on integration -> authentication relationship 2021-10-02 22:22:08 -04:00
Saumya Pandey 0ed7286fc6 fix: Move request helper function (#2594)
* Move request method to passport utils

* Use request method in OIDC provider
2021-09-29 07:20:05 -07:00
Saumya Pandey 7fc450729f Use less than or equal 2021-09-28 08:50:36 +05:30
Saumya Pandey 193b027a52 Even better 2021-09-28 08:49:48 +05:30
Tom Moor 78464f315c fix: Awareness loop in collaborative editing 2021-09-27 18:44:28 -04:00
Saumya Pandey 26466a7342 Restructure ternary operator 2021-09-28 01:47:18 +05:30
Saumya Pandey f4f3588039 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-09-28 01:15:40 +05:30
Tom Moor 79790de9b0 fix: Editor toolbar below fixed header 2021-09-27 10:40:44 -07:00
Tom Moor 252459f1cf fix: Loading flicker in collab editor when no local cache 2021-09-27 10:27:02 -07:00
Tom Moor 20a72481dc Disable embed toggling + collaborative editing 2021-09-26 21:05:32 -07:00
Tom Moor 765c7cdc27 fix: Max menu height should not affect mobile context menus 2021-09-26 17:19:00 -07:00
Tom Moor 6f136e342f fix: Context menus can extend outside of window bounds
closes #2492
2021-09-26 17:07:44 -07:00
Tom Moor 9545113d9e feat: Emoji picker in editor (#2611) 2021-09-26 15:26:32 -07:00
Tom Moor c00001086a fix: IconPicker unclosable on mobile 2021-09-26 15:26:10 -07:00
Tom Moor 95dbc8168c feat: Add 2 collection icons 2021-09-25 14:54:19 -07:00
Tom Moor 0021553518 Typescript, we need you 2021-09-25 08:55:52 -07:00
Tom Moor bcca4b91ee feat: Add 5 new collection icons 2021-09-24 19:39:31 -07:00
Tom Moor c1bd30aac8 Add user to collaboration logs 2021-09-24 19:14:00 -07:00
Tom Moor fd7dd83a4b fix: Updated database references 2021-09-23 20:09:40 -07:00
Tom Moor 26f02cdd05 fix: Table toolbars missing when cells empty 2021-09-23 19:58:16 -07:00
Tom Moor fec2baf361 fix: Memory leak in collaborative editing service 2021-09-23 17:09:15 -07:00
Tom Moor e1601fbe72 chore: Permanent team deletion (#2493) 2021-09-20 20:58:39 -07:00
dependabot[bot] a88b54d26d chore(deps): bump tmpl from 1.0.4 to 1.0.5 (#2601)
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

---
updated-dependencies:
- dependency-name: tmpl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-09-20 18:43:11 -07:00
Translate-O-Tron 88cc964d69 New Crowdin updates (#2590)
* fix: New Polish translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]
2021-09-19 19:02:01 -07:00
Saumya Pandey b8efe772fe fix: Warning when dragging document between collections with different user permissions (#2516) 2021-09-19 19:00:54 -07:00
Tom Moor b2f00d71d3 fix: Image zoom doesn't work in read-only 2021-09-19 15:26:52 -07:00
Tom Moor c2edfca6e5 fix: 'undefined' logged 2021-09-19 15:15:13 -07:00
Tom Moor 576907fdc1 merge 2021-09-19 13:44:09 -07:00
Tom Moor f56a75d2ae Merge branch 'main' into fix/2122 2021-09-19 13:43:34 -07:00
Saumya Pandey 9c3c0fe418 feat: Add Polish to languageOptions (#2593) 2021-09-19 09:45:26 -07:00
Saumya Pandey 5d2ccb5821 Add animation 2021-09-19 11:37:02 +05:30
Tom Moor 313067ff7b Add additional logging for persistence failure 2021-09-18 20:09:08 -07:00
Tom Moor be64c2b206 fix: Restore load cache, fixes TOC not visible on load 2021-09-18 17:49:00 -07:00
Tom Moor d576ce1734 fix: Remote awareness not available on doc load (collab) 2021-09-17 17:36:48 -07:00
Tom Moor 0f624958bc Use new hocuspocus hooks for collaboration metrics 2021-09-17 17:35:20 -07:00
Tom Moor 162da9a3ad fix: Can't edit title in collaborative mode 2021-09-16 22:47:58 -07:00
Tom Moor d7e9ad4f13 Remove usage of internal api 2021-09-16 21:27:37 -07:00
Tom Moor bcf773a1d6 Billibilli default hidden 2021-09-16 18:49:05 -07:00
Tom Moor 97082e8cba Merge branch 'main' of github.com:outline/outline 2021-09-16 18:48:25 -07:00
Su Yang bc3f2e4876 Add bilibili Embed Service (#2550)
* feat: Add bilibili Embed Service

* chore: code format

* chore: update bilibili icon
2021-09-16 18:48:13 -07:00
Translate-O-Tron 49a9b91708 New Crowdin updates (#2566) 2021-09-16 18:45:55 -07:00
Greg Linklater 01cea549a5 feat: map preferred_username claim to user record (#2569) 2021-09-16 18:45:37 -07:00
Tom Moor a9df3f64cf fix: Headings and code should be toggleable 2021-09-16 18:42:42 -07:00
Tom Moor e6cc8f5550 fix: Include log level in development 2021-09-16 17:22:23 -07:00
Tom Moor f6c2a95a55 Bump i18next-parser for true --silent fix 2021-09-16 16:26:57 -07:00
Tom Moor 27736f66ef fix: Various fixes for collaborative editing beta (#2586) 2021-09-15 23:27:22 -07:00
Tom Moor cde2909296 fix: Missing translation tag 2021-09-14 20:15:37 -07:00
Tom Moor 1f6e1a71f9 fix: List reverting to '0' indexing 2021-09-14 18:34:34 -07:00
Tom Moor 15ef8f7dff chore: Upgrade i18next related deps 2021-09-14 18:15:16 -07:00
Tom Moor 83a61b87ed feat: Normalized server logging (#2567)
* feat: Normalize logging

* Remove scattered console.error + Sentry.captureException

* Remove mention of debug

* cleanup dev output

* Edge cases, docs

* Refactor: Move logger, metrics, sentry under 'logging' folder.
Trying to reduce the amount of things under generic 'utils'

* cleanup, last few console calls
2021-09-14 18:04:35 -07:00
Tom Moor 6c605cf720 fix: Forward to incorrect collection url on first signin (#2565)
closes #2560
2021-09-13 21:35:52 -07:00
Tom Moor fb335887cb preventBodyScrollhideOnEsc 2021-09-13 21:00:28 -07:00
Translate-O-Tron 88e7d4c539 New Crowdin updates (#2449) 2021-09-13 20:09:52 -07:00
Tom Moor 400e32da70 fix: Various fixes for collaborative editing beta (#2561)
* fix: Remove Saving… message when collab enabled

* chore: Add tracing extension to collaboration server

* fix: Incorrect debounce behavior due to missing timestamps on events, fixes abundence of notifications when editing in realtime collab mode

* fix: Reload document prompt when collab editing
2021-09-13 17:36:26 -07:00
Tom Moor a699dea286 fix: Cleanup forking model (#2559)
* fix: Cleanup forking model
2021-09-12 21:45:52 -07:00
Tom Moor 2aca760ee0 fix: Double document highlight in sidebar (#2551)
* fix: Single highlighted doc when starred
closes #2544

* fix: Collection expand/collapse as navigating starred docs
2021-09-11 15:54:05 -07:00
Tom Moor f1c9c6fdf9 Update LICENSE 2021-09-11 09:48:19 -07:00
Tom Moor 801f6681ba Collaborative editing (#1660) 2021-09-10 22:46:57 -07:00
Saumya Pandey 2905306c28 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-08-30 15:39:57 +05:30
Saumya Pandey 88bc1aae89 Merge branch 'fix/2122' of https://github.com/outline/outline into fix/2122 2021-08-30 15:37:20 +05:30
Saumya Pandey cd4f76270c Create useListDocumentPath hook 2021-08-30 15:31:39 +05:30
Saumya Pandey 90d0309b33 Create DocumentPathList to rows computation 2021-08-29 16:27:47 +05:30
Saumya Pandey 30a98df712 Pass selected value as a prop 2021-08-29 15:07:27 +05:30
Saumya Pandey c40caea4ab Minor tweaks 2021-08-28 15:31:03 +05:30
Saumya Pandey c8e16e8de0 Merge branch 'fix/2122' of https://github.com/outline/outline into fix/2122 2021-08-27 03:17:37 +05:30
Saumya Pandey 0558049483 Update app/components/NewDocumentButton.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-27 03:17:20 +05:30
Saumya Pandey e96e293988 Use InputSearch 2021-08-27 03:16:35 +05:30
Saumya Pandey a628735b2c Reorder star at the end 2021-08-27 03:01:39 +05:30
Saumya Pandey b785da6159 Convert to functional component 2021-08-27 02:44:24 +05:30
Saumya Pandey e5eb134ae6 Use newDocumentUrl helper 2021-08-27 01:44:51 +05:30
Saumya Pandey 8efd31f1d2 Don't show collections that can't be update by user 2021-08-27 01:42:30 +05:30
Saumya Pandey ac64725964 Add policy check when publishing draft 2021-08-27 01:00:25 +05:30
Saumya Pandey 4fbb5037c5 Authorize for collection publish 2021-08-27 00:25:12 +05:30
Saumya Pandey 78e23026e6 Use hasCollection instead of can.share 2021-08-27 00:16:16 +05:30
Saumya Pandey 21c7d93131 Merge branch 'fix/2122' of https://github.com/outline/outline into fix/2122 2021-08-27 00:11:31 +05:30
Saumya Pandey f76d72ef0a Update app/components/DocumentBreadcrumb.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-27 00:10:10 +05:30
Saumya Pandey 733490f536 Add return type 2021-08-27 00:01:51 +05:30
Saumya Pandey faa67a7403 Remove computedCollectionId 2021-08-27 00:01:41 +05:30
Saumya Pandey 900ee7ada0 Update app/menus/DocumentMenu.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:50:27 +05:30
Saumya Pandey c33515686d Update app/components/DocumentMeta.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:46:51 +05:30
Saumya Pandey e619bce571 Update app/components/DocumentListItem.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:42:20 +05:30
Saumya Pandey c9f0e3a5e6 Update app/components/DocumentListItem.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:41:17 +05:30
Saumya Pandey e5981d45bf Highlight the selected path with primary color 2021-08-25 20:03:38 +05:30
Saumya Pandey eb408b0e82 Rename method 2021-08-25 19:40:18 +05:30
Saumya Pandey ecdca2b8ec Extract dialog to it's own component 2021-08-25 19:25:12 +05:30
Saumya Pandey 20429847c1 Ip from request obj 2021-08-25 18:25:44 +05:30
Saumya Pandey 6d174fe6d6 Fix typo and add translation 2021-08-25 18:21:32 +05:30
Saumya Pandey 3065296a19 Move events inside documentUpdater 2021-08-25 18:19:24 +05:30
Saumya Pandey e0f9f33c81 Remove unnecessary code 2021-08-25 02:32:25 +05:30
Saumya Pandey 2a732fad09 Stop share.create request 2021-08-25 01:01:11 +05:30
Saumya Pandey be04e2abef Add space 2021-08-24 23:41:31 +05:30
Saumya Pandey cec8e375bf Delete NewDocumentMenu 2021-08-24 23:37:50 +05:30
Saumya Pandey 854e4d6af7 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-08-24 23:37:05 +05:30
Saumya Pandey 98e6bf6504 Replace NewDocumentMenu with NewDocumentButton 2021-08-24 23:30:23 +05:30
Saumya Pandey e5bbd7db1d Update position 2021-08-24 23:21:05 +05:30
Saumya Pandey 2fe7370252 Add checkbox and publish btn 2021-08-24 18:19:23 +05:30
Saumya Pandey cbf6aef0eb Minor tweaks 2021-08-21 01:32:56 +05:30
Saumya Pandey aa2980a941 Add tests on drafts doc 2021-08-21 01:32:48 +05:30
Saumya Pandey 7ffe2a90a5 Make modal mobile friendly 2021-08-12 03:49:59 +05:30
Saumya Pandey bbb41a9430 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-08-12 03:07:31 +05:30
Saumya Pandey 3cfca47978 Use star and unstar policies 2021-08-12 03:05:24 +05:30
Saumya Pandey 35c6b64077 Update unarchive policy to not assert for collection 2021-08-12 02:55:27 +05:30
Saumya Pandey 0095830000 Update star policy 2021-08-12 02:44:01 +05:30
Saumya Pandey ab5b3e151a Don't move archive 2021-08-12 02:35:51 +05:30
Saumya Pandey 65e5030efd Fix bug in test 2021-08-12 01:20:38 +05:30
Saumya Pandey e16bfd83ef Add modal to publish flow 2021-08-11 18:21:16 +05:30
Saumya Pandey d98ec86307 Create documentUpdater command 2021-08-11 17:06:27 +05:30
Saumya Pandey 3caeb8ba19 Create choose collection modal 2021-08-11 01:16:16 +05:30
Saumya Pandey ef8be0dd0b Refactor PathToDocument 2021-08-11 01:16:01 +05:30
Saumya Pandey 44f3ef869a Use documents.update endpoint 2021-08-10 03:35:37 +05:30
Saumya Pandey c43157a2fa Quick filteroptions to choose collection 2021-08-07 01:06:29 +05:30
Saumya Pandey b7f7af2480 Use computedCollectionId 2021-08-06 23:32:14 +05:30
Saumya Pandey fd71f82ec9 Create draft without collection 2021-08-06 23:31:49 +05:30
Saumya Pandey 4e29b14426 Updates on server to support drafts with no collection 2021-08-06 23:18:53 +05:30
287 changed files with 8810 additions and 2474 deletions
+11 -3
View File
@@ -29,6 +29,10 @@ REDIS_URL=redis://localhost:6479
URL=http://localhost:3000
PORT=3000
# ALPHA See [documentation](docs/SERVICES.md) on running the alpha version of
# the collaboration server.
COLLABORATION_URL=
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
@@ -85,6 +89,10 @@ OIDC_AUTH_URI=
OIDC_TOKEN_URI=
OIDC_USERINFO_URI=
# Specify which claims to derive user information from
# Supports any valid JSON path with the JWT payload
OIDC_USERNAME_CLAIM=preferred_username
# Display name for OIDC authentication
OIDC_DISPLAY_NAME=OpenID Connect
@@ -116,9 +124,9 @@ WEB_CONCURRENCY=1
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
# You may enable or disable debugging categories to increase the noisiness of
# logs. The default is a good balance
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,processors
# You can remove this line if your reverse proxy already logs incoming http
# requests and this ends up being duplicative
DEBUG=http
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
+5
View File
@@ -11,6 +11,11 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/y-indexeddb/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.55.0
Licensed Work: Outline 0.59.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2024-04-22
Change Date: 2025-09-07
Change License: Apache License, Version 2.0
+1 -1
View File
@@ -2,7 +2,7 @@ up:
docker-compose up -d redis postgres s3
yarn install --pure-lockfile
yarn sequelize db:migrate
yarn dev
yarn dev:watch
build:
docker-compose build --pull outline
+2 -2
View File
@@ -1,2 +1,2 @@
web: node ./build/server/index.js --services=web,websockets
worker: node ./build/server/index.js --services=worker
web: yarn start --services=web,websockets
worker: yarn start --services=worker
+24 -29
View File
@@ -1,5 +1,3 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
</p>
@@ -30,7 +28,6 @@ Outline requires the following dependencies:
- AWS S3 bucket or compatible API for file storage
- Slack or Google developer application for authentication
## Self-Hosted Production
### Docker
@@ -41,16 +38,20 @@ For a manual self-hosted production installation these are the recommended steps
1. Download the latest official Docker image, new releases are available around the middle of every month:
`docker pull outlinewiki/outline`
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
`docker run --env-file=.env outlinewiki/outline`
1. Setup the database with `yarn db:migrate`. Production assumes an SSL connection to the database by default, if
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
`docker run --rm outlinewiki/outline yarn db:migrate`
1. Start the container:
`docker run outlinewiki/outline`
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed using the `PORT` environment variable
@@ -79,29 +80,27 @@ If you're running Outline by cloning this repository, run the following command
yarn run upgrade
```
## Local Development
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
1. Install these dependencies if you don't already have them
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. Clone this repo
1. Register a Slack app at https://api.slack.com/apps
1. Copy the file `.env.sample` to `.env`
1. Fill out the following fields:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
# Contributing
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
@@ -110,26 +109,22 @@ Before submitting a pull request please let the core team know by creating or co
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
- [Translation](docs/TRANSLATION.md) into other languages
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
- Performance improvements, both on server and frontend
- Developer happiness and documentation
- Bugs and other issues listed on GitHub
## Architecture
If you're interested in contributing or learning more about the Outline codebase
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
please refer to the [architecture document](docs/ARCHITECTURE.md) first for a high level overview of how the application is put together.
## Debugging
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
```
DEBUG=sql,cache,presenters,events,importer,exporter,emails,mailer
```
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
## Tests
@@ -145,7 +140,7 @@ make test
make watch
```
Once the test database is created with `make test` you may individually run
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
```shell
+10 -2
View File
@@ -35,7 +35,7 @@ const RealButton = styled.button`
border: 0;
}
&:hover {
&:hover:not(:disabled) {
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
@@ -43,6 +43,10 @@ const RealButton = styled.button`
cursor: default;
pointer-events: none;
color: ${(props) => props.theme.white50};
svg {
fill: ${(props) => props.theme.white50};
}
}
${(props) =>
@@ -65,7 +69,7 @@ const RealButton = styled.button`
}
&:hover {
&:hover:not(:disabled) {
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
@@ -78,6 +82,10 @@ const RealButton = styled.button`
&:disabled {
color: ${props.theme.textTertiary};
svg {
fill: ${props.theme.textTertiary};
}
}
`} ${(props) =>
props.danger &&
+4 -2
View File
@@ -12,17 +12,19 @@ import DocumentViews from "components/DocumentViews";
import Facepile from "components/Facepile";
import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
currentUserId: string,
|};
function Collaborators(props: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
const { users, presence } = useStores();
const { document, currentUserId } = props;
const { document } = props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
+11 -5
View File
@@ -11,18 +11,24 @@ type Props = {
collection: Collection,
expanded?: boolean,
size?: number,
useLuminance?: boolean,
};
function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
function ResolvedCollectionIcon({
collection,
expanded,
size,
useLuminance,
}: Props) {
const { ui } = useStores();
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.12
? collection.color
: "currentColor"
(ui.resolvedTheme === "dark" || useLuminance) &&
collection.color !== "currentColor" &&
getLuminance(collection.color) <= 0.12
? "currentColor"
: collection.color;
if (collection.icon && collection.icon !== "collection") {
+59
View File
@@ -0,0 +1,59 @@
// @flow
import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useStores from "hooks/useStores";
function ConnectionStatus() {
const { ui } = useStores();
const theme = useTheme();
const { t } = useTranslation();
return ui.multiplayerStatus === "connecting" ||
ui.multiplayerStatus === "disconnected" ? (
<Tooltip
tooltip={
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
}
placement="bottom"
>
<Button>
<Fade>
<DisconnectedIcon color={theme.sidebarText} />
</Fade>
</Button>
</Tooltip>
) : null;
}
const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
right: 32px;
margin: 24px;
${breakpoint("tablet")`
display: block;
`};
@media print {
display: none;
}
`;
const Centered = styled.div`
text-align: center;
`;
export default observer(ConnectionStatus);
+6 -3
View File
@@ -2,7 +2,7 @@
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled from "styled-components";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import MenuIconWrapper from "../MenuIconWrapper";
@@ -88,12 +88,12 @@ const Spacer = styled.svg`
flex-shrink: 0;
`;
export const MenuAnchor = styled.a`
export const MenuAnchorCSS = css`
display: flex;
margin: 0;
border: 0;
padding: 12px;
padding-left: ${(props) => 12 + props.level * 10}px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
background: none;
@@ -138,5 +138,8 @@ export const MenuAnchor = styled.a`
font-size: 14px;
`};
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
export default MenuItem;
+4 -3
View File
@@ -71,12 +71,13 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
function Template({ items, ...menu }: Props): React.Node {
const filteredTemplates = filterTemplateItems(items);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) => !!item.icon
(item) => !item.type && !!item.icon
);
return filteredTemplates.map((item, index) => {
if (iconIsPresentInAnyMenuItem)
item.icon = item.icon ? item.icon : <MenuIconWrapper />;
if (iconIsPresentInAnyMenuItem && !item.type) {
item.icon = item.icon || <MenuIconWrapper />;
}
if (item.to) {
return (
+16 -6
View File
@@ -4,6 +4,7 @@ import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import useMenuHeight from "hooks/useMenuHeight";
import usePrevious from "hooks/usePrevious";
import {
fadeIn,
@@ -18,8 +19,12 @@ type Props = {|
placement?: string,
animating?: boolean,
children: React.Node,
unstable_disclosureRef?: {
current: null | React.ElementRef<"button">,
},
onOpen?: () => void,
onClose?: () => void,
hide?: () => void,
|};
export default function ContextMenu({
@@ -29,6 +34,8 @@ export default function ContextMenu({
...rest
}: Props) {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
const backgroundRef = React.useRef();
React.useEffect(() => {
if (rest.visible && !previousVisible) {
@@ -43,6 +50,8 @@ export default function ContextMenu({
}
}, [onOpen, onClose, previousVisible, rest.visible]);
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
@@ -59,6 +68,8 @@ export default function ContextMenu({
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
style={maxHeight && topAnchor ? { maxHeight } : undefined}
>
{rest.visible || rest.animating ? children : null}
</Background>
@@ -68,14 +79,14 @@ export default function ContextMenu({
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop />
<Backdrop onClick={rest.hide} />
</Portal>
)}
</>
);
}
const Backdrop = styled.div`
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
@@ -90,7 +101,7 @@ const Backdrop = styled.div`
`};
`;
const Position = styled.div`
export const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
@@ -104,7 +115,7 @@ const Position = styled.div`
`};
`;
const Background = styled.div`
export const Background = styled.div`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
@@ -125,8 +136,7 @@ const Background = styled.div`
${breakpoint("tablet")`
animation: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props) =>
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
transform-origin: ${(props) => (props.rightAnchor ? "75%" : "25%")} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
+4 -3
View File
@@ -61,8 +61,9 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const { t } = useTranslation();
const category = useCategory(document);
let collection = collections.get(document.collectionId);
if (!collection) {
let collection = collections.get(document.collectionId || "");
if (!collection && document.collectionId) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
@@ -111,7 +112,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
if (onlyText === true) {
return (
<>
{collection.name}
{collection?.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
+10 -9
View File
@@ -63,10 +63,11 @@ function DocumentListItem(props: Props, ref) {
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const canStar = policies.abilities(document.id).star;
const can = policies.abilities(currentTeam.id);
const canCollection = policies.abilities(document.collectionId);
const collectionPolicy = document.collectionId
? policies.abilities(document.collectionId)
: {};
return (
<DocumentLink
@@ -89,11 +90,6 @@ function DocumentListItem(props: Props, ref) {
{document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isDraft && showDraft && (
<Tooltip
tooltip={t("Only visible to you")}
@@ -106,6 +102,11 @@ function DocumentListItem(props: Props, ref) {
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
</Heading>
{!queryIsInTitle && (
@@ -128,7 +129,7 @@ function DocumentListItem(props: Props, ref) {
!document.isArchived &&
!document.isDeleted &&
can.createDocument &&
canCollection.update && (
collectionPolicy.update && (
<>
<Button
as={Link}
+3 -1
View File
@@ -75,7 +75,9 @@ function DocumentMeta({
return null;
}
const collection = collections.get(document.collectionId);
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
let content;
+48 -1
View File
@@ -3,12 +3,13 @@ import { lighten } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import { Extension } from "rich-markdown-editor";
import styled, { withTheme } from "styled-components";
import embeds from "shared/embeds";
import { light } from "shared/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import useMediaQuery from "hooks/useMediaQuery";
import useToasts from "hooks/useToasts";
import { type Theme } from "types";
@@ -30,6 +31,8 @@ export type Props = {|
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
style?: Object,
extensions?: Extension[],
shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
@@ -246,6 +249,50 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
}
}
.ProseMirror {
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
transition: opacity 100ms ease-in-out;
}
}
}
}
`;
const EditorTooltip = ({ children, ...props }) => (
+9 -5
View File
@@ -76,11 +76,6 @@ const FilterOptions = ({
);
};
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
`;
const Note = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
@@ -90,6 +85,15 @@ const Note = styled(HelpText)`
color: ${(props) => props.theme.textTertiary};
`;
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
&:hover ${Note} {
color: ${(props) => props.theme.white50};
}
`;
const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
+2 -1
View File
@@ -39,7 +39,8 @@ const Guide = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScrollhideOnEsc
preventBodyScroll
hideOnEsc
hide={onRequestClose}
>
{(props) => (
+2 -2
View File
@@ -36,7 +36,7 @@ function Header({ breadcrumb, title, actions }: Props) {
}, []);
return (
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
<Wrapper align="center" shrink={false}>
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
{isScrolled ? (
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
@@ -95,7 +95,7 @@ const Wrapper = styled(Flex)`
}
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
padding: 16px 16px 0;
justify-content: "center";
`};
`;
+64 -8
View File
@@ -6,11 +6,14 @@ import {
AcademicCapIcon,
BeakerIcon,
BuildingBlocksIcon,
CameraIcon,
CloudIcon,
CodeIcon,
EditIcon,
EmailIcon,
EyeIcon,
GlobeIcon,
InfoIcon,
ImageIcon,
LeafIcon,
LightBulbIcon,
@@ -19,8 +22,12 @@ import {
NotepadIcon,
PadlockIcon,
PaletteIcon,
PromoteIcon,
QuestionMarkIcon,
SportIcon,
SunIcon,
TargetIcon,
ToolsIcon,
VehicleIcon,
WarningIcon,
} from "outline-icons";
@@ -28,6 +35,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import ContextMenu from "components/ContextMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
@@ -56,6 +64,10 @@ export const icons = {
component: CoinsIcon,
keywords: "coins money finance sales income revenue cash",
},
camera: {
component: CameraIcon,
keywords: "photo picture",
},
academicCap: {
component: AcademicCapIcon,
keywords: "learn teach lesson guide tutorial onboarding training",
@@ -84,6 +96,14 @@ export const icons = {
component: EyeIcon,
keywords: "eye view",
},
globe: {
component: GlobeIcon,
keywords: "world translate",
},
info: {
component: InfoIcon,
keywords: "info information",
},
image: {
component: ImageIcon,
keywords: "image photo picture",
@@ -120,6 +140,10 @@ export const icons = {
component: EditIcon,
keywords: "copy writing post blog",
},
promote: {
component: PromoteIcon,
keywords: "marketing promotion",
},
question: {
component: QuestionMarkIcon,
keywords: "question help support faq",
@@ -128,13 +152,25 @@ export const icons = {
component: SunIcon,
keywords: "day sun weather",
},
sport: {
component: SportIcon,
keywords: "sport outdoor racket game",
},
target: {
component: TargetIcon,
keywords: "target goal sales",
},
tools: {
component: ToolsIcon,
keywords: "tool settings",
},
vehicle: {
component: VehicleIcon,
keywords: "truck car travel transport",
},
warning: {
component: WarningIcon,
keywords: "danger",
keywords: "warning alert error",
},
};
@@ -153,18 +189,18 @@ const colors = [
type Props = {|
onOpen?: () => void,
onClose?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
|};
function IconPicker({ onOpen, icon, color, onChange }: Props) {
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
const Component = icons[icon || "collection"].component;
return (
<Wrapper>
@@ -174,14 +210,22 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
<MenuButton {...menu}>
{(props) => (
<Button aria-label={t("Show menu")} {...props}>
<Component color={color} size={30} />
<Icon
as={icons[icon || "collection"].component}
color={color}
size={30}
/>
</Button>
)}
</MenuButton>
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
<ContextMenu
{...menu}
onOpen={onOpen}
onClose={onClose}
aria-label={t("Choose icon")}
>
<Icons>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<MenuItem
key={name}
@@ -190,7 +234,7 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
>
{(props) => (
<IconButton style={style} {...props}>
<Component color={color} size={30} />
<Icon as={icons[name].component} color={color} size={30} />
</IconButton>
)}
</MenuItem>
@@ -212,13 +256,20 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
);
}
const Icon = styled.svg`
transition: fill 150ms ease-in-out;
`;
const Label = styled.label`
display: block;
`;
const Icons = styled.div`
padding: 16px 8px 0 16px;
width: 276px;
${breakpoint("tablet")`
width: 276px;
`};
`;
const Button = styled(NudeButton)`
@@ -241,6 +292,11 @@ const Loading = styled(HelpText)`
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
width: auto !important;
${breakpoint("tablet")`
width: 276px;
`};
`;
const Wrapper = styled("div")`
+16 -11
View File
@@ -40,14 +40,20 @@ class InputSearchPage extends React.Component<Props> {
}
}
handleSearchInput = (ev: SyntheticInputEvent<>) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
);
handleKeyDown = (ev: SyntheticKeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.currentTarget.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
);
}
if (this.props.onKeyDown) {
this.props.onKeyDown(ev);
}
};
handleFocus = () => {
@@ -59,7 +65,7 @@ class InputSearchPage extends React.Component<Props> {
};
render() {
const { t, value, onChange, onKeyDown } = this.props;
const { t, value, onChange } = this.props;
const { theme, placeholder = `${t("Search")}` } = this.props;
return (
@@ -67,10 +73,9 @@ class InputSearchPage extends React.Component<Props> {
ref={(ref) => (this.input = ref)}
type="search"
placeholder={placeholder}
onInput={this.handleSearchInput}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyDown={this.handleKeyDown}
icon={
<SearchIcon
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
+234 -68
View File
@@ -1,80 +1,120 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import {
Select,
SelectOption,
useSelectState,
useSelectPopover,
SelectPopover,
} from "@renderlesskit/react";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { Outline, LabelText } from "./Input";
const Select = styled.select`
border: 0;
flex: 1;
padding: 4px 0;
margin: 0 12px;
outline: none;
background: none;
color: ${(props) => props.theme.text};
height: 30px;
font-size: 14px;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
}
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
${breakpoint("mobile", "tablet")`
font-size: 16px;
`};
`;
const Wrapper = styled.label`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import Button, { Inner } from "components/Button";
import { Position, Background, Backdrop } from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input";
import useMenuHeight from "hooks/useMenuHeight";
export type Option = { label: string, value: string };
export type Props = {
value?: string,
label?: string,
nude?: boolean,
ariaLabel: string,
short?: boolean,
disabled?: boolean,
className?: string,
labelHidden?: boolean,
options: Option[],
onBlur?: () => void,
onFocus?: () => void,
onChange: (string) => Promise<void> | void,
};
@observer
class InputSelect extends React.Component<Props> {
@observable focused: boolean = false;
const getOptionFromValue = (options: Option[], value) => {
return options.find((option) => option.value === value) || {};
};
handleBlur = () => {
this.focused = false;
};
const InputSelect = (props: Props) => {
const {
value,
label,
className,
labelHidden,
options,
short,
ariaLabel,
onChange,
disabled,
nude,
} = props;
handleFocus = () => {
this.focused = true;
};
const select = useSelectState({
gutter: 0,
modal: true,
selectedValue: value,
animated: 200,
});
render() {
const {
label,
className,
labelHidden,
options,
short,
...rest
} = this.props;
const popOver = useSelectPopover({
...select,
hideOnClickOutside: true,
preventBodyScroll: true,
disabled,
});
const wrappedLabel = <LabelText>{label}</LabelText>;
const previousValue = React.useRef(value);
const contentRef = React.useRef();
const selectedRef = React.useRef();
const buttonRef = React.useRef();
const [offset, setOffset] = React.useState(0);
const minWidth = buttonRef.current?.offsetWidth || 0;
return (
const maxHeight = useMenuHeight(
select.visible,
select.unstable_disclosureRef
);
React.useEffect(() => {
if (previousValue.current === select.selectedValue) return;
previousValue.current = select.selectedValue;
async function load() {
await onChange(select.selectedValue);
}
load();
}, [onChange, select.selectedValue]);
const wrappedLabel = <LabelText>{label}</LabelText>;
const selectedValueIndex = options.findIndex(
(option) => option.value === select.selectedValue
);
// Ensure selected option is visible when opening the input
React.useEffect(() => {
if (!select.animating && selectedRef.current) {
scrollIntoView(selectedRef.current, {
scrollMode: "if-needed",
behavior: "instant",
block: "start",
});
}
}, [select.animating]);
React.useLayoutEffect(() => {
if (select.visible) {
const offset = Math.round(
(selectedRef.current?.getBoundingClientRect().top || 0) -
(contentRef.current?.getBoundingClientRect().top || 0)
);
setOffset(offset);
}
}, [select.visible]);
return (
<>
<Wrapper short={short}>
{label &&
(labelHidden ? (
@@ -82,18 +122,144 @@ class InputSelect extends React.Component<Props> {
) : (
wrappedLabel
))}
<Outline focused={this.focused} className={className}>
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
{options.map((option) => (
<option value={option.value} key={option.value}>
{option.label}
</option>
))}
</Select>
</Outline>
<Select
{...select}
disabled={disabled}
aria-label={ariaLabel}
ref={buttonRef}
>
{(props) => (
<StyledButton
neutral
disclosure
className={className}
nude={nude}
{...props}
>
{getOptionFromValue(options, select.selectedValue).label ||
`Select a ${ariaLabel}`}
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popOver}>
{(props) => {
const topAnchor = props.style.top === "0";
const rightAnchor = props.placement === "bottom-end";
// offset top of select to place selected item under the cursor
if (selectedValueIndex !== -1) {
props.style.top = `-${offset + 32}px`;
}
return (
<Positioner {...props}>
<Background
dir="auto"
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
style={
maxHeight && topAnchor
? { maxHeight, minWidth }
: { minWidth }
}
>
{select.visible || select.animating
? options.map((option) => (
<StyledSelectOption
{...select}
value={option.value}
key={option.value}
animating={select.animating}
ref={
select.selectedValue === option.value
? selectedRef
: undefined
}
>
{select.selectedValue !== undefined && (
<>
{select.selectedValue === option.value ? (
<CheckmarkIcon color="currentColor" />
) : (
<Spacer />
)}
&nbsp;
</>
)}
{option.label}
</StyledSelectOption>
))
: null}
</Background>
</Positioner>
);
}}
</SelectPopover>
</Wrapper>
);
{(select.visible || select.animating) && <Backdrop />}
</>
);
};
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledButton = styled(Button)`
font-weight: normal;
text-transform: none;
margin-bottom: 16px;
display: block;
width: 100%;
${(props) =>
props.nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 16px;
padding-right: 8px;
justify-content: space-between;
}
}
`;
export const StyledSelectOption = styled(SelectOption)`
${MenuAnchorCSS}
${(props) =>
props.animating &&
css`
pointer-events: none;
`}
`;
const Wrapper = styled.label`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const Positioner = styled(Position)`
&.focus-visible {
${StyledSelectOption} {
&[aria-selected="true"] {
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
box-shadow: none;
cursor: pointer;
svg {
fill: ${(props) => props.theme.white};
}
}
}
}
`;
export default InputSelect;
+17 -3
View File
@@ -4,19 +4,33 @@ import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "./InputSelect";
export default function InputSelectPermission(
props: $Rest<Props, { options: Array<Option> }>
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
const handleChange = React.useCallback(
(value) => {
if (value === "no_access") {
value = "";
}
onChange(value);
},
[onChange]
);
return (
<InputSelect
label={t("Default access")}
options={[
{ label: t("View and edit"), value: "read_write" },
{ label: t("View only"), value: "read" },
{ label: t("No access"), value: "" },
{ label: t("No access"), value: "no_access" },
]}
{...props}
ariaLabel={t("Default access")}
value={value || "no_access"}
onChange={handleChange}
{...rest}
/>
);
}
+4 -1
View File
@@ -3,7 +3,9 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "components/InputSelect";
const InputSelectRole = (props: $Rest<Props, { options: Array<Option> }>) => {
const InputSelectRole = (
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
) => {
const { t } = useTranslation();
return (
@@ -14,6 +16,7 @@ const InputSelectRole = (props: $Rest<Props, { options: Array<Option> }>) => {
{ label: t("Viewer"), value: "viewer" },
{ label: t("Admin"), value: "admin" },
]}
ariaLabel={t("Role")}
{...props}
/>
);
+5 -3
View File
@@ -36,7 +36,11 @@ const ListItem = (
</Subtitle>
)}
</Content>
{actions && <Actions $selected={selected}>{actions}</Actions>}
{actions && (
<Actions $selected={selected} gap={4}>
{actions}
</Actions>
)}
</>
);
@@ -56,7 +60,6 @@ const ListItem = (
const Wrapper = styled.div`
display: flex;
user-select: none;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
border-bottom: 1px solid
@@ -105,7 +108,6 @@ const Subtitle = styled.p`
export const Actions = styled(Flex)`
align-self: center;
justify-content: center;
margin-right: 4px;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
+2 -1
View File
@@ -61,7 +61,8 @@ const Modal = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScrollhideOnEsc
preventBodyScroll
hideOnEsc
hide={onRequestClose}
>
{(props) => (
+19
View File
@@ -0,0 +1,19 @@
// @flow
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import Button from "components/Button";
import { newDocumentUrl } from "utils/routeHelpers";
const NewDocumentButton = () => {
const { t } = useTranslation();
return (
<Button as={Link} to={newDocumentUrl()} icon={<PlusIcon />}>
{t("New doc")}
</Button>
);
};
export default NewDocumentButton;
+1 -1
View File
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import { cdnPath } from "../../shared/utils/urls";
import useStores from "hooks/useStores";
import { cdnPath } from "utils/urls";
type Props = {|
title: string,
+41 -49
View File
@@ -13,43 +13,38 @@ type Props = {
result: DocumentPath,
document?: ?Document,
collection: ?Collection,
onSuccess?: () => void,
selected?: boolean,
setSelectedPath?: (DocumentPath) => void,
style?: Object,
ref?: (?React.ElementRef<"div">) => void,
};
@observer
class PathToDocument extends React.Component<Props> {
handleClick = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { document, result, onSuccess } = this.props;
if (!document) return;
const PathToDocument = ({
result,
collection,
document,
ref,
style,
selected,
setSelectedPath,
}: Props) => {
if (!result) return <div />;
if (result.type === "document") {
await document.move(result.collectionId, result.id);
} else {
await document.move(result.collectionId, null);
}
if (onSuccess) onSuccess();
};
render() {
const { result, collection, document, ref, style } = this.props;
const Component = document ? ResultWrapperLink : ResultWrapper;
if (!result) return <div />;
return (
<Component
ref={ref}
onClick={this.handleClick}
href=""
style={style}
role="option"
selectable
>
{collection && <CollectionIcon collection={collection} />}
return (
<ResultWrapper
ref={ref}
onClick={() => {
setSelectedPath && setSelectedPath(result);
}}
style={style}
role="option"
selectable
selected={selected}
>
<Flex>
{collection && (
<CollectionIcon collection={collection} useLuminance={selected} />
)}
&nbsp;
{result.path
.map((doc) => <Title key={doc.id}>{doc.title}</Title>)
@@ -60,10 +55,10 @@ class PathToDocument extends React.Component<Props> {
<StyledGoToIcon /> <Title>{document.title}</Title>
</DocumentTitle>
)}
</Component>
);
}
}
</Flex>
</ResultWrapper>
);
};
const DocumentTitle = styled(Flex)``;
@@ -78,23 +73,19 @@ const StyledGoToIcon = styled(GoToIcon)`
`;
const ResultWrapper = styled.div`
padding: 8px 4px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
user-select: none;
color: ${(props) => props.theme.text};
background: ${(props) => (props.selected ? props.theme.selected : "")};
color: ${(props) => (props.selected ? "white" : props.theme.text)};
cursor: default;
svg {
flex-shrink: 0;
}
`;
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
padding: 8px 4px;
border-radius: 4px;
${DocumentTitle} {
display: none;
display: ${(props) => (props.selected ? "flex" : "none")};
}
svg {
@@ -104,7 +95,8 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
&:hover,
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
background: ${(props) =>
props.selected ? "" : props.theme.listItemHoverBackground};
outline: none;
${DocumentTitle} {
@@ -113,4 +105,4 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
}
`;
export default PathToDocument;
export default observer(PathToDocument);
+37 -10
View File
@@ -6,18 +6,45 @@ import Fade from "components/Fade";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
export default function PlaceholderDocument(props: Object) {
export default function PlaceholderDocument({
includeTitle,
delay,
}: {
includeTitle?: boolean,
delay?: number,
}) {
const content = (
<>
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
</>
);
if (includeTitle === false) {
return (
<DelayedMount delay={delay}>
<Fade>
<Flex column auto>
{content}
</Flex>
</Fade>
</DelayedMount>
);
}
return (
<DelayedMount>
<DelayedMount delay={delay}>
<Wrapper>
<Flex column auto {...props}>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<br />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
</Flex>
<Fade>
<Flex column auto>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<br />
{content}
</Flex>
</Fade>
</Wrapper>
</DelayedMount>
);
+10
View File
@@ -11,6 +11,7 @@ import {
LinkIcon,
TeamIcon,
ExpandedIcon,
BeakerIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -95,6 +96,15 @@ function SettingsSidebar() {
label={t("Security")}
/>
)}
{can.update &&
env.COLLABORATION_URL &&
env.DEPLOYMENT !== "hosted" && (
<SidebarLink
to="/settings/features"
icon={<BeakerIcon color="currentColor" />}
label={t("Features")}
/>
)}
<SidebarLink
to="/settings/members"
icon={<UserIcon color="currentColor" />}
@@ -3,10 +3,14 @@ import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop, useDrag } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import Collection from "models/Collection";
import Document from "models/Document";
import DocumentReparent from "scenes/DocumentReparent";
import CollectionIcon from "components/CollectionIcon";
import Modal from "components/Modal";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
@@ -36,7 +40,15 @@ function CollectionLink({
isDraggingAnyCollection,
onChangeDragging,
}: Props) {
const { t } = useTranslation();
const { search } = useLocation();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [
permissionOpen,
handlePermissionOpen,
handlePermissionClose,
] = useBoolean();
const itemRef = React.useRef();
const handleTitleChange = React.useCallback(
async (name: string) => {
@@ -52,12 +64,17 @@ function CollectionLink({
);
React.useEffect(() => {
// If we're viewing a starred document through the starred menu then don't
// touch the expanded / collapsed state of the collections
if (search === "?starred") {
return;
}
if (isDraggingAnyCollection) {
setExpanded(false);
} else {
setExpanded(collection.id === ui.activeCollectionId);
}
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId]);
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
@@ -67,9 +84,22 @@ function CollectionLink({
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: (item, monitor) => {
const { id, collectionId } = item;
if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id);
if (collection.id === collectionId) return;
const prevCollection = collections.get(collectionId);
if (
prevCollection &&
prevCollection.permission === null &&
prevCollection.permission !== collection.permission
) {
itemRef.current = item;
handlePermissionOpen();
} else {
documents.move(id, collection.id);
}
},
canDrop: (item, monitor) => {
return policies.abilities(collection.id).update;
@@ -203,6 +233,18 @@ function CollectionLink({
index={index}
/>
))}
<Modal
title={t("Move document")}
onRequestClose={handlePermissionClose}
isOpen={permissionOpen}
>
<DocumentReparent
item={itemRef.current}
collection={collection}
onSubmit={handlePermissionClose}
onCancel={handlePermissionClose}
/>
</Modal>
</>
);
}
@@ -128,7 +128,12 @@ function DocumentLink(
// Draggable
const [{ isDragging }, drag] = useDrag({
type: "document",
item: () => ({ ...node, depth, active: isActiveDocument }),
item: () => ({
...node,
depth,
active: isActiveDocument,
collectionId: collection?.id || "",
}),
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
@@ -240,6 +245,9 @@ function DocumentLink(
/>
</>
}
isActive={(match, location) =>
match && location.search !== "?starred"
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
@@ -8,7 +8,7 @@ import EventBoundary from "components/EventBoundary";
import NavLink from "./NavLink";
import { type Theme } from "types";
type Props = {
type Props = {|
to?: string | Object,
href?: string | Object,
innerRef?: (?HTMLElement) => void,
@@ -29,7 +29,7 @@ type Props = {
exact?: boolean,
depth?: number,
scrollIntoViewIfNeeded?: boolean,
};
|};
function SidebarLink(
{
@@ -51,6 +51,7 @@ function SidebarLink(
match,
className,
scrollIntoViewIfNeeded,
...rest
}: Props,
ref
) {
@@ -86,6 +87,7 @@ function SidebarLink(
href={href}
className={className}
ref={ref}
{...rest}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
@@ -69,7 +69,10 @@ function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
<Relative>
<SidebarLink
depth={depth}
to={to}
to={`${to}?starred`}
isActive={(match, location) =>
match && location.search === "?starred"
}
label={
<>
{hasChildDocuments && (
@@ -1,4 +1,5 @@
// @flow
import { observer } from "mobx-react";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
@@ -84,4 +85,4 @@ const Header = styled.button`
}
`;
export default TeamButton;
export default observer(TeamButton);
+9
View File
@@ -0,0 +1,9 @@
// @flow
import invariant from "invariant";
import useStores from "./useStores";
export default function useCurrentToken() {
const { auth } = useStores();
invariant(auth.token, "token is required");
return auth.token;
}
+54
View File
@@ -0,0 +1,54 @@
// @flow
import * as React from "react";
const activityEvents = [
"click",
"mousemove",
"keydown",
"DOMMouseScroll",
"mousewheel",
"mousedown",
"touchstart",
"touchmove",
"focus",
];
/**
* Hook to detect user idle state.
*
* @param {number} timeToIdle
* @returns boolean if the user is idle
*/
export default function useIdle(timeToIdle: number = 3 * 60 * 1000) {
const [isIdle, setIsIdle] = React.useState(false);
const timeout = React.useRef();
const onActivity = React.useCallback(() => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
setIsIdle(true);
}, timeToIdle);
}, [timeToIdle]);
React.useEffect(() => {
const handleUserActivityEvent = () => {
setIsIdle(false);
onActivity();
};
activityEvents.forEach((eventName) =>
window.addEventListener(eventName, handleUserActivityEvent)
);
return () => {
activityEvents.forEach((eventName) =>
window.removeEventListener(eventName, handleUserActivityEvent)
);
};
}, [onActivity]);
return isIdle;
}
+140
View File
@@ -0,0 +1,140 @@
// @flow
import { Search } from "js-search";
import { last } from "lodash";
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import { type DocumentPath } from "stores/CollectionsStore";
import Document from "models/Document";
import PathToDocument from "components/PathToDocument";
import useStores from "hooks/useStores";
export default function useListDocumentPath(document: Document) {
const { collections, documents, policies } = useStores();
const [searchTerm, setSearchTerm] = useState<string>("");
const [selectedPath, setSelectedPath] = useState<?DocumentPath>();
const searchIndex = useMemo(() => {
let paths = collections.pathsToDocuments;
paths = paths.filter((path) => {
if (
(path.type === "collection" && policies.abilities(path.id).update) ||
(path.type === "document" &&
policies.abilities(path.collectionId).update)
)
return true;
return false;
});
const index = new Search("id");
index.addIndex("title");
// Build index
const indexeableDocuments = [];
paths.forEach((path) => {
const doc = documents.get(path.id);
if (!doc || !doc.isTemplate) {
indexeableDocuments.push(path);
}
});
index.addDocuments(indexeableDocuments);
return index;
}, [collections.pathsToDocuments, policies, documents]);
const selected = useCallback(
(result: DocumentPath) => {
if (!selectedPath) return;
if (selectedPath.type === "collection" && selectedPath.id === result.id) {
return true;
}
if (
selectedPath.type === "document" &&
selectedPath.id === result.id &&
selectedPath.collectionId === result.collectionId
) {
return true;
}
return false;
},
[selectedPath]
);
const results: DocumentPath[] = useMemo(() => {
const onlyShowCollections = document.isTemplate;
let results = [];
if (collections.isLoaded) {
if (searchTerm) {
results = searchIndex.search(searchTerm);
} else {
results = searchIndex._documents;
}
}
if (onlyShowCollections) {
results = results.filter((result) => result.type === "collection");
} else {
// Exclude root from search results if document is already at the root
if (!document.parentDocumentId) {
results = results.filter(
(result) => result.id !== document.collectionId
);
}
// Exclude document if on the path to result, or the same result
results = results.filter(
(result) =>
!result.path.map((doc) => doc.id).includes(document.id) &&
last(result.path.map((doc) => doc.id)) !== document.parentDocumentId
);
}
return results;
}, [
document.isTemplate,
document.parentDocumentId,
document.collectionId,
document.id,
collections.isLoaded,
searchTerm,
searchIndex,
]);
const row = React.useCallback(
({
index,
data,
style,
}: {
index: number,
data: Array<DocumentPath>,
style: Object,
}) => {
const result = data[index];
return (
<PathToDocument
key={result.url}
result={result}
document={document}
collection={collections.get(result.collectionId)}
setSelectedPath={setSelectedPath}
style={style}
selected={selected(result)}
/>
);
},
[collections, document, selected]
);
return {
row,
results,
searchTerm,
setSearchTerm,
selectedPath,
setSelectedPath,
};
}
+32
View File
@@ -0,0 +1,32 @@
// @flow
import * as React from "react";
import { type ElementRef } from "reakit";
import useMobile from "hooks/useMobile";
import useWindowSize from "hooks/useWindowSize";
const useMenuHeight = (
visible: void | boolean,
unstable_disclosureRef: void | { current: null | ElementRef<"button"> }
) => {
const [maxHeight, setMaxHeight] = React.useState(undefined);
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useLayoutEffect(() => {
const padding = 8;
if (visible && !isMobile) {
setMaxHeight(
unstable_disclosureRef?.current
? windowHeight -
unstable_disclosureRef.current.getBoundingClientRect().bottom -
padding
: undefined
);
}
}, [visible, unstable_disclosureRef, windowHeight, isMobile]);
return maxHeight;
};
export default useMenuHeight;
+22
View File
@@ -0,0 +1,22 @@
// @flow
import * as React from "react";
/**
* Hook to return page visibility state.
*
* @returns boolean if the page is visible
*/
export default function usePageVisibility(): boolean {
const [visible, setVisible] = React.useState(true);
React.useEffect(() => {
const handleVisibilityChange = () => setVisible(!document.hidden);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return visible;
}
+4 -4
View File
@@ -4,8 +4,8 @@ import * as React from "react";
export default function useWindowSize() {
const [windowSize, setWindowSize] = React.useState({
width: undefined,
height: undefined,
width: parseInt(window.innerWidth),
height: parseInt(window.innerHeight),
});
React.useEffect(() => {
@@ -13,8 +13,8 @@ export default function useWindowSize() {
const handleResize = debounce(() => {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
width: parseInt(window.innerWidth),
height: parseInt(window.innerHeight),
});
}, 100);
+1 -1
View File
@@ -15,8 +15,8 @@ import ScrollToTop from "components/ScrollToTop";
import Theme from "components/Theme";
import Toasts from "components/Toasts";
import Routes from "./routes";
import { initSentry } from "./utils/sentry";
import env from "env";
import { initSentry } from "utils/sentry";
initI18n();
+35 -2
View File
@@ -1,6 +1,6 @@
// @flow
import { observer } from "mobx-react";
import { MoonIcon, SunIcon } from "outline-icons";
import { MoonIcon, SunIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
@@ -16,11 +16,14 @@ import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
import Guide from "components/Guide";
import env from "env";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import usePrevious from "hooks/usePrevious";
import useSessions from "hooks/useSessions";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { deleteAllDatabases } from "utils/developer";
type Props = {|
children: (props: any) => React.Node,
@@ -33,11 +36,13 @@ function AccountMenu(props: Props) {
placement: "bottom-start",
modal: true,
});
const { showToast } = useToasts();
const { auth, ui } = useStores();
const { theme, resolvedTheme } = ui;
const team = useCurrentTeam();
const previousTheme = usePrevious(theme);
const { t } = useTranslation();
const [includeAlt, setIncludeAlt] = React.useState(false);
const [
keyboardShortcutsOpen,
handleKeyboardShortcutsOpen,
@@ -50,6 +55,16 @@ function AccountMenu(props: Props) {
}
}, [menu, theme, previousTheme]);
const handleDeleteAllDatabases = React.useCallback(async () => {
await deleteAllDatabases();
showToast("IndexedDB cache deleted");
menu.hide();
}, [showToast, menu]);
const handleOpenMenu = React.useCallback((event) => {
setIncludeAlt(event.altKey);
}, []);
const items = React.useMemo(() => {
const otherSessions = sessions.filter(
(session) => session.teamId !== team.id && session.url !== team.url
@@ -83,6 +98,20 @@ function AccountMenu(props: Props) {
title: t("Report a bug"),
href: githubIssuesUrl(),
},
...(includeAlt || env.ENVIRONMENT === "development"
? [
{
title: t("Development"),
items: [
{
title: "Delete IndexedDB cache",
icon: <TrashIcon />,
onClick: handleDeleteAllDatabases,
},
],
},
]
: []),
{
title: t("Appearance"),
icon: resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
@@ -130,7 +159,9 @@ function AccountMenu(props: Props) {
team.url,
sessions,
handleKeyboardShortcutsOpen,
handleDeleteAllDatabases,
resolvedTheme,
includeAlt,
theme,
t,
ui,
@@ -145,7 +176,9 @@ function AccountMenu(props: Props) {
>
<KeyboardShortcuts />
</Guide>
<MenuButton {...menu}>{props.children}</MenuButton>
<MenuButton {...menu} onClick={handleOpenMenu}>
{props.children}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Account")}>
<Template {...menu} items={items} />
</ContextMenu>
+1 -1
View File
@@ -180,7 +180,7 @@ function CollectionMenu({
]
);
if (!items.length) {
if (!items.some((item) => item.visible)) {
return null;
}
+19 -7
View File
@@ -37,6 +37,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
@@ -72,6 +73,7 @@ function DocumentMenu({
onOpen,
onClose,
}: Props) {
const team = useCurrentTeam();
const { policies, collections, documents } = useStores();
const { showToast } = useToasts();
const menu = useMenuState({
@@ -160,7 +162,9 @@ function DocumentMenu({
[document]
);
const collection = collections.get(document.collectionId);
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const can = policies.abilities(document.id);
const canViewHistory = can.read && !can.restore;
const restoreItems = React.useMemo(
@@ -270,14 +274,16 @@ function DocumentMenu({
items={[
{
title: t("Restore"),
visible: (!!collection && can.restore) || can.unarchive,
visible: !!collection && (can.restore || can.unarchive),
onClick: handleRestore,
icon: <RestoreIcon />,
},
{
title: t("Restore"),
visible:
!collection && !!can.restore && restoreItems.length !== 0,
!collection &&
(can.restore || can.unarchive) &&
restoreItems.length !== 0,
style: {
left: -170,
position: "relative",
@@ -328,10 +334,10 @@ function DocumentMenu({
},
{
title: t("New nested document"),
to: newDocumentUrl(document.collectionId, {
to: newDocumentUrl(collection?.id, {
parentDocumentId: document.id,
}),
visible: !!can.createChildDocument,
visible: !!can.createChildDocument && !!collection?.id,
icon: <NewDocumentIcon />,
},
{
@@ -385,13 +391,19 @@ function DocumentMenu({
{
title: t("Enable embeds"),
onClick: document.enableEmbeds,
visible: !!showToggleEmbeds && document.embedsDisabled,
visible:
!!showToggleEmbeds &&
document.embedsDisabled &&
!team.collaborativeEditing,
icon: <BuildingBlocksIcon />,
},
{
title: t("Disable embeds"),
onClick: document.disableEmbeds,
visible: !!showToggleEmbeds && !document.embedsDisabled,
visible:
!!showToggleEmbeds &&
!document.embedsDisabled &&
!team.collaborativeEditing,
icon: <BuildingBlocksIcon />,
},
{
+43
View File
@@ -0,0 +1,43 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type Props = {|
id: string,
onDelete: (ev: SyntheticEvent<>) => Promise<void>,
|};
function FileOperationMenu({ id, onDelete }: Props) {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Export options")}>
<Template
{...menu}
items={[
{
title: t("Download"),
href: "/api/fileOperations.redirect?id=" + id,
},
{
type: "separator",
},
{
title: t("Delete"),
onClick: onDelete,
},
]}
/>
</ContextMenu>
</>
);
}
export default FileOperationMenu;
+7 -3
View File
@@ -18,9 +18,13 @@ function NewChildDocumentMenu({ document, label }: Props) {
const menu = useMenuState({ modal: true });
const { collections } = useStores();
const { t } = useTranslation();
const collection = collections.get(document.collectionId);
const collection = collections.get(document.collectionId || "");
const collectionName = collection ? collection.name : t("collection");
if (!collection) {
return null;
}
return (
<>
<MenuButton {...menu}>{label}</MenuButton>
@@ -38,11 +42,11 @@ function NewChildDocumentMenu({ document, label }: Props) {
/>
</span>
),
to: newDocumentUrl(document.collectionId),
to: newDocumentUrl(collection.id),
},
{
title: t("New nested document"),
to: newDocumentUrl(document.collectionId, {
to: newDocumentUrl(collection.id, {
parentDocumentId: document.id,
}),
},
-77
View File
@@ -1,77 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
import ContextMenu from "components/ContextMenu";
import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
function NewDocumentMenu() {
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = policies.abilities(team.id);
const items = React.useMemo(
() =>
collections.orderedData.reduce((filtered, collection) => {
const can = policies.abilities(collection.id);
if (can.update) {
filtered.push({
to: newDocumentUrl(collection.id),
title: <CollectionName>{collection.name}</CollectionName>,
icon: <CollectionIcon collection={collection} />,
});
}
return filtered;
}, []),
[collections.orderedData, policies]
);
if (!can.createDocument || items.length === 0) {
return null;
}
if (items.length === 1) {
return (
<Button as={Link} to={items[0].to} icon={<PlusIcon />}>
{t("New doc")}
</Button>
);
}
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button icon={<PlusIcon />} {...props} small>
{`${t("New doc")}`}
</Button>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("New document")}>
<Header>{t("Choose a collection")}</Header>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
const CollectionName = styled.div`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
export default observer(NewDocumentMenu);
+7 -1
View File
@@ -12,6 +12,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Separator from "components/ContextMenu/Separator";
import CopyToClipboard from "components/CopyToClipboard";
import MenuIconWrapper from "components/MenuIconWrapper";
import useCurrentTeam from "hooks/useCurrentTeam";
import useToasts from "hooks/useToasts";
import { documentHistoryUrl } from "utils/routeHelpers";
@@ -23,6 +24,7 @@ type Props = {|
function RevisionMenu({ document, revisionId, className }: Props) {
const { showToast } = useToasts();
const team = useCurrentTeam();
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
const history = useHistory();
@@ -55,7 +57,11 @@ function RevisionMenu({ document, revisionId, className }: Props) {
{...menu}
/>
<ContextMenu {...menu} aria-label={t("Revision options")}>
<MenuItem {...menu} onClick={handleRestore}>
<MenuItem
{...menu}
onClick={handleRestore}
disabled={team.collaborativeEditing}
>
<MenuIconWrapper>
<RestoreIcon />
</MenuIconWrapper>
+3 -2
View File
@@ -14,9 +14,10 @@ import useStores from "hooks/useStores";
type Props = {|
document: Document,
onSelectTemplate: (template: Document) => void,
|};
function TemplatesMenu({ document }: Props) {
function TemplatesMenu({ onSelectTemplate, document }: Props) {
const menu = useMenuState({ modal: true });
const { documents } = useStores();
const { t } = useTranslation();
@@ -36,7 +37,7 @@ function TemplatesMenu({ document }: Props) {
const renderTemplate = (template) => (
<MenuItem
key={template.id}
onClick={() => document.updateFromTemplate(template)}
onClick={() => onSelectTemplate(template)}
icon={<DocumentIcon />}
{...menu}
>
+32 -18
View File
@@ -10,22 +10,21 @@ import BaseModel from "models/BaseModel";
import User from "models/User";
import View from "./View";
type SaveOptions = {
type SaveOptions = {|
publish?: boolean,
done?: boolean,
autosave?: boolean,
lastRevision?: number,
};
|};
export default class Document extends BaseModel {
@observable isSaving: boolean = false;
@observable embedsDisabled: boolean = false;
@observable injectTemplate: boolean = false;
@observable lastViewedAt: ?string;
store: DocumentsStore;
collaboratorIds: string[];
collectionId: string;
collectionId: ?string;
createdAt: string;
createdBy: User;
updatedAt: string;
@@ -254,15 +253,28 @@ export default class Document extends BaseModel {
};
@action
updateFromTemplate = async (template: Document) => {
this.templateId = template.id;
this.title = template.title;
this.text = template.text;
this.injectTemplate = true;
update = async (options: {| ...SaveOptions, title: string |}) => {
if (this.isSaving) return this;
this.isSaving = true;
try {
if (options.lastRevision) {
return await this.store.update({
id: this.id,
title: this.title,
lastRevision: options.lastRevision,
...options,
});
}
throw new Error("Attempting to update without a lastRevision");
} finally {
this.isSaving = false;
}
};
@action
save = async (options: SaveOptions = {}) => {
save = async (options: ?SaveOptions) => {
if (this.isSaving) return this;
const isCreating = !this.id;
@@ -275,22 +287,24 @@ export default class Document extends BaseModel {
collectionId: this.collectionId,
title: this.title,
text: this.text,
publish: options.publish,
done: options.done,
autosave: options.autosave,
publish: options?.publish,
done: options?.done,
autosave: options?.autosave,
});
}
if (options.lastRevision) {
if (options?.lastRevision) {
return await this.store.update({
id: this.id,
title: this.title,
text: this.text,
templateId: this.templateId,
lastRevision: options.lastRevision,
publish: options.publish,
done: options.done,
autosave: options.autosave,
lastRevision: options?.lastRevision,
publish: options?.publish,
done: options?.done,
autosave: options?.autosave,
collectionId: this.collectionId,
parentDocumentId: this.parentDocumentId,
});
}
+1
View File
@@ -7,6 +7,7 @@ class Team extends BaseModel {
name: string;
avatarUrl: string;
sharing: boolean;
collaborativeEditing: boolean;
documentEmbeds: boolean;
guestSignin: boolean;
subdomain: ?string;
+1
View File
@@ -8,6 +8,7 @@ class User extends BaseModel {
id: string;
name: string;
email: string;
color: string;
isAdmin: boolean;
isViewer: boolean;
lastActiveAt: string;
+57
View File
@@ -0,0 +1,57 @@
// @flow
import { keymap } from "prosemirror-keymap";
import { Extension } from "rich-markdown-editor";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "y-prosemirror";
import * as Y from "yjs";
export default class MultiplayerExtension extends Extension {
get name() {
return "multiplayer";
}
get plugins() {
const { user, provider, document: doc } = this.options;
const type = doc.get("default", Y.XmlFragment);
const assignUser = (tr) => {
const clientIds = Array.from(doc.store.clients.keys());
if (
tr.local &&
tr.changed.size > 0 &&
!clientIds.includes(doc.clientID)
) {
const permanentUserData = new Y.PermanentUserData(doc);
permanentUserData.setUserMapping(doc, doc.clientID, user.id);
doc.off("afterTransaction", assignUser);
}
};
// only once we have authenticated successfully do we initalize awareness.
// we could send this earlier, but getting authenticated faster is more important
provider.on("authenticated", () => {
provider.setAwarenessField("user", user);
});
// only once an actual change has been made do we add the userId <> clientId
// mapping, this avoids stored mappings for clients that never made a change
doc.on("afterTransaction", assignUser);
return [
ySyncPlugin(type),
yCursorPlugin(provider.awareness),
yUndoPlugin(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
];
}
}
+6 -7
View File
@@ -21,10 +21,8 @@ import { matchDocumentSlug as slug } from "utils/routeHelpers";
const SettingsRoutes = React.lazy(() =>
import(/* webpackChunkName: "settings" */ "./settings")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const Document = React.lazy(() =>
import(/* webpackChunkName: "document" */ "scenes/Document")
);
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
@@ -64,10 +62,11 @@ export default function AuthenticatedRoutes() {
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
+4 -6
View File
@@ -12,10 +12,8 @@ const Authenticated = React.lazy(() =>
const AuthenticatedRoutes = React.lazy(() =>
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const SharedDocument = React.lazy(() =>
import(/* webpackChunkName: "shared-document" */ "scenes/Document/Shared")
);
const Login = React.lazy(() =>
import(/* webpackChunkName: "login" */ "scenes/Login")
@@ -37,11 +35,11 @@ export default function Routes() {
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Route exact path="/share/:shareId" component={SharedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={KeyedDocument}
component={SharedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
+2
View File
@@ -2,6 +2,7 @@
import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import Details from "scenes/Settings/Details";
import Features from "scenes/Settings/Features";
import Groups from "scenes/Settings/Groups";
import ImportExport from "scenes/Settings/ImportExport";
import Notifications from "scenes/Settings/Notifications";
@@ -21,6 +22,7 @@ export default function SettingsRoutes() {
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/members" component={People} />
<Route exact path="/settings/features" component={Features} />
<Route exact path="/settings/groups" component={Groups} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
+16 -12
View File
@@ -19,19 +19,23 @@ function APITokenNew({ onSubmit }: Props) {
const { showToast } = useToasts();
const { t } = useTranslation();
const handleSubmit = React.useCallback(async () => {
setIsSaving(true);
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
try {
await apiKeys.create({ name });
showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
}, [t, showToast, name, onSubmit, apiKeys]);
try {
await apiKeys.create({ name });
showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[t, showToast, name, onSubmit, apiKeys]
);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
+1 -1
View File
@@ -50,7 +50,7 @@ function CollectionDelete({ collection, onSubmit }: Props) {
/>
</HelpText>
<Button type="submit" disabled={isDeleting} autoFocus danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
{isDeleting ? `${t("Deleting")}` : t("Im sure Delete")}
</Button>
</form>
</Flex>
+3 -2
View File
@@ -54,8 +54,8 @@ const CollectionEdit = ({ collection, onSubmit }: Props) => {
[collection, color, icon, name, onSubmit, showToast, sort, t]
);
const handleSortChange = (ev: SyntheticInputEvent<HTMLSelectElement>) => {
const [field, direction] = ev.target.value.split(".");
const handleSortChange = (value: string) => {
const [field, direction] = value.split(".");
if (direction === "asc" || direction === "desc") {
setSort({ field, direction });
@@ -101,6 +101,7 @@ const CollectionEdit = ({ collection, onSubmit }: Props) => {
]}
value={`${sort.field}.${sort.direction}`}
onChange={handleSortChange}
ariaLabel={t("Sort")}
/>
<Button type="submit" disabled={isSaving || !collection.name}>
{isSaving ? `${t("Saving")}` : t("Save")}
+2 -2
View File
@@ -88,8 +88,8 @@ class CollectionNew extends React.Component<Props> {
this.hasOpenedIconPicker = true;
};
handlePermissionChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.permission = ev.target.value;
handlePermissionChange = (newPermission: string) => {
this.permission = newPermission;
};
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
@@ -5,7 +5,7 @@ import styled from "styled-components";
import CollectionGroupMembership from "models/CollectionGroupMembership";
import Group from "models/Group";
import GroupListItem from "components/GroupListItem";
import InputSelect from "components/InputSelect";
import InputSelect, { type Props as SelectProps } from "components/InputSelect";
import CollectionGroupMemberMenu from "menus/CollectionGroupMemberMenu";
type Props = {|
@@ -47,8 +47,10 @@ const CollectionGroupMemberListItem = ({
? collectionGroupMembership.permission
: undefined
}
onChange={(ev) => onUpdate(ev.target.value)}
onChange={onUpdate}
ariaLabel={t("Permissions")}
labelHidden
nude
/>
<Spacer />
<CollectionGroupMemberMenu
@@ -65,7 +67,7 @@ const Spacer = styled.div`
width: 8px;
`;
const Select = styled(InputSelect)`
const Select = (styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
@@ -73,6 +75,6 @@ const Select = styled(InputSelect)`
select {
margin: 0;
}
`;
`: React.ComponentType<SelectProps>);
export default CollectionGroupMemberListItem;
@@ -8,7 +8,7 @@ import Avatar from "components/Avatar";
import Badge from "components/Badge";
import Button from "components/Button";
import Flex from "components/Flex";
import InputSelect from "components/InputSelect";
import InputSelect, { type Props as SelectProps } from "components/InputSelect";
import ListItem from "components/List/Item";
import Time from "components/Time";
import MemberMenu from "menus/MemberMenu";
@@ -64,9 +64,11 @@ const MemberListItem = ({
label={t("Permissions")}
options={PERMISSIONS}
value={membership ? membership.permission : undefined}
onChange={(ev) => onUpdate(ev.target.value)}
onChange={onUpdate}
disabled={!canEdit}
ariaLabel={t("Permissions")}
labelHidden
nude
/>
)}
{canEdit && (
@@ -90,7 +92,7 @@ const Spacer = styled.div`
width: 8px;
`;
const Select = styled(InputSelect)`
const Select = (styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
@@ -98,6 +100,6 @@ const Select = styled(InputSelect)`
select {
margin: 0;
}
`;
`: React.ComponentType<SelectProps>);
export default MemberListItem;
+2 -2
View File
@@ -138,9 +138,9 @@ function CollectionPermissions({ collection }: Props) {
);
const handleChangePermission = React.useCallback(
async (ev) => {
async (permission: string) => {
try {
await collection.save({ permission: ev.target.value });
await collection.save({ permission });
showToast(t("Default access permissions were updated"), {
type: "success",
});
-25
View File
@@ -1,25 +0,0 @@
// @flow
import { inject } from "mobx-react";
import * as React from "react";
import DataLoader from "./components/DataLoader";
class KeyedDocument extends React.Component<*> {
componentWillUnmount() {
this.props.ui.clearActiveDocument();
}
render() {
const { documentSlug, revisionId } = this.props.match.params;
// the urlId portion of the url does not include the slugified title
// we only want to force a re-mount of the document component when the
// document changes, not when the title does so only this portion is used
// for the key.
const urlParts = documentSlug ? documentSlug.split("-") : [];
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
return <DataLoader key={[urlId, revisionId].join("/")} {...this.props} />;
}
}
export default inject("ui")(KeyedDocument);
+64
View File
@@ -0,0 +1,64 @@
// @flow
import * as React from "react";
import { type Match } from "react-router-dom";
import { useTheme } from "styled-components";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import useStores from "../../hooks/useStores";
import Document from "./components/Document";
import Loading from "./components/Loading";
import { type LocationWithState } from "types";
import { OfflineError } from "utils/errors";
const EMPTY_OBJECT = {};
type Props = {|
match: Match,
location: LocationWithState,
|};
export default function SharedDocumentScene(props: Props) {
const theme = useTheme();
const [response, setResponse] = React.useState();
const [error, setError] = React.useState<?Error>();
const { documents } = useStores();
const { shareId, documentSlug } = props.match.params;
// ensure the wider page color always matches the theme
React.useEffect(() => {
window.document.body.style.background = theme.background;
}, [theme]);
React.useEffect(() => {
async function fetchData() {
try {
const response = await documents.fetch(documentSlug, {
shareId,
});
setResponse(response);
} catch (err) {
setError(err);
}
}
fetchData();
}, [documents, documentSlug, shareId]);
if (error) {
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
}
if (!response) {
return <Loading location={props.location} />;
}
return (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
location={props.location}
shareId={shareId}
readOnly
/>
);
}
+25 -16
View File
@@ -18,16 +18,15 @@ import Document from "models/Document";
import Revision from "models/Revision";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
import SocketPresence from "./SocketPresence";
import { type LocationWithState, type NavigationNode } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls";
type Props = {|
match: Match,
auth: AuthStore,
location: LocationWithState,
shares: SharesStore,
documents: DocumentsStore,
@@ -36,6 +35,7 @@ type Props = {|
auth: AuthStore,
ui: UiStore,
history: RouterHistory,
children: (any) => React.Node,
|};
const sharedTreeCache = {};
@@ -223,7 +223,7 @@ class DataLoader extends React.Component<Props> {
};
render() {
const { location, policies, ui } = this.props;
const { location, policies, auth, ui } = this.props;
if (this.error) {
return this.error instanceof OfflineError ? (
@@ -233,10 +233,11 @@ class DataLoader extends React.Component<Props> {
);
}
const team = auth.team;
const document = this.document;
const revision = this.revision;
if (!document) {
if (!document || !team) {
return (
<>
<Loading location={location} />
@@ -247,20 +248,28 @@ class DataLoader extends React.Component<Props> {
const abilities = policies.abilities(document.id);
// We do not want to remount the document when changing from view->edit
// on the multiplayer flag as the doc is guaranteed to be upto date.
const key = team.collaborativeEditing
? ""
: this.isEditing
? "editing"
: "read-only";
return (
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
<React.Fragment key={key}>
{this.isEditing && <HideSidebar ui={ui} />}
<DocumentComponent
document={document}
revision={revision}
abilities={abilities}
location={location}
readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
sharedTree={this.sharedTree}
/>
</SocketPresence>
{this.props.children({
document,
revision,
abilities,
isEditing: this.isEditing,
readOnly: !this.isEditing || !abilities.update || document.isArchived,
onSearchLink: this.onSearchLink,
onCreateLink: this.onCreateLink,
sharedTree: this.sharedTree,
})}
</React.Fragment>
);
}
}
+104 -23
View File
@@ -1,8 +1,9 @@
// @flow
import { debounce } from "lodash";
import { observable } from "mobx";
import { action, observable } from "mobx";
import { observer, inject } from "mobx-react";
import { InputIcon } from "outline-icons";
import { AllSelection } from "prosemirror-state";
import * as React from "react";
import { type TFunction, Trans, withTranslation } from "react-i18next";
import keydown from "react-keydown";
@@ -12,12 +13,14 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import getTasks from "shared/utils/getTasks";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import Revision from "models/Revision";
import DocumentMove from "scenes/DocumentMove";
import Branding from "components/Branding";
import ConnectionStatus from "components/ConnectionStatus";
import ErrorBoundary from "components/ErrorBoundary";
import Flex from "components/Flex";
import LoadingIndicator from "components/LoadingIndicator";
@@ -61,6 +64,7 @@ type Props = {
theme: Theme,
auth: AuthStore,
ui: UiStore,
policies: PoliciesStore,
toasts: ToastsStore,
t: TFunction,
};
@@ -88,13 +92,21 @@ class DocumentScene extends React.Component<Props> {
this.updateIsDirty();
}
if (this.props.readOnly) {
if (this.props.readOnly || auth.team?.collaborativeEditing) {
this.lastRevision = document.revision;
}
if (this.props.readOnly) {
if (document.title !== this.title) {
this.title = document.title;
}
} else if (prevProps.document.revision !== this.lastRevision) {
}
if (
!this.props.readOnly &&
!auth.team?.collaborativeEditing &&
prevProps.document.revision !== this.lastRevision
) {
if (auth.user && document.updatedBy.id !== auth.user.id) {
this.props.toasts.showToast(
t(`Document updated by {{userName}}`, {
@@ -113,15 +125,31 @@ class DocumentScene extends React.Component<Props> {
);
}
}
if (document.injectTemplate) {
document.injectTemplate = false;
this.title = document.title;
this.isDirty = true;
this.updateIsDirty();
}
}
onSelectTemplate = (template: Document) => {
this.title = template.title;
this.isDirty = true;
const editorRef = this.editor.current;
if (!editorRef) {
return;
}
const { view, parser } = editorRef;
view.dispatch(
view.state.tr
.setSelection(new AllSelection(view.state.doc))
.replaceSelectionWith(parser.parse(template.text))
);
this.props.document.templateId = template.id;
this.props.document.title = template.title;
this.props.document.text = template.text;
this.updateIsDirty();
};
@keydown("m")
goToMove(ev) {
if (!this.props.readOnly) return;
@@ -195,9 +223,11 @@ class DocumentScene extends React.Component<Props> {
done?: boolean,
publish?: boolean,
autosave?: boolean,
collectionId?: string,
parentDocumentId?: string,
} = {}
) => {
const { document } = this.props;
const { document, auth, policies } = this.props;
// prevent saves when we are already saving
if (document.isSaving) return;
@@ -219,18 +249,40 @@ class DocumentScene extends React.Component<Props> {
document.title = title;
document.text = text;
document.tasks = getTasks(document.text);
if (options.collectionId) {
const collecionPolicies = policies.abilities(options.collectionId);
if (!collecionPolicies.update) return;
document.collectionId = options.collectionId;
if (options.parentDocumentId) {
const documentPolicies = policies.abilities(options.parentDocumentId);
if (!documentPolicies.createChildDocument) return;
document.parentDocumentId = options.parentDocumentId;
}
}
let isNew = !document.id;
this.isSaving = true;
this.isPublishing = !!options.publish;
document.tasks = getTasks(document.text);
try {
const savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
let savedDocument = document;
if (auth.team?.collaborativeEditing) {
// update does not send "text" field to the API, this is a workaround
// while the multiplayer editor is toggleable. Once it's finalized
// this can be cleaned up to single code path
savedDocument = await document.update({
...options,
lastRevision: this.lastRevision,
});
} else {
savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
}
this.isDirty = false;
this.lastRevision = savedDocument.revision;
@@ -275,8 +327,21 @@ class DocumentScene extends React.Component<Props> {
};
onChange = (getEditorText) => {
const { document, auth } = this.props;
this.getEditorText = getEditorText;
// If the multiplayer editor is enabled then we still want to keep the local
// text value in sync as it is used as a cache.
if (auth.team?.collaborativeEditing) {
action(() => {
document.text = this.getEditorText();
document.tasks = getTasks(document.text);
})();
return;
}
// document change while read only is presumed to be a checkbox edit,
// in that case we don't delay in saving for a better user experience.
if (this.props.readOnly) {
@@ -314,7 +379,6 @@ class DocumentScene extends React.Component<Props> {
const isShare = !!shareId;
const value = revision ? revision.text : document.text;
const injectTemplate = document.injectTemplate;
const disableEmbeds =
(team && team.documentEmbeds === false) || document.embedsDisabled;
@@ -323,6 +387,12 @@ class DocumentScene extends React.Component<Props> {
: [];
const showContents = ui.tocVisible && readOnly;
const collaborativeEditing =
team?.collaborativeEditing &&
!document.isArchived &&
!document.isDeleted &&
!revision;
return (
<ErrorBoundary>
<Background
@@ -332,7 +402,7 @@ class DocumentScene extends React.Component<Props> {
auto
>
<Route
path={`${match.url}/move`}
path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
@@ -356,7 +426,11 @@ class DocumentScene extends React.Component<Props> {
{!readOnly && (
<>
<Prompt
when={this.isDirty && !this.isUploading}
when={
this.isDirty &&
!this.isUploading &&
!team?.collaborativeEditing
}
message={t(
`You have unsaved changes.\nAre you sure you want to discard them?`
)}
@@ -383,6 +457,7 @@ class DocumentScene extends React.Component<Props> {
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
goBack={this.goBack}
onSelectTemplate={this.onSelectTemplate}
onSave={this.onSave}
headings={headings}
/>
@@ -443,11 +518,12 @@ class DocumentScene extends React.Component<Props> {
{showContents && <Contents headings={headings} />}
<Editor
id={document.id}
key={disableEmbeds ? "disabled" : "enabled"}
innerRef={this.editor}
multiplayer={collaborativeEditing}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
key={[injectTemplate, disableEmbeds].join("-")}
title={revision ? revision.title : this.title}
document={document}
value={readOnly ? value : undefined}
@@ -492,7 +568,12 @@ class DocumentScene extends React.Component<Props> {
{isShare && !isCustomDomain() && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
{!isShare && <KeyboardShortcutsButton />}
{!isShare && (
<>
<KeyboardShortcutsButton />
<ConnectionStatus />
</>
)}
</ErrorBoundary>
);
}
@@ -537,6 +618,6 @@ const MaxWidth = styled(Flex)`
export default withRouter(
withTranslation()<DocumentScene>(
inject("ui", "auth", "toasts")(DocumentScene)
inject("ui", "auth", "toasts", "policies")(DocumentScene)
)
);
+5 -1
View File
@@ -17,6 +17,7 @@ import Editor, { type Props as EditorProps } from "components/Editor";
import Flex from "components/Flex";
import HoverPreview from "components/HoverPreview";
import Star, { AnimatedStar } from "components/Star";
import MultiplayerEditor from "./MultiplayerEditor";
import { isModKey } from "utils/keyboard";
import { documentHistoryUrl } from "utils/routeHelpers";
@@ -27,6 +28,7 @@ type Props = {|
document: Document,
isDraft: boolean,
shareId: ?string,
multiplayer?: boolean,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
@@ -107,10 +109,12 @@ class DocumentEditor extends React.Component<Props> {
innerRef,
children,
policies,
multiplayer,
t,
...rest
} = this.props;
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
const can = policies.abilities(document.id);
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
@@ -162,7 +166,7 @@ class DocumentEditor extends React.Component<Props> {
}
/>
)}
<Editor
<EditorComponent
ref={innerRef}
autoFocus={!!title && !this.props.defaultValue}
placeholder={t("…the rest is up to you")}
+61 -11
View File
@@ -4,11 +4,14 @@ import {
TableOfContentsIcon,
EditIcon,
PlusIcon,
MoonIcon,
MoreIcon,
SunIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useDialogState } from "reakit";
import styled from "styled-components";
import Document from "models/Document";
import { Action, Separator } from "components/Actions";
@@ -19,7 +22,9 @@ import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Header from "components/Header";
import Tooltip from "components/Tooltip";
import PublicBreadcrumb from "./PublicBreadcrumb";
import PublishDialog from "./PublishDialog";
import ShareButton from "./ShareButton";
import useCurrentTeam from "hooks/useCurrentTeam";
import useMobile from "hooks/useMobile";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
@@ -41,6 +46,7 @@ type Props = {|
isPublishing: boolean,
publishingIsDisabled: boolean,
savingIsDisabled: boolean,
onSelectTemplate: (template: Document) => void,
onDiscard: () => void,
onSave: ({
done?: boolean,
@@ -61,25 +67,34 @@ function DocumentHeader({
savingIsDisabled,
publishingIsDisabled,
sharedTree,
onSelectTemplate,
onSave,
headings,
}: Props) {
const { t } = useTranslation();
const { auth, ui, policies } = useStores();
const { ui, policies, collections } = useStores();
const team = useCurrentTeam();
const { resolvedTheme } = ui;
const isMobile = useMobile();
const dialog = useDialogState({ modal: true });
const hasCollection = !!collections.get(document.collectionId || "");
const handleSave = React.useCallback(() => {
onSave({ done: true });
}, [onSave]);
const handlePublish = React.useCallback(() => {
if (!hasCollection) {
dialog.setVisible(true);
return;
}
onSave({ done: true, publish: true });
}, [onSave]);
}, [dialog, hasCollection, onSave]);
const isNew = document.isNewDocument;
const isTemplate = document.isTemplate;
const can = policies.abilities(document.id);
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
const canToggleEmbeds = team.documentEmbeds;
const canEdit = can.update && !isEditing;
const toc = (
@@ -121,6 +136,27 @@ function DocumentHeader({
</Action>
);
const appearanceAction = (
<Action>
<Tooltip
tooltip={
resolvedTheme === "light" ? t("Switch to dark") : t("Switch to light")
}
delay={500}
placement="bottom"
>
<Button
icon={resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
onClick={() =>
ui.setTheme(resolvedTheme === "light" ? "dark" : "light")
}
neutral
borderOnHover
/>
</Tooltip>
</Action>
);
if (shareId) {
return (
<Header
@@ -134,7 +170,12 @@ function DocumentHeader({
{toc}
</PublicBreadcrumb>
}
actions={canEdit ? editAction : <div />}
actions={
<>
{appearanceAction}
{canEdit ? editAction : <div />}
</>
}
/>
);
}
@@ -160,17 +201,19 @@ function DocumentHeader({
<TableOfContentsMenu headings={headings} />
</TocWrapper>
)}
{!isPublishing && isSaving && <Status>{t("Saving")}</Status>}
<Collaborators
document={document}
currentUserId={auth.user ? auth.user.id : undefined}
/>
{!isPublishing && isSaving && !team.collaborativeEditing && (
<Status>{t("Saving")}</Status>
)}
<Collaborators document={document} />
{isEditing && !isTemplate && isNew && (
<Action>
<TemplatesMenu document={document} />
<TemplatesMenu
document={document}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{!isEditing && (!isMobile || !isTemplate) && (
{!isEditing && !isMobile && !isTemplate && (
<Action>
<ShareButton document={document} />
</Action>
@@ -229,6 +272,13 @@ function DocumentHeader({
</Button>
</Action>
)}
{can.update && isDraft && !isRevision && !hasCollection && (
<PublishDialog
dialog={dialog}
document={document}
onSave={onSave}
/>
)}
{can.update && isDraft && !isRevision && (
<Action>
<Tooltip
@@ -27,12 +27,7 @@ function KeyboardShortcutsButton() {
>
<KeyboardShortcuts />
</Guide>
<Tooltip
tooltip={t("Keyboard shortcuts")}
shortcut="?"
placement="left"
delay={500}
>
<Tooltip tooltip={t("Keyboard shortcuts")} shortcut="?" delay={500}>
<Button onClick={handleOpenKeyboardShortcuts}>
<KeyboardIcon />
</Button>
@@ -0,0 +1,177 @@
// @flow
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import Editor, { type Props as EditorProps } from "components/Editor";
import env from "env";
import useCurrentToken from "hooks/useCurrentToken";
import useCurrentUser from "hooks/useCurrentUser";
import useIdle from "hooks/useIdle";
import usePageVisibility from "hooks/usePageVisibility";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
import { homeUrl } from "utils/routeHelpers";
type Props = {|
...EditorProps,
id: string,
|};
function MultiplayerEditor({ ...props }: Props, ref: any) {
const documentId = props.id;
const history = useHistory();
const { t } = useTranslation();
const currentUser = useCurrentUser();
const { presence, ui } = useStores();
const token = useCurrentToken();
const [remoteProvider, setRemoteProvider] = React.useState();
const [isLocalSynced, setLocalSynced] = React.useState(false);
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
const [ydoc] = React.useState(() => new Y.Doc());
const { showToast } = useToasts();
const isIdle = useIdle();
const isVisible = usePageVisibility();
// Provider initialization must be within useLayoutEffect rather than useState
// or useMemo as both of these are ran twice in React StrictMode resulting in
// an orphaned websocket connection.
// see: https://github.com/facebook/react/issues/20090#issuecomment-715926549
React.useLayoutEffect(() => {
const debug = env.ENVIRONMENT === "development";
const name = `document.${documentId}`;
const localProvider = new IndexeddbPersistence(name, ydoc);
const provider = new HocuspocusProvider({
url: `${env.COLLABORATION_URL}/collaboration`,
debug,
name,
document: ydoc,
token,
maxReconnectTimeout: 10000,
});
provider.on("authenticationFailed", () => {
showToast(
t(
"Sorry, it looks like you dont have permission to access the document"
)
);
history.replace(homeUrl());
});
provider.on("awarenessChange", ({ states }) => {
states.forEach(({ user }) => {
if (user) {
// could know if the user is editing here using `state.cursor` but it
// feels distracting in the UI, once multiplayer is on for everyone we
// can stop diffentiating
presence.touch(documentId, user.id, false);
}
});
});
localProvider.on("synced", () =>
// only set local storage to "synced" if it's loaded a non-empty doc
setLocalSynced(!!ydoc.get("default")._start)
);
provider.on("synced", () => setRemoteSynced(true));
if (debug) {
provider.on("status", (ev) => console.log("status", ev.status));
provider.on("message", (ev) => console.log("incoming", ev.message));
provider.on("outgoingMessage", (ev) =>
console.log("outgoing", ev.message)
);
localProvider.on("synced", (ev) => console.log("local synced"));
}
provider.on("status", (ev) => ui.setMultiplayerStatus(ev.status));
setRemoteProvider(provider);
return () => {
provider?.destroy();
localProvider?.destroy();
setRemoteProvider(null);
ui.setMultiplayerStatus(undefined);
};
}, [history, showToast, t, documentId, ui, presence, token, ydoc]);
const user = React.useMemo(() => {
return {
id: currentUser.id,
name: currentUser.name,
color: currentUser.color,
};
}, [currentUser.id, currentUser.color, currentUser.name]);
const extensions = React.useMemo(() => {
if (!remoteProvider) {
return [];
}
return [
new MultiplayerExtension({
user,
provider: remoteProvider,
document: ydoc,
}),
];
}, [remoteProvider, user, ydoc]);
// Disconnect the realtime connection while idle. `isIdle` also checks for
// page visibility and will immediately disconnect when a tab is hidden.
React.useEffect(() => {
if (!remoteProvider) {
return;
}
if (
isIdle &&
!isVisible &&
remoteProvider.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
}
if (
(!isIdle || isVisible) &&
remoteProvider.status === WebSocketStatus.Disconnected
) {
remoteProvider.connect();
}
}, [remoteProvider, isIdle, isVisible]);
if (!extensions.length) {
return null;
}
// while the collaborative document is loading, we render a version of the
// document from the last text cache in read-only mode if we have it.
const showCache = !isLocalSynced && !isRemoteSynced;
return (
<>
{showCache && (
<Editor defaultValue={props.defaultValue} readOnly ref={ref} />
)}
<Editor
{...props}
value={undefined}
defaultValue={undefined}
extensions={extensions}
ref={showCache ? undefined : ref}
style={showCache ? { display: "none" } : undefined}
/>
</>
);
}
export default React.forwardRef<any, typeof MultiplayerEditor>(
MultiplayerEditor
);
@@ -0,0 +1,203 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import { Dialog, DialogBackdrop, type DialogStateReturn } from "reakit";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import Button from "components/Button";
import Divider from "components/Divider";
import Flex from "components/Flex";
import InputSearch from "components/InputSearch";
import useListDocumentPath from "hooks/useListDocumentPath";
import useToasts from "hooks/useToasts";
import { mobileContextMenu, fadeAndSlideDown } from "styles/animations";
type Props = {|
dialog: DialogStateReturn,
document: Document,
onSave: ({
done?: boolean,
publish?: boolean,
autosave?: boolean,
}) => void,
|};
const PublishDialog = ({ dialog, document, onSave }: Props) => {
const { t } = useTranslation();
const { showToast } = useToasts();
const {
row,
results,
searchTerm,
setSearchTerm,
selectedPath,
setSelectedPath,
} = useListDocumentPath(document);
React.useEffect(() => {
if (!dialog.visible) {
setSelectedPath(undefined);
}
}, [dialog.visible, setSelectedPath]);
const handleChange = React.useCallback(
(event) => {
setSearchTerm(event.target.value);
},
[setSearchTerm]
);
const handleKeyDown = React.useCallback(
(event) => {
if (event.currentTarget.value && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setSearchTerm("");
}
},
[setSearchTerm]
);
const handlePublishFromModal = React.useCallback(
async (selectedPath) => {
if (!document) return;
if (!selectedPath) {
showToast(t("Please select a path"));
return;
}
if (selectedPath.type === "collection") {
await onSave({
done: true,
publish: true,
collectionId: selectedPath.collectionId,
});
} else {
await onSave({
done: true,
publish: true,
collectionId: selectedPath.collectionId,
parentDocumentId: selectedPath.id,
});
}
dialog.setVisible(false);
},
[dialog, document, onSave, showToast, t]
);
const data = results;
return (
<Wrapper>
<DialogBackdrop {...dialog}>
<Dialog
{...dialog}
aria-label="Choose a collection"
preventBodyScroll
hideOnEsc
>
<Position>
<Content>
<Flex align="center">
<InputSearch
onChange={handleChange}
onKeyDown={handleKeyDown}
value={searchTerm}
placeholder={`${t("Search collections & documents")}`}
flex
/>
</Flex>
<Results>
<AutoSizer>
{({ width, height }) => {
return (
<Flex role="listbox" column>
<List
key={data.length}
width={width}
height={height}
itemData={data}
itemCount={data.length}
itemSize={40}
itemKey={(index, data) => data[index].id}
>
{row}
</List>
</Flex>
);
}}
</AutoSizer>
</Results>
<Divider />
<ButtonWrapper justify="flex-end">
<Button
disabled={!selectedPath}
onClick={() => handlePublishFromModal(selectedPath)}
>
Publish
</Button>
</ButtonWrapper>
</Content>
</Position>
</Dialog>
</DialogBackdrop>
</Wrapper>
);
};
const Wrapper = styled.div`
position: relative;
`;
const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
right: 8vh;
top: 4vh;
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
top: auto !important;
right: 8px !important;
bottom: 16px !important;
left: 8px !important;
`};
${breakpoint("tablet")`
animation: ${fadeAndSlideDown} 200ms ease;
transform-origin: 75% 0;
`}
`;
const Results = styled.div`
padding: 8px 0;
height: calc(93% - 52px);
`;
const Content = styled.div`
background: ${(props) => props.theme.background};
width: 70vw;
max-width: 600px;
height: 40vh;
max-height: 500px;
border-radius: 8px;
padding: 10px;
box-shadow: ${(props) => props.theme.menuShadow};
${breakpoint("mobile", "tablet")`
right: -2vh;
width: 95vw;
`};
`;
const ButtonWrapper = styled(Flex)`
margin: 10px 0;
`;
export default PublishDialog;
+4 -3
View File
@@ -1,6 +1,7 @@
// @flow
import { observer, inject } from "mobx-react";
import * as React from "react";
import { Trans } from "react-i18next";
import { withRouter, type Location } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
@@ -26,7 +27,7 @@ class References extends React.Component<Props> {
render() {
const { documents, collections, document } = this.props;
const backlinks = documents.getBacklinedDocuments(document.id);
const collection = collections.get(document.collectionId);
const collection = collections.get(document.collectionId || "");
const children = collection
? collection.getDocumentChildren(document.id)
: [];
@@ -42,12 +43,12 @@ class References extends React.Component<Props> {
<Tabs>
{showNestedDocuments && (
<Tab to="#children" isActive={() => !isBacklinksTab}>
Nested documents
<Trans>Nested documents</Trans>
</Tab>
)}
{showBacklinks && (
<Tab to="#backlinks" isActive={() => isBacklinksTab}>
Referenced by
<Trans>Referenced by</Trans>
</Tab>
)}
</Tabs>
+59 -1
View File
@@ -1,3 +1,61 @@
// @flow
import * as React from "react";
import { type Match } from "react-router-dom";
import DataLoader from "./components/DataLoader";
export default DataLoader;
import Document from "./components/Document";
import SocketPresence from "./components/SocketPresence";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import { type LocationWithState } from "types";
type Props = {|
location: LocationWithState,
match: Match,
|};
export default function DocumentScene(props: Props) {
const { ui } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
React.useEffect(() => {
return () => ui.clearActiveDocument();
}, [ui]);
const { documentSlug, revisionId } = props.match.params;
// the urlId portion of the url does not include the slugified title
// we only want to force a re-mount of the document component when the
// document changes, not when the title does so only this portion is used
// for the key.
const urlParts = documentSlug ? documentSlug.split("-") : [];
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
const key = [urlId, revisionId].join("/");
const isMultiplayer = team.collaborativeEditing;
return (
<DataLoader key={key} match={props.match}>
{({ document, isEditing, ...rest }) => {
const isActive =
!document.isArchived && !document.isDeleted && !revisionId;
// TODO: Remove once multiplayer is 100% rollout, SocketPresence will
// no longer be required
if (isActive && !isMultiplayer) {
return (
<SocketPresence
documentId={document.id}
userId={user.id}
isEditing={isEditing}
>
<Document document={document} match={props.match} {...rest} />
</SocketPresence>
);
}
return <Document document={document} match={props.match} {...rest} />;
}}
</DataLoader>
);
}
+2 -2
View File
@@ -24,7 +24,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
const [isArchiving, setArchiving] = React.useState(false);
const { showToast } = useToasts();
const canArchive = !document.isDraft && !document.isArchived;
const collection = collections.get(document.collectionId);
const collection = collections.get(document.collectionId || "");
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
@@ -104,7 +104,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
</HelpText>
)}
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
{isDeleting ? `${t("Deleting")}` : t("Im sure Delete")}
</Button>
&nbsp;&nbsp;
{canArchive && (
+20 -65
View File
@@ -1,19 +1,17 @@
// @flow
import { Search } from "js-search";
import { last } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import styled from "styled-components";
import { type DocumentPath } from "stores/CollectionsStore";
import Document from "models/Document";
import Button from "components/Button";
import Flex from "components/Flex";
import { Outline } from "components/Input";
import Labeled from "components/Labeled";
import PathToDocument from "components/PathToDocument";
import useListDocumentPath from "hooks/useListDocumentPath";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
@@ -23,62 +21,28 @@ type Props = {|
|};
function DocumentMove({ document, onRequestClose }: Props) {
const [searchTerm, setSearchTerm] = useState();
const { collections, documents } = useStores();
const { collections } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const searchIndex = useMemo(() => {
const paths = collections.pathsToDocuments;
const index = new Search("id");
index.addIndex("title");
const { row, results, setSearchTerm, selectedPath } = useListDocumentPath(
document
);
// Build index
const indexeableDocuments = [];
paths.forEach((path) => {
const doc = documents.get(path.id);
if (!doc || !doc.isTemplate) {
indexeableDocuments.push(path);
}
});
index.addDocuments(indexeableDocuments);
const handleMove = async () => {
if (!document) return;
return index;
}, [documents, collections.pathsToDocuments]);
const results: DocumentPath[] = useMemo(() => {
const onlyShowCollections = document.isTemplate;
let results = [];
if (collections.isLoaded) {
if (searchTerm) {
results = searchIndex.search(searchTerm);
} else {
results = searchIndex._documents;
}
if (!selectedPath) {
showToast(t("Please select a path"));
return;
}
if (onlyShowCollections) {
results = results.filter((result) => result.type === "collection");
if (selectedPath.type === "document") {
await document.move(selectedPath.collectionId, selectedPath.id);
} else {
// Exclude root from search results if document is already at the root
if (!document.parentDocumentId) {
results = results.filter(
(result) => result.id !== document.collectionId
);
}
// Exclude document if on the path to result, or the same result
results = results.filter(
(result) =>
!result.path.map((doc) => doc.id).includes(document.id) &&
last(result.path.map((doc) => doc.id)) !== document.parentDocumentId
);
await document.move(selectedPath.collectionId, null);
}
return results;
}, [document, collections, searchTerm, searchIndex]);
const handleSuccess = () => {
showToast(t("Document moved"), { type: "info" });
onRequestClose();
};
@@ -100,20 +64,6 @@ function DocumentMove({ document, onRequestClose }: Props) {
}
};
const row = ({ index, data, style }) => {
const result = data[index];
return (
<PathToDocument
result={result}
document={document}
collection={collections.get(result.collectionId)}
onSuccess={handleSuccess}
style={style}
/>
);
};
const data = results;
if (!document || !collections.isLoaded) {
@@ -160,6 +110,11 @@ function DocumentMove({ document, onRequestClose }: Props) {
</AutoSizer>
</Results>
</NewLocation>
<Flex justify="flex-end">
<Button onClick={handleMove} disabled={!selectedPath}>
<Trans>Move</Trans>
</Button>
</Flex>
</Section>
</Flex>
);
+4 -3
View File
@@ -19,16 +19,17 @@ function DocumentNew() {
const { t } = useTranslation();
const { documents, collections } = useStores();
const { showToast } = useToasts();
const id = match.params.id || "";
const id = match.params.id;
useEffect(() => {
async function createDocument() {
const params = queryString.parse(location.search);
try {
const collection = await collections.fetch(id);
let collection;
if (id) collection = await collections.fetch(id);
const document = await documents.create({
collectionId: collection.id,
collectionId: collection ? collection.id : null,
parentDocumentId: params.parentDocumentId,
templateId: params.templateId,
template: params.template,
+1 -1
View File
@@ -51,7 +51,7 @@ function DocumentPermanentDelete({ document, onSubmit }: Props) {
/>
</HelpText>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
{isDeleting ? `${t("Deleting")}` : t("Im sure Delete")}
</Button>
</form>
</Flex>
+98
View File
@@ -0,0 +1,98 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import Collection from "models/Collection";
import Document from "models/Document";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { type NavigationNode } from "types";
type Props = {|
document: Document,
item: {|
active: ?boolean,
children: Array<NavigationNode>,
collectionId: string,
depth: number,
id: string,
title: string,
url: string,
|},
collection: Collection,
onCancel: () => void,
onSubmit: () => void,
|};
function DocumentReparent({
document,
collection,
item,
onSubmit,
onCancel,
}: Props) {
const [isSaving, setIsSaving] = useState();
const { showToast } = useToasts();
const { documents, collections } = useStores();
const { t } = useTranslation();
const prevCollection = collections.get(item.collectionId);
const accessMapping = {
read_write: t("view and edit access"),
read: t("view only access"),
null: t("no access"),
};
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
try {
await documents.move(item.id, collection.id);
showToast(t("Document moved"), {
type: "info",
});
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[documents, item.id, collection.id, showToast, t, onSubmit]
);
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Heads up moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all team members <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}."
values={{
title: item.title,
prevCollectionName: prevCollection?.name,
newCollectionName: collection.name,
prevPermission:
accessMapping[prevCollection?.permission || "null"],
newPermission: accessMapping[collection.permission || "null"],
}}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit">
{isSaving ? `${t("Moving")}` : t("Move document")}
</Button>{" "}
<Button type="button" onClick={onCancel} neutral>
{t("Cancel")}
</Button>
</form>
</Flex>
);
}
export default observer(DocumentReparent);
+2 -2
View File
@@ -18,7 +18,7 @@ import InputSearchPage from "components/InputSearchPage";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import NewDocumentMenu from "menus/NewDocumentMenu";
import NewDocumentButton from "../components/NewDocumentButton";
import { type LocationWithState } from "types";
type Props = {|
@@ -89,7 +89,7 @@ class Drafts extends React.Component<Props> {
<InputSearchPage source="drafts" label={t("Search documents")} />
</Action>
<Action>
<NewDocumentMenu />
<NewDocumentButton />
</Action>
</>
}
+1 -1
View File
@@ -47,7 +47,7 @@ function GroupDelete({ group, onSubmit }: Props) {
/>
</HelpText>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
{isDeleting ? `${t("Deleting")}` : t("Im sure Delete")}
</Button>
</form>
</Flex>
+2 -2
View File
@@ -9,12 +9,12 @@ import Empty from "components/Empty";
import Heading from "components/Heading";
import InputSearchPage from "components/InputSearchPage";
import LanguagePrompt from "components/LanguagePrompt";
import NewDocumentButton from "components/NewDocumentButton";
import Scene from "components/Scene";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import PaginatedDocumentList from "../components/PaginatedDocumentList";
import useStores from "../hooks/useStores";
import NewDocumentMenu from "menus/NewDocumentMenu";
function Home() {
const { documents, ui, auth } = useStores();
@@ -33,7 +33,7 @@ function Home() {
<InputSearchPage source="dashboard" label={t("Search documents")} />
</Action>
<Action>
<NewDocumentMenu />
<NewDocumentButton />
</Action>
</>
}
+3 -3
View File
@@ -112,10 +112,10 @@ function Invite({ onSubmit }: Props) {
});
}, [showToast, t]);
const handleRoleChange = React.useCallback((ev, index) => {
const handleRoleChange = React.useCallback((role: Role, index: number) => {
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites[index]["role"] = ev.target.value;
newInvites[index]["role"] = role;
return newInvites;
});
}, []);
@@ -194,7 +194,7 @@ function Invite({ onSubmit }: Props) {
required={!!invite.email}
/>
<InputSelectRole
onChange={(ev) => handleRoleChange(ev, index)}
onChange={(role: any) => handleRoleChange((role: Role), index)}
value={invite.role}
labelHidden={index !== 0}
short
+11 -15
View File
@@ -1,6 +1,6 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { debounce, isEqual } from "lodash";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer, inject } from "mobx-react";
import { PlusIcon } from "outline-icons";
@@ -28,13 +28,13 @@ import Fade from "components/Fade";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import LoadingIndicator from "components/LoadingIndicator";
import NewDocumentButton from "components/NewDocumentButton";
import PageTitle from "components/PageTitle";
import CollectionFilter from "./components/CollectionFilter";
import DateFilter from "./components/DateFilter";
import SearchField from "./components/SearchField";
import SearchInput from "./components/SearchInput";
import StatusFilter from "./components/StatusFilter";
import UserFilter from "./components/UserFilter";
import NewDocumentMenu from "menus/NewDocumentMenu";
import { type LocationWithState } from "types";
import { metaDisplay } from "utils/keyboard";
import { newDocumentUrl, searchUrl } from "utils/routeHelpers";
@@ -88,8 +88,9 @@ class Search extends React.Component<Props> {
this.props.history.goBack();
}
handleKeyDown = (ev: SyntheticKeyboardEvent<>) => {
handleKeyDown = (ev: SyntheticKeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
this.updateLocation(ev.currentTarget.value);
this.fetchResults();
return;
}
@@ -117,7 +118,7 @@ class Search extends React.Component<Props> {
// To prevent "no results" showing before debounce kicks in
this.isLoading = true;
this.fetchResultsDebounced();
this.fetchResults();
};
handleTermChange = () => {
@@ -127,9 +128,9 @@ class Search extends React.Component<Props> {
this.allowLoadMore = true;
// To prevent "no results" showing before debounce kicks in
this.isLoading = !!this.query;
this.isLoading = true;
this.fetchResultsDebounced();
this.fetchResults();
};
handleFilterChange = (search: {
@@ -241,15 +242,11 @@ class Search extends React.Component<Props> {
}
} else {
this.pinToTop = false;
this.isLoading = false;
this.lastQuery = this.query;
}
};
fetchResultsDebounced = debounce(this.fetchResults, 500, {
leading: false,
trailing: true,
});
updateLocation = (query: string) => {
this.props.history.replace({
pathname: searchUrl(query),
@@ -283,10 +280,9 @@ class Search extends React.Component<Props> {
</div>
)}
<ResultsWrapper pinToTop={this.pinToTop} column auto>
<SearchField
<SearchInput
placeholder={`${t("Search")}`}
onKeyDown={this.handleKeyDown}
onChange={this.updateLocation}
defaultValue={this.query}
/>
{showShortcutTip && (
@@ -347,7 +343,7 @@ class Search extends React.Component<Props> {
{t("New doc")}
</Button>
) : (
<NewDocumentMenu />
<NewDocumentButton />
)}
&nbsp;&nbsp;
<Button as={Link} to="/search" neutral>
@@ -16,9 +16,9 @@ function CollectionFilter(props: Props) {
const { onSelect, collectionId } = props;
const options = React.useMemo(() => {
const collectionOptions = collections.orderedData.map((user) => ({
key: user.id,
label: user.name,
const collectionOptions = collections.orderedData.map((collection) => ({
key: collection.id,
label: collection.name,
}));
return [
@@ -1,97 +0,0 @@
// @flow
import { SearchIcon } from "outline-icons";
import * as React from "react";
import styled, { withTheme } from "styled-components";
import Flex from "components/Flex";
import { type Theme } from "types";
type Props = {
onChange: (string) => void,
defaultValue?: string,
placeholder?: string,
theme: Theme,
};
class SearchField extends React.Component<Props> {
input: ?HTMLInputElement;
componentDidMount() {
if (this.props && this.input) {
// ensure that focus is placed at end of input
const len = (this.props.defaultValue || "").length;
this.input.setSelectionRange(len, len);
}
}
handleChange = (ev: SyntheticEvent<HTMLInputElement>) => {
this.props.onChange(ev.currentTarget.value ? ev.currentTarget.value : "");
};
focusInput = (ev: SyntheticEvent<>) => {
if (this.input) this.input.focus();
};
render() {
return (
<Field align="center">
<StyledIcon
type="Search"
size={46}
color={this.props.theme.textTertiary}
onClick={this.focusInput}
/>
<StyledInput
{...this.props}
ref={(ref) => (this.input = ref)}
onChange={this.handleChange}
spellCheck="false"
placeholder={this.props.placeholder}
type="search"
autoFocus
/>
</Field>
);
}
}
const Field = styled(Flex)`
position: relative;
margin-bottom: 8px;
`;
const StyledInput = styled.input`
width: 100%;
padding: 10px 10px 10px 60px;
font-size: 36px;
font-weight: 400;
outline: none;
border: 0;
background: ${(props) => props.theme.sidebarBackground};
transition: ${(props) => props.theme.backgroundTransition};
border-radius: 4px;
color: ${(props) => props.theme.text};
::-webkit-search-cancel-button {
-webkit-appearance: none;
}
::-webkit-input-placeholder {
color: ${(props) => props.theme.placeholder};
}
:-moz-placeholder {
color: ${(props) => props.theme.placeholder};
}
::-moz-placeholder {
color: ${(props) => props.theme.placeholder};
}
:-ms-input-placeholder {
color: ${(props) => props.theme.placeholder};
}
`;
const StyledIcon = styled(SearchIcon)`
position: absolute;
left: 8px;
`;
export default withTheme(SearchField);
@@ -0,0 +1,86 @@
// @flow
import { SearchIcon } from "outline-icons";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import Flex from "components/Flex";
type Props = {
defaultValue?: string,
placeholder?: string,
};
function SearchInput({ defaultValue, ...rest }: Props) {
const theme = useTheme();
const inputRef = React.useRef();
React.useEffect(() => {
// ensure that focus is placed at end of input
const len = (defaultValue || "").length;
inputRef.current?.setSelectionRange(len, len);
}, [defaultValue]);
const focusInput = React.useCallback((ev: SyntheticEvent<>) => {
inputRef.current?.focus();
}, []);
return (
<Wrapper align="center">
<StyledIcon
type="Search"
size={46}
color={theme.textTertiary}
onClick={focusInput}
/>
<StyledInput
{...rest}
defaultValue={defaultValue}
ref={inputRef}
spellCheck="false"
type="search"
autoFocus
/>
</Wrapper>
);
}
const Wrapper = styled(Flex)`
position: relative;
margin-bottom: 8px;
`;
const StyledInput = styled.input`
width: 100%;
padding: 10px 10px 10px 60px;
font-size: 36px;
font-weight: 400;
outline: none;
border: 0;
background: ${(props) => props.theme.sidebarBackground};
transition: ${(props) => props.theme.backgroundTransition};
border-radius: 4px;
color: ${(props) => props.theme.text};
::-webkit-search-cancel-button {
-webkit-appearance: none;
}
::-webkit-input-placeholder {
color: ${(props) => props.theme.placeholder};
}
:-moz-placeholder {
color: ${(props) => props.theme.placeholder};
}
::-moz-placeholder {
color: ${(props) => props.theme.placeholder};
}
:-ms-input-placeholder {
color: ${(props) => props.theme.placeholder};
}
`;
const StyledIcon = styled(SearchIcon)`
position: absolute;
left: 8px;
`;
export default SearchInput;
+70
View File
@@ -0,0 +1,70 @@
// @flow
import { debounce } from "lodash";
import { observer } from "mobx-react";
import { BeakerIcon } from "outline-icons";
import * as React from "react";
import { useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import Checkbox from "components/Checkbox";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import Scene from "components/Scene";
import env from "env";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
function Features() {
const { auth } = useStores();
const team = useCurrentTeam();
const { t } = useTranslation();
const { showToast } = useToasts();
const [data, setData] = useState({
collaborativeEditing: team.collaborativeEditing,
});
const showSuccessMessage = React.useCallback(
debounce(() => {
showToast(t("Settings saved"), { type: "success" });
}, 250),
[t, showToast]
);
const handleChange = React.useCallback(
async (ev: SyntheticInputEvent<*>) => {
const newData = { ...data, [ev.target.name]: ev.target.checked };
setData(newData);
await auth.updateTeam(newData);
showSuccessMessage();
},
[auth, data, showSuccessMessage]
);
return (
<Scene title={t("Features")} icon={<BeakerIcon color="currentColor" />}>
<Heading>
<Trans>Features</Trans>
</Heading>
<HelpText>
<Trans>
Manage optional and beta features. Changing these settings will affect
all team members.
</Trans>
</HelpText>
{env.COLLABORATION_URL && (
<Checkbox
label={t("Collaborative editing")}
name="collaborativeEditing"
checked={data.collaborativeEditing}
onChange={handleChange}
note="When enabled multiple people can edit documents at the same time (Beta)"
/>
)}
</Scene>
);
}
export default observer(Features);
+14
View File
@@ -7,6 +7,7 @@ import { useTranslation, Trans } from "react-i18next";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import { parseOutlineExport } from "shared/utils/zip";
import FileOperation from "models/FileOperation";
import Button from "components/Button";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
@@ -102,6 +103,18 @@ function ImportExport() {
[t, collections, showToast]
);
const handleDelete = React.useCallback(
async (fileOperation: FileOperation) => {
try {
await fileOperations.delete(fileOperation);
showToast(t("Export deleted"));
} catch (err) {
showToast(err.message, { type: "error" });
}
},
[fileOperations, showToast, t]
);
const hasCollections = importDetails
? !!importDetails.filter((detail) => detail.type === "collection").length
: false;
@@ -216,6 +229,7 @@ function ImportExport() {
<FileOperationListItem
key={item.id + item.state}
fileOperation={item}
handleDelete={handleDelete}
/>
)}
/>
+107 -135
View File
@@ -1,13 +1,10 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import { ProfileIcon } from "outline-icons";
import * as React from "react";
import { Trans, withTranslation, type TFunction } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { languageOptions } from "shared/i18n";
import AuthStore from "stores/AuthStore";
import ToastsStore from "stores/ToastsStore";
import UserDelete from "scenes/UserDelete";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -17,162 +14,137 @@ import Input, { LabelText } from "components/Input";
import InputSelect from "components/InputSelect";
import Scene from "components/Scene";
import ImageUpload from "./components/ImageUpload";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
auth: AuthStore,
toasts: ToastsStore,
t: TFunction,
};
const Profile = () => {
const { auth } = useStores();
const form = React.useRef<?HTMLFormElement>();
const [name, setName] = React.useState<string>(auth.user?.name || "");
const [avatarUrl, setAvatarUrl] = React.useState<?string>();
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [language, setLanguage] = React.useState(auth.user?.language);
@observer
class Profile extends React.Component<Props> {
timeout: TimeoutID;
form: ?HTMLFormElement;
const { showToast } = useToasts();
const { t } = useTranslation();
@observable name: string;
@observable avatarUrl: ?string;
@observable showDeleteModal: boolean = false;
@observable language: string;
componentDidMount() {
if (this.props.auth.user) {
this.name = this.props.auth.user.name;
this.language = this.props.auth.user.language;
}
}
componentWillUnmount() {
clearTimeout(this.timeout);
}
handleSubmit = async (ev: SyntheticEvent<>) => {
const { t } = this.props;
const handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await this.props.auth.updateUser({
name: this.name,
avatarUrl: this.avatarUrl,
language: this.language,
await auth.updateUser({
name,
avatarUrl,
language,
});
this.props.toasts.showToast(t("Profile saved"), { type: "success" });
showToast(t("Profile saved"), { type: "success" });
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
const handleNameChange = (ev: SyntheticInputEvent<*>) => {
setName(ev.target.value);
};
handleAvatarUpload = async (avatarUrl: string) => {
const { t } = this.props;
this.avatarUrl = avatarUrl;
const handleAvatarUpload = async (avatarUrl: string) => {
setAvatarUrl(avatarUrl);
await this.props.auth.updateUser({
avatarUrl: this.avatarUrl,
await auth.updateUser({
avatarUrl,
});
this.props.toasts.showToast(t("Profile picture updated"), {
showToast(t("Profile picture updated"), {
type: "success",
});
};
handleAvatarError = (error: ?string) => {
const { t } = this.props;
this.props.toasts.showToast(
error || t("Unable to upload new profile picture"),
{ type: "error" }
);
const handleAvatarError = (error: ?string) => {
showToast(error || t("Unable to upload new profile picture"), {
type: "error",
});
};
handleLanguageChange = (ev: SyntheticInputEvent<*>) => {
this.language = ev.target.value;
const handleLanguageChange = (value: string) => {
setLanguage(value);
};
toggleDeleteAccount = () => {
this.showDeleteModal = !this.showDeleteModal;
const toggleDeleteAccount = () => {
setShowDeleteModal((prev) => !prev);
};
get isValid() {
return this.form && this.form.checkValidity();
}
const isValid = form.current && form.current.checkValidity();
render() {
const { t } = this.props;
const { user, isSaving } = this.props.auth;
if (!user) return null;
const avatarUrl = this.avatarUrl || user.avatarUrl;
const { user, isSaving } = auth;
if (!user) return null;
return (
<Scene title={t("Profile")} icon={<ProfileIcon color="currentColor" />}>
<Heading>{t("Profile")}</Heading>
<ProfilePicture column>
<LabelText>{t("Photo")}</LabelText>
<AvatarContainer>
<ImageUpload
onSuccess={this.handleAvatarUpload}
onError={this.handleAvatarError}
return (
<Scene title={t("Profile")} icon={<ProfileIcon color="currentColor" />}>
<Heading>{t("Profile")}</Heading>
<ProfilePicture column>
<LabelText>{t("Photo")}</LabelText>
<AvatarContainer>
<ImageUpload
onSuccess={handleAvatarUpload}
onError={handleAvatarError}
>
<Avatar src={avatarUrl || user?.avatarUrl} />
<Flex auto align="center" justify="center">
{t("Upload")}
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={handleSubmit} ref={form}>
<Input
label={t("Full name")}
autoComplete="name"
value={name}
onChange={handleNameChange}
required
short
/>
<br />
<InputSelect
label={t("Language")}
options={languageOptions}
value={language}
onChange={handleLanguageChange}
ariaLabel={t("Language")}
short
/>
<HelpText small>
<Trans>
Please note that translations are currently in early access.
<br />
Community contributions are accepted though our{" "}
<a
href="https://translate.getoutline.com"
target="_blank"
rel="noreferrer"
>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
{t("Upload")}
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={this.handleSubmit} ref={(ref) => (this.form = ref)}>
<Input
label={t("Full name")}
autoComplete="name"
value={this.name}
onChange={this.handleNameChange}
required
short
/>
<br />
<InputSelect
label={t("Language")}
options={languageOptions}
value={this.language}
onChange={this.handleLanguageChange}
short
/>
<HelpText small>
<Trans>
Please note that translations are currently in early access.
<br />
Community contributions are accepted though our{" "}
<a
href="https://translate.getoutline.com"
target="_blank"
rel="noreferrer"
>
translation portal
</a>
</Trans>
.
</HelpText>
<Button type="submit" disabled={isSaving || !this.isValid}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
translation portal
</a>
</Trans>
.
</HelpText>
<Button type="submit" disabled={isSaving || !isValid}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
<DangerZone>
<h2>{t("Delete Account")}</h2>
<HelpText small>
<Trans>
You may delete your account at any time, note that this is
unrecoverable
</Trans>
</HelpText>
<Button onClick={this.toggleDeleteAccount} neutral>
{t("Delete account")}
</Button>
</DangerZone>
{this.showDeleteModal && (
<UserDelete onRequestClose={this.toggleDeleteAccount} />
)}
</Scene>
);
}
}
<DangerZone>
<h2>{t("Delete Account")}</h2>
<HelpText small>
<Trans>
You may delete your account at any time, note that this is
unrecoverable
</Trans>
</HelpText>
<Button onClick={toggleDeleteAccount} neutral>
{t("Delete account")}
</Button>
</DangerZone>
{showDeleteModal && <UserDelete onRequestClose={toggleDeleteAccount} />}
</Scene>
);
};
const DangerZone = styled.div`
margin-top: 60px;
@@ -215,4 +187,4 @@ const Avatar = styled.img`
${avatarStyles};
`;
export default withTranslation()<Profile>(inject("auth", "toasts")(Profile));
export default observer(Profile);
@@ -2,16 +2,19 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import FileOperation from "models/FileOperation";
import Button from "components/Button";
import { Action } from "components/Actions";
import ListItem from "components/List/Item";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import FileOperationMenu from "menus/FileOperationMenu";
type Props = {|
fileOperation: FileOperation,
handleDelete: (FileOperation) => Promise<void>,
|};
const FileOperationListItem = ({ fileOperation }: Props) => {
const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
const { t } = useTranslation();
const user = useCurrentUser();
const stateMapping = {
creating: t("Processing"),
@@ -34,7 +37,7 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
)}
{t(`{{userName}} requested`, {
userName:
fileOperation.id === fileOperation.user.id
user.id === fileOperation.user.id
? t("You")
: fileOperation.user.name,
})}
@@ -45,13 +48,15 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
}
actions={
fileOperation.state === "complete" ? (
<Button
as="a"
href={`/api/fileOperations.redirect?id=${fileOperation.id}`}
neutral
>
{t("Download")}
</Button>
<Action>
<FileOperationMenu
id={fileOperation.id}
onDelete={async (ev) => {
ev.preventDefault();
await handleDelete(fileOperation);
}}
/>
</Action>
) : undefined
}
/>
+1 -1
View File
@@ -51,7 +51,7 @@ function Templates(props: Props) {
}
empty={
<Empty>
{t("There are no templates just yet.")}
{t("There are no templates just yet.")}{" "}
{can.createDocument &&
t(
"You can create templates to help your team create consistent and accurate documentation."
+2 -2
View File
@@ -603,7 +603,7 @@ export default class DocumentsStore extends BaseStore<Document> {
async update(params: {
id: string,
title: string,
text: string,
text?: string,
lastRevision: number,
}) {
const document = await super.update(params);
@@ -714,6 +714,6 @@ export default class DocumentsStore extends BaseStore<Document> {
};
getCollectionForDocument(document: Document) {
return this.rootStore.collections.data.get(document.collectionId);
return this.rootStore.collections.data.get(document.collectionId || "");
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import BaseStore from "./BaseStore";
import RootStore from "./RootStore";
export default class FileOperationsStore extends BaseStore<FileOperation> {
actions = ["list", "info"];
actions = ["list", "info", "delete"];
constructor(rootStore: RootStore) {
super(rootStore, FileOperation);
+3 -1
View File
@@ -65,7 +65,9 @@ export default class SharesStore extends BaseStore<Share> {
const document = this.rootStore.documents.get(documentId);
if (!document) return;
const collection = this.rootStore.collections.get(document.collectionId);
const collection = this.rootStore.collections.get(
document.collectionId || ""
);
if (!collection) return;
const parentIds = collection
+8
View File
@@ -6,6 +6,8 @@ import Document from "models/Document";
const UI_STORE = "UI_STORE";
type Status = "connecting" | "connected" | "disconnected" | void;
class UiStore {
// has the user seen the prompt to change the UI language and actioned it
@observable languagePromptDismissed: boolean;
@@ -24,6 +26,7 @@ class UiStore {
@observable sidebarWidth: number;
@observable sidebarCollapsed: boolean = false;
@observable sidebarIsResizing: boolean = false;
@observable multiplayerStatus: Status;
constructor() {
// Rehydrate
@@ -93,6 +96,11 @@ class UiStore {
}
};
@action
setMultiplayerStatus = (status: Status): void => {
this.multiplayerStatus = status;
};
@action
setSidebarResizing = (sidebarIsResizing: boolean): void => {
this.sidebarIsResizing = sidebarIsResizing;
+4 -4
View File
@@ -175,7 +175,7 @@ export default class UsersStore extends BaseStore<User> {
}
};
notInCollection = (collectionId: string, query: string = "") => {
notInCollection = (collectionId: string, query: string = ""): User[] => {
const memberships = filter(
this.rootStore.memberships.orderedData,
(member) => member.collectionId === collectionId
@@ -190,7 +190,7 @@ export default class UsersStore extends BaseStore<User> {
return queriedUsers(users, query);
};
inCollection = (collectionId: string, query: string) => {
inCollection = (collectionId: string, query: string): User[] => {
const memberships = filter(
this.rootStore.memberships.orderedData,
(member) => member.collectionId === collectionId
@@ -204,7 +204,7 @@ export default class UsersStore extends BaseStore<User> {
return queriedUsers(users, query);
};
notInGroup = (groupId: string, query: string = "") => {
notInGroup = (groupId: string, query: string = ""): User[] => {
const memberships = filter(
this.rootStore.groupMemberships.orderedData,
(member) => member.groupId === groupId
@@ -219,7 +219,7 @@ export default class UsersStore extends BaseStore<User> {
return queriedUsers(users, query);
};
inGroup = (groupId: string, query: string) => {
inGroup = (groupId: string, query: string): User[] => {
const groupMemberships = filter(
this.rootStore.groupMemberships.orderedData,
(member) => member.groupId === groupId

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