Compare commits

...

240 Commits

Author SHA1 Message Date
Tom Moor 6a715e4520 fix: Misformatted cache header 2021-11-17 18:48:32 -08:00
Tom Moor eb07aa61ab fix: Only capture 404 2021-11-17 17:13:59 -08:00
Tom Moor 5988bc087a lint 2021-11-17 11:35:48 -08:00
Tom Moor b38f085604 fix: Serve 400 for not found static files 2021-11-17 11:34:28 -08:00
Tom Moor fc7ebaccd8 remove flow type 2021-11-17 10:50:05 -08:00
Tom Moor d9d0e90ae0 fix: More restrictive path parsing 2021-11-17 09:09:03 -08:00
Nan Yu ec5e3120d2 fix: visible groups (#2729)
* updated readme to give some light testing instructions
* updated tests to accept new behavior for group memberships
* use test factories in more places
* add debug logs for mailer events in development
2021-11-15 16:05:58 -08:00
Tom Moor 8a76dd49a0 Bump RME: Fix content in notices 2021-11-14 18:34:26 -08:00
Tom Moor 33524a1322 feat: Add 'Pitch' embed support 2021-11-14 18:33:30 -08:00
dkkb b616292fce feat: Highlight active ListItem in outline. (#2760) 2021-11-12 17:22:37 -08:00
dkkb 94a2e453eb fix: fix issue where the title can be modified in read-only mode (#2761)
The title can be changed (but not saved) when the document is in read only mode.
2021-11-12 08:21:56 -08:00
Tom Moor a674a8668b Update LICENSE 2021-11-11 07:27:39 -08:00
Tom Moor 28ab3402ac 0.60.1 2021-11-11 07:21:24 -08:00
Tom Moor 8a9c09c646 fix: Collaborative sync issue due to doc being prematurely removed in server memory 2021-11-10 17:55:20 -08:00
Tom Moor 30a80fa92d 0.60.0 2021-11-09 21:45:20 -08:00
Tom Moor e899616081 fix: Changing team settings should update in other tabs 2021-11-09 17:28:59 -08:00
dependabot[bot] 411a76f9ff chore(deps): bump y18n from 4.0.0 to 4.0.3 (#2752)
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.3.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/y18n-v4.0.3/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/compare/v4.0.0...y18n-v4.0.3)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-09 07:10:48 -08:00
Tom Moor 87125223de chore: Various dependency updates (#2751) 2021-11-09 07:03:36 -08:00
Tom Moor 6a64dfe4b2 fix: code scanning alerts (#2750) 2021-11-08 22:46:30 -08:00
Tom Moor 87e8ef8fe6 Merge branch 'main' of github.com:outline/outline 2021-11-08 20:52:39 -08:00
Tom Moor c597f2d9a2 feat: Seamless Edit (#2701)
* feat: Remove explicit edit

* Restore revision remains disabled for now

* Bump RME, better differentiation of focused state

* fix: Star not visible in edit mode

* remove stray log

* fix: Occassional user context not available in collaborative persistence
2021-11-08 20:52:17 -08:00
Tom Moor 37be7f99c4 fix: UI flash when loading history sidebar 2021-11-08 20:43:19 -08:00
Tom Moor 9865eab61c fix: Occassional user context not available in collaborative persistence 2021-11-08 18:56:36 -08:00
Tom Moor a600a897c3 fix: Clash between history and table of content shortcuts
related #2733
2021-11-08 18:03:03 -08:00
Tom Moor e170a63094 Missing association cascades 2021-11-07 21:53:40 -08:00
Tom Moor c30908e858 fix: Code highlighting with collaborative editing 2021-11-07 18:48:48 -08:00
Tom Moor 3ac7a839ad fix: Improve share popover focus behavior 2021-11-07 15:48:32 -08:00
Tom Moor 7bc7d7cd6b fix: Incorrect policy returned for parent share 2021-11-07 15:44:37 -08:00
Tom Moor dcec3dd4ec Create codeql-analysis.yml
Testing this out, may revert if it's not useful
2021-11-07 11:27:42 -08:00
Tom Moor b2a1e6b309 feat: Collaborative revision restore (#2721) 2021-11-07 08:58:44 -08:00
dependabot[bot] 5dd5df6268 chore(deps): bump url-parse from 1.4.7 to 1.5.3 (#2740)
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.4.7 to 1.5.3.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.4.7...1.5.3)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-07 08:30:58 -08:00
dependabot[bot] 8cdb78c94a chore(deps): bump normalize-url from 4.5.0 to 4.5.1 (#2739)
Bumps [normalize-url](https://github.com/sindresorhus/normalize-url) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/sindresorhus/normalize-url/releases)
- [Commits](https://github.com/sindresorhus/normalize-url/commits)

---
updated-dependencies:
- dependency-name: normalize-url
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-07 08:30:35 -08:00
dependabot[bot] 398c7eb25c chore(deps): bump jszip from 3.5.0 to 3.7.0 (#2738)
Bumps [jszip](https://github.com/Stuk/jszip) from 3.5.0 to 3.7.0.
- [Release notes](https://github.com/Stuk/jszip/releases)
- [Changelog](https://github.com/Stuk/jszip/blob/master/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.5.0...v3.7.0)

---
updated-dependencies:
- dependency-name: jszip
  dependency-type: direct:production
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-07 08:30:05 -08:00
Tom Moor ee270abbe9 fix: Ensure IntegrationAuthentication is deleted with team 2021-11-07 08:29:37 -08:00
dependabot[bot] 70ec8c551e chore(deps): bump tar from 6.0.5 to 6.1.11 (#2737)
Bumps [tar](https://github.com/npm/node-tar) from 6.0.5 to 6.1.11.
- [Release notes](https://github.com/npm/node-tar/releases)
- [Changelog](https://github.com/npm/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-tar/compare/v6.0.5...v6.1.11)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-05 17:03:53 -07:00
dependabot[bot] 3a29e157b2 chore(deps): bump passport-oauth2 from 1.6.0 to 1.6.1 (#2736)
Bumps [passport-oauth2](https://github.com/jaredhanson/passport-oauth2) from 1.6.0 to 1.6.1.
- [Release notes](https://github.com/jaredhanson/passport-oauth2/releases)
- [Changelog](https://github.com/jaredhanson/passport-oauth2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jaredhanson/passport-oauth2/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: passport-oauth2
  dependency-type: direct:production
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-05 17:02:46 -07:00
Tom Moor ed8334d77a fix: Plug memory leak in collaboration server 2021-11-05 16:55:20 -07:00
Tom Moor 6df8e9e13f Start collaboration service if not otherwise specified 2021-11-04 19:39:41 -07:00
Tom Moor eb9ff990ac feat: Show collab cursor names upon loading document. (#2732)
Second attempt, adds a class to the editor for a couple of seconds when the awareness is loaded to force cursors to display
2021-11-04 17:24:23 -07:00
Tom Moor 1a6921f6c7 fix: Empty doc missing placeholder 2021-11-03 22:02:10 -07:00
Tom Moor 89115a53ca fix: documents.publish event not triggered if nothing else changed in doc (#2728) 2021-11-03 18:43:01 -07:00
Tom Moor ad3bb98087 fix: Various collab cursor issues (#2727) 2021-11-03 17:51:51 -07:00
dependabot[bot] a839f2ed5b chore(deps): bump validator from 5.2.0 to 13.7.0 (#2726)
Bumps [validator](https://github.com/validatorjs/validator.js) from 5.2.0 to 13.7.0.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/5.2.0...13.7.0)

---
updated-dependencies:
- dependency-name: validator
  dependency-type: direct:production
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-03 11:08:01 -07:00
Nan Yu ea12ebea0e Fix: increase left gutter spacing on mobile edit mode (#2720)
* fix: adds space to the left gutter in edit mode so heading annotations have room on mobile
2021-11-02 18:30:37 -07:00
Tom Moor 57fa1305a6 chore: Remove react-keydown (#2713)
* First steps of remove react-keydown, replace with hook

* RegisterKeyDown component to aid transition away from react-keydown
2021-11-01 19:52:04 -07:00
Tom Moor 5f00e1394d fix: Move notifications to be revision driven (#2709) 2021-10-31 18:36:16 -07:00
Tom Moor b6a058147e chore: Refactor two components away from withRouter 2021-10-30 10:51:33 -07:00
Tom Moor 2c6ec11708 lint 2021-10-30 08:44:36 -07:00
Tom Moor 5900176b58 feat: Show collaborative cursors on load
closes #2704
2021-10-29 23:28:41 -07:00
Tom Moor e2c80e5a28 fix: Correctly show editing tooltip
Remove edit icon
closes #2705
2021-10-29 23:04:23 -07:00
Nan Yu 61d56922d5 fix: small improvements to local dev (#2699)
* dont commit pem files to git
* update readme ngrok instructions
* quote the OIDC scope string
2021-10-28 21:44:14 -07:00
Tom Moor 9a1c5c187e fix: Bump hocuspocus for small js fix (https://github.com/ueberdosis/hocuspocus/pull/235) 2021-10-28 21:28:33 -07:00
Tom Moor f2b007bcf5 fix: Flipped load logic 2021-10-28 20:51:40 -07:00
Tom Moor 578d4c4517 Merge branch 'main' of github.com:outline/outline 2021-10-27 21:03:48 -07:00
Tom Moor 313fd0c1b4 fix: disable multiplayer editing on shared docs when logged in 2021-10-27 20:49:52 -07:00
Tom Moor 1641423106 fix: Prevent user.info request loop, keep track of requested users in component state (#2693) 2021-10-27 20:12:22 -07:00
dkkb 67f06895e7 fix: Support uppercase letters in gist link (#2696) 2021-10-27 08:20:39 -07:00
dkkb 030419fa80 fix: Remove redundant scrollbar from iframe. (#2697) 2021-10-27 08:19:13 -07:00
Tom Moor 3987de1d7e Bump kbar, related #2688 2021-10-26 18:12:20 -07:00
Tom Moor 12b9e750e9 chore: Avoid buffer alloc 2021-10-26 18:05:50 -07:00
Tom Moor 1819920c04 fix: React warning size of memo changing between renders 2021-10-26 00:20:32 -07:00
Tom Moor a33bac66e4 fix: Remove invariant from visible calculation 2021-10-25 22:47:14 -07:00
Tom Moor 043a7b41b5 feat: Add print, duplicate, template to command bar 2021-10-25 20:41:28 -07:00
Tom Moor 4266a95569 chore: Bump BME (#2690) 2021-10-24 21:28:15 -07:00
Tom Moor 1d6bae05e6 fix: After renaming collection, url does not update 2021-10-24 17:57:17 -07:00
Tom Moor bb36425175 feat: Enable 'new document' action 2021-10-24 17:51:25 -07:00
Tom Moor adca894e83 fix: Long titles in command bar should not wrap 2021-10-24 17:32:28 -07:00
Tom Moor 2e56bdc388 fix: Command bar should bust cache when docs and collections are renamed
fix: Command bar should get larger on large screens
fix: Editable titles in sidebar should enforce max length
2021-10-24 17:32:28 -07:00
Translate-O-Tron 7f3df8158a New Crowdin updates (#2673) 2021-10-24 16:40:27 -07:00
Tom Moor 1b539dcf83 lint 2021-10-24 12:42:13 -07:00
Tom Moor 1d22b7ae0c chore: Turn on command bar in prod 2021-10-24 12:40:17 -07:00
Tom Moor b1f04145e5 flow 2021-10-24 12:31:17 -07:00
Tom Moor 2a32a4095d Merge branch 'main' of github.com:outline/outline 2021-10-24 12:30:58 -07:00
Tom Moor 33b6fbdee9 feat: Command Bar (#2669) 2021-10-24 12:30:27 -07:00
Gaston Flores dc92e1ead4 fix: ignore emoji when sorting (#2687)
* fix: ignore emoji when sorting

* fix: use correct flow types

* fix: use emoji-regex
2021-10-24 12:29:57 -07:00
Tom Moor 248c8b3c01 Improve beta note 2021-10-24 10:37:19 -07:00
Tom Moor d9f8d2e6d4 fix: Allow tests to pass when default is collab (#2685) 2021-10-23 16:02:25 -07:00
Tom Moor 99684d0900 Upgrade editor, fixes #2682 2021-10-23 10:34:38 -07:00
Tom Moor 6c2d43075c Update README.md 2021-10-23 09:53:26 -07:00
polemius b44c15c6eb fix: small typo (#2683) 2021-10-22 10:23:23 -07:00
Tom Moor f7b587b5a5 fix: Dont show back link on custom domains
closes #2671
2021-10-21 21:45:57 -07:00
Tom Moor c79a22b857 flow 2021-10-21 21:23:58 -07:00
Tom Moor 63c0daf483 fix: mailto links corrupted on save, closes #1090 2021-10-20 08:56:02 -07:00
Tom Moor 51971d2c9a fix: Various aria and React warnings 2021-10-19 22:12:20 -07:00
Tom Moor d443abfc57 chore: Allow websockets and collaboration service to run in the same process (#2674) 2021-10-19 21:18:20 -07:00
Saumya Pandey 3610a7f4a2 fix: Add default role option for new users (#2665)
* Add defaultUserRole on server

* Handle defaultUserRole on frontend

* Handle tests

* Handle user role in userCreator

* Minor improvments

* Fix prettier issue

* Undefined when isNewTeam is false

* Update app/scenes/Settings/Security.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/Settings/Security.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/Settings/Security.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Remove duplicate validation

* Update Team.js

* fix: Move note out of restricted width wrapper

* Move language setting to use 'note' prop

* Remove admin option

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-10-20 09:26:11 +05:30
Translate-O-Tron 90fdf5106a New Crowdin updates (#2639) 2021-10-18 20:13:31 -07:00
Tom Moor 77fb76ff0c lint 2021-10-15 21:09:55 -07:00
Tom Moor 583353e904 fix: Disable guest signin option with not SMTP setup
closes #2250
2021-10-14 21:59:35 -07:00
Tom Moor 26e2ae4bf1 fix: Hide notification settings when SMTP_ configuration is unset 2021-10-14 21:55:48 -07:00
Tom Moor 4f34b69cfa Display notice instead of hide when Slack integration unavailable 2021-10-14 21:49:35 -07:00
Tom Moor 8c1979465f fix: Slack integration should not display if not configured in self hosted
fix: Alignment of Slack channels in settings
closes #2553
2021-10-14 21:37:04 -07:00
Tom Moor cc7a50fbb1 memoization 2021-10-14 21:23:28 -07:00
Tom Moor 5299ada3c9 feat: Support icon prop in InputSelect 2021-10-14 19:00:30 -07:00
Tom Moor 96fc95a9f3 fix: Increase TOC gutter to allow for offset emoji
closes #2661
2021-10-14 17:04:25 -07:00
Tom Moor 2219cfd83e fix: Increase entropy of state string for OAuth process
closes #2663
2021-10-14 16:52:19 -07:00
Tom Moor 6a1566c275 fix: Regression in image upload, closes #2662 2021-10-14 16:47:16 -07:00
Tom Moor b9346fe6ea fix: Minor collab adjusts 2021-10-13 22:01:30 -07:00
Tom Moor 18572cf9de fix: Facepile appears inactive after idle disconnect 2021-10-13 20:30:16 -07:00
Tom Moor 59f4b3bd97 fix: Server markdown parser failing tests 2021-10-13 19:37:04 -07:00
Tom Moor bb9d7d310b fix: Clicking outside editor should close selection toolbar 2021-10-13 19:15:44 -07:00
Tom Moor 3a19c02e34 fix: In page anchor links not working on shared docs
closes #2652
2021-10-12 23:12:47 -07:00
Tom Moor a6b3dbc894 fix: Reduce sensitivity of dark icon switching
fix: Layout issue in icon picker in dark mode
closes #2658
2021-10-12 23:02:14 -07:00
Tom Moor e0405cca0e fix: Bump hocuspocus (memory leak fix) 2021-10-11 10:53:50 -07:00
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
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
Tom Moor 78464f315c fix: Awareness loop in collaborative editing 2021-09-27 18:44:28 -04:00
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
Saumya Pandey 9c3c0fe418 feat: Add Polish to languageOptions (#2593) 2021-09-19 09:45:26 -07:00
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
Tom Moor 0a998789a3 chore: Support Redis v6 on Heroku 2021-09-10 21:05:06 -07:00
Tom Moor 92016bbd06 fix: List behavior when ordered list starts at number other than 1
fix: Image improvements
fix: Image upload race condition
2021-09-10 19:06:54 -07:00
Tom Moor 231ab2da03 fix: Add recording of job errors, remove from queues on failure, centralize options 2021-09-09 22:38:34 -07:00
Tom Moor bd880ee984 chore: Add basic logging of metrics to event queue (#2545)
* chore: Add basic logging of metrics to event queue
closes #2524

* Better naming for multiple queue types

* Add stalled event
2021-09-09 21:55:45 -07:00
Tom Moor 995c6f90b7 fix: Mount _health route before catch-alls
closes #2536
2021-09-09 21:08:34 -07:00
Tom Moor 8ac853bb8b fix: Printing from doc menu is blank in Firefox
closes #2543
2021-09-09 20:41:56 -07:00
Tom Moor 2f5cf90cb7 0.59.0 2021-09-07 22:10:29 -07:00
Tom Moor c709e54738 fix: Init dd trace sooner, closes #2528 2021-09-02 23:22:31 -07:00
Tom Moor 47953b3354 Yarn.lock 2021-09-02 23:17:41 -07:00
Tom Moor d96099b5b8 Move OIDC provider to routes directory 2021-09-02 19:55:06 -07:00
Greg Linklater 4b2bf28531 feat: Generic OAuth2 Authentication (#2388)
* chore: additional dependency

* feat: OAuth2 authentication provider

* docs: add env vars

* chore: lock file

* feat: add malformed user info error and notice

* feat: configurable scopes

* fix: explicitly enable state and disable pkce

* chore: remove externally supplied username from account provisioner use

* chore: remove upstream error

* chore: add explicit import for fetch

* chore: remove unused env var from sample

* docs: openid connect claims

* fix: forward fetch errors

* feat: configurable team claim name

* docs: move OIDC env vars together

* refactor: change provider name

* refactor: rename error to match provider

* fix: resolve claim using lodash.get

* refactor: remove OIDC_TEAM_CLAIM and hard code team name
2021-09-02 19:50:17 -07:00
Tom Moor a3df9e868f fix: Server error when loading documents.info with shareId and user token and child documents shared
closes #2527
2021-09-01 23:38:43 -07:00
Tom Moor 476b5e03f9 perf: Move exports to worker service (#2514)
* first pass

* fixes

* fix: Move export related emails to queue

* i18n
2021-08-31 17:41:57 -07:00
Tom Moor 23a6459ae8 fix: Make GoogleDrive embed links more lenient
closes #2405
2021-08-30 22:43:13 -07:00
Saumya Pandey 4929fbaccb fix: Move "public document sharing" to "Permissions" (#2496)
* Convert to functional component

* Move public sharing to permissions

* Add collections.permission_changed event

* Account for null

* Update server/events.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Add collections.permission_changed event

* Remove name

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-30 11:43:42 +05:30
Tom Moor 08a8fea69a chore: Add /_health endpoint to all services
closes #2506
2021-08-29 19:44:06 -07:00
Tom Moor 2024c6e64f chore: Graceful server shutdown, closes #2507 2021-08-29 14:48:12 -07:00
Tom Moor 3dfd336f59 chore: Move all routes under routes directory (#2513)
closes #2504
2021-08-29 13:25:06 -07:00
Tom Moor 9a875920ac chore: Remove 'attachments' option from mailer 2021-08-29 12:35:55 -07:00
Saumya Pandey f389ac6414 fix: Improvements in share feat (#2502)
* Make request only when popover is visible

* Update policies required for shares.create shares.update

* Create withCollection scope

* Remove team share check from shares.create

* Update tests
2021-08-29 10:44:09 +05:30
Saumya Pandey e4b7aa6761 fix: Add ability to choose user permission level when inviting (#2473)
* Select user role while sending invite

* Add tests to check for role

* Update app/scenes/Invite.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Use select

* Use inviteUser policy

* Remove unnecessary code

* Normalize rank/role
Fix text sizing of select input, fix alignment on users invite form

* Move component to root

* cleanup

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-29 03:05:37 +05:30
Saumya Pandey 00ba65f3ef fix: Refactor collection exports to not send email attachment (#2460)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-29 02:57:07 +05:30
Tom Moor 28aef82af9 chore: Refactoring event processors and service architecture (#2495) 2021-08-27 21:42:13 -07:00
Saumya Pandey 86f008293a fix: Return memberships of collections (#2501) 2021-08-27 20:03:57 -07:00
Tom Moor 835fd26a95 Squashed commit of the following:
commit ebe2fe07d1a9110a99a21772b79f189dd13b4ca8
Author: Tom Moor <tom.moor@gmail.com>
Date:   Thu Aug 26 20:18:52 2021 -0700

    fix: regex, formatting

commit 1fd17b6f8a
Author: Matheus Breguêz <matbrgz@gmail.com>
Date:   Thu Aug 26 09:37:12 2021 -0300

    fix: change image size

commit 30e9bad0f5
Merge: ef99201c cc9468e2
Author: Matheus Breguêz <matbrgz@mail.com>
Date:   Thu Aug 26 09:28:34 2021 -0300

    Merge branch 'main' into feat/google-calendar-embed

commit ef99201c9d
Author: Matheus Breguêz <matbrgz@mail.com>
Date:   Fri Jul 9 11:53:56 2021 -0300

    Update GoogleCalendar.js

commit 0e91084756
Merge: e98f94c0 ec5c47e0
Author: Matheus Breguêz <matbrgz@gmail.com>
Date:   Wed Jul 7 14:49:06 2021 -0300

    Merge remote-tracking branch 'origin/feat/google-calendar-embed' into feat/google-calendar-embed

commit e98f94c02d
Author: Matheus Breguêz <matbrgz@gmail.com>
Date:   Wed Jul 7 14:29:49 2021 -0300

    feat: Add Google Calendar Embed

commit ec5c47e0c8
Author: Matheus Breguêz <matbrgz@gmail.com>
Date:   Wed Jul 7 14:29:49 2021 -0300

    feat: Add Google Calendar Embed
2021-08-26 20:20:15 -07:00
Tom Moor cc9468e2c5 Add 4 additional collection icons, closes #2482 2021-08-25 21:44:30 -07:00
Saumya Pandey 22ba4d0f48 fix: prevent access to docs in trash from deleted private collections (#2431)
* Check for collection in deleted document

* Add tests

* Use update policy

* Set paranoid to false when fetching deleted doc

* Update policy
2021-08-26 09:35:59 +05:30
Tom Moor d335670b91 fix: Starred untitled draft has no title in sidebar
fix: Double click to edit starred document titles
2021-08-24 23:30:55 -07:00
Tom Moor cabaee2d0a Bump editor
closes #2441
closes #2459
2021-08-24 23:23:38 -07:00
Saumya Pandey f6d889f759 fix: Show starred docs in sidebar (#2317)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-23 13:07:28 +05:30
Wesley a50471959b feat: Always show share button (#2469)
This is to enable the share page also for internal team members.

closes #2444
2021-08-22 23:20:29 -07:00
Tom Moor d8ad2fc1a2 fix: Theme in account menu does not update 2021-08-22 22:19:20 -07:00
Wesley 0c48227b57 Feat: add diagrams.net/draw.io embed (#2464)
* feat: Add diagrams.net/draw.io embed

* Rename Diagrams integration to include .net

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-21 11:11:47 -07:00
Tom Moor 72da0653cc Revert "feat: Add hosted domain hint when signing in through Google SSO from subdomain (#2458)" (#2467)
This reverts commit e613ec732b.
2021-08-21 11:11:01 -07:00
Tom Moor e613ec732b feat: Add hosted domain hint when signing in through Google SSO from subdomain (#2458)
* feat: Add hosted domain hint when signing in through Google SSO from subdomain

closes #2454
2021-08-20 14:03:52 -07:00
Tom Moor 0be40609ed feat: Add UI to switch teams where signed in to multiple (#2457)
* feat: Add UI to switch teams where signed in to multiple

* fix: Do not display current team in switch menu

* Refactor to hook
2021-08-18 18:37:50 -07:00
Saumya Pandey ec8fde0a5f fix: Improvements in table component (#2450) 2021-08-18 03:27:23 +05:30
Saumya Pandey 2c52a8cb8b fix: Add icons to menu items (#2373)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-13 14:21:25 -07:00
Tom Moor 1db31eed41 fix: Incorrect empty state text for /created Home tab 2021-08-13 09:57:20 -07:00
Tom Moor 8ba8013c6a fix: Suppressed notification causes missing notifications for other users on the same team 2021-08-13 09:40:43 -07:00
David Herman 1521d4dbac fix: Suppress notifications for suspended users (#2448)
* fix: Supress notifications for suspended users

* spelling

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-13 09:32:19 -07:00
Saumya Pandey a1a4fd1baf fix: Redirect to collection on self-hosted (#2438) 2021-08-13 12:32:18 +05:30
Tom Moor 31f4424018 fix: Time/LocaleTime should default to relative humanized timestamps (regression) 2021-08-12 22:47:14 -07:00
Translate-O-Tron 1f5b83aaeb New Crowdin updates (#2413) 2021-08-12 15:24:40 -07:00
Tom Moor 77db0c2e95 fix: Document history event headings (#2433)
* fix: Document history events from last year but within 12 months shown as 'this year'
fix: Events older than a year have repeated headings

* lint
2021-08-12 15:24:13 -07:00
dependabot[bot] 4cbae1cf7d chore(deps): bump path-parse from 1.0.6 to 1.0.7 (#2439)
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-08-12 15:23:43 -07:00
Tom Moor e985078b80 fix: JS error scrolling overflowed templates page
closes #2445
2021-08-12 11:26:28 -07:00
Saumya Pandey 09b73401de fix: Sidebar links highlighting issue when a template is deleted or archived. (#2420) 2021-08-06 23:01:25 +05:30
Saumya Pandey 42b384688d fix: Options to create a document is available when the policies of collection in the context doesn't permits the user (#2424) 2021-08-06 22:58:26 +05:30
Tom Moor 5bdee1204e fix: Copying header results in '#' copied
fix: urls in text become linked when reloading doc
fix: Allow creation of links to anchors from link toolbar
2021-08-06 09:39:03 -07:00
Tom Moor 9db72217af feat: Include more events in document history sidebar (#2334)
closes #2230
2021-08-05 15:03:55 -07:00
Tom Moor 57a2524fbd fix: /public directory missing in new docker releases (#2417)
closes #2416
2021-08-04 09:21:25 -07:00
Tom Moor bd148f4790 fix: Paste handler should default to HTML when paste source is Outline editor
related #2416
2021-08-04 09:20:51 -07:00
Tom Moor 28d32af613 perf: Remove unused database indexes according to a month of data in production (#2395) 2021-08-03 20:51:12 -07:00
Tom Moor f2f550e1d2 fix: Policies missing on documents.viewed endpoint 2021-08-03 20:02:11 -07:00
Translate-O-Tron dad21b2186 New Crowdin updates (#2400) 2021-08-03 19:32:51 -07:00
Tom Moor 5fb5f1e8b5 perf: Remove backup column migration (#2397)
* perf: Remove no-longer-used 'backup' columns

These were added as part of the move to the v2 editor over a year ago incase any text was not correctly converted. After a year of use no cases of failed conversion have occurred that required the use of this column

* Remove migration, will do in 2-step release

* perf: Remove no-longer-used 'backup' columns

These were added as part of the move to the v2 editor over a year ago incase any text was not correctly converted. After a year of use no cases of failed conversion have occurred that required the use of this column
2021-08-03 18:55:52 -07:00
Tom Moor 2d0690697c 0.58.0 2021-08-03 15:17:06 -07:00
Tom Moor 6b551749d4 chore: Remove version- prefix from docker tags 2021-08-03 14:23:14 -07:00
Jack Baron 52fc861bcf feat: Optimize Dockerfile (#2337)
* feat: optimize dockerfile
use new dockerfile syntaxes
leverage multi-stage builds
strip yarn cache from image
use stricter yarn install command
run as a non-root user

* fix: mark yarn-deduplicate as a required dep
`yarn --production` will fail on a clean install otherwise

* fix: add sequelize required files for migrations

* fix: use correct ARG syntax for multistage builds

* revert: mark yarn-deduplicate as a required dep
no longer required as of 0b3adad751
2021-08-03 13:22:41 -07:00
Tom Moor c81c9a9d2d chore: CI Automated Builds (#2409)
closes #2408
2021-08-02 23:35:13 -07:00
Tom Moor 29c742a673 fix: Settings on 'Security' tab not persisting correctly after refactor (#2407)
* fix: Settings on 'Security' tab not persisting correctly after refactor
closes #2406
2021-08-02 13:37:53 -07:00
Tom Moor dd249021e7 fix: GoogleDrive embeds stopped working with new share urls
closes #2405
2021-08-02 11:09:16 -07:00
Tom Moor 21d3b9c7e0 fix: Formatting of welcome docs :rolleyes: 2021-08-01 13:03:21 -07:00
Tom Moor 6665dfff28 Merge branch 'main' of github.com:outline/outline 2021-08-01 12:55:03 -07:00
Tom Moor cdfe3a7fc3 chore: Add new 'getting started' onboarding document (#2391)
Remove support document
Remove confusing images
Added onboarding checklist
2021-08-01 12:54:41 -07:00
Tom Moor 401c91f90b perf: Correctly parallelize count query in users.list 2021-07-30 12:20:19 -04:00
Tom Moor ed5320507d perf: Separate slow joins (#2394) 2021-07-30 08:50:02 -07:00
474 changed files with 18349 additions and 7624 deletions
+94 -2
View File
@@ -1,4 +1,12 @@
version: 2
version: 2.1
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
working_directory: ~/outline
@@ -40,4 +48,88 @@ jobs:
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
command: yarn build:webpack
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker:
version: 20.10.6
- run:
name: Build Docker image
command: docker build -t $IMAGE_NAME:latest .
- run:
name: Archive Docker image
command: docker save -o image.tar $IMAGE_NAME
- persist_to_workspace:
root: .
paths:
- ./image.tar
publish-latest:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
publish-tag:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:$IMAGE_TAG
workflows:
version: 2
build-and-test:
jobs:
- build:
filters:
tags:
ignore: /^v.*/
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- publish-latest:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+$/
branches:
ignore: /.*/
- publish-tag:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+-.*$/
branches:
ignore: /.*/
+39 -17
View File
@@ -1,6 +1,6 @@
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# setting the Slack keys and the SECRET_KEY.
@@ -12,7 +12,7 @@
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key. The format is not important but you could still use
# Generate a unique random key. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to produce this.
UTILS_SECRET=generate_a_new_key
@@ -29,9 +29,13 @@ REDIS_URL=redis://localhost:6479
URL=http://localhost:3000
PORT=3000
# See [documentation](docs/SERVICES.md) on running a separate collaboration
# server, for normal operation this does not need to be set.
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
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
# A more detailed guide on setting up S3 is available here:
@@ -69,24 +73,42 @@ SLACK_SECRET=get_the_secret_of_above_key
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# the guide for details on setting up your Azure App:
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
AZURE_CLIENT_ID=
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# To configure generic OIDC auth, you'll need some kind of identity provider.
# See documentation for whichever IdP you use to acquire the following info:
# Redirect URI is https://<URL>/auth/oidc.callback
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
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
# Space separated auth scopes.
OIDC_SCOPES="openid profile email"
# –––––––––––––––– OPTIONAL ––––––––––––––––
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# Auto-redirect to https in production. The default is true but you may set to
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
@@ -102,15 +124,15 @@ 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,services
# 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
ALLOWED_DOMAINS=
# For a complete Slack integration with search and posting to channels the
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
@@ -118,13 +140,13 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
# To support sending outgoing transactional emails such as "document updated" or
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=
SMTP_PORT=
@@ -138,6 +160,6 @@ SMTP_SECURE=true
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# The default interface language. See translate.getoutline.com for a list of
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
+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
+70
View File
@@ -0,0 +1,70 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '28 15 * * 2'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
+1
View File
@@ -8,3 +8,4 @@ stats.json
.DS_Store
fakes3/*
.idea
*.pem
+40 -13
View File
@@ -1,23 +1,50 @@
FROM node:14-alpine
# syntax=docker/dockerfile:1.2
ARG APP_PATH=/opt/outline
FROM node:14-alpine AS deps-common
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
# ---
FROM deps-common AS deps-dev
RUN yarn install --no-optional --frozen-lockfile && \
yarn cache clean
# ---
FROM deps-common AS deps-prod
RUN yarn install --production=true --frozen-lockfile && \
yarn cache clean
# ---
FROM node:14-alpine AS builder
ARG APP_PATH
WORKDIR $APP_PATH
COPY package.json ./
COPY yarn.lock ./
RUN yarn --pure-lockfile
COPY . .
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
RUN yarn build
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf shared && \
rm -rf app
# ---
FROM node:14-alpine AS runner
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV production
CMD yarn start
COPY --from=builder $APP_PATH/build ./build
COPY --from=builder $APP_PATH/server ./server
COPY --from=builder $APP_PATH/public ./public
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
COPY --from=builder $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs $APP_PATH/build
USER nodejs
EXPOSE 3000
CMD ["yarn", "start"]
+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.60.1
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-11-11
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 -1
View File
@@ -1 +1,2 @@
web: node ./build/server/index.js
web: yarn start --services=web,websockets
worker: yarn start --services=worker
+31 -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>
@@ -28,8 +26,7 @@ Outline requires the following dependencies:
- [Postgres](https://www.postgresql.org/download/) >=9.5
- [Redis](https://redis.io/) >= 4
- AWS S3 bucket or compatible API for file storage
- Slack or Google developer application for authentication
- Slack, Google, Azure, or OIDC application for authentication
## Self-Hosted Production
@@ -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,28 +80,33 @@ 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 and update the `URL` env var to match
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
### Testing
The `Makefile` has other useful scripts, including some test automation.
1. To run the entire test suite, run `make test`
1. During development, it's often useful, to re-run some tests every time a file is changed. Use `make watch` to start the test daemon and follow the instructions in the console
# Contributing
@@ -110,26 +116,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 +147,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
+70
View File
@@ -0,0 +1,70 @@
// @flow
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionNew from "scenes/CollectionNew";
import DynamicCollectionIcon from "components/CollectionIcon";
import { createAction } from "actions";
import { CollectionSection } from "actions/sections";
import history from "utils/history";
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
section: CollectionSection,
shortcut: ["o", "c"],
icon: <CollectionIcon />,
children: ({ stores }) => {
const collections = stores.collections.orderedData;
return collections.map((collection) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.url,
name: collection.name,
icon: <DynamicCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.url),
}));
},
});
export const createCollection = createAction({
name: ({ t }) => t("New collection"),
section: CollectionSection,
icon: <PlusIcon />,
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create a collection"),
content: <CollectionNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const editCollection = createAction({
name: ({ t }) => t("Edit collection"),
section: CollectionSection,
icon: <EditIcon />,
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
stores.dialogs.openModal({
title: t("Edit collection"),
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={activeCollectionId}
/>
),
});
},
});
export const rootCollectionActions = [openCollection, createCollection];
+33
View File
@@ -0,0 +1,33 @@
// @flow
import { ToolsIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import { createAction } from "actions";
import { DebugSection } from "actions/sections";
import env from "env";
import { deleteAllDatabases } from "utils/developer";
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DebugSection,
perform: async ({ t }) => {
await deleteAllDatabases();
stores.toasts.showToast(t("IndexedDB cache deleted"));
},
});
export const development = createAction({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DebugSection,
visible: ({ event }) =>
env.ENVIRONMENT === "development" ||
(event instanceof KeyboardEvent && event.altKey),
children: [clearIndexedDB],
});
export const rootDebugActions = [development];
+240
View File
@@ -0,0 +1,240 @@
// @flow
import invariant from "invariant";
import {
DownloadIcon,
DuplicateIcon,
StarredIcon,
PrintIcon,
UnstarredIcon,
DocumentIcon,
NewDocumentIcon,
ShapesIcon,
ImportIcon,
} from "outline-icons";
import * as React from "react";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import { createAction } from "actions";
import { DocumentSection } from "actions/sections";
import getDataTransferFiles from "utils/getDataTransferFiles";
import history from "utils/history";
import { newDocumentPath } from "utils/routeHelpers";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
section: DocumentSection,
shortcut: ["o", "d"],
keywords: "go to",
icon: <DocumentIcon />,
children: ({ stores }) => {
const paths = stores.collections.pathsToDocuments;
return paths
.filter((path) => path.type === "document")
.map((path) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: path.url,
name: path.title,
icon: () =>
stores.documents.get(path.id)?.isStarred ? (
<StarredIcon />
) : undefined,
section: DocumentSection,
perform: () => history.push(path.url),
}));
},
});
export const createDocument = createAction({
name: ({ t }) => t("New document"),
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ activeCollectionId }) =>
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
section: DocumentSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!document?.isStarred && stores.policies.abilities(activeDocumentId).star
);
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
document?.star();
},
});
export const unstarDocument = createAction({
name: ({ t }) => t("Unstar"),
section: DocumentSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isStarred &&
stores.policies.abilities(activeDocumentId).unstar
);
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
document?.unstar();
},
});
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
section: DocumentSection,
icon: <DownloadIcon />,
keywords: "export",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
document?.download();
},
});
export const duplicateDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
section: DocumentSection,
icon: <DuplicateIcon />,
keywords: "copy",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ activeDocumentId, t, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
invariant(document, "Document must exist");
const duped = await document.duplicate();
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
stores.toasts.showToast(t("Document duplicated"), { type: "success" });
},
});
export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
section: DocumentSection,
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: async () => {
window.print();
},
});
export const importDocument = createAction({
name: ({ t, activeDocumentId }) => t("Import document"),
section: DocumentSection,
icon: <ImportIcon />,
keywords: "upload",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (activeDocumentId) {
return !!stores.policies.abilities(activeDocumentId).createChildDocument;
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).update;
}
return false;
},
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
const { documents, toasts } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev: SyntheticEvent<>) => {
const files = getDataTransferFiles(ev);
try {
const file = files[0];
const document = await documents.import(
file,
activeDocumentId,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toasts.showToast(err.message, {
type: "error",
});
throw err;
}
};
input.click();
},
});
export const createTemplate = createAction({
name: ({ t }) => t("Templatize"),
section: DocumentSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create template"),
content: (
<DocumentTemplatize
documentId={activeDocumentId}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const rootDocumentActions = [
openDocument,
createDocument,
createTemplate,
importDocument,
downloadDocument,
starDocument,
unstarDocument,
duplicateDocument,
printDocument,
];
+159
View File
@@ -0,0 +1,159 @@
// @flow
import {
HomeIcon,
SearchIcon,
ArchiveIcon,
TrashIcon,
EditIcon,
OpenIcon,
SettingsIcon,
ShapesIcon,
KeyboardIcon,
EmailIcon,
} from "outline-icons";
import * as React from "react";
import {
developersUrl,
changelogUrl,
mailToUrl,
githubIssuesUrl,
} from "shared/utils/routeHelpers";
import stores from "stores";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import { createAction } from "actions";
import { NavigationSection } from "actions/sections";
import history from "utils/history";
import {
settingsPath,
homePath,
searchUrl,
draftsPath,
templatesPath,
archivePath,
trashPath,
} from "utils/routeHelpers";
export const navigateToHome = createAction({
name: ({ t }) => t("Home"),
section: NavigationSection,
shortcut: ["d"],
icon: <HomeIcon />,
perform: () => history.push(homePath()),
visible: ({ location }) => location.pathname !== homePath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
section: NavigationSection,
shortcut: ["/"],
icon: <SearchIcon />,
perform: () => history.push(searchUrl()),
visible: ({ location }) => location.pathname !== searchUrl(),
});
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
section: NavigationSection,
icon: <EditIcon />,
perform: () => history.push(draftsPath()),
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToTemplates = createAction({
name: ({ t }) => t("Templates"),
section: NavigationSection,
icon: <ShapesIcon />,
perform: () => history.push(templatesPath()),
visible: ({ location }) => location.pathname !== templatesPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
section: NavigationSection,
icon: <ArchiveIcon />,
perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(),
});
export const navigateToTrash = createAction({
name: ({ t }) => t("Trash"),
section: NavigationSection,
icon: <TrashIcon />,
perform: () => history.push(trashPath()),
visible: ({ location }) => location.pathname !== trashPath(),
});
export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
section: NavigationSection,
shortcut: ["g", "s"],
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(settingsPath()),
});
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(developersUrl()),
});
export const openFeedbackUrl = createAction({
name: ({ t }) => t("Send us feedback"),
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => window.open(mailToUrl()),
});
export const openBugReportUrl = createAction({
name: ({ t }) => t("Report a bug"),
section: NavigationSection,
perform: () => window.open(githubIssuesUrl()),
});
export const openChangelog = createAction({
name: ({ t }) => t("Changelog"),
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(changelogUrl()),
});
export const openKeyboardShortcuts = createAction({
name: ({ t }) => t("Keyboard shortcuts"),
section: NavigationSection,
shortcut: ["?"],
iconInContextMenu: false,
icon: <KeyboardIcon />,
perform: ({ t }) => {
stores.dialogs.openGuide({
title: t("Keyboard shortcuts"),
content: <KeyboardShortcuts />,
});
},
});
export const logout = createAction({
name: ({ t }) => t("Log out"),
section: NavigationSection,
perform: () => stores.auth.logout(),
});
export const rootNavigationActions = [
navigateToHome,
navigateToSearch,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
navigateToSettings,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
openChangelog,
openKeyboardShortcuts,
logout,
];
+48
View File
@@ -0,0 +1,48 @@
// @flow
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import { createAction } from "actions";
import { SettingsSection } from "actions/sections";
export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"),
icon: <MoonIcon />,
iconInContextMenu: false,
keywords: "theme dark night",
section: SettingsSection,
selected: () => stores.ui.theme === "dark",
perform: () => stores.ui.setTheme("dark"),
});
export const changeToLightTheme = createAction({
name: ({ t }) => t("Light"),
icon: <SunIcon />,
iconInContextMenu: false,
keywords: "theme light day",
section: SettingsSection,
selected: () => stores.ui.theme === "light",
perform: () => stores.ui.setTheme("light"),
});
export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"),
icon: <BrowserIcon />,
iconInContextMenu: false,
keywords: "theme system default",
section: SettingsSection,
selected: () => stores.ui.theme === "system",
perform: () => stores.ui.setTheme("system"),
});
export const changeTheme = createAction({
name: ({ t }) => t("Change theme"),
placeholder: ({ t }) => t("Change theme to"),
icon: () =>
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
keywords: "appearance display",
section: SettingsSection,
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
});
export const rootSettingsActions = [changeTheme];
+24
View File
@@ -0,0 +1,24 @@
// @flow
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import Invite from "scenes/Invite";
import { createAction } from "actions";
import { UserSection } from "actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
icon: <PlusIcon />,
keywords: "team member user",
section: UserSection,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite people"),
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const rootUserActions = [inviteUser];
+117
View File
@@ -0,0 +1,117 @@
// @flow
import { flattenDeep } from "lodash";
import * as React from "react";
import { v4 as uuidv4 } from "uuid";
import type {
Action,
ActionContext,
CommandBarAction,
MenuItemClickable,
MenuItemWithChildren,
} from "types";
export function createAction(
definition: $Diff<Action, { id?: string }>
): Action {
return {
id: uuidv4(),
...definition,
};
}
export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemClickable | MenuItemWithChildren {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
const resolvedIcon = resolve<React.Element<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? React.cloneElement(resolvedIcon, { color: "currentColor" })
: undefined;
if (resolvedChildren) {
return {
title,
icon,
items: resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter((a) => !!a),
visible,
};
}
return {
title,
icon,
visible,
onClick: () => action.perform && action.perform(context),
selected: action.selected ? action.selected(context) : undefined,
};
}
export function actionToKBar(
action: Action,
context: ActionContext
): CommandBarAction[] {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
const resolvedIcon = resolve<React.Element<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const resolvedSection = resolve<string>(action.section);
const resolvedName = resolve<string>(action.name);
const resolvedPlaceholder = resolve<string>(action.placeholder);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
)
: [];
return [
{
id: action.id,
name: resolvedName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: `${action.keywords || ""} ${children
.filter((c) => !!c.keywords)
.map((c) => c.keywords)
.join(" ")}`,
shortcut: action.shortcut,
icon: resolvedIcon
? React.cloneElement(resolvedIcon, { color: "currentColor" })
: undefined,
perform: action.perform
? () => action.perform && action.perform(context)
: undefined,
children: children.length ? children.map((a) => a.id) : undefined,
},
].concat(
children.map((child) => ({
...child,
parent: action.id,
}))
);
}
+16
View File
@@ -0,0 +1,16 @@
// @flow
import { rootCollectionActions } from "./definitions/collections";
import { rootDebugActions } from "./definitions/debug";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootSettingsActions } from "./definitions/settings";
import { rootUserActions } from "./definitions/users";
export default [
...rootCollectionActions,
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootSettingsActions,
...rootDebugActions,
];
+14
View File
@@ -0,0 +1,14 @@
// @flow
import { type ActionContext } from "types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const DebugSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
+1
View File
@@ -11,6 +11,7 @@ type Props = {|
size: number,
icon?: React.Node,
user?: User,
alt?: string,
onClick?: () => void,
className?: string,
|};
@@ -1,7 +1,6 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
@@ -65,7 +64,6 @@ class AvatarWithPresence extends React.Component<Props> {
: this.handleOpenProfile
}
size={32}
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
/>
</AvatarWrapper>
</Tooltip>
+17 -5
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,8 +69,12 @@ const RealButton = styled.button`
}
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
&:hover:not(:disabled) {
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
: darken(0.05, props.theme.buttonNeutralBackground)
};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
@@ -74,6 +82,10 @@ const RealButton = styled.button`
&:disabled {
color: ${props.theme.textTertiary};
svg {
fill: ${props.theme.textTertiary};
}
}
`} ${(props) =>
props.danger &&
@@ -124,11 +136,11 @@ export type Props = {|
fullwidth?: boolean,
autoFocus?: boolean,
style?: Object,
as?: React.ComponentType<any>,
as?: React.ComponentType<any> | string,
to?: string,
onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
href?: string,
"data-on"?: string,
"data-event-category"?: string,
"data-event-action"?: string,
+1 -1
View File
@@ -12,7 +12,7 @@ export type Props = {|
name?: string,
disabled?: boolean,
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
note?: string,
note?: React.Node,
short?: boolean,
small?: boolean,
|};
+18 -12
View File
@@ -1,5 +1,5 @@
// @flow
import { sortBy, filter, uniq } from "lodash";
import { sortBy, filter, uniq, isEqual } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -12,17 +12,20 @@ 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 [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
const { users, presence } = useStores();
const { document, currentUserId } = props;
const { document } = props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
@@ -49,18 +52,21 @@ function Collaborators(props: Props) {
[document.collaboratorIds, users.orderedData, presentIds]
);
// load any users we don't know about
// load any users we don't yet have in memory
React.useEffect(() => {
if (users.isFetching) {
return;
const userIdsToFetch = uniq([
...document.collaboratorIds,
...presentIds,
]).filter((userId) => !users.get(userId));
if (!isEqual(requestedUserIds, userIdsToFetch)) {
setRequestedUserIds(userIdsToFetch);
}
uniq([...document.collaboratorIds, ...presentIds]).forEach((userId) => {
if (!users.get(userId)) {
return users.fetch(userId);
}
});
}, [document, users, presentIds, document.collaboratorIds]);
userIdsToFetch
.filter((userId) => requestedUserIds.includes(userId))
.forEach((userId) => users.fetch(userId));
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
const popover = usePopoverState({
gutter: 0,
+1 -1
View File
@@ -20,7 +20,7 @@ function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
// otherwise it will be impossible to see against the dark background.
const color =
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.12
? getLuminance(collection.color) > 0.09
? collection.color
: "currentColor"
: collection.color;
+97
View File
@@ -0,0 +1,97 @@
// @flow
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import CommandBarResults from "components/CommandBarResults";
import rootActions from "actions/root";
import useCommandBarActions from "hooks/useCommandBarActions";
export const CommandBarOptions = {
animations: {
enterMs: 250,
exitMs: 200,
},
};
function CommandBar() {
const { t } = useTranslation();
useCommandBarActions(rootActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.actions[state.currentRootActionId],
}));
return (
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
);
}
function KBarPortal({ children }: { children: React.Node }) {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
}
const Positioner = styled(KBarPositioner)`
z-index: ${(props) => props.theme.depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
padding: 16px 20px;
width: 100%;
outline: none;
border: none;
background: ${(props) => props.theme.menuBackground};
color: ${(props) => props.theme.text};
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
`;
const Animator = styled(KBarAnimator)`
max-width: 600px;
max-height: 75vh;
width: 90vw;
background: ${(props) => props.theme.menuBackground};
color: ${(props) => props.theme.text};
border-radius: 8px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
transition: max-width 0.2s ease-in-out;
${breakpoint("desktopLarge")`
max-width: 740px;
`};
@media print {
display: none;
}
`;
export default observer(CommandBar);
+71
View File
@@ -0,0 +1,71 @@
// @flow
import { BackIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
import Key from "components/Key";
import type { CommandBarAction } from "types";
type Props = {|
action: CommandBarAction,
active: Boolean,
|};
function CommandBarItem({ action, active }: Props, ref) {
return (
<Item active={active} ref={ref}>
<Text align="center" gap={8}>
<Icon>
{action.icon ? (
React.cloneElement(action.icon, { size: 22 })
) : (
<ForwardIcon color="currentColor" size={22} />
)}
</Icon>
{action.name}
{action.children?.length ? "…" : ""}
</Text>
{action.shortcut?.length ? (
<div style={{ display: "grid", gridAutoFlow: "column", gap: "4px" }}>
{action.shortcut.map((sc) => (
<Key key={sc}>{sc}</Key>
))}
</div>
) : null}
</Item>
);
}
const Icon = styled.div`
width: 22px;
height: 22px;
color: ${(props) => props.theme.textSecondary};
`;
const Text = styled(Flex)`
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
`;
const Item = styled.div`
font-size: 15px;
padding: 12px 16px;
background: ${(props) =>
props.active ? props.theme.menuItemSelected : "none"};
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const ForwardIcon = styled(BackIcon)`
transform: rotate(180deg);
`;
export default React.forwardRef<Props, HTMLDivElement>(CommandBarItem);
+44
View File
@@ -0,0 +1,44 @@
// @flow
import { useMatches, KBarResults, NO_GROUP } from "kbar";
import * as React from "react";
import styled from "styled-components";
import CommandBarItem from "components/CommandBarItem";
export default function CommandBarResults() {
const matches = useMatches();
const items = React.useMemo(
() =>
matches
.reduce((acc, curr) => {
const { actions, name } = curr;
acc.push(name);
acc.push(...actions);
return acc;
}, [])
.filter((i) => i !== NO_GROUP),
[matches]
);
return (
<KBarResults
items={items}
maxHeight={400}
onRender={({ item, active }) =>
typeof item === "string" ? (
<Header>{item}</Header>
) : (
<CommandBarItem action={item} active={active} />
)
}
/>
);
}
const Header = styled.h3`
font-size: 13px;
letter-spacing: 0.04em;
margin: 0;
padding: 16px 0 4px 20px;
color: ${(props) => props.theme.textTertiary};
height: 36px;
`;
+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);
+107
View File
@@ -0,0 +1,107 @@
// @flow
import isPrintableKeyEvent from "is-printable-key-event";
import * as React from "react";
import styled from "styled-components";
type Props = {|
disabled?: boolean,
readOnly?: boolean,
onChange?: (text: string) => void,
onBlur?: (event: SyntheticInputEvent<>) => void,
onInput?: (event: SyntheticInputEvent<>) => void,
onKeyDown?: (event: SyntheticInputEvent<>) => void,
placeholder?: string,
maxLength?: number,
autoFocus?: boolean,
className?: string,
children?: React.Node,
value: string,
|};
/**
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
*/
function ContentEditable({
disabled,
onChange,
onInput,
onBlur,
onKeyDown,
value,
children,
className,
maxLength,
autoFocus,
placeholder,
readOnly,
...rest
}: Props) {
const ref = React.useRef<?HTMLSpanElement>();
const [innerHTML, setInnerHTML] = React.useState<string>(value);
const lastValue = React.useRef("");
const wrappedEvent = (callback) => (
event: SyntheticInputEvent<HTMLInputElement>
) => {
const text = ref.current?.innerText || "";
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event.preventDefault();
return false;
}
if (text !== lastValue.current) {
lastValue.current = text;
onChange && onChange(text);
}
callback && callback(event);
};
React.useLayoutEffect(() => {
if (autoFocus) {
ref.current?.focus();
}
});
React.useEffect(() => {
if (value !== ref.current?.innerText) {
setInnerHTML(value);
}
}, [value]);
return (
<div className={className}>
<Content
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
ref={ref}
data-placeholder={placeholder}
role="textbox"
dangerouslySetInnerHTML={{ __html: innerHTML }}
{...rest}
/>
{children}
</div>
);
}
const Content = styled.span`
&:empty {
display: inline-block;
}
&:empty::before {
display: inline-block;
color: ${(props) => props.theme.placeholder};
-webkit-text-fill-color: ${(props) => props.theme.placeholder};
content: attr(data-placeholder);
pointer-events: none;
height: 0;
}
`;
export default React.memo<Props>(ContentEditable);
+12 -5
View File
@@ -2,8 +2,9 @@
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";
type Props = {|
onClick?: (SyntheticEvent<>) => void | Promise<void>,
@@ -16,6 +17,7 @@ type Props = {|
as?: string | React.ComponentType<*>,
hide?: () => void,
level?: number,
icon?: React.Node,
|};
const MenuItem = ({
@@ -25,6 +27,7 @@ const MenuItem = ({
disabled,
as,
hide,
icon,
...rest
}: Props) => {
const handleClick = React.useCallback(
@@ -71,6 +74,7 @@ const MenuItem = ({
&nbsp;
</>
)}
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{children}
</MenuAnchor>
)}
@@ -84,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;
@@ -130,9 +134,12 @@ export const MenuAnchor = styled.a`
`};
${breakpoint("tablet")`
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
font-size: 15px;
padding: 4px 12px;
font-size: 14px;
`};
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
export default MenuItem;
+61 -6
View File
@@ -2,21 +2,33 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import Flex from "components/Flex";
import MenuIconWrapper from "components/MenuIconWrapper";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
import { type MenuItem as TMenuItem } from "types";
import { actionToMenuItem } from "actions";
import useStores from "hooks/useStores";
import type {
MenuItem as TMenuItem,
Action,
ActionContext,
MenuSeparator,
MenuHeading,
} from "types";
type Props = {|
items: TMenuItem[],
actions: (Action | MenuSeparator | MenuHeading)[],
context?: $Shape<ActionContext>,
|};
const Disclosure = styled(ExpandedIcon)`
@@ -48,7 +60,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
// this block literally just trims unnecessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) return acc;
@@ -66,8 +78,39 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return filtered;
}
function Template({ items, ...menu }: Props): React.Node {
return filterTemplateItems(items).map((item, index) => {
function Template({ items, actions, context, ...menu }: Props): React.Node {
const { t } = useTranslation();
const location = useLocation();
const stores = useStores();
const { ui } = stores;
const ctx = {
t,
isCommandBar: false,
isContextMenu: true,
activeCollectionId: ui.activeCollectionId,
activeDocumentId: ui.activeDocumentId,
location,
stores,
...context,
};
const filteredTemplates = filterTemplateItems(
actions
? actions.map((action) =>
action.type ? action : actionToMenuItem(action, ctx)
)
: items
);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) => !item.type && !!item.icon
);
return filteredTemplates.map((item, index) => {
if (iconIsPresentInAnyMenuItem && !item.type) {
item.icon = item.icon || <MenuIconWrapper />;
}
if (item.to) {
return (
<MenuItem
@@ -76,6 +119,7 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
disabled={item.disabled}
selected={item.selected}
icon={item.icon}
{...menu}
>
{item.title}
@@ -92,6 +136,7 @@ function Template({ items, ...menu }: Props): React.Node {
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={item.icon}
{...menu}
>
{item.title}
@@ -107,6 +152,7 @@ function Template({ items, ...menu }: Props): React.Node {
disabled={item.disabled}
selected={item.selected}
key={index}
icon={item.icon}
{...menu}
>
{item.title}
@@ -120,7 +166,7 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
as={Submenu}
templateItems={item.items}
title={item.title}
title={<Title title={item.title} icon={item.icon} />}
{...menu}
/>
);
@@ -139,4 +185,13 @@ function Template({ items, ...menu }: Props): React.Node {
});
}
function Title({ title, icon }) {
return (
<Flex align="center">
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
+17 -8
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%;
@@ -112,6 +123,7 @@ const Background = styled.div`
border-radius: 6px;
padding: 6px 0;
min-width: 180px;
min-height: 44px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
@@ -125,12 +137,9 @@ 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};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`};
`;
+37
View File
@@ -0,0 +1,37 @@
// @flow
import { observer } from "mobx-react-lite";
import * as React from "react";
import Guide from "components/Guide";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
function Dialogs() {
const { dialogs } = useStores();
const { guide, modalStack } = dialogs;
return (
<>
{guide ? (
<Guide
isOpen={guide.isOpen}
onRequestClose={dialogs.closeGuide}
title={guide.title}
>
{guide.content}
</Guide>
) : undefined}
{[...modalStack].map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
>
{modal.content}
</Modal>
))}
</>
);
}
export default observer(Dialogs);
+124
View File
@@ -0,0 +1,124 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "models/Event";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import PaginatedEventList from "components/PaginatedEventList";
import Scrollable from "components/Scrollable";
import useStores from "hooks/useStores";
import { documentUrl } from "utils/routeHelpers";
const EMPTY_ARRAY = [];
function DocumentHistory() {
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const history = useHistory();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
? events.inDocument(document.id)
: EMPTY_ARRAY;
const onCloseHistory = () => {
history.push(documentUrl(document));
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event({
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
})
);
}
return eventsInDocument;
}, [eventsInDocument, document]);
return (
<Sidebar>
{document ? (
<Position column>
<Header>
<Title>{t("History")}</Title>
<Button
icon={<CloseIcon />}
onClick={onCloseHistory}
borderOnHover
neutral
/>
</Header>
<Scrollable topShadow>
<PaginatedEventList
fetch={events.fetchPage}
events={items}
options={{ documentId: document.id }}
document={document}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>
</Scrollable>
</Position>
) : null}
</Sidebar>
);
}
const Position = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(Flex)`
display: none;
position: relative;
flex-shrink: 0;
background: ${(props) => props.theme.background};
width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default observer(DocumentHistory);
@@ -1,199 +0,0 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import PlaceholderList from "components/List/Placeholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
type Props = {
match: Match,
documents: DocumentsStore,
revisions: RevisionsStore,
history: RouterHistory,
};
@observer
class DocumentHistory extends React.Component<Props> {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable redirectTo: ?string;
async componentDidMount() {
await this.loadMoreResults();
this.selectFirstRevision();
}
fetchResults = async () => {
this.isFetching = true;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.revisions.fetchPage({
limit,
offset: this.offset,
documentId: this.props.match.params.documentSlug,
});
if (
results &&
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
) {
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
this.isLoaded = true;
this.isFetching = false;
};
selectFirstRevision = () => {
if (this.revisions.length) {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return;
this.props.history.replace(
documentHistoryUrl(document, this.revisions[0].id)
);
}
};
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
await this.fetchResults();
};
get revisions() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return [];
return this.props.revisions.getDocumentRevisions(document.id);
}
onCloseHistory = () => {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
this.redirectTo = documentUrl(document);
};
render() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
const showLoading = (!this.isLoaded && this.isFetching) || !document;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Sidebar>
<Wrapper column>
<Header>
<Title>History</Title>
<Button
icon={<CloseIcon />}
onClick={this.onCloseHistory}
borderOnHover
neutral
/>
</Header>
{showLoading ? (
<Loading>
<PlaceholderList count={5} />
</Loading>
) : (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.revisions.map((revision, index) => (
<Revision
key={revision.id}
revision={revision}
document={document}
showMenu={index !== 0}
selected={this.props.match.params.revisionId === revision.id}
/>
))}
</ArrowKeyNavigation>
)}
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
</Wrapper>
</Sidebar>
);
}
}
const Loading = styled.div`
margin: 0 16px;
`;
const Wrapper = styled(Flex)`
position: fixed;
top: 0;
right: 0;
z-index: 1;
min-width: ${(props) => props.theme.sidebarWidth}px;
height: 100%;
overflow-y: auto;
overscroll-behavior: none;
`;
const Sidebar = styled(Flex)`
display: none;
background: ${(props) => props.theme.background};
min-width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.divider};
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default inject("documents", "revisions")(DocumentHistory);
@@ -1,87 +0,0 @@
// @flow
import { format } from "date-fns";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Document from "models/Document";
import Revision from "models/Revision";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { type Theme } from "types";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
theme: Theme,
showMenu: boolean,
selected: boolean,
document: Document,
revision: Revision,
};
class RevisionListItem extends React.Component<Props> {
render() {
const { revision, document, showMenu, selected, theme } = this.props;
return (
<StyledNavLink
to={documentHistoryUrl(document, revision.id)}
activeStyle={{ background: theme.primary, color: theme.white }}
>
<Author>
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
</Time>
</Meta>
{showMenu && (
<StyledRevisionMenu
document={document}
revision={revision}
iconColor={selected ? theme.white : theme.textTertiary}
/>
)}
</StyledNavLink>
);
}
}
const StyledAvatar = styled(Avatar)`
border-color: transparent;
margin-right: 4px;
`;
const StyledRevisionMenu = styled(RevisionMenu)`
position: absolute;
right: 16px;
top: 20px;
`;
const StyledNavLink = styled(NavLink)`
color: ${(props) => props.theme.text};
display: block;
padding: 8px 16px;
font-size: 15px;
position: relative;
`;
const Author = styled(Flex)`
font-weight: 500;
padding: 0;
margin: 0;
`;
const Meta = styled.p`
font-size: 14px;
opacity: 0.75;
margin: 0 0 2px;
padding: 0;
`;
export default withTheme(RevisionListItem);
-3
View File
@@ -1,3 +0,0 @@
// @flow
import DocumentHistory from "./DocumentHistory";
export default DocumentHistory;
+5 -3
View File
@@ -20,7 +20,7 @@ import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
type Props = {|
document: Document,
@@ -66,6 +66,7 @@ function DocumentListItem(props: Props, ref) {
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = policies.abilities(currentTeam.id);
const canCollection = policies.abilities(document.collectionId);
return (
<DocumentLink
@@ -126,11 +127,12 @@ function DocumentListItem(props: Props, ref) {
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument && (
can.createDocument &&
canCollection.update && (
<>
<Button
as={Link}
to={newDocumentUrl(document.collectionId, {
to={newDocumentPath(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
+63 -4
View File
@@ -3,18 +3,19 @@ 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";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl } from "utils/urls";
import { isInternalUrl, isHash } from "utils/urls";
const RichMarkdownEditor = React.lazy(() =>
import(/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor")
@@ -30,6 +31,8 @@ export type Props = {|
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
style?: Object,
extensions?: Extension[],
shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
@@ -37,6 +40,7 @@ export type Props = {|
maxLength?: number,
scrollTo?: string,
theme?: Theme,
className?: string,
handleDOMEvents?: Object,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
@@ -75,7 +79,7 @@ function Editor(props: PropsWithRef) {
const onClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
// on page hash
if (href[0] === "#") {
if (isHash(href)) {
window.location.href = href;
return;
}
@@ -224,7 +228,8 @@ const StyledEditor = styled(RichMarkdownEditor)`
visibility: hidden;
}
.heading-name:first-child {
.heading-name:first-child,
.heading-name:first-child + .ProseMirror-yjs-cursor {
& + h1,
& + h2,
& + h3,
@@ -246,6 +251,60 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
}
}
.ProseMirror {
& > .ProseMirror-yjs-cursor {
display: none;
}
.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;
transition: opacity 100ms ease-in-out;
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;
}
}
}
}
&.show-cursor-names .ProseMirror-yjs-cursor > div {
opacity: 1;
}
`;
const EditorTooltip = ({ children, ...props }) => (
+164
View File
@@ -0,0 +1,164 @@
// @flow
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import Avatar from "components/Avatar";
import Item, { Actions } from "components/List/Item";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
event: Event,
latest?: boolean,
|};
const EventListItem = ({ event, latest, document }: Props) => {
const { t } = useTranslation();
const opts = { userName: event.actor.name };
const isRevision = event.name === "revisions.create";
let meta, icon, to;
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon color="currentColor" size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.restore":
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
break;
default:
console.warn("Unhandled event: ", event.name);
}
if (!meta) {
return null;
}
return (
<ListItem
small
exact
to={to}
title={
<Time
dateTime={event.createdAt}
tooltipDelay={250}
format="MMMM do, h:mm a"
relative={false}
addSuffix
/>
}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
/>
);
};
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ListItem = styled(Item)`
border: 0;
position: relative;
margin: 8px;
padding: 8px;
border-radius: 8px;
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${(props) => props.theme.textSecondary};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: auto;
bottom: -4px;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${Actions} {
opacity: 0.25;
transition: opacity 100ms ease-in-out;
}
&:hover {
${Actions} {
opacity: 1;
}
}
`;
export default EventListItem;
+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";
`};
`;
-2
View File
@@ -162,8 +162,6 @@ const CardContent = styled.div`
const Card = styled.div`
backdrop-filter: blur(10px);
background: ${(props) => props.theme.background};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
+89 -8
View File
@@ -1,28 +1,41 @@
// @flow
import {
BookmarkedIcon,
CollectionIcon,
CoinsIcon,
AcademicCapIcon,
BeakerIcon,
BuildingBlocksIcon,
CameraIcon,
CloudIcon,
CodeIcon,
EditIcon,
EmailIcon,
EyeIcon,
GlobeIcon,
InfoIcon,
ImageIcon,
LeafIcon,
LightBulbIcon,
MathIcon,
MoonIcon,
NotepadIcon,
PadlockIcon,
PaletteIcon,
PromoteIcon,
QuestionMarkIcon,
SportIcon,
SunIcon,
TargetIcon,
ToolsIcon,
VehicleIcon,
WarningIcon,
} from "outline-icons";
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";
@@ -39,6 +52,10 @@ const TwitterPicker = React.lazy(() =>
);
export const icons = {
bookmark: {
component: BookmarkedIcon,
keywords: "bookmark",
},
collection: {
component: CollectionIcon,
keywords: "collection",
@@ -47,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",
@@ -67,10 +88,26 @@ export const icons = {
component: CodeIcon,
keywords: "developer api code development engineering programming",
},
email: {
component: EmailIcon,
keywords: "email at",
},
eye: {
component: EyeIcon,
keywords: "eye view",
},
globe: {
component: GlobeIcon,
keywords: "world translate",
},
info: {
component: InfoIcon,
keywords: "info information",
},
image: {
component: ImageIcon,
keywords: "image photo picture",
},
leaf: {
component: LeafIcon,
keywords: "leaf plant outdoors nature ecosystem climate",
@@ -79,6 +116,10 @@ export const icons = {
component: LightBulbIcon,
keywords: "lightbulb idea",
},
math: {
component: MathIcon,
keywords: "math formula",
},
moon: {
component: MoonIcon,
keywords: "night moon dark",
@@ -99,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",
@@ -107,10 +152,26 @@ 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: "warning alert error",
},
};
const colors = [
@@ -128,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>
@@ -149,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}
@@ -165,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>
@@ -187,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: 15px 9px 9px 15px;
width: 276px;
padding: 16px 8px 0 16px;
${breakpoint("tablet")`
width: 276px;
`};
`;
const Button = styled(NudeButton)`
@@ -216,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")`
+68 -67
View File
@@ -1,20 +1,17 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { SearchIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Input from "./Input";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import useBoolean from "hooks/useBoolean";
import useKeyDown from "hooks/useKeyDown";
import { isModKey } from "utils/keyboard";
import { searchUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
theme: Theme,
type Props = {|
source: string,
placeholder?: string,
label?: string,
@@ -23,73 +20,77 @@ type Props = {
value: string,
onChange: (event: SyntheticInputEvent<>) => mixed,
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
t: TFunction,
};
|};
@observer
class InputSearchPage extends React.Component<Props> {
input: ?Input;
@observable focused: boolean = false;
function InputSearchPage({
onKeyDown,
value,
onChange,
placeholder,
label,
collectionId,
source,
}: Props) {
const inputRef = React.useRef();
const theme = useTheme();
const history = useHistory();
const { t } = useTranslation();
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
@keydown(`${meta}+f`)
focus(ev: SyntheticEvent<>) {
ev.preventDefault();
const focus = React.useCallback(() => {
inputRef.current?.focus();
}, []);
if (this.input) {
this.input.focus();
useKeyDown("f", (ev: KeyboardEvent) => {
if (isModKey(ev)) {
ev.preventDefault();
focus();
}
}
});
handleSearchInput = (ev: SyntheticInputEvent<>) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
);
};
const handleKeyDown = React.useCallback(
(ev: SyntheticKeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchUrl(ev.currentTarget.value, {
collectionId,
ref: source,
})
);
}
handleFocus = () => {
this.focused = true;
};
if (onKeyDown) {
onKeyDown(ev);
}
},
[history, collectionId, source, onKeyDown]
);
handleBlur = () => {
this.focused = false;
};
render() {
const { t, value, onChange, onKeyDown } = this.props;
const { theme, placeholder = `${t("Search")}` } = this.props;
return (
<InputMaxWidth
ref={(ref) => (this.input = ref)}
type="search"
placeholder={placeholder}
onInput={this.handleSearchInput}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
icon={
<SearchIcon
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
/>
}
label={this.props.label}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
margin={0}
labelHidden
/>
);
}
return (
<InputMaxWidth
ref={inputRef}
type="search"
placeholder={placeholder || `${t("Search")}`}
value={value}
onChange={onChange}
onKeyDown={handleKeyDown}
icon={
<SearchIcon
color={isFocused ? theme.inputBorderFocused : theme.inputBorder}
/>
}
label={label}
onFocus={setFocused}
onBlur={setUnfocused}
margin={0}
labelHidden
/>
);
}
const InputMaxWidth = styled(Input)`
max-width: 30vw;
`;
export default withTranslation()<InputSearchPage>(
withTheme(withRouter(InputSearchPage))
);
export default observer(InputSearchPage);
+247 -62
View File
@@ -1,74 +1,125 @@
// @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 { 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;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
}
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
`;
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 HelpText from "components/HelpText";
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,
icon?: React.Node,
options: Option[],
onBlur?: () => void,
onFocus?: () => void,
note?: React.Node,
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,
note,
icon,
} = 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 ? (
@@ -76,18 +127,152 @@ 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} ref={buttonRef}>
{(props) => (
<StyledButton
neutral
disclosure
className={className}
nude={nude}
icon={icon}
{...props}
>
{getOptionFromValue(options, select.selectedValue).label || (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popOver} aria-label={ariaLabel}>
{(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>
);
{note && <HelpText small>{note}</HelpText>}
{(select.visible || select.animating) && <Backdrop />}
</>
);
};
const Placeholder = styled.span`
color: ${(props) => props.theme.placeholder};
`;
const Spacer = styled.div`
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;
}
}
svg {
justify-self: flex-end;
margin-left: auto;
}
`;
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}
/>
);
}
+25
View File
@@ -0,0 +1,25 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "components/InputSelect";
const InputSelectRole = (
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
) => {
const { t } = useTranslation();
return (
<InputSelect
label={t("Role")}
options={[
{ label: t("Member"), value: "member" },
{ label: t("Viewer"), value: "viewer" },
{ label: t("Admin"), value: "admin" },
]}
ariaLabel={t("Role")}
{...props}
/>
);
};
export default InputSelectRole;
+46 -61
View File
@@ -4,12 +4,10 @@ import { observer, inject } from "mobx-react";
import { MenuIcon } from "outline-icons";
import * as React from "react";
import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withTranslation } from "react-i18next";
import {
Switch,
Route,
Redirect,
withRouter,
type RouterHistory,
} from "react-router-dom";
@@ -20,24 +18,35 @@ import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Button from "components/Button";
import DocumentHistory from "components/DocumentHistory";
import Flex from "components/Flex";
import Guide from "components/Guide";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
import RegisterKeyDown from "components/RegisterKeyDown";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink";
import { meta } from "utils/keyboard";
import { isModKey } from "utils/keyboard";
import {
homeUrl,
searchUrl,
matchDocumentSlug as slug,
newDocumentUrl,
newDocumentPath,
settingsPath,
} from "utils/routeHelpers";
const DocumentHistory = React.lazy(() =>
import(
/* webpackChunkName: "document-history" */ "components/DocumentHistory"
)
);
const CommandBar = React.lazy(() =>
import(
/* webpackChunkName: "command-bar" */
"components/CommandBar"
)
);
type Props = {
documents: DocumentsStore,
children?: ?React.Node,
@@ -48,70 +57,50 @@ type Props = {
history: RouterHistory,
policies: PoliciesStore,
notifications?: React.Node,
i18n: Object,
t: TFunction,
};
@observer
class Layout extends React.Component<Props> {
scrollable: ?HTMLDivElement;
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
componentDidUpdate() {
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
@keydown(`${meta}+.`)
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
}
@keydown("shift+/")
handleOpenKeyboardShortcuts() {
this.keyboardShortcutsOpen = true;
}
handleCloseKeyboardShortcuts = () => {
this.keyboardShortcutsOpen = false;
};
@keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent<>) {
goToSearch = (ev: KeyboardEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.redirectTo = searchUrl();
}
this.props.history.push(searchUrl());
};
@keydown("d")
goToDashboard() {
this.redirectTo = homeUrl();
}
@keydown("n")
goToNewDocument() {
goToNewDocument = () => {
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) return;
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) return;
this.props.history.push(newDocumentUrl(activeCollectionId));
}
this.props.history.push(newDocumentPath(activeCollectionId));
};
render() {
const { auth, t, ui } = this.props;
const { auth, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Container column auto>
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
<RegisterKeyDown
trigger="."
handler={(event) => {
if (isModKey(event)) {
ui.toggleCollapsedSidebar();
}
}}
/>
<Helmet>
<title>{team && team.name ? team.name : "Outline"}</title>
<meta
@@ -134,7 +123,7 @@ class Layout extends React.Component<Props> {
<Container auto>
{showSidebar && (
<Switch>
<Route path="/settings" component={SettingsSidebar} />
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
)}
@@ -154,20 +143,16 @@ class Layout extends React.Component<Props> {
{this.props.children}
</Content>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
<React.Suspense fallback={null}>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</React.Suspense>
</Container>
<Guide
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
</Guide>
<CommandBar />
</Container>
);
}
+52 -22
View File
@@ -1,40 +1,64 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import Flex from "components/Flex";
import NavLink from "components/NavLink";
type Props = {
type Props = {|
image?: React.Node,
to?: string,
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
small?: boolean,
};
|};
const ListItem = ({
image,
title,
subtitle,
actions,
small,
border,
}: Props) => {
const ListItem = (
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
ref
) => {
const theme = useTheme();
const compact = !subtitle;
return (
<Wrapper compact={compact} $border={border}>
const content = (selected) => (
<>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Content
justify={compact ? "center" : undefined}
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
</Subtitle>
)}
</Content>
{actions && <Actions>{actions}</Actions>}
{actions && (
<Actions $selected={selected} gap={4}>
{actions}
</Actions>
)}
</>
);
return (
<Wrapper
ref={ref}
$border={border}
activeStyle={{ background: theme.primary }}
{...rest}
as={to ? NavLink : undefined}
to={to}
>
{to ? content : content(false)}
</Wrapper>
);
};
const Wrapper = styled.li`
const Wrapper = styled.div`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
@@ -57,28 +81,34 @@ const Image = styled(Flex)`
`;
const Heading = styled.p`
font-size: ${(props) => (props.$small ? 15 : 16)}px;
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1.2;
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
const Content = styled(Flex)`
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) => props.theme.textTertiary};
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
margin-top: -2px;
`;
const Actions = styled.div`
export const Actions = styled(Flex)`
align-self: center;
justify-content: center;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default ListItem;
export default React.forwardRef<Props, HTMLDivElement>(ListItem);
+21 -41
View File
@@ -1,39 +1,9 @@
// @flow
import { format, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
faIR,
fr,
es,
it,
ja,
ko,
ptBR,
pt,
zhCN,
zhTW,
ru,
} from "date-fns/locale";
import { format as formatDate, formatDistanceToNow } from "date-fns";
import * as React from "react";
import Tooltip from "components/Tooltip";
import useUserLocale from "hooks/useUserLocale";
const locales = {
en_US: enUS,
de_DE: de,
es_ES: es,
fa_IR: faIR,
fr_FR: fr,
it_IT: it,
ja_JP: ja,
ko_KR: ko,
pt_BR: ptBR,
pt_PT: pt,
zh_CN: zhCN,
zh_TW: zhTW,
ru_RU: ru,
};
import { dateLocale } from "utils/i18n";
let callbacks = [];
@@ -57,6 +27,8 @@ type Props = {
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
relative?: boolean,
format?: string,
};
function LocaleTime({
@@ -64,6 +36,8 @@ function LocaleTime({
children,
dateTime,
shorten,
format,
relative,
tooltipDelay,
}: Props) {
const userLocale = useUserLocale();
@@ -82,25 +56,31 @@ function LocaleTime({
};
}, []);
let content = formatDistanceToNow(Date.parse(dateTime), {
const locale = dateLocale(userLocale);
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
addSuffix,
locale: userLocale ? locales[userLocale] : undefined,
locale,
});
if (shorten) {
content = content
relativeContent = relativeContent
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
const tooltipContent = formatDate(
Date.parse(dateTime),
format || "MMMM do, yyyy h:mm a",
{ locale }
);
const content =
children || relative !== false ? relativeContent : tooltipContent;
return (
<Tooltip
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
delay={tooltipDelay}
placement="bottom"
>
<time dateTime={dateTime}>{children || content}</time>
<Tooltip tooltip={tooltipContent} delay={tooltipDelay} placement="bottom">
<time dateTime={dateTime}>{content}</time>
</Tooltip>
);
}
+11
View File
@@ -0,0 +1,11 @@
// @flow
import styled from "styled-components";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
`;
export default MenuIconWrapper;
+5 -2
View File
@@ -52,7 +52,9 @@ const Modal = ({
}
});
if (!isOpen) return null;
if (!isOpen && !wasOpen) {
return null;
}
return (
<DialogBackdrop {...dialog}>
@@ -61,7 +63,8 @@ const Modal = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScrollhideOnEsc
preventBodyScroll
hideOnEsc
hide={onRequestClose}
>
{(props) => (
+26
View File
@@ -0,0 +1,26 @@
// @flow
import * as React from "react";
import { NavLink, Route, type Match } from "react-router-dom";
type Props = {
children?: (match: Match) => React.Node,
exact?: boolean,
to: string,
};
export default function NavLinkWithChildrenFunc({
to,
exact = false,
children,
...rest
}: Props) {
return (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink {...rest} to={to} exact={exact}>
{children ? children(match) : null}
</NavLink>
)}
</Route>
);
}
+1
View File
@@ -12,6 +12,7 @@ const Button = styled.button`
padding: 0;
cursor: pointer;
user-select: none;
color: inherit;
`;
export default React.forwardRef<any, typeof Button>(
+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,
+53
View File
@@ -0,0 +1,53 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import PaginatedList from "components/PaginatedList";
import EventListItem from "./EventListItem";
type Props = {|
events: Event[],
document: Document,
fetch: (options: ?Object) => Promise<void>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
|};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
events,
fetch,
options,
document,
...rest
}: Props) {
return (
<PaginatedList
items={events}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...rest}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
/>
);
});
const Heading = styled("h3")`
font-size: 14px;
padding: 0 12px;
`;
export default PaginatedEventList;
+49 -6
View File
@@ -2,20 +2,26 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Waypoint } from "react-waypoint";
import AuthStore from "stores/AuthStore";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import PlaceholderList from "components/List/Placeholder";
import { dateToHeading } from "utils/dates";
type Props = {
fetch?: (options: ?Object) => Promise<void>,
fetch?: (options: ?Object) => Promise<any>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
items: any[],
renderItem: (any) => React.Node,
auth: AuthStore,
renderItem: (any, index: number) => React.Node,
renderHeading?: (name: React.Element<any> | string) => React.Node,
t: TFunction,
};
@observer
@@ -101,8 +107,9 @@ class PaginatedList extends React.Component<Props> {
};
render() {
const { items, heading, empty } = this.props;
const { items, heading, auth, empty, renderHeading } = this.props;
let previousHeading = "";
const showLoading =
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
const showEmpty = !items.length && !showLoading;
@@ -119,7 +126,41 @@ class PaginatedList extends React.Component<Props> {
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.slice(0, this.renderCount).map(this.props.renderItem)}
{items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
@@ -136,4 +177,6 @@ class PaginatedList extends React.Component<Props> {
}
}
export default PaginatedList;
export const Component = PaginatedList;
export default withTranslation()<PaginatedList>(inject("auth")(PaginatedList));
+12 -3
View File
@@ -2,15 +2,21 @@
import "../stores";
import { shallow } from "enzyme";
import * as React from "react";
import AuthStore from "stores/AuthStore";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import RootStore from "stores/RootStore";
import { runAllPromises } from "../test/support";
import PaginatedList from "./PaginatedList";
import { Component as PaginatedList } from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;
const rootStore = new RootStore();
const props = { auth: new AuthStore(rootStore), t: (string) => "test" };
it("with no items renders nothing", () => {
const list = shallow(<PaginatedList items={[]} renderItem={render} />);
const list = shallow(
<PaginatedList items={[]} renderItem={render} {...props} />
);
expect(list).toEqual({});
});
@@ -20,6 +26,7 @@ describe("PaginatedList", () => {
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>
);
expect(list.text()).toEqual("Sorry, no results");
@@ -35,6 +42,7 @@ describe("PaginatedList", () => {
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>
);
expect(fetch).toHaveBeenCalledWith({
@@ -46,7 +54,7 @@ describe("PaginatedList", () => {
it("calls fetch when options prop changes", async () => {
const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill();
const fetch = jest.fn().mockReturnValue(fetchedItems);
const fetch = jest.fn().mockReturnValue(Promise.resolve(fetchedItems));
const list = shallow(
<PaginatedList
@@ -54,6 +62,7 @@ describe("PaginatedList", () => {
fetch={fetch}
options={{ id: "one" }}
renderItem={render}
{...props}
/>
);
+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>
);
-2
View File
@@ -27,8 +27,6 @@ const Contents = styled.div`
overflow-y: scroll;
width: ${(props) => props.width}px;
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`;
export default Popover;
+17
View File
@@ -0,0 +1,17 @@
// @flow
import useKeyDown, { type KeyFilter } from "hooks/useKeyDown";
type Props = {
trigger: KeyFilter,
handler: (event: KeyboardEvent) => void,
};
/**
* This method is a wrapper around the useKeyDown hook to allow easier use in
* class components that have not yet been converted to functions. Do not use
* this method in functional components.
*/
export default function RegisterKeyDown({ trigger, handler }: Props) {
useKeyDown(trigger, handler);
return null;
}
+14 -16
View File
@@ -1,31 +1,29 @@
// @flow
// based on: https://reacttraining.com/react-router/web/guides/scroll-restoration
import * as React from "react";
import { withRouter } from "react-router-dom";
import type { Location } from "react-router-dom";
import { useLocation } from "react-router-dom";
import usePrevious from "hooks/usePrevious";
type Props = {
location: Location,
type Props = {|
children: React.Node,
};
|};
class ScrollToTop extends React.Component<Props> {
componentDidUpdate(prevProps) {
if (this.props.location.pathname === prevProps.location.pathname) return;
export default function ScrollToTop({ children }: Props) {
const location = useLocation();
const previousLocationPathname = usePrevious(location.pathname);
React.useEffect(() => {
if (location.pathname === previousLocationPathname) return;
// exception for when entering or exiting document edit, scroll position should not reset
if (
this.props.location.pathname.match(/\/edit\/?$/) ||
prevProps.location.pathname.match(/\/edit\/?$/)
location.pathname.match(/\/edit\/?$/) ||
previousLocationPathname?.match(/\/edit\/?$/)
)
return;
window.scrollTo(0, 0);
}
}, [location.pathname, previousLocationPathname]);
render() {
return this.props.children;
}
return children;
}
export default withRouter(ScrollToTop);
+16 -6
View File
@@ -11,14 +11,17 @@ type Props = {|
flex?: boolean,
|};
function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
const ref = React.useRef<?HTMLDivElement>();
function Scrollable(
{ shadow, topShadow, bottomShadow, flex, ...rest }: Props,
ref: any
) {
const fallbackRef = React.useRef<?HTMLDivElement>();
const [topShadowVisible, setTopShadow] = React.useState(false);
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
const { height } = useWindowSize();
const updateShadows = React.useCallback(() => {
const c = ref.current;
const c = (ref || fallbackRef).current;
if (!c) return;
const scrollTop = c.scrollTop;
@@ -33,7 +36,14 @@ function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
if (bsv !== bottomShadowVisible) {
setBottomShadow(bsv);
}
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
}, [
shadow,
topShadow,
bottomShadow,
ref,
topShadowVisible,
bottomShadowVisible,
]);
React.useEffect(() => {
updateShadows();
@@ -41,7 +51,7 @@ function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
return (
<Wrapper
ref={ref}
ref={ref || fallbackRef}
onScroll={updateShadows}
$flex={flex}
$topShadowVisible={topShadowVisible}
@@ -75,4 +85,4 @@ const Wrapper = styled.div`
transition: all 100ms ease-in-out;
`;
export default observer(Scrollable);
export default observer(React.forwardRef(Scrollable));
+25 -72
View File
@@ -2,73 +2,52 @@
import { observer } from "mobx-react";
import {
EditIcon,
HomeIcon,
PlusIcon,
SearchIcon,
SettingsIcon,
ShapesIcon,
StarredIcon,
HomeIcon,
SettingsIcon,
} from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarAction from "./components/SidebarAction";
import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
import { inviteUser } from "actions/definitions/users";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
import {
homePath,
searchUrl,
draftsPath,
templatesPath,
settingsPath,
} from "utils/routeHelpers";
function MainSidebar() {
const { t } = useTranslation();
const { policies, documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
setCreateCollectionModalOpen,
] = React.useState(false);
React.useEffect(() => {
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
const handleCreateCollectionModalOpen = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
setCreateCollectionModalOpen(true);
},
[]
);
const handleCreateCollectionModalClose = React.useCallback(() => {
setCreateCollectionModalOpen(false);
}, []);
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
setInviteModalOpen(true);
}, []);
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
const html5Options = React.useMemo(() => ({ rootElement: dndArea }), [
@@ -95,29 +74,23 @@ function MainSidebar() {
<Scrollable flex topShadow>
<Section>
<SidebarLink
to="/home"
to={homePath()}
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={{
pathname: "/search",
pathname: searchUrl(),
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
/>
{can.createDocument && (
<SidebarLink
to="/drafts"
to={draftsPath()}
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
@@ -135,21 +108,24 @@ function MainSidebar() {
/>
)}
</Section>
<Starred />
<Section auto>
<Collections
onCreateCollection={handleCreateCollectionModalOpen}
/>
<Collections />
</Section>
<Section>
{can.createDocument && (
<>
<SidebarLink
to="/templates"
to={templatesPath()}
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
documents.active
? documents.active.isTemplate &&
!documents.active.isDeleted &&
!documents.active.isArchived
: undefined
}
/>
<ArchiveLink documents={documents} />
@@ -157,37 +133,14 @@ function MainSidebar() {
</>
)}
<SidebarLink
to="/settings"
to={settingsPath()}
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
{can.inviteUser && (
<SidebarLink
to="/settings/members"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
/>
)}
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
{can.inviteUser && (
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
)}
<Modal
title={t("Create a collection")}
onRequestClose={handleCreateCollectionModalClose}
isOpen={createCollectionModalOpen}
>
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
</Modal>
</DndProvider>
)}
</Sidebar>
+16 -6
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,13 @@ function SettingsSidebar() {
label={t("Security")}
/>
)}
{can.update && env.DEPLOYMENT !== "hosted" && (
<SidebarLink
to="/settings/features"
icon={<BeakerIcon color="currentColor" />}
label={t("Features")}
/>
)}
<SidebarLink
to="/settings/members"
icon={<UserIcon color="currentColor" />}
@@ -120,14 +128,16 @@ function SettingsSidebar() {
/>
)}
</Section>
{can.update && (
{can.update && (env.SLACK_KEY || isHosted) && (
<Section>
<Header>{t("Integrations")}</Header>
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
{env.SLACK_KEY && (
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
)}
{isHosted && (
<SidebarLink
to="/settings/integrations/zapier"
@@ -4,9 +4,10 @@ import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { archivePath } from "utils/routeHelpers";
function ArchiveLink({ documents }) {
const { policies } = useStores();
@@ -29,7 +30,7 @@ function ArchiveLink({ documents }) {
return (
<div ref={dropToArchiveDocument}>
<SidebarLink
to="/archive"
to={archivePath()}
icon={<ArchiveIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Archive")}
@@ -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, useHistory } 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,13 +40,23 @@ function CollectionLink({
isDraggingAnyCollection,
onChangeDragging,
}: Props) {
const history = useHistory();
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) => {
await collection.save({ name });
history.push(collection.url);
},
[collection]
[collection, history]
);
const { ui, documents, policies, collections } = useStores();
@@ -52,12 +66,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 +86,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;
@@ -147,8 +179,6 @@ function CollectionLink({
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
iconColor={collection.color}
expanded={expanded}
showActions={menuOpen || expanded}
isActiveDrop={isOver && canDrop}
label={
@@ -159,6 +189,7 @@ function CollectionLink({
/>
}
exact={false}
depth={0.5}
menu={
<>
{can.update && (
@@ -198,10 +229,22 @@ function CollectionLink({
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
depth={2}
index={index}
/>
))}
<Modal
title={t("Move document")}
onRequestClose={handlePermissionClose}
isOpen={permissionOpen}
>
<DocumentReparent
item={itemRef.current}
collection={collection}
onSubmit={handlePermissionClose}
onCancel={handlePermissionClose}
/>
</Modal>
</>
);
}
@@ -1,35 +1,31 @@
// @flow
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarAction from "./SidebarAction";
import SidebarLink from "./SidebarLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import { createCollection } from "actions/definitions/collections";
import useToasts from "hooks/useToasts";
type Props = {
onCreateCollection: () => void,
};
function Collections({ onCreateCollection }: Props) {
function Collections() {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { ui, policies, documents, collections } = useStores();
const { showToast } = useToasts();
const [expanded, setExpanded] = React.useState(true);
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const team = useCurrentTeam();
const orderedCollections = collections.orderedData;
const can = policies.abilities(team.id);
const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState(
false
);
@@ -92,22 +88,17 @@ function Collections({ onCreateCollection }: Props) {
belowCollection={orderedCollections[index + 1]}
/>
))}
{can.createCollection && (
<SidebarLink
to="/collections"
onClick={onCreateCollection}
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
exact
/>
)}
<SidebarAction action={createCollection} depth={0.5} />
</>
);
if (!collections.isLoaded || fetchError) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
<SidebarLink
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
<PlaceholderCollections />
</Flex>
);
@@ -115,10 +106,19 @@ function Collections({ onCreateCollection }: Props) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
{isPreloaded ? content : <Fade>{content}</Fade>}
<SidebarLink
onClick={() => setExpanded((prev) => !prev)}
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (isPreloaded ? content : <Fade>{content}</Fade>)}
</Flex>
);
}
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Collections);
@@ -0,0 +1,13 @@
// @flow
import { CollapsedIcon } from "outline-icons";
import styled from "styled-components";
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default Disclosure;
@@ -1,13 +1,14 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "shared/constants";
import Collection from "models/Collection";
import Document from "models/Document";
import Fade from "components/Fade";
import Disclosure from "./Disclosure";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
@@ -128,7 +129,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(),
}),
@@ -210,7 +216,7 @@ function DocumentLink(
return (
<>
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
<Relative onDragLeave={resetHoverExpanding}>
<Draggable
key={node.id}
ref={drag}
@@ -237,13 +243,18 @@ function DocumentLink(
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
</>
}
isActive={(match, location) =>
match && location.search !== "?starred"
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
scrollIntoViewIfNeeded={!document?.isStarred}
ref={ref}
menu={
document && !isMoving ? (
@@ -263,7 +274,7 @@ function DocumentLink(
{manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div>
</Relative>
{expanded && !isDragging && (
<>
{node.children.map((childNode, index) => (
@@ -285,17 +296,13 @@ function DocumentLink(
);
}
const Draggable = styled("div")`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
const Relative = styled.div`
position: relative;
`;
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
const Draggable = styled.div`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
`;
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
@@ -27,7 +27,7 @@ const Cursor = styled("div")`
width: 100%;
height: 14px;
${(props) => (props.from === "collections" ? "top: 15px;" : "bottom: -7px;")}
${(props) => (props.from === "collections" ? "top: 25px;" : "bottom: -7px;")}
background: transparent;
::after {
@@ -14,7 +14,6 @@ type Props = {|
collectionId: string,
documentId?: string,
disabled: boolean,
staticContext: Object,
|};
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
@@ -56,7 +55,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
}) => (
<DropzoneContainer
{...getRootProps()}
{...{ isDragActive }}
$isDragActive={isDragActive}
tabIndex="-1"
>
<input {...getInputProps()} />
@@ -71,8 +70,8 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const DropzoneContainer = styled.div`
border-radius: 4px;
${({ isDragActive, theme }) =>
isDragActive &&
${({ $isDragActive, theme }) =>
$isDragActive &&
css`
background: ${theme.slateDark};
a {
@@ -7,9 +7,10 @@ type Props = {|
onSubmit: (title: string) => Promise<void>,
title: string,
canUpdate: boolean,
maxLength?: number,
|};
function EditableTitle({ title, onSubmit, canUpdate }: Props) {
function EditableTitle({ title, onSubmit, canUpdate, ...rest }: Props) {
const [isEditing, setIsEditing] = React.useState(false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
@@ -79,6 +80,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
onChange={handleChange}
onBlur={handleSave}
autoFocus
{...rest}
/>
</form>
) : (
+2 -1
View File
@@ -5,10 +5,11 @@ import Flex from "components/Flex";
const Header = styled(Flex)`
font-size: 11px;
font-weight: 600;
user-select: none;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 4px 16px;
margin: 4px 12px;
`;
export default Header;
+5 -3
View File
@@ -27,10 +27,11 @@ const joinClassnames = (...classnames) => {
return classnames.filter((i) => i).join(" ");
};
type Props = {|
export type Props = {|
activeClassName?: String,
activeStyle?: Object,
className?: string,
scrollIntoViewIfNeeded?: boolean,
exact?: boolean,
isActive?: any,
location?: Location,
@@ -52,6 +53,7 @@ const NavLink = ({
location: locationProp,
strict,
style: styleProp,
scrollIntoViewIfNeeded,
to,
...rest
}: Props) => {
@@ -83,13 +85,13 @@ const NavLink = ({
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
React.useEffect(() => {
if (isActive && linkRef.current) {
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
scrollIntoView(linkRef.current, {
scrollMode: "if-needed",
behavior: "instant",
});
}
}, [linkRef, isActive]);
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
const props = {
"aria-current": (isActive && ariaCurrent) || null,
@@ -15,6 +15,7 @@ function PlaceholderCollections() {
const Wrapper = styled.div`
margin: 4px 16px;
margin-left: 40px;
width: 75%;
`;
+1 -1
View File
@@ -5,7 +5,7 @@ import Flex from "components/Flex";
const Section = styled(Flex)`
position: relative;
flex-direction: column;
margin: 0 8px 20px;
margin: 0 8px 12px;
min-width: ${(props) => props.theme.sidebarMinWidth}px;
flex-shrink: 0;
@@ -0,0 +1,44 @@
// @flow
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import SidebarLink from "./SidebarLink";
import { actionToMenuItem } from "actions";
import useStores from "hooks/useStores";
import type { Action } from "types";
type Props = {|
action: Action,
|};
function SidebarAction({ action, ...rest }: Props) {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const context = {
isContextMenu: false,
isCommandBar: false,
activeCollectionId: undefined,
activeDocumentId: undefined,
location,
stores,
t,
};
const menuItem = actionToMenuItem(action, context);
invariant(menuItem.onClick, "passed action must have perform");
return (
<SidebarLink
onClick={menuItem.onClick}
icon={menuItem.icon}
label={menuItem.title}
{...rest}
/>
);
}
export default observer(SidebarAction);
@@ -1,33 +1,31 @@
// @flow
import { transparentize } from "polished";
import * as React from "react";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "components/EventBoundary";
import NavLink from "./NavLink";
import { type Theme } from "types";
import NavLink, { type Props as NavLinkProps } from "./NavLink";
type Props = {
type Props = {|
...NavLinkProps,
to?: string | Object,
href?: string | Object,
innerRef?: (?HTMLElement) => void,
onClick?: (SyntheticEvent<>) => void,
onClick?: (SyntheticEvent<>) => mixed,
onMouseEnter?: (SyntheticEvent<>) => void,
className?: string,
children?: React.Node,
icon?: React.Node,
label?: React.Node,
menu?: React.Node,
showActions?: boolean,
iconColor?: string,
active?: boolean,
isActiveDrop?: boolean,
history: RouterHistory,
match: Match,
theme: Theme,
exact?: boolean,
depth?: number,
scrollIntoViewIfNeeded?: boolean,
|};
const activeDropStyle = {
fontWeight: 600,
};
function SidebarLink(
@@ -42,37 +40,38 @@ function SidebarLink(
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
scrollIntoViewIfNeeded,
...rest
}: Props,
ref
) {
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
};
}, [depth]);
const theme = useTheme();
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + 12}px`,
}),
[depth]
);
const activeStyle = {
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
...style,
};
const activeDropStyle = {
fontWeight: 600,
};
const activeStyle = React.useMemo(
() => ({
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
...style,
}),
[theme, style]
);
return (
<>
<Link
$isActiveDrop={isActiveDrop}
scrollIntoViewIfNeeded={scrollIntoViewIfNeeded}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
@@ -83,6 +82,7 @@ function SidebarLink(
href={href}
className={className}
ref={ref}
{...rest}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
@@ -131,6 +131,7 @@ const Link = styled(NavLink)`
padding: 6px 16px;
border-radius: 4px;
transition: background 50ms, color 50ms;
user-select: none;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
@@ -156,13 +157,11 @@ const Link = styled(NavLink)`
`}
@media (hover: hover) {
&:hover + ${Actions},
&:active + ${Actions} {
display: inline-flex;
&:hover + ${Actions}, &:active + ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
svg {
opacity: 0.75;
}
}
@@ -183,4 +182,4 @@ const Label = styled.div`
}
`;
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
export default React.forwardRef<Props, HTMLAnchorElement>(SidebarLink);
@@ -0,0 +1,171 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "components/Flex";
import PlaceholderCollections from "./PlaceholderCollections";
import Section from "./Section";
import SidebarLink from "./SidebarLink";
import StarredLink from "./StarredLink";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
const STARRED_PAGINATION_LIMIT = 10;
const STARRED = "STARRED";
function Starred() {
const [isFetching, setIsFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const [expanded, setExpanded] = React.useState(true);
const [show, setShow] = React.useState("Nothing");
const [offset, setOffset] = React.useState(0);
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
const { showToast } = useToasts();
const { documents } = useStores();
const { t } = useTranslation();
const { fetchStarred, starred } = documents;
const fetchResults = React.useCallback(async () => {
try {
setIsFetching(true);
await fetchStarred({
limit: STARRED_PAGINATION_LIMIT,
offset,
});
} catch (error) {
showToast(t("Starred documents could not be loaded"), {
type: "error",
});
setFetchError(error);
} finally {
setIsFetching(false);
}
}, [fetchStarred, offset, showToast, t]);
useEffect(() => {
let stateInLocal;
try {
stateInLocal = localStorage.getItem(STARRED);
} catch (_) {
// no-op Safari private mode
}
if (!stateInLocal) {
localStorage.setItem(STARRED, expanded ? "true" : "false");
} else {
setExpanded(stateInLocal === "true");
}
}, [expanded]);
useEffect(() => {
setOffset(starred.length);
if (starred.length <= STARRED_PAGINATION_LIMIT) {
setShow("Nothing");
} else if (starred.length >= upperBound) {
setShow("More");
} else if (starred.length < upperBound) {
setShow("Less");
}
}, [starred, upperBound]);
useEffect(() => {
if (offset === 0) {
fetchResults();
}
}, [fetchResults, offset]);
const handleShowMore = React.useCallback(
async (ev) => {
setUpperBound(
(previousUpperBound) => previousUpperBound + STARRED_PAGINATION_LIMIT
);
await fetchResults();
},
[fetchResults]
);
const handleShowLess = React.useCallback((ev) => {
setUpperBound(STARRED_PAGINATION_LIMIT);
setShow("More");
}, []);
const handleExpandClick = React.useCallback(
(ev) => {
ev.preventDefault();
ev.stopPropagation();
try {
localStorage.setItem(STARRED, !expanded ? "true" : "false");
} catch (_) {
// no-op Safari private mode
}
setExpanded((prev) => !prev);
},
[expanded]
);
const content = starred.slice(0, upperBound).map((document, index) => {
return (
<StarredLink
key={document.id}
documentId={document.id}
collectionId={document.collectionId}
to={document.url}
title={document.title}
url={document.url}
depth={2}
/>
);
});
if (!starred.length) {
return null;
}
return (
<Section>
<Flex column>
<SidebarLink
onClick={handleExpandClick}
label={t("Starred")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (
<>
{content}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={2}
/>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={2}
/>
)}
{(isFetching || fetchError) && (
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</>
)}
</Flex>
</Section>
);
}
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Starred);
@@ -0,0 +1,129 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "shared/constants";
import Fade from "components/Fade";
import useStores from "../../../hooks/useStores";
import Disclosure from "./Disclosure";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useBoolean from "hooks/useBoolean";
import DocumentMenu from "menus/DocumentMenu";
type Props = {|
depth: number,
title: string,
to: string,
documentId: string,
collectionId: string,
|};
function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
const { t } = useTranslation();
const { collections, documents, policies } = useStores();
const collection = collections.get(collectionId);
const document = documents.get(documentId);
const [expanded, setExpanded] = useState(false);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const canUpdate = policies.abilities(documentId).update;
const childDocuments = collection
? collection.getDocumentChildren(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
useEffect(() => {
async function load() {
if (!document) {
await documents.fetch(documentId);
}
}
load();
}, [collection, collectionId, collections, document, documentId, documents]);
const handleDisclosureClick = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
}, []);
const handleTitleChange = React.useCallback(
async (title: string) => {
if (!document) return;
await documents.update({
id: document.id,
lastRevision: document.revision,
text: document.text,
title,
});
},
[documents, document]
);
return (
<>
<Relative>
<SidebarLink
depth={depth}
to={`${to}?starred`}
isActive={(match, location) =>
match && location.search === "?starred"
}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
</>
}
exact={false}
showActions={menuOpen}
menu={
document ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Relative>
{expanded &&
childDocuments.map((childDocument) => (
<ObserveredStarredLink
key={childDocument.id}
depth={depth + 1}
title={childDocument.title}
to={childDocument.url}
documentId={childDocument.id}
collectionId={collectionId}
/>
))}
</>
);
}
const Relative = styled.div`
position: relative;
`;
const ObserveredStarredLink = observer(StarredLink);
export default ObserveredStarredLink;
@@ -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 -1
View File
@@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Arrow from "components/Arrow";
@@ -12,9 +13,16 @@ type Props = {
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
({ direction = "left", onClick, style }: Props, ref) => {
const { t } = useTranslation();
return (
<Positioner style={style}>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<ToggleButton
ref={ref}
$direction={direction}
onClick={onClick}
aria-label={t("Toggle sidebar")}
>
<Arrow />
</ToggleButton>
</Positioner>
@@ -7,8 +7,9 @@ import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import DocumentDelete from "scenes/DocumentDelete";
import Modal from "components/Modal";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import { trashPath } from "utils/routeHelpers";
function TrashLink({ documents }) {
const { policies } = useStores();
@@ -33,7 +34,7 @@ function TrashLink({ documents }) {
<>
<div ref={dropToTrashDocument}>
<SidebarLink
to="/trash"
to={trashPath()}
icon={<TrashIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Trash")}
+24 -1
View File
@@ -8,6 +8,7 @@ import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import DocumentsStore from "stores/DocumentsStore";
import FileOperationsStore from "stores/FileOperationsStore";
import GroupsStore from "stores/GroupsStore";
import MembershipsStore from "stores/MembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
@@ -28,6 +29,7 @@ type Props = {
views: ViewsStore,
auth: AuthStore,
toasts: ToastsStore,
fileOperations: FileOperationsStore,
};
@observer
@@ -80,6 +82,7 @@ class SocketProvider extends React.Component<Props> {
policies,
presence,
views,
fileOperations,
} = this.props;
if (!auth.token) return;
@@ -240,6 +243,10 @@ class SocketProvider extends React.Component<Props> {
}
}
}
if (event.teamIds) {
await auth.fetch();
}
});
this.socket.on("documents.star", (event) => {
@@ -287,6 +294,21 @@ class SocketProvider extends React.Component<Props> {
}
});
this.socket.on("fileOperations.update", async (event) => {
const user = auth.user;
let collection = null;
if (event.collectionId)
collection = await collections.fetch(event.collectionId);
if (user) {
fileOperations.add({
...event,
user,
collection,
});
}
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event) => {
@@ -345,5 +367,6 @@ export default inject(
"memberships",
"presence",
"policies",
"views"
"views",
"fileOperations"
)(SocketProvider);
+4 -17
View File
@@ -1,25 +1,13 @@
// @flow
import { m } from "framer-motion";
import * as React from "react";
import { NavLink, Route } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
import styled, { useTheme } from "styled-components";
import NavLinkWithChildrenFunc from "components/NavLink";
type Props = {
theme: Theme,
children: React.Node,
};
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink to={to} exact={exact} {...rest}>
{children(match)}
</NavLink>
)}
</Route>
);
const TabLink = styled(NavLinkWithChildrenFunc)`
position: relative;
display: inline-flex;
@@ -53,7 +41,8 @@ const transition = {
damping: 30,
};
function Tab({ theme, children, ...rest }: Props) {
export default function Tab({ children, ...rest }: Props) {
const theme = useTheme();
const activeStyle = {
color: theme.textSecondary,
};
@@ -75,5 +64,3 @@ function Tab({ theme, children, ...rest }: Props) {
</TabLink>
);
}
export default withTheme(Tab);
+55 -16
View File
@@ -1,4 +1,5 @@
// @flow
import { isEqual } from "lodash";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
@@ -24,6 +25,7 @@ export type Props = {|
onChangePage: (index: number) => void,
onChangeSort: (sort: ?string, direction: "ASC" | "DESC") => void,
columns: any,
defaultSortDirection: "ASC" | "DESC",
|};
function Table({
@@ -39,6 +41,7 @@ function Table({
topRef,
onChangeSort,
onChangePage,
defaultSortDirection,
}: Props) {
const { t } = useTranslation();
const {
@@ -62,32 +65,52 @@ function Table({
autoResetPage: false,
pageCount: totalPages,
initialState: {
sortBy: [{ id: defaultSort, desc: false }],
sortBy: [
{
id: defaultSort,
desc: defaultSortDirection === "DESC" ? true : false,
},
],
pageSize,
pageIndex: page,
},
stateReducer: (newState, action, prevState) => {
if (!isEqual(newState.sortBy, prevState.sortBy)) {
return { ...newState, pageIndex: 0 };
}
return newState;
},
},
useSortBy,
usePagination
);
React.useEffect(() => {
onChangePage(pageIndex);
}, [pageIndex]);
const prevSortBy = React.useRef(sortBy);
React.useEffect(() => {
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
sortBy.length && sortBy[0].desc ? "DESC" : "ASC"
);
}, [sortBy]);
if (!isEqual(sortBy, prevSortBy.current)) {
prevSortBy.current = sortBy;
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
);
}
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
const handleNextPage = () => {
nextPage();
onChangePage(pageIndex + 1);
};
const handlePreviousPage = () => {
previousPage();
onChangePage(pageIndex - 1);
};
const isEmpty = !isLoading && data.length === 0;
const showPlaceholder = isLoading && data.length === 0;
console.log({ canNextPage, pageIndex, totalPages, rows, data });
return (
<>
<Anchor ref={topRef} />
@@ -142,12 +165,12 @@ function Table({
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={previousPage} neutral>
<Button onClick={handlePreviousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={nextPage} neutral>
<Button onClick={handleNextPage} neutral>
{t("Next page")}
</Button>
)}
@@ -209,7 +232,7 @@ const SortWrapper = styled(Flex)`
`;
const Cell = styled.td`
padding: 8px 0;
padding: 6px;
border-bottom: 1px solid ${(props) => props.theme.divider};
font-size: 14px;
@@ -226,6 +249,14 @@ const Cell = styled.td`
`;
const Row = styled.tr`
${Cell} {
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
&:last-child {
${Cell} {
border-bottom: 0;
@@ -237,7 +268,7 @@ const Head = styled.th`
text-align: left;
position: sticky;
top: 54px;
padding: 6px 0;
padding: 6px;
border-bottom: 1px solid ${(props) => props.theme.divider};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
@@ -245,6 +276,14 @@ const Head = styled.th`
color: ${(props) => props.theme.textSecondary};
font-weight: 500;
z-index: 1;
:first-child {
padding-left: 0;
}
:last-child {
padding-right: 0;
}
`;
export default observer(Table);
+1
View File
@@ -11,6 +11,7 @@ type Props = {
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
format?: string,
shorten?: boolean,
};
+33
View File
@@ -0,0 +1,33 @@
// @flow
import { useRegisterActions } from "kbar";
import { flattenDeep } from "lodash";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { actionToKBar } from "actions";
import useStores from "hooks/useStores";
import type { Action } from "types";
export default function useCommandBarActions(actions: Action[]) {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const context = {
t,
isCommandBar: true,
isContextMenu: false,
activeCollectionId: stores.ui.activeCollectionId,
activeDocumentId: stores.ui.activeDocumentId,
location,
stores,
};
const registerable = flattenDeep(
actions.map((action) => actionToKBar(action, context))
);
useRegisterActions(registerable, [
registerable.map((r) => r.id).join(""),
location.pathname,
]);
}
+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;
}
+21
View File
@@ -0,0 +1,21 @@
// @flow
import * as React from "react";
/**
* Hook to check if component is still mounted
*
* @returns {boolean} true if the component is mounted, false otherwise
*/
export default function useIsMounted() {
const isMounted = React.useRef(false);
React.useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return React.useCallback(() => isMounted.current, []);
}
+69
View File
@@ -0,0 +1,69 @@
// @flow
import * as React from "react";
import isTextInput from "utils/isTextInput";
export type KeyFilter = ((event: KeyboardEvent) => boolean) | string;
// Registered keyboard event callbacks
let callbacks = [];
// Track if IME input suggestions are open so we can ignore keydown shortcuts
// in this case, they should never be triggered from mobile keyboards.
let imeOpen = false;
// Based on implementation in react-use
// https://github.com/streamich/react-use/blob/master/src/useKey.ts#L15-L22
const createKeyPredicate = (keyFilter: KeyFilter) =>
typeof keyFilter === "function"
? keyFilter
: typeof keyFilter === "string"
? (event: KeyboardEvent) =>
event.key === keyFilter ||
event.code === `Key${keyFilter.toUpperCase()}`
: keyFilter
? (_event) => true
: (_event) => false;
export default function useKeyDown(
key: KeyFilter,
fn: (event: KeyboardEvent) => void
): void {
const predicate = createKeyPredicate(key);
React.useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (predicate(event)) {
fn(event);
}
};
callbacks.push(handler);
return () => {
callbacks = callbacks.filter((cb) => cb !== handler);
};
}, []);
}
window.addEventListener("keydown", (event) => {
if (imeOpen) {
return;
}
// reverse so that the last registered callbacks get executed first
for (const callback of callbacks.reverse()) {
if (event.defaultPrevented === true) {
break;
}
if (!isTextInput(event.target) || event.ctrlKey || event.metaKey) {
callback(event);
}
}
});
window.addEventListener("compositionstart", () => {
imeOpen = true;
});
window.addEventListener("compositionend", () => {
imeOpen = false;
});
+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;
}
+1 -1
View File
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
export default function usePrevious(value: any) {
export default function usePrevious<T>(value: T): T | void {
const ref = React.useRef();
React.useEffect(() => {
ref.current = value;
+29
View File
@@ -0,0 +1,29 @@
// @flow
import * as React from "react";
import { getCookie } from "tiny-cookie";
type Session = {|
url: string,
logoUrl: string,
name: string,
teamId: string,
|};
function loadSessionsFromCookie(): Session[] {
const sessions = JSON.parse(getCookie("sessions") || "{}");
return Object.keys(sessions).map((teamId) => ({
teamId,
...sessions[teamId],
}));
}
export default function useSessions() {
const [sessions, setSessions] = React.useState(loadSessionsFromCookie);
const reload = React.useCallback(() => {
setSessions(loadSessionsFromCookie());
}, []);
return [sessions, reload];
}

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