Compare commits

...

136 Commits

Author SHA1 Message Date
Tom Moor d4c594423f More email styling 2021-07-08 22:38:42 -04:00
Tom Moor 2bf237d54b Merge branch 'main' into email-diff 2021-07-08 21:20:18 -04:00
Tom Moor 9a1c8f07d1 feat: Add documentId filter to events.list (#2287) 2021-07-08 10:12:06 -07:00
Tom Moor 241cb11493 chore: Automate running yarn-deduplicate, see comment:
https://github.com/outline/outline/pull/2283\#discussion_r665301770
2021-07-07 22:26:56 -04:00
Saumya Pandey 8195791bb2 fix: Make search query string user friendly (#2283)
* Upgrade query-string package and skip empty string

* Run yarn-deduplicate command
2021-07-07 18:45:40 -07:00
Saumya Pandey b037ae5dc1 fix: Improve isChildDocument performance (#2284) 2021-07-07 04:53:40 -07:00
Tom Moor aeba8ce4eb fix: Empty context menu when user does not have permission to update collection 2021-07-06 22:02:31 -04:00
Tom Moor 3565e68725 Merge branch 'main' into email-diff 2021-07-06 20:43:51 -04:00
Tom Moor 429c5fba85 0.57.0 2021-07-06 09:12:54 -04:00
Tom Moor 9495ddba25 fix: Restore previous WSS CORS behavior 2021-07-05 23:01:25 -04:00
dependabot[bot] 486a60e97c chore(deps): bump socket.io from 2.3.0 to 2.4.0 (#1831)
Bumps [socket.io](https://github.com/socketio/socket.io) from 2.3.0 to 2.4.0.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/2.4.0/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/2.3.0...2.4.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-05 17:11:40 -07:00
Tom Moor c687745263 fix: E-mail signin on incorrect subdomain should allow the process to continue instead of error
closes #2276
2021-07-05 19:25:21 -04:00
Translate-O-Tron 1b92993b90 fix: New Portuguese, Brazilian translations from Crowdin (#2271) 2021-07-05 13:37:09 -07:00
Tom Moor 181a20a268 fix: More context menu fixes 2021-07-05 16:35:46 -04:00
Tom Moor f8ffa4e25a tweak sidebar item background 2021-07-04 18:44:09 -04:00
Farzad 7e139ca8f7 fix: nested link positions for RTL titles in sidebar (#2272) 2021-07-04 12:08:05 -07:00
Tom Moor bb58db507d fix: ew-resize -> col-resize cursor 2021-07-04 11:54:29 -04:00
Translate-O-Tron 49bf86d6d9 New Crowdin updates (#2268) 2021-07-04 06:54:52 -07:00
Tom Moor 286a15cf10 fix: Clicking dropdown menu items in FF (#2269)
* fix: Clicking dropdown menu items in FF
closes #2264

* fix: Anchor items, add comment

* fix: CI test memory issues
2021-07-04 06:54:40 -07:00
Tom Moor f65469b777 lockfile 2021-07-03 21:22:52 -04:00
Translate-O-Tron fe65a79d66 New Crowdin updates (#2267) 2021-07-03 07:02:01 -07:00
Tom Moor a1d5ac0907 RTL document support (#2263)
* Basic RTL support in documents

* fix: DocumentListItem and ReferenceListItem for RTL content
2021-07-03 07:00:10 -07:00
Tom Moor 04eabe68a7 feat: Enable traditional Chinese translations (#2266) 2021-07-02 12:08:08 -07:00
Tom Moor 1c0c694c22 fix: Email auth should allow same guest user on multiple subdomains (#2252)
* test: Add email auth tests to establish current state of system

* fix: Update logic to account for dupe emails used between subdomains

* test

* test
2021-07-02 12:07:43 -07:00
Translate-O-Tron 2ae74f2834 New Crowdin updates (#2262) 2021-07-02 11:23:02 -07:00
Tom Moor 0f01fc5faa test: Reduce memory usage by not requiring stores into all (#2265) 2021-07-02 11:16:07 -07:00
Tom Moor 7f1322b7ba fix: Down arrow in search input should move focus to results (#2257)
closes #2253
2021-07-01 15:01:30 -07:00
dependabot[bot] 3c98133e24 chore(deps): bump socket.io-parser from 3.3.1 to 3.3.2 (#2258)
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 3.3.1 to 3.3.2.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/3.3.2/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/3.3.1...3.3.2)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-01 12:08:57 -07:00
Tom Moor 088353d61f fix: Data loading state not reset when props change to PaginatedList (#2254)
* fix: Data loading state not reset when significant props change to PaginatedList

closes #2251

* test: Add enzyme and component test
2021-06-26 21:49:25 -07:00
Translate-O-Tron 31180619e1 New Crowdin updates (#2182) 2021-06-26 13:43:54 -07:00
Saumya Pandey 9fccc280d7 fix: Add ability to permanently delete documents in trash (#2192)
* Align false conditions before true

* Update documents.delete endpoint for permanent delete

* Add permanent delete to events table and integrate with socket.io

* Add permanent delete to document menu

* Update parentDocumentId of direct child to null

* Add translation

* Add test for permanent delete

* Add space

* Update app/scenes/DocumentPermanentDelete.js

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

* Update app/stores/DocumentsStore.js

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

* Update server/commands/documentPermanentDeleter.js

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

* Update app/scenes/DocumentPermanentDelete.js

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

* Change socket room from team to collection

* Add translation

* Create log func for commands

* Move tests from utils to permanentDeleter command

* Add additional tests

* Set redirect to trash

* Return promise from beforeEach

* Add undeleted documents validation

* Include deleteAt attribute in db query

* Update server/commands/documentPermanentDeleter.js

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

* tweak language

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-25 16:14:40 -07:00
Tom Moor c69b4efc34 fix: Aligned images do not load in publicly shared documents (#2248) 2021-06-25 10:09:44 -07:00
Tom Moor 61039e9d0d Allow images in email diff 2021-06-25 09:41:34 -07:00
Tom Moor 6d09122d56 test: Deletion 2021-06-24 20:10:42 -07:00
Tom Moor 5fb6097153 Improved diff 2021-06-23 23:58:32 -07:00
Tom Moor ec17874568 Remove test harness 2021-06-22 07:35:38 -07:00
Tom Moor 40c3e9e85f test 2021-06-22 07:27:55 -07:00
Tom Moor 9f739f3788 Merge main 2021-06-22 07:26:45 -07:00
Tom Moor 3cec6b4903 fix: Allow for offline development 2021-06-21 21:40:28 -07:00
Tom Moor ede7f2e3e6 fix: Bump RME (table and image fixes) 2021-06-21 17:39:14 -07:00
Tom Moor f6837b4742 wip 2021-06-20 23:15:04 -07:00
Tom Moor 1560e3c9f7 refactor 2021-06-20 12:49:15 -07:00
Tom Moor ca74908dc5 test 2021-06-20 00:20:37 -07:00
Tom Moor de7ec1119b Integrate into mailer, basic styling 2021-06-19 23:50:36 -07:00
Tom Moor 2093b4297f Merge main 2021-06-19 17:05:19 -07:00
Tom Moor cf8fa5ffa3 fix: Bump RME (checkbox list fixes) 2021-06-18 16:28:27 -07:00
Tom Moor 1a2a0f4264 fix: Long search term causes server error writing query to db (#2237)
closes #2234
2021-06-17 23:23:35 -07:00
Tom Moor 5f3a38bf87 fix: todo list checkbox consistency issue
closes #2179
2021-06-17 22:57:55 -07:00
Tom Moor afff3a6f25 fix: Server error when user cancels OAuth process with Azure (#2231) 2021-06-16 21:45:20 -07:00
Tom Moor b5824879a3 Merge branch 'fix/concat-tags' 2021-06-16 18:36:28 -07:00
Tom Moor 1c82e292e0 fix: Allow embed of private mindmeister embeds
fix: Missing right and bottom border of some embeds
2021-06-16 18:36:21 -07:00
Tom Moor 317289ac2a fix: Error in Datadog tracking, if only we had TS :( 2021-06-16 08:52:54 -07:00
Tom Moor 8331026cb3 fix: No search results from link editor search due to error parsing date (date-fns upgrade) 2021-06-16 07:54:56 -07:00
Tom Moor de285f2b63 feat: Add TLS ciphers option (#2217)
closes #2175
2021-06-15 21:37:41 -07:00
G. Santos d205c48296 docs: Fix SECRET_KEY variable description (#2229)
Updated the description of the SECRET_KEY variable in the .env.sample
file to clarify that the key needs to be 32 bytes long and hex-encoded.
The previous description of "32 character hexadecimal" was confusing
as it left open the possibility of a hex-encoded 16-byte key.
2021-06-15 21:37:19 -07:00
Tom Moor 277c37dae6 fix: Metrics lib to account for multiple server instances 2021-06-15 20:34:46 -07:00
Tom Moor 2c39cd6496 chore: Normalize "new" actions in settings (#2226)
* fix: Unauthorized request to views.list from shared documents

* Bump dep styled-components

* chore: Normalize 'new' actions in settings area to top right
chore: Add translation hooks to API tokens screen
chore: Move API tokens loading to paginated list
2021-06-15 19:10:50 -07:00
Tom Moor d85592b5f3 feat: DataDog metrics (#2228)
* wip

* chore: Change event names, add additional events

* fix: Not counting connect events
2021-06-15 19:10:38 -07:00
Tom Moor cdf0df0faa Bump dep styled-components 2021-06-13 18:26:25 -07:00
Tom Moor 48f54b5aa2 fix: Unauthorized request to views.list from shared documents 2021-06-13 18:24:02 -07:00
Tom Moor 2ca57fc7cf fix: 3 locations with return undefined (not compatible with React 17) 2021-06-13 17:47:17 -07:00
Tom Moor 470920e2c3 feat: Allow templates from any collection to be used
fix: Hover state of context menu items with icons
2021-06-13 17:43:50 -07:00
Tom Moor beee8ebee7 fix: Sidebar flash when moving between collection/document due to mobx-react upgrade 2021-06-13 17:22:35 -07:00
Tom Moor 9f05c9bd43 chore: Upgrade React to v17 (#2045)
* chore: Upgrade React v17

* chore: Upgrade additional deps to reduce warnings

* fix: Restore react-table dep

* Bump react-avatar-editor, mobx-react

* Remove unmaintained @rehooks/window-scroll-position dep

* Bump react-waypoint dep for React 17 support

* fix: Syntax error in autotrack chunk name comment
2021-06-13 15:23:53 -07:00
Tom Moor 65be808556 fix: Cause of sporadic test failures in CI, promise not returned for flushdb 2021-06-13 14:52:24 -07:00
Tom Moor 89f8df619c fix: Remove export permission for read-only users (#2220) 2021-06-13 14:41:29 -07:00
Tom Moor 756ec92cdb fix: Link copied to clipboard takes dark mode styles (#2218)
Upgrade copy-to-clipboard
closes #2207
2021-06-12 15:44:58 -07:00
Dave a8e2e349e9 fix: change metaDisplay key to Alt for "Table of contents" (#2187)
* change `metaDisplay` key to Alt for "Table of contents"

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-12 15:37:35 -07:00
Tom Moor 25f745e7e5 fix: Text alignment in templates menu
closes #2204
2021-06-12 13:31:22 -07:00
Nainterceptor 07b1811993 feat: Use SMTP_SECURE environment variable to force secure parameter of smtp configuration (#2214) 2021-06-12 11:01:48 -07:00
Saumya Pandey d71f0ae6bd fix: Two restore options when an archived document is deleted (#2194)
* Merge two menu items

* Add deletedAt guard condition in document unarchive policy

* Make the parentDocumentId null

* Update test
2021-06-10 22:52:32 -07:00
Tom Moor f58032d305 fix: Flash of sidebar when first loading Document chunk 2021-06-09 18:01:35 -07:00
Saumya Pandey 6beb6febc4 fix: Use friendly urls for collections (#2162)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-09 17:48:48 -07:00
Saumya Pandey a6d4d4ea36 fix: Add Portugese, Brazil to language options (#2164)
* Add Portugese, Brazil to language options

* Upgrade date-fns package

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-09 17:42:14 -07:00
Tom Moor a99f6bed42 feat: Return publicly shared document title in SSR HTML (#2191)
* feat: Return publicly shared document title in SSR HTML
closes #2146

* tests
2021-06-09 17:41:39 -07:00
Tom Moor 4cd61db1ea fix: Move views loading to avoid duplicate request 2021-06-08 21:13:56 -07:00
Tom Moor 0db7bb7f3e Bump editor
closes #2156
closes #2067
2021-06-07 20:32:41 -07:00
Tom Moor d8ca9c6111 fix: Server error if non-array passed to users.invite 2021-06-07 20:28:28 -07:00
Dave 4a8d357084 style: add option background for InputSelect (#2188) 2021-06-07 18:34:01 -07:00
Yao Wang e0fb76cb63 documentation: Instructions for local development (#2180)
* Fix the instruction for local development

* update readme for Slack OAuth in local development

* Fix the callback URL setting instruction
2021-06-07 18:11:45 -07:00
Saumya Pandey ffed38bf71 fix: Prevent API request for views (#2193) 2021-06-07 18:10:54 -07:00
Tom Moor b4c08a027b fix: Remove hover state css on sidebar items on mobile
closes #2043
2021-06-06 19:56:31 -07:00
Tom Moor 74e0f4dfb3 fix: Parallelize loading attachments in document presenter (#2184)
closes #2157
2021-06-05 18:40:55 -07:00
Tom Moor 5c7f2cf164 feat: Add optional http logging in production (#2183)
* feat: Add optional http logging in production
closes #2174

* Update app.js
2021-06-05 15:19:54 -07:00
Tom Moor f517a2cecb chore: Add React.StrictMode
closes #2177
2021-06-05 14:59:14 -07:00
Saumya Pandey a19ac6aa5f fix: Failure loading collections on frontend results in loading loop (#2176)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-05 09:52:49 -07:00
Saumya Pandey ddbbb963b6 fix: Add guard condition for matchMedia usage (#2178) 2021-06-05 09:51:42 -07:00
Viorel Cojocaru ba24a3318e Fix chunks setup (#2181)
* build: Webpack config - use named chunk ids

prevent invalidation across builds by using a deterministic chunkId algorithm

* fix: Autotrack chunk name syntax
2021-06-05 09:50:38 -07:00
Viorel Cojocaru 7a6491cf0d build: Webpack config - allow package.json module field usage (#2173)
- revert resolve.alias to default
- revert bundless-* package aliases to commonjs folder to avoid transpiling
2021-06-04 18:18:17 -07:00
Tom Moor 0c8d4428fc Update stale.yml 2021-06-04 09:11:09 -07:00
Viorel Cojocaru b19fd799ef chore: Update @relative-ci/agent to v2 (#2171) 2021-06-03 22:02:38 -07:00
Viorel Cojocaru 082ced3072 build: Add async chunk names (#2170) 2021-06-03 22:01:23 -07:00
Tom Moor 1f49b35c89 documentation: Improve notes around SECRET_KEY generation 2021-06-03 08:30:53 -07:00
Tom Moor 9817e2f3bf 0.56.0 2021-06-02 12:52:19 -07:00
Translate-O-Tron 04d7c7ac0e New Crowdin updates (#2143) 2021-06-02 12:51:14 -07:00
Tom Moor e625e77a56 fix: Data loading loop on old browsers 2021-06-02 12:45:07 -07:00
Tom Moor 636023aceb fix: Bump RME, improved image download behavior in editor 2021-05-24 20:56:58 -07:00
dependabot[bot] f2dfed4c72 chore(deps): bump browserslist from 4.14.7 to 4.16.6 (#2149)
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.14.7 to 4.16.6.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.14.7...4.16.6)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-24 18:35:53 -07:00
Tom Moor 8cfa724200 feat: Animate disclosure when toggling sidebar items 2021-05-22 21:22:28 -07:00
Tom Moor 6c011eb9b5 fix: Guard empty documentStructure 2021-05-22 21:12:47 -07:00
Tom Moor 7dc11e5b86 fix: Local cache for shared link share trees to reduce render flashing 2021-05-22 20:03:50 -07:00
Tom Moor 44920a25f4 feat: Nested document sharing (#2075)
* migration

* frontend routing, api permissioning

* feat: apiVersion=2

* feat: re-writing document links to point to share

* poc nested documents on share links

* fix: nested shareId permissions

* ui and language tweaks, comments

* breadcrumbs

* Add icons to reference list items

* refactor: Breadcrumb component

* tweaks

* Add shared parent note
2021-05-22 19:34:05 -07:00
Tom Moor dc4b5588b7 feat: Add 'Descript' embed (#2144) 2021-05-22 19:21:56 -07:00
Tom Moor 635910195b i18n 2021-05-22 17:18:10 -07:00
Tom Moor eaf2e50af8 feat: Add 'download image' button
feat: Enable Enter+Shift shortcut in blockquotes
fix: Improve behavior of caret around inline code marks
fix: Disallow pasting embeds in table cells
2021-05-22 17:17:46 -07:00
Tom Moor 505ed3403a fix: Bump RME, improves behavior typing words with underscores 2021-05-20 19:29:59 -07:00
Tom Moor b93d15e967 fix: PaginatedList loading loop 2021-05-20 19:21:30 -07:00
Tom Moor 028eb72f9c fix: Restore behavior of displaying document collaborators in facepile 2021-05-19 22:05:17 -07:00
Tom Moor b0196f0cf0 feat: Rebuilt member admin (#2139) 2021-05-19 21:36:10 -07:00
Translate-O-Tron 833bd51f4c New Crowdin updates (#2120) 2021-05-18 20:00:18 -07:00
Tom Moor 14d9adefe7 test 2021-05-15 18:22:42 -07:00
Tom Moor ec3ea09b2d fix: Return lastActiveAt 2021-05-15 18:14:44 -07:00
Tom Moor 2c0f14f07b fix: Explicit import of fetch-with-proxy 2021-05-13 17:20:24 -07:00
Tom Moor a93d034091 fix: Moving documents between collections does not update attachment permissions (#2136)
* fix: Copy attachments when neccessary and moving between collections

* test: regression
2021-05-12 22:38:24 -07:00
Tom Moor 447371f35a fix: Add server-side proxy support via fetch-with-proxy (#2044)
* fix: Add server-side proxy support via fetch-with-proxy

closes #1893

For some fun discussion on why this is required, see this issue: https://github.com/nodejs/node/issues/8381

* lint
2021-05-12 22:37:32 -07:00
Tom Moor 3bd56fff9e fix: Search query backslash replacement only touched first instance
closes #2111
2021-05-12 20:27:14 -07:00
Tom Moor 9d03c89c02 chore: Return new permissions-policy header on app pages
closes #2040
2021-05-12 20:16:55 -07:00
Tom Moor 9f226cf3b4 fix: Extra space on lhs when printing in Firefox, closes #2128 2021-05-12 20:06:58 -07:00
Tom Moor d01e3f7c72 fix: Print styles in dark mode when OS is light mode
closes #2124
2021-05-12 20:00:10 -07:00
Tom Moor 2cb0bab82a fix: Welcome emails should not be sent when inviting a user (#2132)
* chore: Bump nodemailer

* fix: Welcome email sent to invites

* test: Add regression test for emails from accountProvisioner
2021-05-11 18:59:31 -07:00
dependabot[bot] 456a7e497b chore(deps): bump nodemailer from 4.7.0 to 6.4.16 (#2131)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 4.7.0 to 6.4.16.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v4.7.0...v6.4.16)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-11 18:11:36 -07:00
Saumya Pandey a39f7a4e55 fix: Remove application/octet-stream as valid frontend mimetype (#2126)
* Remove application/octet-stream and add explicit extensions

* Modify the condition to check for extensions too
2021-05-11 08:07:41 -07:00
Tom Moor fed3774cee chore: Bump RME 2021-05-09 22:36:20 -07:00
Saumya Pandey 985f0da674 fix: Move collection index validation logic to a context assert function (#2116)
* Abstract validation logic for readability

* Add index validation in collections.move

* Add tests
2021-05-09 22:30:37 -07:00
dependabot[bot] 721e7466e6 chore(deps): bump hosted-git-info from 2.8.8 to 2.8.9 (#2127)
Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.8 to 2.8.9.
- [Release notes](https://github.com/npm/hosted-git-info/releases)
- [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md)
- [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.8...v2.8.9)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-09 19:28:51 -07:00
Tom Moor 8e1d9f0a7d fix: Welcome collection should be visible to all by default 2021-05-05 21:12:49 -07:00
Tom Moor 71de0c7e5f fix: Currently viewing users should be ordered to top 2021-05-05 21:11:09 -07:00
Tom Moor 4f4067c449 fix: Upgrade RME, fixes image flicked post-upload in editor 2021-05-05 20:09:37 -07:00
Tom Moor b945b614f8 fix: Layout of Keyboard Shortcuts guide for languages where definition wraps onto two lines 2021-05-05 20:05:49 -07:00
Tom Moor 896ee5c20d feat: Improved viewers popover (#2106)
* refactoring popover

* feat: DocumentViews popover

* i18n

* fix: tab focus warnings

* test: Add tests around users.info changes

* snapshots
2021-05-05 19:35:23 -07:00
Translate-O-Tron e984a3dcdb New Crowdin updates (#2100)
* fix: New Polish translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]
2021-05-05 19:35:04 -07:00
Tom Moor 69802cc985 fix: Add application/octet-stream as a valid mimetype for docx uploads (#2105)
* fix: Add application/octet-stream as a valid mimetype for docx uploads

* fix: Include application/octet-stream in frontend filter
fix: Add file size and file type guards

* Validate .docx extension in files with application/octet-stream mimetype

* refactor: Move MAXIMUM_IMPORT_SIZE to an optional environment config
fix: Add file size check on server too

Co-authored-by: Saumya Pandey <sp160899@gmail.com>
2021-05-05 18:48:37 -07:00
Saumya Pandey 6ef8d9ddb3 fix: Handle null case (#2118) 2021-05-05 18:47:23 -07:00
John Viscel M. Sangkal d21594a6f4 fix: Add onClick event listener to show Appearance Menu options in mobile (#2119) 2021-05-05 18:46:57 -07:00
Tom Moor 974d6b2cbe fix: Submenu overflow broken 2021-05-05 09:13:44 -07:00
Nan Yu 3df82c500b wip 2021-02-21 11:52:00 -08:00
224 changed files with 9366 additions and 3491 deletions
+12 -6
View File
@@ -8,12 +8,12 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
# DO NOT LEAVE UNSET
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
# DO NOT LEAVE UNSET
# 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
# For production point these at your databases, in development the default
@@ -94,9 +94,13 @@ FORCE_HTTPS=true
# the maintainers
ENABLE_UPDATES=true
# Override the maxium size of document imports, could be required if you have
# 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,multiplayer,server,services
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,services
# 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
@@ -124,10 +128,12 @@ SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
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
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
DEFAULT_LANGUAGE=en_US
+2 -2
View File
@@ -1,8 +1,8 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
daysUntilStale: 90
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
daysUntilClose: 14
# Issues with these labels will never be considered stale
exemptLabels:
+2 -1
View File
@@ -96,7 +96,8 @@ For contributing features and fixes you can quickly get an environment running u
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. Add `http://localhost:3000/auth/slack.callback` as an Oauth redirect URL
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
+9
View File
@@ -135,6 +135,15 @@
"description": "wikireply@example.com (optional)",
"required": false
},
"SMTP_SECURE": {
"value": "true",
"description": "Use a secure SMTP connection (optional)",
"required": false
},
"SMTP_TLS_CIPHERS": {
"description": "Override SMTP cipher configuration (optional)",
"required": false
},
"GOOGLE_ANALYTICS_ID": {
"description": "UA-xxxx (optional)",
"required": false
+30
View File
@@ -0,0 +1,30 @@
{
"testURL": "http://localhost",
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/app",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleFileExtensions": [
"js",
"jsx",
"json"
],
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.js"
]
}
+3 -1
View File
@@ -9,7 +9,9 @@ type Props = {
export default class Analytics extends React.Component<Props> {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) return;
if (!env.GOOGLE_ANALYTICS_ID) {
return null;
}
// standard Google Analytics script
window.ga =
+14 -16
View File
@@ -1,5 +1,4 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { EditIcon } from "outline-icons";
@@ -16,7 +15,7 @@ type Props = {
isPresent: boolean,
isEditing: boolean,
isCurrentUser: boolean,
lastViewedAt: string,
profileOnClick: boolean,
t: TFunction,
};
@@ -33,22 +32,13 @@ class AvatarWithPresence extends React.Component<Props> {
};
render() {
const {
user,
lastViewedAt,
isPresent,
isEditing,
isCurrentUser,
t,
} = this.props;
const { user, isPresent, isEditing, isCurrentUser, t } = this.props;
const action = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("viewed {{ timeAgo }} ago", {
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
});
: t("previously edited");
return (
<>
@@ -56,8 +46,12 @@ class AvatarWithPresence extends React.Component<Props> {
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
<br />
{action}
{action && (
<>
<br />
{action}
</>
)}
</Centered>
}
placement="bottom"
@@ -65,7 +59,11 @@ class AvatarWithPresence extends React.Component<Props> {
<AvatarWrapper isPresent={isPresent}>
<Avatar
src={user.avatarUrl}
onClick={this.handleOpenProfile}
onClick={
this.props.profileOnClick === false
? undefined
: this.handleOpenProfile
}
size={32}
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
/>
+52 -158
View File
@@ -1,193 +1,87 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import { GoToIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
children?: React.Node,
onlyText: boolean,
type MenuItem = {|
icon?: React.Node,
title: React.Node,
to?: string,
|};
function Icon({ document }) {
const { t } = useTranslation();
type Props = {|
items: MenuItem[],
max?: number,
children?: React.Node,
highlightFirstItem?: boolean,
|};
if (document.isDeleted) {
return (
<>
<CategoryName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CategoryName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CategoryName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CategoryName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CategoryName>
<Slash />
</>
);
}
return null;
}
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
const totalItems = items.length;
let topLevelItems: MenuItem[] = [...items];
let overflowItems;
const Breadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
if (!collections.isLoaded) {
return;
// chop middle breadcrumbs and present a "..." menu instead
if (totalItems > max) {
const halfMax = Math.floor(max / 2);
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
topLevelItems.splice(halfMax, 0, {
title: <BreadcrumbMenu items={overflowItems} />,
});
}
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
};
}
const path = collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [];
if (onlyText === true) {
return (
<>
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Flex justify="flex-start" align="center">
<Icon document={document} />
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
<span>{collection.name}</span>
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu path={menuPath} />
</>
)}
{lastPath && (
<>
<Slash />{" "}
<Crumb to={lastPath.url} title={lastPath.title}>
{lastPath.title}
</Crumb>
</>
)}
{topLevelItems.map((item, index) => (
<React.Fragment key={item.to || index}>
{item.icon}
{item.to ? (
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={highlightFirstItem && index === 0}
>
{item.title}
</Item>
) : (
item.title
)}
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
</React.Fragment>
))}
{children}
</Flex>
);
};
}
export const Slash = styled(GoToIcon)`
const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
vertical-align: middle;
flex-shrink: 0;
fill: ${(props) => props.theme.slate};
opacity: 0.5;
`;
const Crumb = styled(Link)`
const Item = styled(Link)`
display: flex;
flex-shrink: 1;
min-width: 0;
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
svg {
flex-shrink: 0;
}
&:hover {
text-decoration: underline;
}
`;
const CollectionName = styled(Link)`
display: flex;
flex-shrink: 1;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
min-width: 0;
svg {
flex-shrink: 0;
}
`;
const CategoryName = styled(CollectionName)`
flex-shrink: 0;
`;
export default observer(Breadcrumb);
export default Breadcrumb;
+83 -59
View File
@@ -1,79 +1,103 @@
// @flow
import { sortBy, keyBy } from "lodash";
import { observer, inject } from "mobx-react";
import { sortBy, filter, uniq } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import ViewsStore from "stores/ViewsStore";
import Document from "models/Document";
import { AvatarWithPresence } from "components/Avatar";
import DocumentViews from "components/DocumentViews";
import Facepile from "components/Facepile";
import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useStores from "hooks/useStores";
type Props = {
views: ViewsStore,
presence: DocumentPresenceStore,
type Props = {|
document: Document,
currentUserId: string,
};
|};
@observer
class Collaborators extends React.Component<Props> {
componentDidMount() {
if (!this.props.document.isDeleted) {
this.props.views.fetchPage({ documentId: this.props.document.id });
function Collaborators(props: Props) {
const { t } = useTranslation();
const { users, presence } = useStores();
const { document, currentUserId } = props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const collaborators = React.useMemo(
() =>
sortBy(
filter(
users.orderedData,
(user) =>
presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)
),
(user) => presentIds.includes(user.id)
),
[document.collaboratorIds, users.orderedData, presentIds]
);
// load any users we don't know about
React.useEffect(() => {
if (users.isFetching) {
return;
}
}
render() {
const { document, presence, views, currentUserId } = this.props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const documentViews = views.inDocument(document.id);
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const mostRecentViewers = sortBy(
documentViews.slice(0, MAX_AVATAR_DISPLAY),
(view) => {
return presentIds.includes(view.user.id);
uniq([...document.collaboratorIds, ...presentIds]).forEach((userId) => {
if (!users.get(userId)) {
return users.fetch(userId);
}
);
});
}, [document, users, presentIds, document.collaboratorIds]);
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
const overflow = documentViews.length - mostRecentViewers.length;
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
});
return (
<FacepileHiddenOnMobile
users={mostRecentViewers.map((v) => v.user)}
overflow={overflow}
renderAvatar={(user) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
const { lastViewedAt } = viewersKeyedByUserId[user.id];
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<NudeButton width={collaborators.length * 32} height={32} {...props}>
<FacepileHiddenOnMobile
users={collaborators}
renderAvatar={(user) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
return (
<AvatarWithPresence
key={user.id}
user={user}
lastViewedAt={lastViewedAt}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
return (
<AvatarWithPresence
key={user.id}
user={user}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
profileOnClick={false}
/>
);
}}
/>
);
}}
/>
);
}
</NudeButton>
)}
</PopoverDisclosure>
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</>
);
}
const FacepileHiddenOnMobile = styled(Facepile)`
@@ -82,4 +106,4 @@ const FacepileHiddenOnMobile = styled(Facepile)`
`};
`;
export default inject("views", "presence")(Collaborators);
export default observer(Collaborators);
+20 -10
View File
@@ -29,15 +29,26 @@ const MenuItem = ({
const handleClick = React.useCallback(
(ev) => {
if (onClick) {
ev.preventDefault();
ev.stopPropagation();
onClick(ev);
}
if (hide) {
hide();
}
},
[hide, onClick]
[onClick, hide]
);
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const handleMouseDown = React.useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
}, []);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
@@ -48,12 +59,14 @@ const MenuItem = ({
{(props) => (
<MenuAnchor
{...props}
$toggleable={selected !== undefined}
as={onClick ? "button" : as}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
&nbsp;
</>
)}
@@ -64,9 +77,10 @@ const MenuItem = ({
);
};
const Spacer = styled.div`
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
export const MenuAnchor = styled.a`
@@ -99,7 +113,8 @@ export const MenuAnchor = styled.a`
? "pointer-events: none;"
: `
&:hover,
&:hover,
&:focus,
&.focus-visible {
color: ${props.theme.white};
background: ${props.theme.primary};
@@ -110,15 +125,10 @@ export const MenuAnchor = styled.a`
fill: ${props.theme.white};
}
}
&:focus {
color: ${props.theme.white};
background: ${props.theme.primary};
}
`};
${breakpoint("tablet")`
padding: 6px 12px;
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
font-size: 15px;
`};
`;
+6 -2
View File
@@ -83,7 +83,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
);
});
function Template({ items, ...menu }: Props): React.Node {
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
@@ -101,7 +101,11 @@ function Template({ items, ...menu }: Props): React.Node {
return [...acc, item];
}, []);
return filtered.map((item, index) => {
return filtered;
}
function Template({ items, ...menu }: Props): React.Node {
return filterTemplateItems(items).map((item, index) => {
if (item.to) {
return (
<MenuItem
+2 -4
View File
@@ -1,5 +1,4 @@
// @flow
import { rgba } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
@@ -47,7 +46,7 @@ export default function ContextMenu({
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => (
<Position {...props}>
<Background>
<Background dir="auto">
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
@@ -114,8 +113,7 @@ const Background = styled.div`
transform-origin: ${(props) =>
props.left !== undefined ? "25%" : "75%"} 0;
max-width: 276px;
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
backdrop-filter: blur(20px);
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
+1
View File
@@ -15,6 +15,7 @@ class CopyToClipboard extends React.PureComponent<Props> {
const elem = React.Children.only(children);
copy(text, {
debug: process.env.NODE_ENV !== "production",
format: "text/plain",
});
if (onCopy) onCopy();
+138
View File
@@ -0,0 +1,138 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import CollectionIcon from "components/CollectionIcon";
import useStores from "hooks/useStores";
import { collectionUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
children?: React.Node,
onlyText: boolean,
|};
function useCategory(document) {
const { t } = useTranslation();
if (document.isDeleted) {
return {
icon: <TrashIcon color="currentColor" />,
title: t("Trash"),
to: "/trash",
};
}
if (document.isArchived) {
return {
icon: <ArchiveIcon color="currentColor" />,
title: t("Archive"),
to: "/archive",
};
}
if (document.isDraft) {
return {
icon: <EditIcon color="currentColor" />,
title: t("Drafts"),
to: "/drafts",
};
}
if (document.isTemplate) {
return {
icon: <ShapesIcon color="currentColor" />,
title: t("Templates"),
to: "/templates",
};
}
return null;
}
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
url: "deleted-collection",
};
}
const path = React.useMemo(
() =>
collection && collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [],
[collection, document.id]
);
const items = React.useMemo(() => {
let output = [];
if (category) {
output.push(category);
}
if (collection) {
output.push({
icon: <CollectionIcon collection={collection} expanded />,
title: collection.name,
to: collectionUrl(collection.url),
});
}
path.forEach((p) => {
output.push({
title: p.title,
to: p.url,
});
});
return output;
}, [path, category, collection]);
if (!collections.isLoaded) {
return null;
}
if (onlyText === true) {
return (
<>
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
return <Breadcrumb items={items} children={children} highlightFirstItem />;
};
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
vertical-align: middle;
flex-shrink: 0;
fill: ${(props) => props.theme.slate};
opacity: 0.5;
`;
export default observer(DocumentBreadcrumb);
@@ -1,5 +1,5 @@
// @flow
import format from "date-fns/format";
import { format } from "date-fns";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@@ -37,7 +37,7 @@ class RevisionListItem extends React.Component<Props> {
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
</Time>
</Meta>
{showMenu && (
+11 -4
View File
@@ -41,7 +41,7 @@ function replaceResultMarks(tag: string) {
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(props: Props) {
function DocumentListItem(props: Props, ref) {
const { t } = useTranslation();
const { policies } = useStores();
const currentUser = useCurrentUser();
@@ -68,6 +68,8 @@ function DocumentListItem(props: Props) {
return (
<DocumentLink
ref={ref}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
@@ -76,8 +78,12 @@ function DocumentListItem(props: Props) {
}}
>
<Content>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
@@ -221,6 +227,7 @@ const DocumentLink = styled(Link)`
const Heading = styled.h3`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 24px;
margin-top: 0;
@@ -251,4 +258,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em;
`;
export default observer(DocumentListItem);
export default observer(React.forwardRef(DocumentListItem));
+4 -3
View File
@@ -5,12 +5,13 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Flex from "components/Flex";
import Time from "components/Time";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
@@ -135,14 +136,14 @@ function DocumentMeta({
: 0;
return (
<Container align="center" {...rest}>
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
<strong>
<Breadcrumb document={document} onlyText />
<DocumentBreadcrumb document={document} onlyText />
</strong>
</span>
)}
+39 -8
View File
@@ -1,38 +1,69 @@
// @flow
import { useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import DocumentViews from "components/DocumentViews";
import Popover from "components/Popover";
import useStores from "../hooks/useStores";
type Props = {|
document: Document,
isDraft: boolean,
to?: string,
rtl?: boolean,
|};
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const { views } = useStores();
const { t } = useTranslation();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
React.useEffect(() => {
if (!document.isDeleted) {
views.fetchPage({ documentId: document.id });
}
}, [views, document.id, document.isDeleted]);
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
modal: true,
});
return (
<Meta document={document} to={to}>
<Meta document={document} to={to} {...rest}>
{totalViewers && !isDraft ? (
<>
&nbsp;&middot; Viewed by{" "}
{onlyYou
? "only you"
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
</>
<PopoverDisclosure {...popover}>
{(props) => (
<>
&nbsp;&middot;&nbsp;
<a {...props}>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</a>
</>
)}
</PopoverDisclosure>
) : null}
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</Meta>
);
}
const Meta = styled(DocumentMeta)`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
margin: -12px 0 2em 0;
font-size: 14px;
position: relative;
+80
View File
@@ -0,0 +1,80 @@
// @flow
import { formatDistanceToNow } from "date-fns";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "models/Document";
import Avatar from "components/Avatar";
import ListItem from "components/List/Item";
import PaginatedList from "components/PaginatedList";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
isOpen?: boolean,
|};
function DocumentViews({ document, isOpen }: Props) {
const { t } = useTranslation();
const { views, presence } = useStores();
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const documentViews = views.inDocument(document.id);
const sortedViews = sortBy(
documentViews,
(view) => !presentIds.includes(view.user.id)
);
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
sortedViews,
]);
return (
<>
{isOpen && (
<PaginatedList
items={users}
renderItem={(item) => {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);
const subtitle = isPresent
? isEditing
? t("Currently editing")
: t("Currently viewing")
: t("Viewed {{ timeAgo }} ago", {
timeAgo: formatDistanceToNow(
view ? Date.parse(view.lastViewedAt) : new Date()
),
});
return (
<ListItem
key={item.id}
title={item.name}
subtitle={subtitle}
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
border={false}
small
/>
);
}}
/>
)}
</>
);
}
export default observer(DocumentViews);
+17 -3
View File
@@ -4,15 +4,20 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { light } from "shared/styles/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 { type Theme } from "types";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl } from "utils/urls";
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
const RichMarkdownEditor = React.lazy(() =>
import(/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor")
);
const EMPTY_ARRAY = [];
@@ -24,11 +29,13 @@ export type Props = {|
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
maxLength?: number,
scrollTo?: string,
theme?: Theme,
handleDOMEvents?: Object,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
@@ -51,8 +58,9 @@ type PropsWithRef = Props & {
};
function Editor(props: PropsWithRef) {
const { id, ui, history } = props;
const { id, ui, shareId, history } = props;
const { t } = useTranslation();
const isPrinting = useMediaQuery("print");
const onUploadImage = React.useCallback(
async (file: File) => {
@@ -84,12 +92,16 @@ function Editor(props: PropsWithRef) {
}
}
if (shareId) {
navigateTo = `/share/${shareId}${navigateTo}`;
}
history.push(navigateTo);
} else if (href) {
window.open(href, "_blank");
}
},
[history]
[history, shareId]
);
const onShowToast = React.useCallback(
@@ -122,6 +134,7 @@ function Editor(props: PropsWithRef) {
deleteRow: t("Delete row"),
deleteTable: t("Delete table"),
deleteImage: t("Delete image"),
downloadImage: t("Download image"),
alignImageLeft: t("Float left"),
alignImageRight: t("Float right"),
alignImageDefault: t("Center large"),
@@ -175,6 +188,7 @@ function Editor(props: PropsWithRef) {
tooltip={EditorTooltip}
dictionary={dictionary}
{...props}
theme={isPrinting ? light : props.theme}
/>
</ErrorBoundary>
);
+27 -31
View File
@@ -1,45 +1,41 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import styled, { withTheme } from "styled-components";
import styled from "styled-components";
import User from "models/User";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
type Props = {
type Props = {|
users: User[],
size?: number,
overflow: number,
renderAvatar: (user: User) => React.Node,
};
onClick?: (event: SyntheticEvent<>) => mixed,
renderAvatar?: (user: User) => React.Node,
|};
@observer
class Facepile extends React.Component<Props> {
render() {
const {
users,
overflow,
size = 32,
renderAvatar = renderDefaultAvatar,
...rest
} = this.props;
return (
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>+{overflow}</span>
</More>
)}
{users.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
}
function Facepile({
users,
overflow,
size = 32,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
return (
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>+{overflow}</span>
</More>
)}
{users.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
}
function renderDefaultAvatar(user: User) {
function DefaultAvatar(user: User) {
return <Avatar user={user} src={user.avatarUrl} size={32} />;
}
@@ -73,4 +69,4 @@ const Avatars = styled(Flex)`
cursor: pointer;
`;
export default inject("views", "presence")(withTheme(Facepile));
export default observer(Facepile);
@@ -5,7 +5,8 @@ import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import Button, { Inner } from "components/Button";
import ContextMenu from "components/ContextMenu";
import FilterOption from "./FilterOption";
import MenuItem from "components/ContextMenu/MenuItem";
import HelpText from "components/HelpText";
type TFilterOption = {|
key: string,
@@ -35,7 +36,7 @@ const FilterOptions = ({
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
return (
<SearchFilter>
<Wrapper>
<MenuButton {...menu}>
{(props) => (
<StyledButton
@@ -50,30 +51,49 @@ const FilterOptions = ({
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} {...menu}>
<List>
{options.map((option) => (
<FilterOption
key={option.key}
onSelect={() => {
onSelect(option.key);
menu.hide();
}}
active={option.key === activeKey}
{...option}
{...menu}
/>
))}
</List>
{options.map((option) => (
<MenuItem
key={option.key}
onClick={() => {
onSelect(option.key);
menu.hide();
}}
selected={option.key === activeKey}
{...menu}
>
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
))}
</ContextMenu>
</SearchFilter>
</Wrapper>
);
};
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
`;
const Note = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
line-height: 1.2em;
font-size: 14px;
font-weight: 400;
color: ${(props) => props.theme.textTertiary};
`;
const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
border-color: transparent;
height: 28px;
&:hover {
background: transparent;
@@ -84,14 +104,8 @@ const StyledButton = styled(Button)`
}
`;
const SearchFilter = styled.div`
const Wrapper = styled.div`
margin-right: 8px;
`;
const List = styled("ol")`
list-style: none;
margin: 0;
padding: 0 8px;
`;
export default FilterOptions;
+2
View File
@@ -25,6 +25,7 @@ type Props = {|
className?: string,
children?: React.Node,
role?: string,
gap?: number,
|};
const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => {
@@ -44,6 +45,7 @@ const Container = styled.div`
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => `${gap}px` || "initial"};
min-height: 0;
min-width: 0;
`;
+4 -1
View File
@@ -32,7 +32,10 @@ import NudeButton from "components/NudeButton";
const style = { width: 30, height: 30 };
const TwitterPicker = React.lazy(() =>
import("react-color/lib/components/twitter/Twitter")
import(
/* webpackChunkName: "twitter-picker" */
"react-color/lib/components/twitter/Twitter"
)
);
export const icons = {
+3 -2
View File
@@ -3,13 +3,14 @@ import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Input from "./Input";
import Input, { type Props as InputProps } from "./Input";
type Props = {|
...InputProps,
placeholder?: string,
value?: string,
onChange: (event: SyntheticInputEvent<>) => mixed,
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|};
export default function InputSearch(props: Props) {
+4
View File
@@ -16,6 +16,10 @@ const Select = styled.select`
color: ${(props) => props.theme.text};
height: 30px;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
}
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
+1 -1
View File
@@ -202,7 +202,7 @@ const Content = styled(Flex)`
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
@media print {
margin: 0;
margin: 0 !important;
}
${breakpoint("mobile", "tablet")`
+26 -11
View File
@@ -8,17 +8,26 @@ type Props = {
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
small?: boolean,
};
const ListItem = ({ image, title, subtitle, actions }: Props) => {
const ListItem = ({
image,
title,
subtitle,
actions,
small,
border,
}: Props) => {
const compact = !subtitle;
return (
<Wrapper compact={compact}>
<Wrapper compact={compact} $border={border}>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Heading>{title}</Heading>
{subtitle && <Subtitle>{subtitle}</Subtitle>}
<Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
</Content>
{actions && <Actions>{actions}</Actions>}
</Wrapper>
@@ -27,9 +36,11 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
const Wrapper = styled.li`
display: flex;
padding: 10px 0;
margin: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
border-bottom: 1px solid
${(props) =>
props.$border === false ? "transparent" : props.theme.divider};
&:last-child {
border-bottom: 0;
@@ -46,9 +57,12 @@ const Image = styled(Flex)`
`;
const Heading = styled.p`
font-size: 16px;
font-size: ${(props) => (props.$small ? 15 : 16)}px;
font-weight: 500;
line-height: 1.1;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1.2;
margin: 0;
`;
@@ -58,8 +72,9 @@ const Content = styled(Flex)`
const Subtitle = styled.p`
margin: 0;
font-size: 14px;
color: ${(props) => props.theme.slate};
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) => props.theme.textTertiary};
margin-top: -2px;
`;
const Actions = styled.div`
+27 -13
View File
@@ -1,20 +1,34 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import format from "date-fns/format";
import { format, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
fr,
es,
it,
ko,
ptBR,
pt,
zhCN,
zhTW,
ru,
} from "date-fns/locale";
import * as React from "react";
import Tooltip from "components/Tooltip";
import useUserLocale from "hooks/useUserLocale";
const locales = {
en: require(`date-fns/locale/en`),
de: require(`date-fns/locale/de`),
es: require(`date-fns/locale/es`),
fr: require(`date-fns/locale/fr`),
it: require(`date-fns/locale/it`),
ko: require(`date-fns/locale/ko`),
pt: require(`date-fns/locale/pt`),
zh: require(`date-fns/locale/zh_cn`),
ru: require(`date-fns/locale/ru`),
en_US: enUS,
de_DE: de,
es_ES: es,
fr_FR: fr,
it_IT: it,
ko_KR: ko,
pt_BR: ptBR,
pt_PT: pt,
zh_CN: zhCN,
zh_TW: zhTW,
ru_RU: ru,
};
let callbacks = [];
@@ -64,7 +78,7 @@ function LocaleTime({
};
}, []);
let content = distanceInWordsToNow(dateTime, {
let content = formatDistanceToNow(Date.parse(dateTime), {
addSuffix,
locale: userLocale ? locales[userLocale] : undefined,
});
@@ -78,7 +92,7 @@ function LocaleTime({
return (
<Tooltip
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
delay={tooltipDelay}
placement="bottom"
>
+4 -2
View File
@@ -8,6 +8,8 @@ import Flex from "components/Flex";
type Props = {|
header?: boolean,
height?: number,
minWidth?: number,
maxWidth?: number,
|};
class Mask extends React.Component<Props> {
@@ -17,9 +19,9 @@ class Mask extends React.Component<Props> {
return false;
}
constructor() {
constructor(props: Props) {
super();
this.width = randomInteger(75, 100);
this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100);
}
render() {
+2 -2
View File
@@ -3,8 +3,8 @@ import * as React from "react";
import styled from "styled-components";
const Button = styled.button`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
width: ${(props) => props.width || props.size}px;
height: ${(props) => props.height || props.size}px;
background: none;
border-radius: 4px;
line-height: 0;
+14 -4
View File
@@ -38,14 +38,24 @@ class PaginatedList extends React.Component<Props> {
}
componentDidUpdate(prevProps: Props) {
if (prevProps.fetch !== this.props.fetch) {
this.fetchResults();
}
if (!isEqual(prevProps.options, this.props.options)) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
) {
this.reset();
this.fetchResults();
}
}
reset = () => {
this.offset = 0;
this.allowLoadMore = true;
this.renderCount = DEFAULT_PAGINATION_LIMIT;
this.isFetching = false;
this.isFetchingMore = false;
this.isLoaded = false;
};
fetchResults = async () => {
if (!this.props.fetch) return;
+84
View File
@@ -0,0 +1,84 @@
// @flow
import "../stores";
import { shallow } from "enzyme";
import * as React from "react";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import { runAllPromises } from "../test/support";
import PaginatedList from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;
it("with no items renders nothing", () => {
const list = shallow(<PaginatedList items={[]} renderItem={render} />);
expect(list).toEqual({});
});
it("with no items renders empty prop", () => {
const list = shallow(
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
/>
);
expect(list.text()).toEqual("Sorry, no results");
});
it("calls fetch with options + pagination on mount", () => {
const fetch = jest.fn();
const options = { id: "one" };
shallow(
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
/>
);
expect(fetch).toHaveBeenCalledWith({
...options,
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
});
it("calls fetch when options prop changes", async () => {
const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill();
const fetch = jest.fn().mockReturnValue(fetchedItems);
const list = shallow(
<PaginatedList
items={[]}
fetch={fetch}
options={{ id: "one" }}
renderItem={render}
/>
);
await runAllPromises();
expect(fetch).toHaveBeenCalledWith({
id: "one",
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
fetch.mockReset();
list.setProps({
fetch,
items: [],
options: { id: "two" },
});
await runAllPromises();
expect(fetch).toHaveBeenCalledWith({
id: "two",
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
});
});
+34
View File
@@ -0,0 +1,34 @@
// @flow
import * as React from "react";
import { Popover as ReakitPopover } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
type Props = {
children: React.Node,
width?: number,
};
function Popover({ children, width = 380, ...rest }: Props) {
return (
<ReakitPopover {...rest}>
<Contents width={width}>{children}</Contents>
</ReakitPopover>
);
}
const Contents = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: 12px 24px;
max-height: 50vh;
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;
+1 -1
View File
@@ -179,7 +179,7 @@ function MainSidebar() {
/>
{can.inviteUser && (
<SidebarLink
to="/settings/people"
to="/settings/members"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
+2 -2
View File
@@ -96,10 +96,10 @@ function SettingsSidebar() {
/>
)}
<SidebarLink
to="/settings/people"
to="/settings/members"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("People")}
label={t("Members")}
/>
<SidebarLink
to="/settings/groups"
@@ -20,6 +20,7 @@ type Props = {
function Collections({ onCreateCollection }: Props) {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { ui, policies, documents, collections } = useStores();
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
@@ -32,17 +33,25 @@ function Collections({ onCreateCollection }: Props) {
React.useEffect(() => {
async function load() {
if (!collections.isLoaded && !isFetching) {
if (!collections.isLoaded && !isFetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({ limit: 100 });
} catch (error) {
ui.showToast(
t("Collections could not be loaded, please reload the app"),
{
type: "error",
}
);
setFetchError(error);
} finally {
setFetching(false);
}
}
}
load();
}, [collections, isFetching]);
}, [collections, isFetching, ui, fetchError, t]);
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
accept: "collection",
@@ -92,7 +101,7 @@ function Collections({ onCreateCollection }: Props) {
</>
);
if (!collections.isLoaded) {
if (!collections.isLoaded || fetchError) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
@@ -27,16 +27,19 @@ type Props = {|
parentId?: string,
|};
function DocumentLink({
node,
canUpdate,
collection,
activeDocument,
prefetchDocument,
depth,
index,
parentId,
}: Props) {
function DocumentLink(
{
node,
canUpdate,
collection,
activeDocument,
prefetchDocument,
depth,
index,
parentId,
}: Props,
ref
) {
const { documents, policies } = useStores();
const { t } = useTranslation();
@@ -236,6 +239,7 @@ function DocumentLink({
depth={depth}
exact={false}
showActions={menuOpen}
ref={ref}
menu={
document && !isMoving ? (
<Fade>
@@ -282,11 +286,13 @@ const Draggable = styled("div")`
`;
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
const ObservedDocumentLink = observer(DocumentLink);
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
export default ObservedDocumentLink;
@@ -65,6 +65,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
{isEditing ? (
<form onSubmit={handleSave}>
<Input
dir="auto"
type="text"
value={value}
onKeyDown={handleKeyDown}
@@ -7,7 +7,7 @@ const ResizeBorder = styled.div`
bottom: 0;
right: -6px;
width: 12px;
cursor: ew-resize;
cursor: col-resize;
`;
export default ResizeBorder;
@@ -1,4 +1,5 @@
// @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";
@@ -29,25 +30,28 @@ type Props = {
depth?: number,
};
function SidebarLink({
icon,
children,
onClick,
onMouseEnter,
to,
label,
active,
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
}: Props) {
function SidebarLink(
{
icon,
children,
onClick,
onMouseEnter,
to,
label,
active,
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
}: Props,
ref
) {
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
@@ -78,6 +82,7 @@ function SidebarLink({
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
@@ -139,30 +144,33 @@ const Link = styled(NavLink)`
transition: fill 50ms;
}
&:hover {
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
&:focus {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.black05};
}
&:hover + ${Actions},
&:active + ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
}
background: ${(props) =>
transparentize("0.25", props.theme.sidebarItemBackground)};
}
${breakpoint("tablet")`
padding: 4px 32px 4px 16px;
font-size: 15px;
`}
@media (hover: hover) {
&:hover + ${Actions},
&:active + ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
}
}
&:hover {
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
}
`;
const Label = styled.div`
@@ -170,6 +178,9 @@ const Label = styled.div`
width: 100%;
max-height: 4.8em;
line-height: 1.6;
* {
unicode-bidi: plaintext;
}
`;
export default withRouter(withTheme(SidebarLink));
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
+6 -1
View File
@@ -144,9 +144,10 @@ class SocketProvider extends React.Component<Props> {
// otherwise, grab the latest version of the document
try {
document = await documents.fetch(documentId, {
const response = await documents.fetch(documentId, {
force: true,
});
document = response.document;
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
documents.remove(documentId);
@@ -249,6 +250,10 @@ class SocketProvider extends React.Component<Props> {
documents.starredIds.set(event.documentId, false);
});
this.socket.on("documents.permanent_delete", (event) => {
documents.remove(event.documentId);
});
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on("collections.add_user", (event) => {
+4
View File
@@ -61,6 +61,10 @@ export const AnimatedStar = styled(StarredIcon)`
&:active {
transform: scale(0.95);
}
@media print {
display: none;
}
`;
export default Star;
+250
View File
@@ -0,0 +1,250 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTable, useSortBy, usePagination } from "react-table";
import styled from "styled-components";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import Mask from "components/Mask";
export type Props = {|
data: any[],
offset?: number,
isLoading: boolean,
empty?: React.Node,
currentPage?: number,
page: number,
pageSize?: number,
totalPages?: number,
defaultSort?: string,
topRef?: React.Ref<any>,
onChangePage: (index: number) => void,
onChangeSort: (sort: ?string, direction: "ASC" | "DESC") => void,
columns: any,
|};
function Table({
data,
offset,
isLoading,
totalPages,
empty,
columns,
page,
pageSize = 50,
defaultSort = "name",
topRef,
onChangeSort,
onChangePage,
}: Props) {
const { t } = useTranslation();
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
canNextPage,
nextPage,
canPreviousPage,
previousPage,
state: { pageIndex, sortBy },
} = useTable(
{
columns,
data,
manualPagination: true,
manualSortBy: true,
autoResetSortBy: false,
autoResetPage: false,
pageCount: totalPages,
initialState: {
sortBy: [{ id: defaultSort, desc: false }],
pageSize,
pageIndex: page,
},
},
useSortBy,
usePagination
);
React.useEffect(() => {
onChangePage(pageIndex);
}, [pageIndex]);
React.useEffect(() => {
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
sortBy.length && sortBy[0].desc ? "DESC" : "ASC"
);
}, [sortBy]);
const isEmpty = !isLoading && data.length === 0;
const showPlaceholder = isLoading && data.length === 0;
console.log({ canNextPage, pageIndex, totalPages, rows, data });
return (
<>
<Anchor ref={topRef} />
<InnerTable {...getTableProps()}>
<thead>
{headerGroups.map((headerGroup) => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column) => (
<Head {...column.getHeaderProps(column.getSortByToggleProps())}>
<SortWrapper align="center" gap={4}>
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
<DescSortIcon />
) : (
<AscSortIcon />
))}
</SortWrapper>
</Head>
))}
</tr>
))}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row);
return (
<Row {...row.getRowProps()}>
{row.cells.map((cell) => (
<Cell
{...cell.getCellProps([
{
className: cell.column.className,
},
])}
>
{cell.render("Cell")}
</Cell>
))}
</Row>
);
})}
</tbody>
{showPlaceholder && <Placeholder columns={columns.length} />}
</InnerTable>
{isEmpty ? (
empty || <Empty>{t("No results")}</Empty>
) : (
<Pagination
justify={canPreviousPage ? "space-between" : "flex-end"}
gap={8}
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={previousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={nextPage} neutral>
{t("Next page")}
</Button>
)}
</Pagination>
)}
</>
);
}
export const Placeholder = ({
columns,
rows = 3,
}: {
columns: number,
rows?: number,
}) => {
return (
<tbody>
{new Array(rows).fill().map((_, row) => (
<Row key={row}>
{new Array(columns).fill().map((_, col) => (
<Cell key={col}>
<Mask minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
);
};
const Anchor = styled.div`
top: -32px;
position: relative;
`;
const Pagination = styled(Flex)`
margin: 0 0 32px;
`;
const DescSortIcon = styled(CollapsedIcon)`
&:hover {
fill: ${(props) => props.theme.text};
}
`;
const AscSortIcon = styled(DescSortIcon)`
transform: rotate(180deg);
`;
const InnerTable = styled.table`
border-collapse: collapse;
margin: 16px 0;
width: 100%;
`;
const SortWrapper = styled(Flex)`
height: 24px;
`;
const Cell = styled.td`
padding: 8px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
font-size: 14px;
&:first-child {
font-size: 15px;
font-weight: 500;
}
&.actions,
&.right-aligned {
text-align: right;
vertical-align: bottom;
}
`;
const Row = styled.tr`
&:last-child {
${Cell} {
border-bottom: 0;
}
}
`;
const Head = styled.th`
text-align: left;
position: sticky;
top: 54px;
padding: 6px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
font-size: 14px;
color: ${(props) => props.theme.textSecondary};
font-weight: 500;
z-index: 1;
`;
export default observer(Table);
+5 -3
View File
@@ -1,8 +1,10 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import { formatDistanceToNow } from "date-fns";
import * as React from "react";
const LocaleTime = React.lazy(() => import("components/LocaleTime"));
const LocaleTime = React.lazy(() =>
import(/* webpackChunkName: "locale-time" */ "components/LocaleTime")
);
type Props = {
dateTime: string,
@@ -13,7 +15,7 @@ type Props = {
};
function Time(props: Props) {
let content = distanceInWordsToNow(props.dateTime, {
let content = formatDistanceToNow(Date.parse(props.dateTime), {
addSuffix: props.addSuffix,
});
+28
View File
@@ -0,0 +1,28 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Descript extends React.Component<Props> {
static ENABLED = [new RegExp("https?://share.descript.com/view/(\\w+)$")];
render() {
const { matches } = this.props.attrs;
const shareId = matches[1];
return (
<Frame
{...this.props}
src={`https://share.descript.com/embed/${shareId}`}
title={`Descript (${shareId})`}
width="400px"
/>
);
}
}
+4 -1
View File
@@ -17,7 +17,10 @@ export default class Mindmeister extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const chartId = this.props.attrs.matches[4] + this.props.attrs.matches[6];
const chartId =
this.props.attrs.matches[4] +
(this.props.attrs.matches[5] || "") +
(this.props.attrs.matches[6] || "");
return (
<Frame
+9 -9
View File
@@ -11,9 +11,7 @@ import Flex from "components/Flex";
const Iframe = (props) => <iframe title="Embed" {...props} />;
const StyledIframe = styled(Iframe)`
border: 1px solid;
border-color: ${(props) => props.theme.embedBorder};
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
border-radius: ${(props) => (props.$withBar ? "3px 3px 0 0" : "3px")};
display: block;
`;
@@ -70,13 +68,13 @@ class Frame extends React.Component<PropsWithRef> {
<Rounded
width={width}
height={height}
withBar={withBar}
$withBar={withBar}
className={isSelected ? "ProseMirror-selectednode" : ""}
>
{this.isLoaded && (
<Component
ref={forwardedRef}
withBar={withBar}
$withBar={withBar}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
width={width}
height={height}
@@ -108,10 +106,11 @@ class Frame extends React.Component<PropsWithRef> {
}
const Rounded = styled.div`
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
border: 1px solid ${(props) => props.theme.embedBorder};
border-radius: 6px;
overflow: hidden;
width: ${(props) => props.width};
height: ${(props) => (props.withBar ? props.height + 28 : props.height)};
height: ${(props) => (props.$withBar ? props.height + 28 : props.height)};
`;
const Open = styled.a`
@@ -132,11 +131,12 @@ const Title = styled.span`
`;
const Bar = styled(Flex)`
border-top: 1px solid ${(props) => props.theme.embedBorder};
background: ${(props) => props.theme.secondaryBackground};
color: ${(props) => props.theme.textSecondary};
padding: 0 8px;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
user-select: none;
`;
+8
View File
@@ -7,6 +7,7 @@ import Airtable from "./Airtable";
import Cawemo from "./Cawemo";
import ClickUp from "./ClickUp";
import Codepen from "./Codepen";
import Descript from "./Descript";
import Figma from "./Figma";
import Framer from "./Framer";
import Gist from "./Gist";
@@ -85,6 +86,13 @@ export default [
component: Codepen,
matcher: matcher(Codepen),
},
{
title: "Descript",
keywords: "audio",
icon: () => <Img src="/images/descript.png" />,
component: Descript,
matcher: matcher(Descript),
},
{
title: "Figma",
keywords: "design svg vector",
+1 -1
View File
@@ -36,7 +36,7 @@ export default function useImportDocument(
const redirect = files.length === 1;
if (documentId && !collectionId) {
const document = await documents.fetch(documentId);
const { document } = await documents.fetch(documentId);
invariant(document, "Document not available");
cId = document.collectionId;
}
+10 -8
View File
@@ -5,15 +5,17 @@ export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
if (window.matchMedia) {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => {
setMatches(media.matches);
};
media.addListener(listener);
return () => media.removeListener(listener);
}
const listener = () => {
setMatches(media.matches);
};
media.addListener(listener);
return () => media.removeListener(listener);
}, [matches, query]);
return matches;
+1 -1
View File
@@ -8,5 +8,5 @@ export default function useUserLocale() {
return undefined;
}
return auth.user.language.split("_")[0];
return auth.user.language;
}
+52
View File
@@ -0,0 +1,52 @@
// @flow
// Based on https://github.com/rehooks/window-scroll-position which is no longer
// maintained.
import { throttle } from "lodash";
import { useState, useEffect } from "react";
let supportsPassive = false;
try {
var opts = Object.defineProperty({}, "passive", {
get: function () {
supportsPassive = true;
},
});
window.addEventListener("testPassive", null, opts);
window.removeEventListener("testPassive", null, opts);
} catch (e) {}
const getPosition = () => ({
x: window.pageXOffset,
y: window.pageYOffset,
});
const defaultOptions = {
throttle: 100,
};
export default function useWindowScrollPosition(options: {
throttle: number,
}): { x: number, y: number } {
let opts = Object.assign({}, defaultOptions, options);
let [position, setPosition] = useState(getPosition());
useEffect(() => {
let handleScroll = throttle(() => {
setPosition(getPosition());
}, opts.throttle);
window.addEventListener(
"scroll",
handleScroll,
supportsPassive ? { passive: true } : false
);
return () => {
handleScroll.cancel();
window.removeEventListener("scroll", handleScroll);
};
}, [opts.throttle]);
return position;
}
+20 -18
View File
@@ -51,23 +51,25 @@ if ("serviceWorker" in window.navigator) {
if (element) {
const App = () => (
<Provider {...stores}>
<Analytics>
<Theme>
<ErrorBoundary>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</ErrorBoundary>
</Theme>
</Analytics>
</Provider>
<React.StrictMode>
<Provider {...stores}>
<Analytics>
<Theme>
<ErrorBoundary>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</ErrorBoundary>
</Theme>
</Analytics>
</Provider>
</React.StrictMode>
);
render(<App />, element);
@@ -79,7 +81,7 @@ window.addEventListener("load", async () => {
if (!env.GOOGLE_ANALYTICS_ID || !window.ga) return;
// https://github.com/googleanalytics/autotrack/issues/137#issuecomment-305890099
await import("autotrack/autotrack.js");
await import(/* webpackChunkName: "autotrack" */ "autotrack/autotrack.js");
window.ga("require", "outboundLinkTracker");
window.ga("require", "urlChangeTracker");
+1 -1
View File
@@ -33,7 +33,7 @@ const AppearanceMenu = React.forwardRef((props, ref) => {
return (
<>
<MenuButton ref={ref} {...menu} {...props}>
<MenuButton ref={ref} {...menu} {...props} onClick={menu.show}>
{(props) => (
<MenuAnchor {...props}>
<ChangeTheme justify="space-between">
+9 -9
View File
@@ -6,11 +6,17 @@ import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type MenuItem = {|
icon?: React.Node,
title: React.Node,
to?: string,
|};
type Props = {
path: Array<any>,
items: MenuItem[],
};
export default function BreadcrumbMenu({ path }: Props) {
export default function BreadcrumbMenu({ items }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
@@ -21,13 +27,7 @@ export default function BreadcrumbMenu({ path }: Props) {
<>
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
<ContextMenu {...menu} aria-label={t("Path to document")}>
<Template
{...menu}
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
+48 -42
View File
@@ -12,7 +12,7 @@ import CollectionExport from "scenes/CollectionExport";
import CollectionPermissions from "scenes/CollectionPermissions";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import getDataTransferFiles from "utils/getDataTransferFiles";
@@ -110,6 +110,52 @@ function CollectionMenu({
);
const can = policies.abilities(collection.id);
const items = React.useMemo(
() =>
filterTemplateItems([
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]),
[can, collection, handleNewDocument, handleImportDocument, t]
);
if (!items.length) {
return null;
}
return (
<>
@@ -134,47 +180,7 @@ function CollectionMenu({
onClose={onClose}
aria-label={t("Collection")}
>
<Template
{...menu}
items={[
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]}
/>
<Template {...menu} items={items} />
</ContextMenu>
{renderModals && (
<>
+64 -39
View File
@@ -9,6 +9,7 @@ import styled from "styled-components";
import Document from "models/Document";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentMove from "scenes/DocumentMove";
import DocumentPermanentDelete from "scenes/DocumentPermanentDelete";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import CollectionIcon from "components/CollectionIcon";
import ContextMenu from "components/ContextMenu";
@@ -61,6 +62,10 @@ function DocumentMenu({
const { t } = useTranslation();
const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [
showPermanentDeleteModal,
setShowPermanentDeleteModal,
] = React.useState(false);
const [showMoveModal, setShowMoveModal] = React.useState(false);
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
const file = React.useRef<?HTMLInputElement>();
@@ -218,12 +223,7 @@ function DocumentMenu({
items={[
{
title: t("Restore"),
visible: !!can.unarchive,
onClick: handleRestore,
},
{
title: t("Restore"),
visible: !!(collection && can.restore),
visible: (!!collection && can.restore) || can.unarchive,
onClick: handleRestore,
},
{
@@ -332,6 +332,11 @@ function DocumentMenu({
onClick: () => setShowDeleteModal(true),
visible: !!can.delete,
},
{
title: `${t("Permanently delete")}`,
onClick: () => setShowPermanentDeleteModal(true),
visible: can.permanentDelete,
},
{
title: `${t("Move")}`,
onClick: () => setShowMoveModal(true),
@@ -362,40 +367,60 @@ function DocumentMenu({
</ContextMenu>
{renderModals && (
<>
<Modal
title={t("Move {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowMoveModal(false)}
isOpen={showMoveModal}
>
<DocumentMove
document={document}
{can.move && (
<Modal
title={t("Move {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowMoveModal(false)}
/>
</Modal>
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowDeleteModal(false)}
isOpen={showDeleteModal}
>
<DocumentDelete
document={document}
onSubmit={() => setShowDeleteModal(false)}
/>
</Modal>
<Modal
title={t("Create template")}
onRequestClose={() => setShowTemplateModal(false)}
isOpen={showTemplateModal}
>
<DocumentTemplatize
document={document}
onSubmit={() => setShowTemplateModal(false)}
/>
</Modal>
isOpen={showMoveModal}
>
<DocumentMove
document={document}
onRequestClose={() => setShowMoveModal(false)}
/>
</Modal>
)}
{can.delete && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowDeleteModal(false)}
isOpen={showDeleteModal}
>
<DocumentDelete
document={document}
onSubmit={() => setShowDeleteModal(false)}
/>
</Modal>
)}
{can.permanentDelete && (
<Modal
title={t("Permanently delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowPermanentDeleteModal(false)}
isOpen={showPermanentDeleteModal}
>
<DocumentPermanentDelete
document={document}
onSubmit={() => setShowPermanentDeleteModal(false)}
/>
</Modal>
)}
{can.update && (
<Modal
title={t("Create template")}
onRequestClose={() => setShowTemplateModal(false)}
isOpen={showTemplateModal}
>
<DocumentTemplatize
document={document}
onSubmit={() => setShowTemplateModal(false)}
/>
</Modal>
)}
</>
)}
</>
+1 -1
View File
@@ -25,7 +25,7 @@ function NewDocumentMenu() {
const can = policies.abilities(team.id);
if (!can.createDocument) {
return;
return null;
}
if (singleCollection) {
+1 -1
View File
@@ -23,7 +23,7 @@ function NewTemplateMenu() {
const can = policies.abilities(team.id);
if (!can.createDocument) {
return;
return null;
}
return (
+32 -16
View File
@@ -9,6 +9,7 @@ import Document from "models/Document";
import Button from "components/Button";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import Separator from "components/ContextMenu/Separator";
import useStores from "hooks/useStores";
type Props = {|
@@ -19,12 +20,36 @@ function TemplatesMenu({ document }: Props) {
const menu = useMenuState({ modal: true });
const { documents } = useStores();
const { t } = useTranslation();
const templates = documents.templatesInCollection(document.collectionId);
const templates = documents.templates;
if (!templates.length) {
return null;
}
const templatesInCollection = templates.filter(
(t) => t.collectionId === document.collectionId
);
const otherTemplates = templates.filter(
(t) => t.collectionId !== document.collectionId
);
const renderTemplate = (template) => (
<MenuItem
key={template.id}
onClick={() => document.updateFromTemplate(template)}
{...menu}
>
<DocumentIcon />
<div>
<strong>{template.titleWithDefault}</strong>
<br />
<Author>
{t("By {{ author }}", { author: template.createdBy.name })}
</Author>
</div>
</MenuItem>
);
return (
<>
<MenuButton {...menu}>
@@ -35,21 +60,11 @@ function TemplatesMenu({ document }: Props) {
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Templates")}>
{templates.map((template) => (
<MenuItem
key={template.id}
onClick={() => document.updateFromTemplate(template)}
>
<DocumentIcon />
<div>
<strong>{template.titleWithDefault}</strong>
<br />
<Author>
{t("By {{ author }}", { author: template.createdBy.name })}
</Author>
</div>
</MenuItem>
))}
{templatesInCollection.map(renderTemplate)}
{otherTemplates.length && templatesInCollection.length ? (
<Separator />
) : undefined}
{otherTemplates.map(renderTemplate)}
</ContextMenu>
</>
);
@@ -57,6 +72,7 @@ function TemplatesMenu({ document }: Props) {
const Author = styled.div`
font-size: 13px;
text-align: left;
`;
export default observer(TemplatesMenu);
+1
View File
@@ -24,6 +24,7 @@ export default class Collection extends BaseModel {
deletedAt: ?string;
sort: { field: string, direction: "asc" | "desc" };
url: string;
urlId: string;
@computed
get isEmpty(): boolean {
+21 -2
View File
@@ -1,6 +1,5 @@
// @flow
import addDays from "date-fns/add_days";
import differenceInDays from "date-fns/difference_in_days";
import { addDays, differenceInDays } from "date-fns";
import invariant from "invariant";
import { action, computed, observable, set } from "mobx";
import parseTitle from "shared/utils/parseTitle";
@@ -59,6 +58,26 @@ export default class Document extends BaseModel {
return emoji;
}
/**
* Best-guess the text direction of the document based on the language the
* title is written in. Note: wrapping as a computed getter means that it will
* only be called directly when the title changes.
*/
@computed
get dir(): "rtl" | "ltr" {
const element = document.createElement("p");
element.innerHTML = this.title;
element.style.visibility = "hidden";
element.dir = "auto";
// element must appear in body for direction to be computed
document.body?.appendChild(element);
const direction = window.getComputedStyle(element).direction;
document.body?.removeChild(element);
return direction;
}
@computed
get noun(): string {
return this.template ? "template" : "document";
+1
View File
@@ -10,6 +10,7 @@ class Share extends BaseModel {
documentTitle: string;
documentUrl: string;
lastAccessedAt: ?string;
includeChildDocuments: boolean;
createdBy: User;
createdAt: string;
updatedAt: string;
+45 -38
View File
@@ -3,7 +3,6 @@ import * as React from "react";
import { Switch, Redirect, type Match } from "react-router-dom";
import Archive from "scenes/Archive";
import Collection from "scenes/Collection";
import KeyedDocument from "scenes/Document/KeyedDocument";
import DocumentNew from "scenes/DocumentNew";
import Drafts from "scenes/Drafts";
import Error404 from "scenes/Error404";
@@ -20,8 +19,14 @@ import Route from "components/ProfiledRoute";
import SocketProvider from "components/SocketProvider";
import { matchDocumentSlug as slug } from "utils/routeHelpers";
const SettingsRoutes = React.lazy(() => import("./settings"));
const SettingsRoutes = React.lazy(() =>
import(/* webpackChunkName: "settings" */ "./settings")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
);
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
<Redirect
@@ -35,42 +40,44 @@ export default function AuthenticatedRoutes() {
return (
<SocketProvider>
<Layout>
<Switch>
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/templates" component={Templates} />
<Route exact path="/templates/:sort" component={Templates} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} />
<Route exact path="/collections/:id/new" component={DocumentNew} />
<Route exact path="/collections/:id/:tab" component={Collection} />
<Route exact path="/collections/:id" component={Collection} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
/>
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
<React.Suspense
fallback={
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
}
>
<React.Suspense
fallback={
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
}
>
<Switch>
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/templates" component={Templates} />
<Route exact path="/templates/:sort" component={Templates} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route exact path="/collection/:id/:tab" component={Collection} />
<Route exact path="/collection/:id" component={Collection} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
/>
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
<SettingsRoutes />
</React.Suspense>
<Route component={NotFound} />
</Switch>
<Route component={NotFound} />
</Switch>{" "}
</React.Suspense>
</Layout>
</SocketProvider>
);
+23 -5
View File
@@ -4,12 +4,25 @@ import { Switch } from "react-router-dom";
import DelayedMount from "components/DelayedMount";
import FullscreenLoading from "components/FullscreenLoading";
import Route from "components/ProfiledRoute";
import { matchDocumentSlug as slug } from "utils/routeHelpers";
const Authenticated = React.lazy(() => import("components/Authenticated"));
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
const KeyedDocument = React.lazy(() => import("scenes/Document/KeyedDocument"));
const Login = React.lazy(() => import("scenes/Login"));
const Logout = React.lazy(() => import("scenes/Logout"));
const Authenticated = React.lazy(() =>
import(/* webpackChunkName: "authenticated" */ "components/Authenticated")
);
const AuthenticatedRoutes = React.lazy(() =>
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
);
const Login = React.lazy(() =>
import(/* webpackChunkName: "login" */ "scenes/Login")
);
const Logout = React.lazy(() =>
import(/* webpackChunkName: "logout" */ "scenes/Logout")
);
export default function Routes() {
return (
@@ -25,6 +38,11 @@ export default function Routes() {
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={KeyedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
</Authenticated>
+3 -3
View File
@@ -1,6 +1,6 @@
// @flow
import * as React from "react";
import { Switch } from "react-router-dom";
import { Switch, Redirect } from "react-router-dom";
import Details from "scenes/Settings/Details";
import Groups from "scenes/Settings/Groups";
import ImportExport from "scenes/Settings/ImportExport";
@@ -20,8 +20,7 @@ export default function SettingsRoutes() {
<Route exact path="/settings" component={Profile} />
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/people" component={People} />
<Route exact path="/settings/people/:filter" component={People} />
<Route exact path="/settings/members" component={People} />
<Route exact path="/settings/groups" component={Groups} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
@@ -29,6 +28,7 @@ export default function SettingsRoutes() {
<Route exact path="/settings/integrations/slack" component={Slack} />
<Route exact path="/settings/integrations/zapier" component={Zapier} />
<Route exact path="/settings/import-export" component={ImportExport} />
<Redirect from="/settings/people" to="/settings/members" />
</Switch>
);
}
+65
View File
@@ -0,0 +1,65 @@
// @flow
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import useStores from "hooks/useStores";
type Props = {|
onSubmit: () => void,
|};
function APITokenNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [isSaving, setIsSaving] = React.useState(false);
const { apiKeys, ui } = useStores();
const { t } = useTranslation();
const handleSubmit = React.useCallback(async () => {
setIsSaving(true);
try {
await apiKeys.create({ name });
ui.showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
ui.showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
}, [t, ui, name, onSubmit, apiKeys]);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
}, []);
return (
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
Name your token something that will help you to remember it's use in
the future, for example "local development", "production", or
"continuous integration".
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? "Creating…" : "Create"}
</Button>
</form>
);
}
export default APITokenNew;
+65 -42
View File
@@ -4,7 +4,15 @@ import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import Dropzone from "react-dropzone";
import { useTranslation, Trans } from "react-i18next";
import { useParams, Redirect, Link, Switch, Route } from "react-router-dom";
import {
useParams,
Redirect,
Link,
Switch,
Route,
useHistory,
useRouteMatch,
} from "react-router-dom";
import styled, { css } from "styled-components";
import CollectionPermissions from "scenes/CollectionPermissions";
import Search from "scenes/Search";
@@ -29,15 +37,18 @@ import Subheading from "components/Subheading";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import Collection from "../models/Collection";
import { updateCollectionUrl } from "../utils/routeHelpers";
import useCurrentTeam from "hooks/useCurrentTeam";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useUnmount from "hooks/useUnmount";
import CollectionMenu from "menus/CollectionMenu";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
function CollectionScene() {
const params = useParams();
const history = useHistory();
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, policies, collections, ui } = useStores();
const team = useCurrentTeam();
@@ -45,11 +56,21 @@ function CollectionScene() {
const [error, setError] = React.useState();
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
const collectionId = params.id || "";
const collection = collections.get(collectionId);
const can = policies.abilities(collectionId || "");
const id = params.id || "";
const collection: ?Collection =
collections.getByUrl(id) || collections.get(id);
const can = policies.abilities(collection?.id || "");
const canUser = policies.abilities(team.id);
const { handleFiles, isImporting } = useImportDocument(collectionId);
const { handleFiles, isImporting } = useImportDocument(collection?.id || "");
React.useEffect(() => {
if (collection) {
const canonicalUrl = updateCollectionUrl(match.url, collection);
if (match.url !== canonicalUrl) {
history.replace(canonicalUrl);
}
}
}, [collection, history, id, match.url]);
React.useEffect(() => {
if (collection) {
@@ -59,8 +80,10 @@ function CollectionScene() {
React.useEffect(() => {
setError(null);
documents.fetchPinned({ collectionId });
}, [documents, collectionId]);
if (collection) {
documents.fetchPinned({ collectionId: collection.id });
}
}, [documents, collection]);
React.useEffect(() => {
async function load() {
@@ -68,7 +91,7 @@ function CollectionScene() {
try {
setError(null);
setFetching(true);
await collections.fetch(collectionId);
await collections.fetch(id);
} catch (err) {
setError(err);
} finally {
@@ -77,9 +100,7 @@ function CollectionScene() {
}
}
load();
}, [collections, isFetching, collection, error, collectionId, can]);
useUnmount(ui.clearActiveCollection);
}, [collections, isFetching, collection, error, id, can]);
const handlePermissionsModalOpen = React.useCallback(() => {
setPermissionsModalOpen(true);
@@ -124,29 +145,31 @@ function CollectionScene() {
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
collectionId={collectionId}
collectionId={collection.id}
/>
</Action>
{can.update && (
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
<Separator />
<Action>
<CollectionMenu
collection={collection}
@@ -257,27 +280,27 @@ function CollectionScene() {
)}
<Tabs>
<Tab to={collectionUrl(collection.id)} exact>
<Tab to={collectionUrl(collection.url)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.id, "updated")} exact>
<Tab to={collectionUrl(collection.url, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "published")} exact>
<Tab to={collectionUrl(collection.url, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.id, "old")} exact>
<Tab to={collectionUrl(collection.url, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab
to={collectionUrl(collection.id, "alphabetical")}
to={collectionUrl(collection.url, "alphabetical")}
exact
>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.id, "alphabetical")}>
<Route path={collectionUrl(collection.url, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
@@ -288,7 +311,7 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "old")}>
<Route path={collectionUrl(collection.url, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
@@ -299,12 +322,12 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "recent")}>
<Route path={collectionUrl(collection.url, "recent")}>
<Redirect
to={collectionUrl(collection.id, "published")}
to={collectionUrl(collection.url, "published")}
/>
</Route>
<Route path={collectionUrl(collection.id, "published")}>
<Route path={collectionUrl(collection.url, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
@@ -316,7 +339,7 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "updated")}>
<Route path={collectionUrl(collection.url, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
@@ -327,7 +350,7 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.id)} exact>
<Route path={collectionUrl(collection.url)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
+1 -1
View File
@@ -1,9 +1,9 @@
// @flow
import useWindowScrollPosition from "@rehooks/window-scroll-position";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import HelpText from "components/HelpText";
import useWindowScrollPosition from "hooks/useWindowScrollPosition";
const HEADING_OFFSET = 20;
+21 -11
View File
@@ -1,5 +1,5 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import { formatDistanceToNow } from "date-fns";
import invariant from "invariant";
import { deburr, sortBy } from "lodash";
import { observable } from "mobx";
@@ -22,11 +22,10 @@ import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
import SocketPresence from "./SocketPresence";
import { type LocationWithState } from "types";
import { type LocationWithState, type NavigationNode } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls";
type Props = {|
match: Match,
location: LocationWithState,
@@ -39,8 +38,11 @@ type Props = {|
history: RouterHistory,
|};
const sharedTreeCache = {};
@observer
class DataLoader extends React.Component<Props> {
sharedTree: ?NavigationNode;
@observable document: ?Document;
@observable revision: ?Revision;
@observable error: ?Error;
@@ -48,6 +50,9 @@ class DataLoader extends React.Component<Props> {
componentDidMount() {
const { documents, match } = this.props;
this.document = documents.getByUrl(match.params.documentSlug);
this.sharedTree = this.document
? sharedTreeCache[this.document.id]
: undefined;
this.loadDocument();
}
@@ -89,8 +94,11 @@ class DataLoader extends React.Component<Props> {
// search for exact internal document
const slug = parseDocumentSlug(term);
try {
const document = await this.props.documents.fetch(slug);
const time = distanceInWordsToNow(document.updatedAt, {
const {
document,
}: { document: Document } = await this.props.documents.fetch(slug);
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
addSuffix: true,
});
return [
@@ -113,7 +121,7 @@ class DataLoader extends React.Component<Props> {
return sortBy(
results.map((document) => {
const time = distanceInWordsToNow(document.updatedAt, {
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
addSuffix: true,
});
return {
@@ -159,10 +167,14 @@ class DataLoader extends React.Component<Props> {
}
try {
this.document = await this.props.documents.fetch(documentSlug, {
const response = await this.props.documents.fetch(documentSlug, {
shareId,
});
this.sharedTree = response.sharedTree;
this.document = response.document;
sharedTreeCache[this.document.id] = response.sharedTree;
if (revisionId && revisionId !== "latest") {
await this.loadRevision();
} else {
@@ -202,10 +214,7 @@ class DataLoader extends React.Component<Props> {
const isMove = this.props.location.pathname.match(/move$/);
const canRedirect = !revisionId && !isMove && !shareId;
if (canRedirect) {
const canonicalUrl = updateDocumentUrl(
this.props.match.url,
document.url
);
const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
if (this.props.location.pathname !== canonicalUrl) {
this.props.history.replace(canonicalUrl);
}
@@ -249,6 +258,7 @@ class DataLoader extends React.Component<Props> {
readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
sharedTree={this.sharedTree}
/>
</SocketPresence>
);
+19 -15
View File
@@ -29,13 +29,13 @@ import Editor from "./Editor";
import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import MarkAsViewed from "./MarkAsViewed";
import PublicReferences from "./PublicReferences";
import References from "./References";
import { type LocationWithState, type Theme } from "types";
import { type LocationWithState, type NavigationNode, type Theme } from "types";
import { isCustomDomain } from "utils/domains";
import { emojiToUrl } from "utils/emoji";
import { meta } from "utils/keyboard";
import {
collectionUrl,
documentMoveUrl,
documentHistoryUrl,
editDocumentUrl,
@@ -57,6 +57,7 @@ type Props = {
match: Match,
history: RouterHistory,
location: LocationWithState,
sharedTree: ?NavigationNode,
abilities: Object,
document: Document,
revision: Revision,
@@ -172,7 +173,7 @@ class DocumentScene extends React.Component<Props> {
this.onSave({ publish: true, done: true });
}
@keydown(`${meta}+ctrl+h`)
@keydown("ctrl+alt+h")
onToggleTableOfContents(ev) {
if (!this.props.readOnly) return;
@@ -289,15 +290,7 @@ class DocumentScene extends React.Component<Props> {
};
goBack = () => {
let url;
if (this.props.document.url) {
url = this.props.document.url;
} else if (this.props.match.params.id) {
url = collectionUrl(this.props.match.params.id);
}
if (url) {
this.props.history.push(url);
}
this.props.history.push(this.props.document.url);
};
render() {
@@ -311,7 +304,8 @@ class DocumentScene extends React.Component<Props> {
match,
} = this.props;
const team = auth.team;
const isShare = !!match.params.shareId;
const { shareId } = match.params;
const isShare = !!shareId;
const value = revision ? revision.text : document.text;
const injectTemplate = document.injectTemplate;
@@ -367,7 +361,7 @@ class DocumentScene extends React.Component<Props> {
)}
<Header
document={document}
isShare={isShare}
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly}
@@ -377,6 +371,7 @@ class DocumentScene extends React.Component<Props> {
document.isSaving || this.isPublishing || this.isEmpty
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
goBack={this.goBack}
onSave={this.onSave}
/>
@@ -420,7 +415,7 @@ class DocumentScene extends React.Component<Props> {
<Editor
id={document.id}
innerRef={this.editor}
isShare={isShare}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
key={[injectTemplate, disableEmbeds].join("-")}
@@ -442,6 +437,15 @@ class DocumentScene extends React.Component<Props> {
readOnlyWriteCheckboxes={readOnly && abilities.update}
ui={this.props.ui}
>
{shareId && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
)}
{!isShare && !revision && (
<>
<MarkAsViewed document={document} />
+29 -9
View File
@@ -6,6 +6,7 @@ import Textarea from "react-autosize-textarea";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_TITLE_LENGTH } from "shared/constants";
import { light } from "shared/styles/theme";
import parseTitle from "shared/utils/parseTitle";
import Document from "models/Document";
import ClickablePadding from "components/ClickablePadding";
@@ -23,7 +24,7 @@ type Props = {|
title: string,
document: Document,
isDraft: boolean,
isShare: boolean,
shareId: ?string,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
@@ -32,6 +33,7 @@ type Props = {|
@observer
class DocumentEditor extends React.Component<Props> {
@observable activeLinkEvent: ?MouseEvent;
ref = React.createRef<HTMLDivElement | HTMLInputElement>();
focusAtStart = () => {
if (this.props.innerRef.current) {
@@ -96,7 +98,7 @@ class DocumentEditor extends React.Component<Props> {
title,
onChangeTitle,
isDraft,
isShare,
shareId,
readOnly,
innerRef,
children,
@@ -113,15 +115,18 @@ class DocumentEditor extends React.Component<Props> {
{readOnly ? (
<Title
as="div"
ref={this.ref}
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
$isStarred={document.isStarred}
dir="auto"
>
<span>{normalizedTitle}</span>{" "}
{!isShare && <StarButton document={document} size={32} />}
{!shareId && <StarButton document={document} size={32} />}
</Title>
) : (
<Title
type="text"
ref={this.ref}
onChange={onChangeTitle}
onKeyDown={this.handleTitleKeyDown}
placeholder={document.placeholder}
@@ -129,13 +134,21 @@ class DocumentEditor extends React.Component<Props> {
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
autoFocus={!title}
maxLength={MAX_TITLE_LENGTH}
dir="auto"
/>
)}
{!shareId && (
<DocumentMetaWithViews
isDraft={isDraft}
document={document}
to={documentHistoryUrl(document)}
rtl={
this.ref.current
? window.getComputedStyle(this.ref.current).direction === "rtl"
: false
}
/>
)}
<DocumentMetaWithViews
isDraft={isDraft}
document={document}
to={documentHistoryUrl(document)}
/>
<Editor
ref={innerRef}
autoFocus={!!title && !this.props.defaultValue}
@@ -143,11 +156,12 @@ class DocumentEditor extends React.Component<Props> {
onHoverLink={this.handleLinkActive}
scrollTo={window.location.hash}
readOnly={readOnly}
shareId={shareId}
grow
{...rest}
/>
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
{this.activeLinkEvent && !isShare && readOnly && (
{this.activeLinkEvent && !shareId && readOnly && (
<HoverPreview
node={this.activeLinkEvent.target}
event={this.activeLinkEvent}
@@ -202,6 +216,12 @@ const Title = styled(Textarea)`
}
}
}
@media print {
color: ${(props) => light.text};
-webkit-text-fill-color: ${(props) => light.text};
background: none;
}
`;
export default DocumentEditor;
+25 -21
View File
@@ -13,24 +13,26 @@ import styled from "styled-components";
import Document from "models/Document";
import { Action, Separator } from "components/Actions";
import Badge from "components/Badge";
import Breadcrumb, { Slash } from "components/Breadcrumb";
import Button from "components/Button";
import Collaborators from "components/Collaborators";
import Fade from "components/Fade";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Header from "components/Header";
import Tooltip from "components/Tooltip";
import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton";
import useMobile from "hooks/useMobile";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import TemplatesMenu from "menus/TemplatesMenu";
import { type NavigationNode } from "types";
import { metaDisplay } from "utils/keyboard";
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
isShare: boolean,
sharedTree: ?NavigationNode,
shareId: ?string,
isDraft: boolean,
isEditing: boolean,
isRevision: boolean,
@@ -48,7 +50,7 @@ type Props = {|
function DocumentHeader({
document,
isShare,
shareId,
isEditing,
isDraft,
isPublishing,
@@ -56,6 +58,7 @@ function DocumentHeader({
isSaving,
savingIsDisabled,
publishingIsDisabled,
sharedTree,
onSave,
}: Props) {
const { t } = useTranslation();
@@ -80,7 +83,7 @@ function DocumentHeader({
const toc = (
<Tooltip
tooltip={ui.tocVisible ? t("Hide contents") : t("Show contents")}
shortcut={`ctrl+${metaDisplay}+h`}
shortcut="ctrl+alt+h"
delay={250}
placement="bottom"
>
@@ -116,11 +119,19 @@ function DocumentHeader({
</Action>
);
if (isShare) {
if (shareId) {
return (
<Header
title={document.title}
breadcrumb={toc}
breadcrumb={
<PublicBreadcrumb
documentId={document.id}
shareId={shareId}
sharedTree={sharedTree}
>
{toc}
</PublicBreadcrumb>
}
actions={canEdit ? editAction : <div />}
/>
);
@@ -130,14 +141,9 @@ function DocumentHeader({
<>
<Header
breadcrumb={
<Breadcrumb document={document}>
{!isEditing && (
<>
<Slash />
{toc}
</>
)}
</Breadcrumb>
<DocumentBreadcrumb document={document}>
{!isEditing && toc}
</DocumentBreadcrumb>
}
title={
<>
@@ -148,12 +154,10 @@ function DocumentHeader({
actions={
<>
{!isPublishing && isSaving && <Status>{t("Saving")}</Status>}
<Fade>
<Collaborators
document={document}
currentUserId={auth.user ? auth.user.id : undefined}
/>
</Fade>
<Collaborators
document={document}
currentUserId={auth.user ? auth.user.id : undefined}
/>
{isEditing && !isTemplate && isNew && (
<Action>
<TemplatesMenu document={document} />
@@ -0,0 +1,54 @@
// @flow
import * as React from "react";
import Breadcrumb from "components/Breadcrumb";
import type { NavigationNode } from "types";
type Props = {|
documentId: string,
shareId: string,
sharedTree: ?NavigationNode,
children?: React.Node,
|};
function pathToDocument(sharedTree, documentId) {
let path = [];
const traveler = (nodes, previousPath) => {
nodes.forEach((childNode) => {
const newPath = [...previousPath, childNode];
if (childNode.id === documentId) {
path = newPath;
return;
}
return traveler(childNode.children, newPath);
});
};
if (sharedTree) {
traveler([sharedTree], []);
}
return path;
}
const PublicBreadcrumb = ({
documentId,
shareId,
sharedTree,
children,
}: Props) => {
const items = React.useMemo(
() =>
pathToDocument(sharedTree, documentId)
.slice(0, -1)
.map((item) => {
return {
...item,
to: `/share/${shareId}${item.url}`,
};
}),
[sharedTree, shareId, documentId]
);
return <Breadcrumb items={items} children={children} />;
};
export default PublicBreadcrumb;
@@ -0,0 +1,57 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Subheading from "components/Subheading";
import ReferenceListItem from "./ReferenceListItem";
import { type NavigationNode } from "types";
type Props = {|
shareId: string,
documentId: string,
sharedTree: NavigationNode,
|};
function PublicReferences(props: Props) {
const { t } = useTranslation();
const { shareId, documentId, sharedTree } = props;
// The sharedTree is the entire document tree starting at the shared document
// we must filter down the tree to only the part with the document we're
// currently viewing
const children = React.useMemo(() => {
let result;
function findChildren(node) {
if (!node) return;
if (node.id === documentId) {
result = node.children;
} else {
node.children.forEach((node) => {
if (result) {
return;
}
findChildren(node);
});
}
return result;
}
return findChildren(sharedTree) || [];
}, [documentId, sharedTree]);
if (!children.length) {
return null;
}
return (
<>
<Subheading>{t("Nested documents")}</Subheading>
{children.map((node) => (
<ReferenceListItem key={node.id} document={node} shareId={shareId} />
))}
</>
);
}
export default observer(PublicReferences);
@@ -1,5 +1,6 @@
// @flow
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -8,6 +9,7 @@ import DocumentMeta from "components/DocumentMeta";
import type { NavigationNode } from "types";
type Props = {|
shareId?: string,
document: Document | NavigationNode,
anchor?: string,
showCollection?: boolean,
@@ -31,7 +33,8 @@ const DocumentLink = styled(Link)`
`;
const Title = styled.h3`
max-width: 90%;
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
@@ -43,27 +46,52 @@ const Title = styled.h3`
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
@observer
class ReferenceListItem extends React.Component<Props> {
render() {
const { document, showCollection, anchor, ...rest } = this.props;
const StyledDocumentIcon = styled(DocumentIcon)`
margin-left: -4px;
color: ${(props) => props.theme.textSecondary};
`;
return (
<DocumentLink
to={{
pathname: document.url,
hash: anchor ? `d-${anchor}` : undefined,
state: { title: document.title },
}}
{...rest}
>
<Title>{document.title}</Title>
{document.updatedBy && (
<DocumentMeta document={document} showCollection={showCollection} />
)}
</DocumentLink>
);
}
const Emoji = styled.span`
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: -4px;
font-size: 16px;
width: 24px;
height: 24px;
`;
function ReferenceListItem({
document,
showCollection,
anchor,
shareId,
...rest
}: Props) {
return (
<DocumentLink
to={{
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
hash: anchor ? `d-${anchor}` : undefined,
state: { title: document.title },
}}
{...rest}
>
<Title dir="auto">
{document.emoji ? (
<Emoji>{document.emoji}</Emoji>
) : (
<StyledDocumentIcon color="currentColor" />
)}{" "}
{document.emoji
? document.title.replace(new RegExp(`^${document.emoji}`), "")
: document.title}
</Title>
{document.updatedBy && (
<DocumentMeta document={document} showCollection={showCollection} />
)}
</DocumentLink>
);
}
export default ReferenceListItem;
export default observer(ReferenceListItem);
+11 -23
View File
@@ -3,11 +3,10 @@ import { observer } from "mobx-react";
import { GlobeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { usePopoverState, Popover, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "models/Document";
import Button from "components/Button";
import Popover from "components/Popover";
import Tooltip from "components/Tooltip";
import SharePopover from "./SharePopover";
import useStores from "hooks/useStores";
@@ -20,7 +19,9 @@ function ShareButton({ document }: Props) {
const { t } = useTranslation();
const { shares } = useStores();
const share = shares.getByDocumentId(document.id);
const isPubliclyShared = share && share.published;
const sharedParent = shares.getByDocumentParents(document.id);
const isPubliclyShared =
(share && share.published) || (sharedParent && sharedParent.published);
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
@@ -55,28 +56,15 @@ function ShareButton({ document }: Props) {
)}
</PopoverDisclosure>
<Popover {...popover} aria-label={t("Share")}>
<Contents>
<SharePopover
document={document}
share={share}
onSubmit={popover.hide}
/>
</Contents>
<SharePopover
document={document}
share={share}
sharedParent={sharedParent}
onSubmit={popover.hide}
/>
</Popover>
</>
);
}
const Contents = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: 24px 24px 12px;
width: 380px;
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`;
export default observer(ShareButton);
+71 -26
View File
@@ -1,10 +1,10 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import { formatDistanceToNow } from "date-fns";
import invariant from "invariant";
import { observer } from "mobx-react";
import { GlobeIcon, PadlockIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Share from "models/Share";
@@ -13,23 +13,25 @@ import CopyToClipboard from "components/CopyToClipboard";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import Notice from "components/Notice";
import Switch from "components/Switch";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
share: Share,
sharedParent: ?Share,
onSubmit: () => void,
|};
function DocumentShare({ document, share, onSubmit }: Props) {
function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
const { t } = useTranslation();
const { policies, shares, ui } = useStores();
const [isCopied, setIsCopied] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const timeout = React.useRef<?TimeoutID>();
const can = policies.abilities(share ? share.id : "");
const canPublish = can.update && !document.isTemplate;
const isPubliclyShared = (share && share.published) || sharedParent;
React.useEffect(() => {
document.share();
@@ -41,14 +43,26 @@ function DocumentShare({ document, share, onSubmit }: Props) {
const share = shares.getByDocumentId(document.id);
invariant(share, "Share must exist");
setIsSaving(true);
try {
await share.save({ published: event.currentTarget.checked });
} catch (err) {
ui.showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[document.id, shares, ui]
);
const handleChildDocumentsChange = React.useCallback(
async (event) => {
const share = shares.getByDocumentId(document.id);
invariant(share, "Share must exist");
try {
await share.save({
includeChildDocuments: event.currentTarget.checked,
});
} catch (err) {
ui.showToast(err.message, { type: "error" });
}
},
[document.id, shares, ui]
@@ -68,7 +82,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
return (
<>
<Heading>
{share && share.published ? (
{isPubliclyShared ? (
<GlobeIcon size={28} color="currentColor" />
) : (
<PadlockIcon size={28} color="currentColor" />
@@ -76,40 +90,71 @@ function DocumentShare({ document, share, onSubmit }: Props) {
{t("Share this document")}
</Heading>
{sharedParent && (
<Notice>
<Trans
defaults="This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared"
values={{ documentTitle: sharedParent.documentTitle }}
components={{ em: <strong /> }}
/>
</Notice>
)}
{canPublish && (
<PrivacySwitch>
<SwitchWrapper>
<Switch
id="published"
label={t("Publish to internet")}
onChange={handlePublishedChange}
checked={share ? share.published : false}
disabled={!share || isSaving}
disabled={!share}
/>
<Privacy>
<PrivacyText>
<SwitchLabel>
<SwitchText>
{share.published
? t("Anyone with the link can view this document")
: t("Only team members with access can view")}
: t("Only team members with permission can view")}
{share.lastAccessedAt && (
<>
.{" "}
{t("The shared link was last accessed {{ timeAgo }}.", {
timeAgo: distanceInWordsToNow(share.lastAccessedAt, {
addSuffix: true,
}),
timeAgo: formatDistanceToNow(
Date.parse(share.lastAccessedAt),
{
addSuffix: true,
}
),
})}
</>
)}
</PrivacyText>
</Privacy>
</PrivacySwitch>
</SwitchText>
</SwitchLabel>
</SwitchWrapper>
)}
{share && share.published && (
<SwitchWrapper>
<Switch
id="includeChildDocuments"
label={t("Share nested documents")}
onChange={handleChildDocumentsChange}
checked={share ? share.includeChildDocuments : false}
disabled={!share}
/>
<SwitchLabel>
<SwitchText>
{share.includeChildDocuments
? t("Nested documents are publicly available")
: t("Nested documents are not shared")}
</SwitchText>
</SwitchLabel>
</SwitchWrapper>
)}
<Flex>
<InputLink
type="text"
label={t("Link")}
placeholder={`${t("Loading")}`}
value={share ? share.url : undefined}
value={share ? share.url : ""}
labelHidden
readOnly
/>
@@ -126,11 +171,11 @@ function DocumentShare({ document, share, onSubmit }: Props) {
const Heading = styled.h2`
display: flex;
align-items: center;
margin-top: 0;
margin-top: 12px;
margin-left: -4px;
`;
const PrivacySwitch = styled.div`
const SwitchWrapper = styled.div`
margin: 20px 0;
`;
@@ -139,7 +184,7 @@ const InputLink = styled(Input)`
margin-right: 8px;
`;
const Privacy = styled(Flex)`
const SwitchLabel = styled(Flex)`
flex-align: center;
svg {
@@ -147,9 +192,9 @@ const Privacy = styled(Flex)`
}
`;
const PrivacyText = styled(HelpText)`
const SwitchText = styled(HelpText)`
margin: 0;
font-size: 15px;
`;
export default observer(DocumentShare);
export default observer(SharePopover);
+4 -3
View File
@@ -17,12 +17,13 @@ type Props = {
function DocumentDelete({ document, onSubmit }: Props) {
const { t } = useTranslation();
const { ui, documents } = useStores();
const { ui, documents, collections } = useStores();
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const { showToast } = ui;
const canArchive = !document.isDraft && !document.isArchived;
const collection = collections.get(document.collectionId);
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
@@ -45,7 +46,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
}
// otherwise, redirect to the collection home
history.push(collectionUrl(document.collectionId));
history.push(collectionUrl(collection?.url || "/"));
}
onSubmit();
} catch (err) {
@@ -54,7 +55,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
setDeleting(false);
}
},
[showToast, onSubmit, ui, document, documents, history]
[showToast, onSubmit, ui, document, documents, history, collection]
);
const handleArchive = React.useCallback(
+43 -44
View File
@@ -1,58 +1,57 @@
// @flow
import { inject } from "mobx-react";
import { observer } from "mobx-react";
import queryString from "query-string";
import * as React from "react";
import {
type RouterHistory,
type Location,
type Match,
} from "react-router-dom";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import CenteredContent from "components/CenteredContent";
import Flex from "components/Flex";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import useStores from "hooks/useStores";
import { editDocumentUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
location: Location,
documents: DocumentsStore,
ui: UiStore,
match: Match,
};
function DocumentNew() {
const history = useHistory();
const location = useLocation();
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, ui, collections } = useStores();
const id = match.params.id || "";
class DocumentNew extends React.Component<Props> {
async componentDidMount() {
const params = queryString.parse(this.props.location.search);
useEffect(() => {
async function createDocument() {
const params = queryString.parse(location.search);
try {
const collection = await collections.fetch(id);
try {
const document = await this.props.documents.create({
collectionId: this.props.match.params.id,
parentDocumentId: params.parentDocumentId,
templateId: params.templateId,
template: params.template,
title: "",
text: "",
});
this.props.history.replace(editDocumentUrl(document));
} catch (err) {
this.props.ui.showToast("Couldnt create the document, try again?", {
type: "error",
});
this.props.history.goBack();
const document = await documents.create({
collectionId: collection.id,
parentDocumentId: params.parentDocumentId,
templateId: params.templateId,
template: params.template,
title: "",
text: "",
});
history.replace(editDocumentUrl(document));
} catch (err) {
ui.showToast(t("Couldnt create the document, try again?"), {
type: "error",
});
history.goBack();
}
}
}
createDocument();
});
render() {
return (
<Flex column auto>
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
</Flex>
);
}
return (
<Flex column auto>
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
</Flex>
);
}
export default inject("documents", "ui")(DocumentNew);
export default observer(DocumentNew);
+60
View File
@@ -0,0 +1,60 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import Document from "models/Document.js";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
onSubmit: () => void,
|};
function DocumentPermanentDelete({ document, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState(false);
const { t } = useTranslation();
const { ui, documents } = useStores();
const { showToast } = ui;
const history = useHistory();
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
try {
setIsDeleting(true);
await documents.delete(document, { permanent: true });
showToast(t("Document permanently deleted"), { type: "success" });
onSubmit();
history.push("/trash");
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsDeleting(false);
}
},
[document, onSubmit, showToast, t, history, documents]
);
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone."
values={{ documentTitle: document.titleWithDefault }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
</form>
</Flex>
);
}
export default observer(DocumentPermanentDelete);
+7 -4
View File
@@ -50,10 +50,13 @@ class Drafts extends React.Component<Props> {
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
search: queryString.stringify(
{
...queryString.parse(this.props.location.search),
...search,
},
{ skipEmptyString: true }
),
});
};
+4 -5
View File
@@ -33,7 +33,7 @@ function KeyboardShortcuts() {
{
shortcut: (
<>
<Key>Ctrl</Key> + <Key>{metaDisplay}</Key> + <Key>h</Key>
<Key>Ctrl</Key> + <Key>Alt</Key> + <Key>h</Key>
</>
),
label: t("Table of contents"),
@@ -413,8 +413,8 @@ const List = styled.dl`
const Keys = styled.dt`
float: right;
width: 45%;
height: 30px;
margin: 0;
margin: 0 0 10px;
clear: left;
text-align: right;
font-size: 12px;
color: ${(props) => props.theme.textSecondary};
@@ -426,8 +426,7 @@ const Keys = styled.dt`
const Label = styled.dd`
float: left;
width: 55%;
height: 30px;
margin: 0;
margin: 0 0 10px;
display: flex;
align-items: center;
color: ${(props) => props.theme.textSecondary};
+10 -7
View File
@@ -6,7 +6,6 @@ import { observer, inject } from "mobx-react";
import { PlusIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import ReactDOM from "react-dom";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, Link } from "react-router-dom";
@@ -103,8 +102,9 @@ class Search extends React.Component<Props> {
if (ev.key === "ArrowDown") {
ev.preventDefault();
if (this.firstDocument) {
const element = ReactDOM.findDOMNode(this.firstDocument);
if (element instanceof HTMLElement) element.focus();
if (this.firstDocument instanceof HTMLElement) {
this.firstDocument.focus();
}
}
}
};
@@ -140,10 +140,13 @@ class Search extends React.Component<Props> {
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
search: queryString.stringify(
{
...queryString.parse(this.props.location.search),
...search,
},
{ skipEmptyString: true }
),
});
};
@@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions";
import FilterOptions from "components/FilterOptions";
import useStores from "hooks/useStores";
type Props = {|
+1 -1
View File
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions";
import FilterOptions from "components/FilterOptions";
type Props = {|
dateFilter: ?string,
@@ -1,85 +0,0 @@
// @flow
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
type Props = {|
label: string,
note?: string,
onSelect: () => void,
active: boolean,
|};
const FilterOption = ({ label, note, onSelect, active, ...rest }: Props) => {
return (
<MenuItem onClick={active ? undefined : onSelect} {...rest}>
{(props) => (
<ListItem>
<Button active={active} {...props}>
<Flex align="center" justify="space-between">
<span>
{label}
{note && <Description small>{note}</Description>}
</span>
{active && <Checkmark />}
</Flex>
</Button>
</ListItem>
)}
</MenuItem>
);
};
const Description = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
line-height: 1.2em;
`;
const Checkmark = styled(CheckmarkIcon)`
flex-shrink: 0;
padding-left: 4px;
fill: ${(props) => props.theme.text};
`;
const Button = styled.button`
display: flex;
flex-direction: column;
font-size: 16px;
padding: 8px;
margin: 0;
border: 0;
background: none;
color: ${(props) => props.theme.text};
text-align: left;
font-weight: ${(props) => (props.active ? "600" : "normal")};
justify-content: center;
width: 100%;
min-height: 42px;
${HelpText} {
font-weight: normal;
user-select: none;
}
&:hover {
background: ${(props) => props.theme.listItemHoverBackground};
}
${breakpoint("tablet")`
padding: 4px 8px;
font-size: 15px;
min-height: 32px;
`};
`;
const ListItem = styled("li")`
list-style: none;
max-width: 250px;
`;
export default FilterOption;
+1 -1
View File
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions";
import FilterOptions from "components/FilterOptions";
type Props = {|
includeArchived?: boolean,
+1 -1
View File
@@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "./FilterOptions";
import FilterOptions from "components/FilterOptions";
import useStores from "hooks/useStores";
type Props = {|
+20 -13
View File
@@ -4,6 +4,7 @@ import { PlusIcon, GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import GroupNew from "scenes/GroupNew";
import { Action } from "components/Actions";
import Button from "components/Button";
import Empty from "components/Empty";
import GroupListItem from "components/GroupListItem";
@@ -33,25 +34,31 @@ function Groups() {
}, []);
return (
<Scene title={t("Groups")} icon={<GroupIcon color="currentColor" />}>
<Scene
title={t("Groups")}
icon={<GroupIcon color="currentColor" />}
actions={
<>
{can.createGroup && (
<Action>
<Button
type="button"
onClick={handleNewGroupModalOpen}
icon={<PlusIcon />}
>
{`${t("New group")}`}
</Button>
</Action>
)}
</>
}
>
<Heading>{t("Groups")}</Heading>
<HelpText>
<Trans>
Groups can be used to organize and manage the people on your team.
</Trans>
</HelpText>
{can.createGroup && (
<Button
type="button"
onClick={handleNewGroupModalOpen}
icon={<PlusIcon />}
neutral
>
{`${t("New group")}`}
</Button>
)}
<Subheading>{t("All groups")}</Subheading>
<PaginatedList
items={groups.orderedData}
+218 -133
View File
@@ -1,161 +1,246 @@
// @flow
import invariant from "invariant";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import { PlusIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import { type Match } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import UsersStore from "stores/UsersStore";
import { Trans, useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import { PAGINATION_SYMBOL } from "stores/BaseStore";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import { Action } from "components/Actions";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import InputSearch from "components/InputSearch";
import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import Scene from "components/Scene";
import Tab from "components/Tab";
import Tabs, { Separator } from "components/Tabs";
import UserListItem from "./components/UserListItem";
import PeopleTable from "./components/PeopleTable";
import UserStatusFilter from "./components/UserStatusFilter";
import useCurrentTeam from "hooks/useCurrentTeam";
import useQuery from "hooks/useQuery";
import useStores from "hooks/useStores";
type Props = {
auth: AuthStore,
users: UsersStore,
policies: PoliciesStore,
match: Match,
t: TFunction,
};
function People(props) {
const topRef = React.useRef();
const location = useLocation();
const history = useHistory();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const team = useCurrentTeam();
const { users, policies } = useStores();
const { t } = useTranslation();
const params = useQuery();
const [isLoading, setIsLoading] = React.useState(false);
const [data, setData] = React.useState([]);
const [totalPages, setTotalPages] = React.useState(0);
const [userIds, setUserIds] = React.useState([]);
const can = policies.abilities(team.id);
const query = params.get("query") || "";
const filter = params.get("filter") || "";
const sort = params.get("sort") || "name";
const direction = (params.get("direction") || "asc").toUpperCase();
const page = parseInt(params.get("page") || 0, 10);
const limit = 25;
@observer
class People extends React.Component<Props> {
@observable inviteModalOpen: boolean = false;
React.useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
componentDidMount() {
const { team } = this.props.auth;
if (team) {
this.props.users.fetchCounts(team.id);
}
}
try {
const response = await users.fetchPage({
offset: page * limit,
limit,
sort,
direction,
query,
filter,
});
handleInviteModalOpen = () => {
this.inviteModalOpen = true;
};
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
setUserIds(response.map((u) => u.id));
} finally {
setIsLoading(false);
}
};
handleInviteModalClose = () => {
this.inviteModalOpen = false;
};
fetchData();
}, [query, sort, filter, page, direction, users]);
fetchPage = (params) => {
return this.props.users.fetchPage({ ...params, includeSuspended: true });
};
render() {
const { auth, policies, match, t } = this.props;
const { filter } = match.params;
const currentUser = auth.user;
const team = auth.team;
invariant(currentUser, "User should exist");
invariant(team, "Team should exist");
let users = this.props.users.active;
if (filter === "all") {
users = this.props.users.all;
React.useEffect(() => {
let filtered = users.orderedData;
if (!filter) {
filtered = users.active.filter((u) => userIds.includes(u.id));
} else if (filter === "all") {
filtered = users.orderedData.filter((u) => userIds.includes(u.id));
} else if (filter === "admins") {
users = this.props.users.admins;
filtered = users.admins.filter((u) => userIds.includes(u.id));
} else if (filter === "suspended") {
users = this.props.users.suspended;
filtered = users.suspended.filter((u) => userIds.includes(u.id));
} else if (filter === "invited") {
users = this.props.users.invited;
filtered = users.invited.filter((u) => userIds.includes(u.id));
} else if (filter === "viewers") {
users = this.props.users.viewers;
filtered = users.viewers.filter((u) => userIds.includes(u.id));
}
const can = policies.abilities(team.id);
const { counts } = this.props.users;
// sort the resulting data by the original order from the server
setData(sortBy(filtered, (item) => userIds.indexOf(item.id)));
}, [
filter,
users.active,
users.admins,
users.orderedData,
users.suspended,
users.invited,
users.viewers,
userIds,
]);
return (
<Scene title={t("People")} icon={<UserIcon color="currentColor" />}>
<Heading>{t("People")}</Heading>
<HelpText>
<Trans>
Everyone that has signed into Outline appears here. Its possible
that there are other users who have access through{" "}
{team.signinMethods} but havent signed in yet.
</Trans>
</HelpText>
{can.inviteUser && (
<Button
type="button"
data-on="click"
data-event-category="invite"
data-event-action="peoplePage"
onClick={this.handleInviteModalOpen}
icon={<PlusIcon />}
neutral
>
{t("Invite people")}
</Button>
)}
const handleInviteModalOpen = React.useCallback(() => {
setInviteModalOpen(true);
}, []);
<Tabs>
<Tab to="/settings/people" exact>
{t("Active")} <Bubble count={counts.active} />
</Tab>
<Tab to="/settings/people/admins" exact>
{t("Admins")} <Bubble count={counts.admins} />
</Tab>
{can.update && (
<Tab to="/settings/people/suspended" exact>
{t("Suspended")} <Bubble count={counts.suspended} />
</Tab>
)}
<Tab to="/settings/people/viewers" exact>
{t("Viewers")} <Bubble count={counts.viewers} />
</Tab>
<Tab to="/settings/people/all" exact>
{t("Everyone")} <Bubble count={counts.all - counts.invited} />
</Tab>
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
const handleFilter = React.useCallback(
(filter) => {
if (filter) {
params.set("filter", filter);
params.delete("page");
} else {
params.delete("filter");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = React.useCallback(
(event) => {
const { value } = event.target;
if (value) {
params.set("query", event.target.value);
params.delete("page");
} else {
params.delete("query");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangeSort = React.useCallback(
(sort, direction) => {
if (sort) {
params.set("sort", sort);
} else {
params.delete("sort");
}
if (direction === "DESC") {
params.set("direction", direction.toLowerCase());
} else {
params.delete("direction");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangePage = React.useCallback(
(page) => {
if (page) {
params.set("page", page.toString());
} else {
params.delete("page");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
if (topRef.current) {
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "instant",
block: "start",
});
}
},
[params, history, location.pathname]
);
return (
<Scene
title={t("Members")}
icon={<UserIcon color="currentColor" />}
actions={
<>
{can.inviteUser && (
<>
<Separator />
<Tab to="/settings/people/invited" exact>
{t("Invited")} <Bubble count={counts.invited} />
</Tab>
</>
)}
</Tabs>
<PaginatedList
items={users}
empty={<Empty>{t("No people to see here.")}</Empty>}
fetch={this.fetchPage}
renderItem={(item) => (
<UserListItem
key={item.id}
user={item}
showMenu={can.update && currentUser.id !== item.id}
/>
<Action>
<Button
type="button"
data-on="click"
data-event-category="invite"
data-event-action="peoplePage"
onClick={handleInviteModalOpen}
icon={<PlusIcon />}
>
{t("Invite people")}
</Button>
</Action>
)}
</>
}
>
<Heading>{t("Members")}</Heading>
<HelpText>
<Trans>
Everyone that has signed into Outline appears here. Its possible that
there are other users who have access through {team.signinMethods} but
havent signed in yet.
</Trans>
</HelpText>
<Flex gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
{can.inviteUser && (
<Modal
title={t("Invite people")}
onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen}
>
<Invite onSubmit={this.handleInviteModalClose} />
</Modal>
)}
</Scene>
);
}
<UserStatusFilter activeKey={filter} onSelect={handleFilter} />
</Flex>
<PeopleTable
topRef={topRef}
data={data}
canManage={can.manage}
isLoading={isLoading}
onChangeSort={handleChangeSort}
onChangePage={handleChangePage}
page={page}
totalPages={totalPages}
/>
{can.inviteUser && (
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
)}
</Scene>
);
}
export default inject(
"auth",
"users",
"policies"
)(withTranslation()<People>(People));
export default observer(People);
+70 -73
View File
@@ -1,89 +1,86 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import * as React from "react";
import ApiKeysStore from "stores/ApiKeysStore";
import UiStore from "stores/UiStore";
import { useTranslation, Trans } from "react-i18next";
import APITokenNew from "scenes/APITokenNew";
import { Action } from "components/Actions";
import Button from "components/Button";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import Input from "components/Input";
import List from "components/List";
import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import TokenListItem from "./components/TokenListItem";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
type Props = {
apiKeys: ApiKeysStore,
ui: UiStore,
};
function Tokens() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { apiKeys, policies } = useStores();
const [newModalOpen, setNewModalOpen] = React.useState(false);
const can = policies.abilities(team.id);
@observer
class Tokens extends React.Component<Props> {
@observable name: string = "";
const handleNewModalOpen = React.useCallback(() => {
setNewModalOpen(true);
}, []);
componentDidMount() {
this.props.apiKeys.fetchPage({ limit: 100 });
}
const handleNewModalClose = React.useCallback(() => {
setNewModalOpen(false);
}, []);
handleUpdate = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
handleSubmit = async (ev: SyntheticEvent<>) => {
try {
ev.preventDefault();
await this.props.apiKeys.create({ name: this.name });
this.name = "";
} catch (error) {
this.props.ui.showToast(error.message, { type: "error" });
}
};
render() {
const { apiKeys } = this.props;
const hasApiKeys = apiKeys.orderedData.length > 0;
return (
<Scene title="API Tokens" icon={<CodeIcon color="currentColor" />}>
<Heading>API Tokens</Heading>
<HelpText>
You can create an unlimited amount of personal tokens to authenticate
with the API. For more details about the API take a look at the{" "}
<a href="https://www.getoutline.com/developers">
developer documentation
</a>
.
</HelpText>
{hasApiKeys && (
<List>
{apiKeys.orderedData.map((token) => (
<TokenListItem
key={token.id}
token={token}
onDelete={token.delete}
return (
<Scene
title={t("API Tokens")}
icon={<CodeIcon color="currentColor" />}
actions={
<>
{can.createApiKey && (
<Action>
<Button
type="submit"
value={`${t("New token")}`}
onClick={handleNewModalOpen}
/>
))}
</List>
)}
</Action>
)}
</>
}
>
<Heading>{t("API Tokens")}</Heading>
<HelpText>
<Trans
defaults="You can create an unlimited amount of personal tokens to authenticate
with the API. Tokens have the same permissions as your user account.
For more details see the <em>developer documentation</em>."
components={{
em: (
<a href="https://www.getoutline.com/developers" target="_blank" />
),
}}
/>
</HelpText>
<form onSubmit={this.handleSubmit}>
<Input
onChange={this.handleUpdate}
placeholder="Token label (eg. development)"
value={this.name}
required
/>
<Button
type="submit"
value="Create Token"
disabled={apiKeys.isSaving}
/>
</form>
</Scene>
);
}
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<Subheading sticky>{t("Tokens")}</Subheading>}
renderItem={(token) => (
<TokenListItem key={token.id} token={token} onDelete={token.delete} />
)}
/>
<Modal
title={t("Create a token")}
onRequestClose={handleNewModalClose}
isOpen={newModalOpen}
>
<APITokenNew onSubmit={handleNewModalClose} />
</Modal>
</Scene>
);
}
export default inject("apiKeys", "ui")(Tokens);
export default observer(Tokens);
@@ -0,0 +1,94 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "models/User";
import Avatar from "components/Avatar";
import Badge from "components/Badge";
import Flex from "components/Flex";
import { type Props as TableProps } from "components/Table";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import UserMenu from "menus/UserMenu";
const Table = React.lazy<TableProps>(() =>
import(/* webpackChunkName: "table" */ "components/Table")
);
type Props = {|
...$Diff<TableProps, { columns: any }>,
data: User[],
canManage: boolean,
|};
function PeopleTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const columns = React.useMemo(
() =>
[
{
id: "name",
Header: t("Name"),
accessor: "name",
Cell: observer(({ value, row }) => (
<Flex align="center" gap={8}>
<Avatar src={row.original.avatarUrl} size={32} /> {value}{" "}
{currentUser.id === row.original.id && `(${t("You")})`}
</Flex>
)),
},
canManage
? {
id: "email",
Header: t("Email"),
accessor: "email",
Cell: observer(({ value }) => value),
}
: undefined,
{
id: "lastActiveAt",
Header: t("Last active"),
accessor: "lastActiveAt",
Cell: observer(
({ value }) => value && <Time dateTime={value} addSuffix />
),
},
{
id: "isAdmin",
Header: t("Role"),
accessor: "rank",
Cell: observer(({ row }) => (
<Badges>
{!row.original.lastActiveAt && <Badge>{t("Invited")}</Badge>}
{row.original.isAdmin && <Badge primary>{t("Admin")}</Badge>}
{row.original.isViewer && <Badge>{t("Viewer")}</Badge>}
{row.original.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
)),
},
canManage
? {
Header: " ",
accessor: "id",
className: "actions",
Cell: observer(
({ row, value }) =>
currentUser.id !== value && <UserMenu user={row.original} />
),
}
: undefined,
].filter((i) => i),
[t, canManage, currentUser]
);
return <Table columns={columns} {...rest} />;
}
const Badges = styled.div`
margin-left: -10px;
`;
export default PeopleTable;
@@ -4,17 +4,20 @@ import ApiKey from "models/ApiKey";
import Button from "components/Button";
import ListItem from "components/List/Item";
type Props = {
type Props = {|
token: ApiKey,
onDelete: (tokenId: string) => Promise<void>,
};
|};
const TokenListItem = ({ token, onDelete }: Props) => {
return (
<ListItem
key={token.id}
title={token.name}
subtitle={<code>{token.secret}</code>}
title={
<>
{token.name} <code>{token.secret}</code>
</>
}
actions={
<Button onClick={() => onDelete(token.id)} neutral>
Revoke
@@ -0,0 +1,54 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "components/FilterOptions";
type Props = {|
activeKey: string,
onSelect: (key: ?string) => void,
|};
const UserStatusFilter = ({ activeKey, onSelect }: Props) => {
const { t } = useTranslation();
const options = React.useMemo(
() => [
{
key: "",
label: t("Active"),
},
{
key: "all",
label: t("Everyone"),
},
{
key: "admins",
label: t("Admins"),
},
{
key: "suspended",
label: t("Suspended"),
},
{
key: "invited",
label: t("Invited"),
},
{
key: "viewers",
label: t("Viewers"),
},
],
[t]
);
return (
<FilterOptions
options={options}
activeKey={activeKey}
onSelect={onSelect}
defaultLabel={t("Active")}
/>
);
};
export default UserStatusFilter;
+2 -2
View File
@@ -1,5 +1,5 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import { formatDistanceToNow } from "date-fns";
import { observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import * as React from "react";
@@ -52,7 +52,7 @@ function UserProfile(props: Props) {
? t("Joined")
: t("Invited")}{" "}
{t("{{ time }} ago.", {
time: distanceInWordsToNow(new Date(user.createdAt)),
time: formatDistanceToNow(Date.parse(user.createdAt)),
})}
{user.isAdmin && (
<StyledBadge primary={user.isAdmin}>{t("Admin")}</StyledBadge>
+8 -2
View File
@@ -15,6 +15,8 @@ function modelNameFromClassName(string) {
export const DEFAULT_PAGINATION_LIMIT = 25;
export const PAGINATION_SYMBOL = Symbol.for("pagination");
export default class BaseStore<T: BaseModel> {
@observable data: Map<string, T> = new Map();
@observable isFetching: boolean = false;
@@ -137,7 +139,8 @@ export default class BaseStore<T: BaseModel> {
throw new Error(`Cannot fetch ${this.modelName}`);
}
let item = this.data.get(id);
const item = this.data.get(id);
if (item && !options.force) return item;
this.isFetching = true;
@@ -175,7 +178,10 @@ export default class BaseStore<T: BaseModel> {
res.data.forEach(this.add);
this.isLoaded = true;
});
return res.data;
let response = res.data;
response[PAGINATION_SYMBOL] = res.pagination;
return response;
} finally {
this.isFetching = false;
}
+29 -1
View File
@@ -1,6 +1,6 @@
// @flow
import invariant from "invariant";
import { concat, last } from "lodash";
import { concat, find, last } from "lodash";
import { computed, action } from "mobx";
import Collection from "models/Collection";
import BaseStore from "./BaseStore";
@@ -126,6 +126,30 @@ export default class CollectionsStore extends BaseStore<Collection> {
return result;
}
@action
async fetch(id: string, options: Object = {}): Promise<*> {
const item = this.get(id) || this.getByUrl(id);
if (item && !options.force) return item;
this.isFetching = true;
try {
const res = await client.post(`/collections.info`, { id });
invariant(res && res.data, "Collection not available");
this.addPolicies(res.policies);
return this.add(res.data);
} catch (err) {
if (err.statusCode === 403) {
this.remove(id);
}
throw err;
} finally {
this.isFetching = false;
}
}
getPathForDocument(documentId: string): ?DocumentPath {
return this.pathsToDocuments.find((path) => path.id === documentId);
}
@@ -135,6 +159,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
if (path) return path.title;
}
getByUrl(url: string): ?Collection {
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
}
delete = async (collection: Collection) => {
await super.delete(collection);
+33 -11
View File
@@ -1,4 +1,5 @@
// @flow
import path from "path";
import invariant from "invariant";
import { find, orderBy, filter, compact, omitBy } from "lodash";
import { observable, action, computed, runInAction } from "mobx";
@@ -8,7 +9,13 @@ import naturalSort from "shared/utils/naturalSort";
import BaseStore from "stores/BaseStore";
import RootStore from "stores/RootStore";
import Document from "models/Document";
import type { FetchOptions, PaginationParams, SearchResult } from "types";
import env from "env";
import type {
NavigationNode,
FetchOptions,
PaginationParams,
SearchResult,
} from "types";
import { client } from "utils/ApiClient";
type ImportOptions = {
@@ -23,6 +30,8 @@ export default class DocumentsStore extends BaseStore<Document> {
importFileTypes: string[] = [
".md",
".doc",
".docx",
"text/markdown",
"text/plain",
"text/html",
@@ -443,30 +452,30 @@ export default class DocumentsStore extends BaseStore<Document> {
fetch = async (
id: string,
options: FetchOptions = {}
): Promise<?Document> => {
): Promise<{ document: ?Document, sharedTree?: NavigationNode }> => {
if (!options.prefetch) this.isFetching = true;
try {
const doc: ?Document = this.data.get(id) || this.getByUrl(id);
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
if (doc && policy && !options.force) {
return doc;
return { document: doc };
}
const res = await client.post("/documents.info", {
id,
shareId: options.shareId,
apiVersion: 2,
});
invariant(res && res.data, "Document not available");
this.addPolicies(res.policies);
this.add(res.data);
this.add(res.data.document);
runInAction("DocumentsStore#fetch", () => {
this.isLoaded = true;
});
return this.data.get(res.data.id);
return {
document: this.data.get(res.data.document.id),
sharedTree: res.data.sharedTree,
};
} finally {
this.isFetching = false;
}
@@ -529,6 +538,19 @@ export default class DocumentsStore extends BaseStore<Document> {
collectionId: string,
options: ImportOptions
) => {
// file.type can be an empty string sometimes
if (
file.type &&
!this.importFileTypes.includes(file.type) &&
!this.importFileTypes.includes(path.extname(file.name))
) {
throw new Error(`The selected file type is not supported (${file.type})`);
}
if (file.size > env.MAXIMUM_IMPORT_SIZE) {
throw new Error("The selected file was too large to import");
}
const title = file.name.replace(/\.[^/.]+$/, "");
const formData = new FormData();
@@ -594,8 +616,8 @@ export default class DocumentsStore extends BaseStore<Document> {
}
@action
async delete(document: Document) {
await super.delete(document);
async delete(document: Document, options?: {| permanent: boolean |}) {
await super.delete(document, options);
// check to see if we have any shares related to this document already
// loaded in local state. If so we can go ahead and remove those too.

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