Compare commits

...

85 Commits

Author SHA1 Message Date
codegen-sh[bot] b378af2294 Fix linting issues in OIDCStrategy 2025-08-19 00:15:29 +00:00
codegen-sh[bot] 5fec2681da Fix TypeScript errors in OIDCStrategy 2025-08-19 00:11:01 +00:00
codegen-sh[bot] 64f51ee40d Fix linting issues: replace any with unknown types and add missing React keys 2025-08-19 00:09:05 +00:00
codegen-sh[bot] fec0f193a8 Fix linting issues 2025-08-19 00:02:14 +00:00
codegen-sh[bot] b689ebd8ca fix: Update GitLabIssueStatusIcon to match style of other icon components 2025-08-18 23:49:32 +00:00
codegen-sh[bot] 8e74bb7d01 fix: add type guard for draft property in GitLabIssueStatusIcon 2025-08-18 23:20:56 +00:00
codegen-sh[bot] c02bc22cce Fix code formatting issues to pass CI checks 2025-08-18 23:16:18 +00:00
codegen-sh[bot] 7ea308a52c Fix linting issues in React Hook dependencies 2025-08-18 23:15:39 +00:00
codegen-sh[bot] e4d1a38367 fix: Fix TypeScript errors in OIDCStrategy
- Change Record<string, unknown> to any to match the Strategy interface
- Add null check for options parameter
2025-08-18 23:15:22 +00:00
codegen-sh[bot] 98d54da0de fix: Remove trailing whitespace in HoverPreviewIssue.tsx 2025-08-18 23:03:21 +00:00
codegen-sh[bot] 4b638ae346 fix: fix remaining linting errors 2025-08-18 22:38:22 +00:00
codegen-sh[bot] abb849e1f6 fix: replace any with unknown in types and add proper type definitions 2025-08-18 22:30:48 +00:00
codegen-sh[bot] 2ec65e3dfc fix: replace any with unknown in MutexLock 2025-08-18 22:27:16 +00:00
codegen-sh[bot] 4e493972e5 fix: fix remaining linting errors 2025-08-18 22:24:07 +00:00
codegen-sh[bot] 4a8b8d5fa7 fix: replace any types with unknown to fix linting errors 2025-08-18 22:19:11 +00:00
codegen-sh[bot] 391fc5fdee fix: replace any types in MultiplayerEditor and SlackUtils 2025-08-18 22:15:50 +00:00
codegen-sh[bot] cbcf7d6a8e fix: replace more any types with more specific types to fix linting errors 2025-08-18 22:15:05 +00:00
codegen-sh[bot] 94eb1aa07d fix: replace any types with more specific types to fix linting errors 2025-08-18 22:13:36 +00:00
codegen-sh[bot] ca66a6b2fa fix: Fix linting issues in GitLab integration and OIDC strategy
- Remove unused IntegrationService import in UploadGitLabProjectAvatarTask
- Replace 'any' type with proper types in gitlab.ts and OIDCStrategy.ts
2025-08-18 21:59:03 +00:00
codegen-sh[bot] 404a5991b3 feat: Add GitLab integration matching Linear integration patterns 2025-08-18 21:47:16 +00:00
Hemachandar bd5de2e185 Display correct child document structure & auto open share section (#9854)
* Show correct child structure for shared documents

* useMemo dep

* auto open tree, show child docs in references

* document.children
2025-08-17 23:09:49 -04:00
codegen-sh[bot] b8eefe4b78 Add functionality to split code blocks with triple backticks (#9959)
* Add functionality to split code blocks with triple backticks

This change allows users to split a code block into two by typing three backticks within it.
When a user types three backticks inside a code block, the block will be split at that point,
creating a new code block below with the same language.

Fixes #9958

* testing

* refactor

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-17 22:08:20 -04:00
Tom Moor cc8a3d8b5e chore: Still seeing redis connection failures in CI (#9957) 2025-08-17 18:42:20 -04:00
Tom Moor dd061790a8 fix: Frontend requests do not send Content-Type header in request (#9956)
* Revert "Revert "fix: Frontend requests do not send Content-Type in request (#…"

This reverts commit 7fddd99c28.

* Update authentication.ts
2025-08-17 21:47:06 +00:00
Tom Moor 7fddd99c28 Revert "fix: Frontend requests do not send Content-Type in request (#9954)" (#9955)
This reverts commit 7ab7e6efb7.
2025-08-17 21:42:28 +00:00
Tom Moor 7ab7e6efb7 fix: Frontend requests do not send Content-Type in request (#9954) 2025-08-17 16:02:56 -04:00
Tom Moor 4464d3c8b4 chore: More CSP hardening (#9951) 2025-08-16 22:10:28 +00:00
Tom Moor e0f40f9bc1 chore: Make no-unused-vars rule strict (#9950) 2025-08-16 11:29:11 -04:00
Tom Moor c83a6b4f41 fix: Usage of ctx.attachment overrides explicit Content-Type (#9949) 2025-08-16 11:29:00 -04:00
Tom Moor 82994c7b7b fix: Scroll to anchor reliability (#9945)
* fix: Scroll to anchor reliability

* feedback
2025-08-16 08:18:35 -04:00
Tom Moor e891de7f49 fix: Document move/copy/publish dialog behavior (#9947)
* fix: Document move/copy/publish dialog behavior

* More fixes
2025-08-16 08:18:25 -04:00
dependabot[bot] fc2648becf chore(deps): bump mammoth from 1.9.1 to 1.10.0 (#9908)
Bumps [mammoth](https://github.com/mwilliamson/mammoth.js) from 1.9.1 to 1.10.0.
- [Release notes](https://github.com/mwilliamson/mammoth.js/releases)
- [Changelog](https://github.com/mwilliamson/mammoth.js/blob/master/NEWS)
- [Commits](https://github.com/mwilliamson/mammoth.js/compare/1.9.1...1.10.0)

---
updated-dependencies:
- dependency-name: mammoth
  dependency-version: 1.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-15 20:41:40 -04:00
Tom Moor ff548eae5c chore: Remove usage of vite-static-copy plugin (#9916) 2025-08-15 20:41:27 -04:00
Tom Moor 866a7f264b Upgrade vite-plugin-pwa (#9943) 2025-08-15 20:33:13 -04:00
Tom Moor 8fcb629bdf fix: Standardize request filtering between cloud / self-hosted (#9914)
* fix: Add request-filtering-agent to self-hosted environment

* refactor

* Debug logging

* self-review

* Remove unused AbortController

* test

* test

* Address feedback
2025-08-15 07:16:29 -04:00
Tom Moor 99655c65d4 fix: Drafts without a collection should be publishable by all members with update rights (#9941) 2025-08-15 07:16:09 -04:00
Tom Moor 5e176415ab fix: Line-height too compact on editor headings (#9942)
closes #9932
2025-08-15 07:16:00 -04:00
Tom Moor 34555bce86 fix: Properly truncate multiline labels in sidebar (#9940) 2025-08-15 00:10:44 -04:00
Tom Moor dcd7a050bd chore: Formatting (#9939) 2025-08-14 22:49:07 -04:00
Tom Moor dc0df7c7e9 fix: Prevent both img parseDOM rules matching (#9938)
closes #9930
2025-08-14 22:09:24 -04:00
codegen-sh[bot] e2c8ee7b54 chore: Migrate from dotenv to dotenvx with minimal changes (#9921)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-08-14 19:43:42 -04:00
Tom Moor d2a0ddab12 fix: Increase timeout on remote file storage operations, make configurable for edge cases (#9936) 2025-08-14 19:39:36 -04:00
Tom Moor 31b254ff09 chore: Upgrade request-filtering-agent (#9937) 2025-08-14 19:39:14 -04:00
Volodymyr Koval 025af4f9fd docs: Update architecture docs to reflect Oxlint usage (#9935)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-14 17:44:51 -04:00
Tom Moor 221169db51 fix: Remove mime-types usage from the browser – fixes dev/vite warnings (#9926) 2025-08-14 08:02:13 -04:00
Tom Moor d2a50256b0 fix: Remove attachments.redirect sw caching (#9927) 2025-08-14 08:02:00 -04:00
Tom Moor 6bc80720c9 fix: Allow user account lookup with mismatching email capitalization (#9929) 2025-08-14 08:01:51 -04:00
Tom Moor 23106bfce8 fix: Use safeEqual in VerificationCode verify method (#9915) 2025-08-13 22:45:11 -04:00
Tom Moor e8046f0d2f fix: Tighten rate limits on email.callback endpoint from defaults (#9917) 2025-08-13 22:45:03 -04:00
Tom Moor ba8ade0244 chore: Add some additional debugging around auth failures (#9924) 2025-08-13 22:44:53 -04:00
Translate-O-Tron 119eb92f27 New Crowdin updates (#9805)
* fix: New Romanian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

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

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

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

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

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

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

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

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

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

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

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

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

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

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]
2025-08-11 22:11:12 -04:00
dependabot[bot] c26a75af27 chore(deps-dev): bump rollup-plugin-webpack-stats from 2.1.0 to 2.1.3 (#9907)
Bumps [rollup-plugin-webpack-stats](https://github.com/relative-ci/rollup-plugin-webpack-stats) from 2.1.0 to 2.1.3.
- [Release notes](https://github.com/relative-ci/rollup-plugin-webpack-stats/releases)
- [Commits](https://github.com/relative-ci/rollup-plugin-webpack-stats/compare/v2.1.0...v2.1.3)

---
updated-dependencies:
- dependency-name: rollup-plugin-webpack-stats
  dependency-version: 2.1.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 20:45:05 -04:00
dependabot[bot] 10f508a8dd chore(deps-dev): bump typescript from 5.8.3 to 5.9.2 (#9906)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.8.3 to 5.9.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 5.9.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 19:59:02 -04:00
dependabot[bot] d872293551 chore(deps): bump validator and @types/validator (#9905)
Bumps [validator](https://github.com/validatorjs/validator.js) and [@types/validator](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/validator). These dependencies needed to be updated together.

Updates `validator` from 13.15.0 to 13.15.15
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.15.0...13.15.15)

Updates `@types/validator` from 13.15.0 to 13.15.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/validator)

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/validator"
  dependency-version: 13.15.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 19:35:00 -04:00
dependabot[bot] e419aa6c3a chore(deps): bump nodemailer from 6.10.0 to 6.10.1 (#9904)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 6.10.0 to 6.10.1.
- [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/v6.10.0...v6.10.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 6.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 19:22:49 -04:00
dependabot[bot] ba60d4bc0a chore(deps): bump @octokit/webhooks from 13.8.0 to 13.9.1 (#9903)
Bumps [@octokit/webhooks](https://github.com/octokit/webhooks.js) from 13.8.0 to 13.9.1.
- [Release notes](https://github.com/octokit/webhooks.js/releases)
- [Commits](https://github.com/octokit/webhooks.js/compare/v13.8.0...v13.9.1)

---
updated-dependencies:
- dependency-name: "@octokit/webhooks"
  dependency-version: 13.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 19:22:28 -04:00
codegen-sh[bot] b1dffc3486 Convert insights from sidebar to modal dialog (#9892)
* Convert insights from sidebar to modal dialog

- Remove insights routing and sidebar layout
- Update DocumentMeta to use modal action instead of navigation
- Convert Insights component to modal-ready format
- Clean up route helpers and authenticated layout
- Remove insights route from authenticated routes

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

* Applied automatic fixes

* refactor

* singular

* refactor

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-11 18:57:27 -04:00
codegen-sh[bot] 4fc6ac1f15 Add in-app reaction notifications (#9893)
* Add ReactionsCreate notification event type

- Add ReactionsCreate to NotificationEventType enum and defaults
- Add notification settings UI with SmileyIcon and proper labels
- Create ReactionsCreateNotificationsTask to handle comment reactions
- Update NotificationsProcessor to handle comments.add_reaction events
- Add eventText and path handling in client Notification model
- Notifications are enabled by default but never send emails

* Applied automatic fixes

* Show the actual emoji in the notification

* Cleanup notifications if reaction is removed

* PR feedback

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-11 18:54:43 -04:00
dependabot[bot] 289302fd2e chore(deps): bump fs-extra from 11.3.0 to 11.3.1 (#9899)
Bumps [fs-extra](https://github.com/jprichardson/node-fs-extra) from 11.3.0 to 11.3.1.
- [Changelog](https://github.com/jprichardson/node-fs-extra/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jprichardson/node-fs-extra/compare/11.3.0...11.3.1)

---
updated-dependencies:
- dependency-name: fs-extra
  dependency-version: 11.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 18:26:53 -04:00
dependabot[bot] 00db4010d0 chore(deps): bump the aws group with 5 updates (#9900)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.859.0` | `3.864.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.859.0` | `3.864.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.859.0` | `3.864.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.859.0` | `3.864.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.858.0` | `3.864.0` |


Updates `@aws-sdk/client-s3` from 3.859.0 to 3.864.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.864.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.859.0 to 3.864.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.864.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.859.0 to 3.864.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.864.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.859.0 to 3.864.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.864.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.858.0 to 3.864.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.864.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.864.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.864.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.864.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.864.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.864.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 18:26:45 -04:00
dependabot[bot] bc14699994 chore(deps): bump dd-trace from 5.41.1 to 5.62.0 (#9901)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.41.1 to 5.62.0.
- [Release notes](https://github.com/DataDog/dd-trace-js/releases)
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.41.1...v5.62.0)

---
updated-dependencies:
- dependency-name: dd-trace
  dependency-version: 5.62.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 18:26:37 -04:00
Tom Moor 013a7a6d39 Improved error boundary with option to clear cache on repeated errors… (#9891)
* Improved error boundary with option to clear cache on repeated errors and more detail for reporting

* fix

* db
2025-08-10 15:24:12 -04:00
codegen-sh[bot] c3f93a3e9d Add relationships API endpoints (#9402)
* Migrate Backlink model to generic Relationship model

- Create new Relationship model with type field to support different relationship types
- Add database migration to create relationships table and migrate existing backlinks
- Update Backlink model to delegate to Relationship model for backward compatibility
- Update BacklinksProcessor to use Relationship model with backlink type
- Update API routes to use new Relationship model
- Update test files to use Relationship model
- Maintain backward compatibility through database view and model delegation

Fixes #9366

* Update migration to rename table instead of creating new one

- Rename existing backlinks table to relationships instead of creating new table
- Add type column with default value to existing table
- Update existing rows to have type='backlink'
- Avoid expensive data migration by keeping existing data in place
- Maintain backward compatibility with database view
- Update rollback to reverse table rename and column addition

This approach is much more efficient for large datasets as it avoids copying millions of rows.

* Remove unnecessary UPDATE statement from migration

The UPDATE statement is not needed since defaultValue automatically
applies to existing rows when adding a column with a default value.

Thanks @tommoor for catching this!

* Wrap up migration in transaction

- Wrap all migration operations in a transaction for atomicity
- Add transaction parameter to all queryInterface calls
- Follow the same pattern as other migrations in the codebase
- Ensures all operations succeed or fail together

* Remove Backlink class entirely and use Relationship everywhere

- Delete server/models/Backlink.ts
- Remove Backlink export from server/models/index.ts
- Remove Backlink import and association from Document model
- All functionality now uses Relationship model with RelationshipType.Backlink
- Maintains same API through Relationship model methods
- Cleaner architecture with single relationship model

* Update documents.test.ts to use RelationshipType enum instead of string

- Import RelationshipType from Relationship model
- Replace type: "backlink" with type: RelationshipType.Backlink
- Improves type safety and consistency with enum usage

* Address code review feedback

- Add transaction wrapper to migration down method for safer rollback
- Remove unused findByTypeForUser method from Relationship model
- Method wasn't used and won't work for all relationship types (e.g., user mentions)
- Clean up code structure and improve safety

* Restore imports

* Add relationships API endpoints

- Create relationships API following stars pattern
- Add CRUD operations: create, list, delete
- Include proper validation, authentication, and authorization
- Support filtering by relationship type and document IDs
- Add relationship presenter and policies
- Register routes in main API router

* Remove relationships.create and relationships.delete endpoints

- Keep only relationships.list endpoint as requested
- Remove create and delete schemas from validation
- Update policies to only allow read operations
- Relationships will be managed internally, not via external API

* Add relationships.info endpoint

- Use Document.findByPk for authorization as requested
- Find relationship by ID and verify user has access to related document
- Return relationship details with accessible documents
- Include proper validation schema for UUID parameter

* Update 20250601223331-migrate-backlink-to-relationship.js

* Update Relationship.ts

* wip

* test

* Final tweaks

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-10 15:24:01 -04:00
Tom Moor c5cd4d9335 fix: Remove nullable on teams.name column (#9890)
* fix: Remove nullable on teams.name column

* Add test
2025-08-10 14:02:01 -04:00
Hemachandar c2f84466df chore: Move TableOfContentsMenu & BreadcrumbMenu to Radix (#9882)
* mobile toc menu

* indent heading levels, filter > H3

* breadcrumb menu

* tiny
2025-08-09 21:59:46 -04:00
Tom Moor 00d7239601 v0.86.1 (#9885) 2025-08-09 18:00:47 -04:00
Hemachandar 5c070d0428 chore: Move more dropdowns to Radix (part 2) (#9880)
* revision menu

* new child document menu; dropdown trigger now supports tooltip

* import menu

* oauth authentication

* oauth client

* tsc
2025-08-09 14:18:19 -04:00
Hemachandar 4822261187 chore: Move DocumentMenu to Radix (#9879)
* works without custom items in menu

* tooltip support for dropdown button

* fix: menu goes out of bounds

* custom node, lazy render menu for perf

* bug fixes

* fix: useTemplateActions re-computed templates on every render

* fix: hide subscribe/unsubscribe for inactive documents & collections

* cleanup `MenuContext` & `useTemplateMenuItems`

* rename prop, increase dropdown max-height

* jsdoc
2025-08-09 18:11:20 +05:30
Tom Moor cda503e7af fix: Correct transaction usage through team creation process (#9878)
* fix: Correct transaction usage through team creation process

* refactor
2025-08-09 07:47:39 -04:00
Hemachandar 45cc5ee20f chore: Move CollectionMenu to Radix (#9874)
* chore: Move `CollectionMenu` to Radix

* action button supports v2

* rename button

* respect disabled pointer-events

* separator should go all the way

* Revert "separator should go all the way"

This reverts commit c76afa1252.
2025-08-09 13:47:13 +05:30
Neha Prasad ecf8783af0 fix: undefined width/height when pasting HTML images (#9868)
* Remove the extra padding from the transform calculation to match the image implementation
Improve the scrollable area handling for full-width tables

* Fix image dimensions parsing for HTML paste operations

* Update Styles.ts

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-08 11:52:54 +00:00
Tom Moor 7b7af55826 feat: Convert attachment links to absolute when copying Markdown (#9873) 2025-08-08 07:49:27 -04:00
Hemachandar 5c0fc87504 chore: Move more dropdowns to Radix (#9871)
* collection group member menu

* comment menu

* group member menu

* group menu; added support for disabled items

* insights menu

* member menu

* notifications menu

* share menu

* alignment

* tsc, graphite feedback

* remove unused `CollectionGroupMenu` & `MemberMenu`
2025-08-08 08:54:11 +05:30
Tom Moor 93cda9327a fix: Admins unable to revoke workspace API keys created by other users (#9865)
* fix: Admins unable to revoke workspace API keys created by other users

closes #9864

* fix policy
2025-08-07 22:49:23 -04:00
Hemachandar 888add7920 Use shareLoader for loading public docs in documents.info endpoint (#9822)
* Use `shareLoader` for loading public docs in `documents.info` endpoint

* cleanup sharedCache in documents store

* remove shareId path param for authenticated routes

* require auth in document loader

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-07 22:41:04 -04:00
Apoorv Mishra 7dfc4fb57d Don't shift body horizontally when dialogs open (#9872)
* fix: don't shift body horizontally when dialogs open

* Update globals.ts

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-08 01:39:56 +00:00
Tom Moor 6ef03d3aa7 chore: Move SkinTonePicker to Radix popover (#9866)
* chore: Move SkinTonePicker to Radix popover

towards #6456

* fix: Graphite PR review

* fix: Even up padding
2025-08-07 21:31:14 -04:00
Hemachandar d5510f4454 fix: Allow users to revoke a collection share (#9870) 2025-08-08 00:07:04 +05:30
Hemachandar 5b19f7c711 fix: Prefetch non-archived notificatios only (#9869) 2025-08-07 22:22:53 +05:30
Hemachandar f28f41eb76 Add sub-menu support for dropdown (#9787)
* sub menu primitives

* user menu

* mobile sub menu

* fluid transition for mobile sub-menu

* account menu

* selected icon

* submenu icon

* tsc: migrate TeamMenu

* reactivity
2025-08-07 08:21:27 +05:30
Hemachandar 80b9fb1daa fix: Full-width nodes misaligned when ToC is open in full-width document (#9860)
* fix: Full-width nodes misaligned when ToC is open in full-width document

* Remove extra div

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-06 20:46:23 -04:00
Tom Moor 80c547c1fc Merge branch 'main' of github.com:outline/outline 2025-08-06 20:06:49 -04:00
Hemachandar 04c3d81b1f chore: Setup missing oxlint configs (#9862)
* shared

* server

* app

* remove vestigial eslintrc files

* update comment directives
2025-08-06 19:54:22 -04:00
Hemachandar f41f93d6c9 Move ColorPicker to Radix popover (#9863) 2025-08-06 19:52:15 -04:00
dependabot[bot] 74acf9601b chore(deps): bump tmp from 0.2.3 to 0.2.4 (#9861)
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.3 to 0.2.4.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.3...v0.2.4)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-06 18:25:49 -04:00
314 changed files with 8944 additions and 5999 deletions
+1
View File
@@ -6,6 +6,7 @@ __mocks__
.DS_Store
.env*
.eslint*
.oxlintrc*
.log
Makefile
Procfile
+4
View File
@@ -211,6 +211,10 @@ GITHUB_APP_PRIVATE_KEY=
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# The GitLab integration allows previewing issue and merge request links as rich mentions
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
+8 -9
View File
@@ -25,15 +25,14 @@ jobs:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
lint:
needs: build
-1
View File
@@ -12,7 +12,6 @@
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalSetup": "<rootDir>/server/test/globalSetup.js",
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
},
+5 -34
View File
@@ -1,9 +1,5 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript"],
"env": {
"builtin": true
},
"ignorePatterns": [
"build/**",
"node_modules/**",
@@ -35,6 +31,7 @@
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-explicit-any": "warn",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
@@ -71,31 +68,12 @@
},
"overrides": [
{
"files": [
"**/*.{js,jsx,ts,tsx}"
],
"files": ["**/*.{js,jsx,ts,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "reakit/Menu",
"importNames": [
"useMenuState"
],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
],
"eqeqeq": "error",
"curly": "error",
"no-console": "error",
"arrow-body-style": [
"error",
"as-needed"
],
"arrow-body-style": ["error", "as-needed"],
"no-useless-escape": "off",
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": [
@@ -106,11 +84,10 @@
}
],
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-explicit-any": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"no-unused-vars": [
"warn",
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
@@ -119,13 +96,7 @@
}
]
},
"plugins": [
"eslint",
"oxc",
"react",
"typescript",
"import"
]
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
}
]
}
+1 -1
View File
@@ -1,4 +1,4 @@
require("dotenv").config({
require("@dotenvx/dotenvx").config({
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
});
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.86.0
Licensed Work: Outline 0.86.1
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-08-06
Change Date: 2029-08-09
Change License: Apache License, Version 2.0
-17
View File
@@ -1,17 +0,0 @@
{
"extends": [
"../.eslintrc",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"plugins": [
"eslint-plugin-react-hooks"
],
"rules": {
"react/react-in-jsx-scope": "off"
},
"env": {
"jest": true,
"browser": true
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"extends": ["../.oxlintrc.json"],
"plugins": ["oxc", "eslint", "typescript", "react"],
"overrides": [
{
"files": ["**/*.{jsx,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
],
"paths": [
{
"name": "reakit/Menu",
"importNames": ["useMenuState"],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
]
},
"plugins": ["import"]
}
],
"env": {
"jest": true,
"browser": true
}
}
+102 -24
View File
@@ -2,6 +2,8 @@ import {
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
RestoreIcon,
@@ -22,11 +24,19 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import { createAction } from "~/actions";
import {
createAction,
createActionV2,
createInternalLinkActionV2,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
import {
newDocumentPath,
newTemplatePath,
searchPath,
} from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
@@ -70,7 +80,7 @@ export const createCollection = createAction({
},
});
export const editCollection = createAction({
export const editCollection = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
analyticsName: "Edit collection",
@@ -96,7 +106,7 @@ export const editCollection = createAction({
},
});
export const editCollectionPermissions = createAction({
export const editCollectionPermissions = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
@@ -128,7 +138,7 @@ export const editCollectionPermissions = createAction({
},
});
export const searchInCollection = createAction({
export const searchInCollection = createInternalLinkActionV2({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: ActiveCollectionSection,
@@ -146,13 +156,20 @@ export const searchInCollection = createAction({
return stores.policies.abilities(activeCollectionId).readDocument;
},
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = searchPath({
collectionId: activeCollectionId,
}).split("?");
perform: ({ activeCollectionId }) => {
history.push(searchPath({ collectionId: activeCollectionId }));
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const starCollection = createAction({
export const starCollection = createActionV2({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
section: ActiveCollectionSection,
@@ -179,7 +196,7 @@ export const starCollection = createAction({
},
});
export const unstarCollection = createAction({
export const unstarCollection = createActionV2({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection",
section: ActiveCollectionSection,
@@ -205,7 +222,7 @@ export const unstarCollection = createAction({
},
});
export const subscribeCollection = createAction({
export const subscribeCollection = createActionV2({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
@@ -218,6 +235,7 @@ export const subscribeCollection = createAction({
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
@@ -235,7 +253,7 @@ export const subscribeCollection = createAction({
},
});
export const unsubscribeCollection = createAction({
export const unsubscribeCollection = createActionV2({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
@@ -248,6 +266,7 @@ export const unsubscribeCollection = createAction({
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
@@ -265,10 +284,10 @@ export const unsubscribeCollection = createAction({
},
});
export const archiveCollection = createAction({
export const archiveCollection = createActionV2({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
section: CollectionSection,
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
@@ -306,7 +325,7 @@ export const archiveCollection = createAction({
},
});
export const restoreCollection = createAction({
export const restoreCollection = createActionV2({
name: ({ t }) => t("Restore"),
analyticsName: "Restore collection",
section: CollectionSection,
@@ -331,7 +350,7 @@ export const restoreCollection = createAction({
},
});
export const deleteCollection = createAction({
export const deleteCollection = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
section: ActiveCollectionSection,
@@ -365,7 +384,65 @@ export const deleteCollection = createAction({
},
});
export const createTemplate = createAction({
export const exportCollection = createActionV2({
name: ({ t }) => `${t("Export")}`,
analyticsName: "Export collection",
section: ActiveCollectionSection,
icon: <ExportIcon />,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (!currentTeamId || !activeCollectionId) {
return false;
}
return (
!!stores.policies.abilities(currentTeamId).createExport &&
!!stores.policies.abilities(activeCollectionId).export
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Export collection"),
content: (
<ExportDialog
collection={collection}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const createDocument = createInternalLinkActionV2({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveCollectionSection,
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createTemplate = createInternalLinkActionV2({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
@@ -376,13 +453,14 @@ export const createTemplate = createAction({
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
perform: ({ activeCollectionId, event }) => {
if (!activeCollectionId) {
return;
}
event?.preventDefault();
event?.stopPropagation();
history.push(newTemplatePath(activeCollectionId));
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
+16 -17
View File
@@ -1,12 +1,11 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import stores from "~/stores";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createAction } from "..";
import { DocumentSection } from "../sections";
import { createActionV2 } from "..";
import { ActiveDocumentSection } from "../sections";
export const deleteCommentFactory = ({
comment,
@@ -15,15 +14,15 @@ export const deleteCommentFactory = ({
comment: Comment;
onDelete: () => void;
}) =>
createAction({
createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <TrashIcon />,
keywords: "trash",
dangerous: true,
visible: () => stores.policies.abilities(comment.id).delete,
perform: ({ t, event }) => {
visible: ({ stores }) => stores.policies.abilities(comment.id).delete,
perform: ({ t, stores, event }) => {
event?.preventDefault();
event?.stopPropagation();
@@ -41,12 +40,12 @@ export const resolveCommentFactory = ({
comment: Comment;
onResolve: () => void;
}) =>
createAction({
createActionV2({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <DoneIcon outline />,
visible: () =>
visible: ({ stores }) =>
stores.policies.abilities(comment.id).resolve &&
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
@@ -73,12 +72,12 @@ export const unresolveCommentFactory = ({
comment: Comment;
onUnresolve: () => void;
}) =>
createAction({
createActionV2({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <DoneIcon outline />,
visible: () =>
visible: ({ stores }) =>
stores.policies.abilities(comment.id).unresolve &&
stores.policies.abilities(comment.documentId).update,
perform: async () => {
@@ -102,15 +101,15 @@ export const viewCommentReactionsFactory = ({
}: {
comment: Comment;
}) =>
createAction({
createActionV2({
name: ({ t }) => `${t("View reactions")}`,
analyticsName: "View comment reactions",
section: DocumentSection,
section: ActiveDocumentSection,
icon: <SmileyIcon />,
visible: () =>
visible: ({ stores }) =>
stores.policies.abilities(comment.id).read &&
comment.reactions.length > 0,
perform: ({ t, event }) => {
perform: ({ t, stores, event }) => {
event?.preventDefault();
event?.stopPropagation();
+13
View File
@@ -1,3 +1,4 @@
import Storage from "@shared/utils/Storage";
import copy from "copy-to-clipboard";
import {
BeakerIcon,
@@ -127,6 +128,17 @@ export const clearIndexedDB = createAction({
},
});
export const clearStorage = createAction({
name: ({ t }) => t("Clear local storage"),
icon: <TrashIcon />,
keywords: "cache clear localstorage",
section: DeveloperSection,
perform: ({ t }) => {
Storage.clear();
toast.success(t("Local storage cleared"));
},
});
export const createTestUsers = createAction({
name: "Create 10 test users",
icon: <UserIcon />,
@@ -201,6 +213,7 @@ export const developer = createAction({
createToast,
createTestUsers,
clearIndexedDB,
clearStorage,
startTyping,
],
});
+297 -98
View File
@@ -26,11 +26,12 @@ import {
PublishIcon,
CommentIcon,
CopyIcon,
EyeIcon,
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
RestoreIcon,
EditIcon,
} from "outline-icons";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
@@ -52,7 +53,13 @@ import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import { createAction } from "~/actions";
import {
createAction,
createActionV2,
createActionV2Group,
createActionV2WithChildren,
createInternalLinkActionV2,
} from "~/actions";
import {
ActiveDocumentSection,
DocumentSection,
@@ -62,7 +69,6 @@ import env from "~/env";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import {
documentInsightsPath,
documentHistoryPath,
homePath,
newDocumentPath,
@@ -71,7 +77,12 @@ import {
documentPath,
urlify,
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import Insights from "~/scenes/Document/components/Insights";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -103,6 +114,38 @@ export const openDocument = createAction({
},
});
export const editDocument = createInternalLinkActionV2({
name: ({ t }) => t("Edit"),
analyticsName: "Edit document",
section: ActiveDocumentSection,
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, documents, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return (
!!can?.update && !!auth.user?.separateEditMode && !document?.template
);
},
to: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
return documentEditPath(document);
},
});
export const createDocument = createAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
@@ -141,7 +184,7 @@ export const createDraftDocument = createAction({
}),
});
export const createDocumentFromTemplate = createAction({
export const createDocumentFromTemplate = createInternalLinkActionV2({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
section: DocumentSection,
@@ -171,16 +214,24 @@ export const createDocumentFromTemplate = createAction({
}
return stores.policies.abilities(currentTeamId).createDocument;
},
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
history.push(
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
{
sidebarContext,
}
),
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
if (!activeDocumentId || !activeCollectionId) {
return "";
}
const [pathname, search] = newDocumentPath(activeCollectionId, {
templateId: activeDocumentId,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createNestedDocument = createAction({
export const createNestedDocument = createInternalLinkActionV2({
name: ({ t }) => t("New nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
@@ -191,13 +242,19 @@ export const createNestedDocument = createAction({
!!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument,
perform: ({ activeDocumentId, sidebarContext }) =>
history.push(newNestedDocumentPath(activeDocumentId), {
sidebarContext,
}),
to: ({ activeDocumentId, sidebarContext }) => {
const [pathname, search] =
newNestedDocumentPath(activeDocumentId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const starDocument = createAction({
export const starDocument = createActionV2({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
section: ActiveDocumentSection,
@@ -223,7 +280,7 @@ export const starDocument = createAction({
},
});
export const unstarDocument = createAction({
export const unstarDocument = createActionV2({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar document",
section: ActiveDocumentSection,
@@ -249,7 +306,7 @@ export const unstarDocument = createAction({
},
});
export const publishDocument = createAction({
export const publishDocument = createActionV2({
name: ({ t }) => t("Publish"),
analyticsName: "Publish document",
section: ActiveDocumentSection,
@@ -291,7 +348,7 @@ export const publishDocument = createAction({
},
});
export const unpublishDocument = createAction({
export const unpublishDocument = createActionV2({
name: ({ t }) => t("Unpublish"),
analyticsName: "Unpublish document",
section: ActiveDocumentSection,
@@ -322,11 +379,27 @@ export const unpublishDocument = createAction({
},
});
export const subscribeDocument = createAction({
export const subscribeDocument = createActionV2({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
icon: <SubscribeIcon />,
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
return undefined;
}
return stores.collections.get(activeCollectionId)?.isSubscribed
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
return false;
}
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
},
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -335,6 +408,7 @@ export const subscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isActive &&
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
@@ -351,11 +425,27 @@ export const subscribeDocument = createAction({
},
});
export const unsubscribeDocument = createAction({
export const unsubscribeDocument = createActionV2({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
icon: <UnsubscribeIcon />,
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
return undefined;
}
return stores.collections.get(activeCollectionId)?.isSubscribed
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
return false;
}
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
},
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -364,9 +454,10 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe)
!!document?.isActive &&
(!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe))
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -382,7 +473,7 @@ export const unsubscribeDocument = createAction({
},
});
export const shareDocument = createAction({
export const shareDocument = createActionV2({
name: ({ t }) => `${t("Permissions")}`,
analyticsName: "Share document",
section: ActiveDocumentSection,
@@ -415,7 +506,7 @@ export const shareDocument = createAction({
},
});
export const downloadDocumentAsHTML = createAction({
export const downloadDocumentAsHTML = createActionV2({
name: ({ t }) => t("HTML"),
analyticsName: "Download document as HTML",
section: ActiveDocumentSection,
@@ -434,7 +525,7 @@ export const downloadDocumentAsHTML = createAction({
},
});
export const downloadDocumentAsPDF = createAction({
export const downloadDocumentAsPDF = createActionV2({
name: ({ t }) => t("PDF"),
analyticsName: "Download document as PDF",
section: ActiveDocumentSection,
@@ -442,9 +533,11 @@ export const downloadDocumentAsPDF = createAction({
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId &&
stores.policies.abilities(activeDocumentId).download &&
env.PDF_EXPORT_ENABLED,
!!(
activeDocumentId &&
stores.policies.abilities(activeDocumentId).download &&
env.PDF_EXPORT_ENABLED
),
perform: ({ activeDocumentId, t, stores }) => {
if (!activeDocumentId) {
return;
@@ -458,7 +551,7 @@ export const downloadDocumentAsPDF = createAction({
},
});
export const downloadDocumentAsMarkdown = createAction({
export const downloadDocumentAsMarkdown = createActionV2({
name: ({ t }) => t("Markdown"),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
@@ -477,7 +570,7 @@ export const downloadDocumentAsMarkdown = createAction({
},
});
export const downloadDocument = createAction({
export const downloadDocument = createActionV2WithChildren({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
analyticsName: "Download document",
@@ -493,7 +586,7 @@ export const downloadDocument = createAction({
],
});
export const copyDocumentAsMarkdown = createAction({
export const copyDocumentAsMarkdown = createActionV2({
name: ({ t }) => t("Copy as Markdown"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -512,7 +605,7 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentAsPlainText = createAction({
export const copyDocumentAsPlainText = createActionV2({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -531,7 +624,7 @@ export const copyDocumentAsPlainText = createAction({
},
});
export const copyDocumentShareLink = createAction({
export const copyDocumentShareLink = createActionV2({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
keywords: "clipboard share",
@@ -552,7 +645,7 @@ export const copyDocumentShareLink = createAction({
},
});
export const copyDocumentLink = createAction({
export const copyDocumentLink = createActionV2({
name: ({ t }) => t("Copy link"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -570,7 +663,7 @@ export const copyDocumentLink = createAction({
},
});
export const copyDocument = createAction({
export const copyDocument = createActionV2WithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy document",
section: ActiveDocumentSection,
@@ -584,7 +677,7 @@ export const copyDocument = createAction({
],
});
export const duplicateDocument = createAction({
export const duplicateDocument = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
analyticsName: "Duplicate document",
@@ -620,7 +713,7 @@ export const duplicateDocument = createAction({
* Pin a document to a collection. Pinned documents will be displayed at the top
* of the collection for all collection members to see.
*/
export const pinDocumentToCollection = createAction({
export const pinDocumentToCollection = createActionV2({
name: ({ activeDocumentId = "", t, stores }) => {
const selectedDocument = stores.documents.get(activeDocumentId);
const collectionName = selectedDocument
@@ -665,7 +758,7 @@ export const pinDocumentToCollection = createAction({
* Pin a document to team home. Pinned documents will be displayed at the top
* of the home screen for all team members to see.
*/
export const pinDocumentToHome = createAction({
export const pinDocumentToHome = createActionV2({
name: ({ t }) => t("Pin to home"),
analyticsName: "Pin document to home",
section: ActiveDocumentSection,
@@ -697,7 +790,7 @@ export const pinDocumentToHome = createAction({
},
});
export const pinDocument = createAction({
export const pinDocument = createActionV2WithChildren({
name: ({ t }) => t("Pin"),
analyticsName: "Pin document",
section: ActiveDocumentSection,
@@ -705,7 +798,7 @@ export const pinDocument = createAction({
children: [pinDocumentToCollection, pinDocumentToHome],
});
export const searchInDocument = createAction({
export const searchInDocument = createInternalLinkActionV2({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
@@ -718,12 +811,24 @@ export const searchInDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return !!document?.isActive;
},
perform: ({ activeDocumentId }) => {
history.push(searchPath({ documentId: activeDocumentId }));
to: ({ activeDocumentId, sidebarContext }) => {
if (!activeDocumentId) {
return "";
}
const [pathname, search] = searchPath({
documentId: activeDocumentId,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const printDocument = createAction({
export const printDocument = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
analyticsName: "Print document",
@@ -735,7 +840,7 @@ export const printDocument = createAction({
},
});
export const importDocument = createAction({
export const importDocument = createActionV2({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: DocumentSection,
@@ -782,7 +887,7 @@ export const importDocument = createAction({
},
});
export const createTemplateFromDocument = createAction({
export const createTemplateFromDocument = createActionV2({
name: ({ t }) => t("Templatize"),
analyticsName: "Templatize document",
section: ActiveDocumentSection,
@@ -844,7 +949,7 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
export const moveTemplateToWorkspace = createActionV2({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
@@ -874,7 +979,7 @@ export const moveTemplateToWorkspace = createAction({
},
});
export const moveDocumentToCollection = createAction({
export const moveDocumentToCollection = createActionV2({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
@@ -911,7 +1016,7 @@ export const moveDocumentToCollection = createAction({
},
});
export const moveDocument = createAction({
export const moveDocument = createActionV2({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
@@ -930,7 +1035,7 @@ export const moveDocument = createAction({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createAction({
export const moveTemplate = createActionV2WithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
@@ -949,7 +1054,7 @@ export const moveTemplate = createAction({
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
export const archiveDocument = createActionV2({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
section: ActiveDocumentSection,
@@ -989,7 +1094,102 @@ export const archiveDocument = createAction({
},
});
export const deleteDocument = createAction({
export const restoreDocument = createActionV2({
name: ({ t }) => `${t("Restore")}`,
analyticsName: "Restore document",
section: ActiveDocumentSection,
icon: <RestoreIcon />,
visible: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return false;
}
const collection = document.collectionId
? stores.collections.get(document.collectionId)
: undefined;
const can = stores.policies.abilities(document.id);
return (
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
},
perform: async ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
await document.restore();
toast.success(
t("{{ documentName }} restored", {
documentName: capitalize(document.noun),
})
);
},
});
export const restoreDocumentToCollection = createActionV2WithChildren({
name: ({ t }) => `${t("Restore")}`,
analyticsName: "Restore document",
section: ActiveDocumentSection,
icon: <RestoreIcon />,
visible: ({ stores, activeDocumentId }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return false;
}
const can = stores.policies.abilities(document.id);
const collection = document.collectionId
? stores.collections.get(document.collectionId)
: undefined;
return (
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
},
children: ({ t, activeDocumentId, stores }) => {
const { collections, documents, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
if (!document) {
return [];
}
const actions = collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return createActionV2({
name: collection.name,
section: ActiveDocumentSection,
icon: <CollectionIcon collection={collection} />,
visible: can.createDocument,
perform: async () => {
await document.restore({ collectionId: collection.id });
toast.success(
t("{{ documentName }} restored", {
documentName: capitalize(document.noun),
})
);
},
});
});
return [createActionV2Group({ name: t("Choose a collection"), actions })];
},
});
export const deleteDocument = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete document",
section: ActiveDocumentSection,
@@ -1023,7 +1223,7 @@ export const deleteDocument = createAction({
},
});
export const permanentlyDeleteDocument = createAction({
export const permanentlyDeleteDocument = createActionV2({
name: ({ t }) => t("Permanently delete"),
analyticsName: "Permanently delete document",
section: ActiveDocumentSection,
@@ -1078,7 +1278,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
},
});
export const openDocumentComments = createAction({
export const openDocumentComments = createActionV2({
name: ({ t }) => t("Comments"),
analyticsName: "Open comments",
section: ActiveDocumentSection,
@@ -1101,7 +1301,7 @@ export const openDocumentComments = createAction({
},
});
export const openDocumentHistory = createAction({
export const openDocumentHistory = createInternalLinkActionV2({
name: ({ t }) => t("History"),
analyticsName: "Open document history",
section: ActiveDocumentSection,
@@ -1110,19 +1310,25 @@ export const openDocumentHistory = createAction({
const can = stores.policies.abilities(activeDocumentId ?? "");
return !!activeDocumentId && can.listRevisions;
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
return "";
}
history.push(documentHistoryPath(document));
const [pathname, search] = documentHistoryPath(document).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const openDocumentInsights = createAction({
export const openDocumentInsights = createActionV2({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
@@ -1140,50 +1346,22 @@ export const openDocumentInsights = createAction({
!document?.isDeleted
);
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
history.push(documentInsightsPath(document));
},
});
export const toggleViewerInsights = createAction({
name: ({ t, stores, activeDocumentId }) => {
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
return document?.insightsEnabled
? t("Disable viewer insights")
: t("Enable viewer insights");
},
analyticsName: "Toggle viewer insights",
section: ActiveDocumentSection,
icon: <EyeIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return can.updateInsights;
},
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.save({
insightsEnabled: !document.insightsEnabled,
stores.dialogs.openModal({
title: t("Insights"),
content: <Insights document={document} />,
});
},
});
export const leaveDocument = createAction({
export const leaveDocument = createActionV2({
name: ({ t }) => t("Leave document"),
analyticsName: "Leave document",
section: ActiveDocumentSection,
@@ -1219,6 +1397,27 @@ export const leaveDocument = createAction({
},
});
export const applyTemplateFactory = ({
actions,
}: {
actions: (ActionV2 | ActionV2Group | ActionV2Separator)[];
}) =>
createActionV2WithChildren({
name: ({ t }) => t("Apply template"),
analyticsName: "Apply template",
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: ({ activeDocumentId, stores }) => {
const { policies } = stores;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return !!can?.update;
},
children: actions,
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
+32 -33
View File
@@ -12,13 +12,19 @@ import {
BrowserIcon,
ShapesIcon,
DraftsIcon,
BugIcon,
} from "outline-icons";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isMac } from "@shared/utils/browser";
import stores from "~/stores";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import {
createAction,
createActionV2,
createExternalLinkActionV2,
createInternalLinkActionV2,
} from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
@@ -97,7 +103,7 @@ export const navigateToSettings = createAction({
to: settingsPath(),
});
export const navigateToWorkspaceSettings = createAction({
export const navigateToWorkspaceSettings = createInternalLinkActionV2({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to workspace settings",
section: NavigationSection,
@@ -106,7 +112,7 @@ export const navigateToWorkspaceSettings = createAction({
to: settingsPath("details"),
});
export const navigateToProfileSettings = createAction({
export const navigateToProfileSettings = createInternalLinkActionV2({
name: ({ t }) => t("Profile"),
analyticsName: "Navigate to profile settings",
section: NavigationSection,
@@ -124,8 +130,9 @@ export const navigateToTemplateSettings = createAction({
to: settingsPath("templates"),
});
export const navigateToNotificationSettings = createAction({
name: ({ t }) => t("Notifications"),
export const navigateToNotificationSettings = createInternalLinkActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings",
section: NavigationSection,
iconInContextMenu: false,
@@ -133,7 +140,7 @@ export const navigateToNotificationSettings = createAction({
to: settingsPath("notifications"),
});
export const navigateToAccountPreferences = createAction({
export const navigateToAccountPreferences = createInternalLinkActionV2({
name: ({ t }) => t("Preferences"),
analyticsName: "Navigate to account preferences",
section: NavigationSection,
@@ -142,28 +149,24 @@ export const navigateToAccountPreferences = createAction({
to: settingsPath("preferences"),
});
export const openDocumentation = createAction({
export const openDocumentation = createExternalLinkActionV2({
name: ({ t }) => t("Documentation"),
analyticsName: "Open documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
to: {
url: UrlHelper.guide,
target: "_blank",
},
url: UrlHelper.guide,
target: "_blank",
});
export const openAPIDocumentation = createAction({
export const openAPIDocumentation = createExternalLinkActionV2({
name: ({ t }) => t("API documentation"),
analyticsName: "Open API documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
to: {
url: UrlHelper.developers,
target: "_blank",
},
url: UrlHelper.developers,
target: "_blank",
});
export const toggleSidebar = createAction({
@@ -174,41 +177,37 @@ export const toggleSidebar = createAction({
perform: () => stores.ui.toggleCollapsedSidebar(),
});
export const openFeedbackUrl = createAction({
export const openFeedbackUrl = createExternalLinkActionV2({
name: ({ t }) => t("Send us feedback"),
analyticsName: "Open feedback",
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
to: {
url: UrlHelper.contact,
target: "_blank",
},
url: UrlHelper.contact,
target: "_blank",
});
export const openBugReportUrl = createAction({
export const openBugReportUrl = createExternalLinkActionV2({
name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report",
section: NavigationSection,
to: {
url: UrlHelper.github,
target: "_blank",
},
iconInContextMenu: false,
icon: <BugIcon />,
url: UrlHelper.github,
target: "_blank",
});
export const openChangelog = createAction({
export const openChangelog = createExternalLinkActionV2({
name: ({ t }) => t("Changelog"),
analyticsName: "Open changelog",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
to: {
url: UrlHelper.changelog,
target: "_blank",
},
url: UrlHelper.changelog,
target: "_blank",
});
export const openKeyboardShortcuts = createAction({
export const openKeyboardShortcuts = createActionV2({
name: ({ t }) => t("Keyboard shortcuts"),
analyticsName: "Open keyboard shortcuts",
section: NavigationSection,
@@ -239,7 +238,7 @@ export const downloadApp = createAction({
},
});
export const logout = createAction({
export const logout = createActionV2({
name: ({ t }) => t("Log out"),
analyticsName: "Log out",
section: NavigationSection,
+2 -2
View File
@@ -1,5 +1,5 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import { createAction } from "..";
import { createAction, createActionV2 } from "..";
import { NotificationSection } from "../sections";
export const markNotificationsAsRead = createAction({
@@ -12,7 +12,7 @@ export const markNotificationsAsRead = createAction({
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
});
export const markNotificationsAsArchived = createAction({
export const markNotificationsAsArchived = createActionV2({
name: ({ t }) => t("Archive all notifications"),
analyticsName: "Mark notifications as archived",
section: NotificationSection,
+3 -3
View File
@@ -3,7 +3,7 @@ import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
import { createAction } from "~/actions";
import { createAction, createActionV2 } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history";
import {
@@ -11,7 +11,7 @@ import {
matchDocumentHistory,
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
export const restoreRevision = createActionV2({
name: ({ t }) => t("Restore"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
@@ -73,7 +73,7 @@ export const deleteRevision = createAction({
},
});
export const copyLinkToRevision = createAction({
export const copyLinkToRevision = createActionV2({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
+13 -15
View File
@@ -1,50 +1,48 @@
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import stores from "~/stores";
import { Theme } from "~/stores/UiStore";
import { createAction } from "~/actions";
import { createActionV2, createActionV2WithChildren } from "~/actions";
import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createAction({
export const changeToDarkTheme = createActionV2({
name: ({ t }) => t("Dark"),
analyticsName: "Change to dark theme",
icon: <MoonIcon />,
iconInContextMenu: false,
keywords: "theme dark night",
section: SettingsSection,
selected: () => stores.ui.theme === "dark",
perform: () => stores.ui.setTheme(Theme.Dark),
selected: ({ stores }) => stores.ui.theme === "dark",
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
});
export const changeToLightTheme = createAction({
export const changeToLightTheme = createActionV2({
name: ({ t }) => t("Light"),
analyticsName: "Change to light theme",
icon: <SunIcon />,
iconInContextMenu: false,
keywords: "theme light day",
section: SettingsSection,
selected: () => stores.ui.theme === "light",
perform: () => stores.ui.setTheme(Theme.Light),
selected: ({ stores }) => stores.ui.theme === "light",
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
});
export const changeToSystemTheme = createAction({
export const changeToSystemTheme = createActionV2({
name: ({ t }) => t("System"),
analyticsName: "Change to system theme",
icon: <BrowserIcon />,
iconInContextMenu: false,
keywords: "theme system default",
section: SettingsSection,
selected: () => stores.ui.theme === "system",
perform: () => stores.ui.setTheme(Theme.System),
selected: ({ stores }) => stores.ui.theme === "system",
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
});
export const changeTheme = createAction({
export const changeTheme = createActionV2WithChildren({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Appearance") : t("Change theme"),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: function _Icon() {
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
},
icon: ({ stores }) =>
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
keywords: "appearance display",
section: SettingsSection,
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
+59
View File
@@ -0,0 +1,59 @@
import copy from "copy-to-clipboard";
import Share from "~/models/Share";
import { createActionV2, createInternalLinkActionV2 } from "..";
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
import { ShareSection } from "../sections";
import env from "~/env";
import { toast } from "sonner";
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
createActionV2({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy share link",
section: ShareSection,
icon: <CopyIcon />,
perform: ({ t }) => {
copy(share.url, {
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
toast.success(t("Share link copied"));
},
});
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
createInternalLinkActionV2({
name: ({ t }) =>
share.collectionId ? t("Go to collection") : t("Go to document"),
analyticsName: "Go to share source",
section: ShareSection,
icon: <ArrowIcon />,
to: {
pathname: share.sourcePathWithFallback,
state: { sidebarContext: "collections" }, // optimistic preference of "collections"
},
});
export const revokeShareFactory = ({
share,
can,
}: {
share: Share;
can: Record<string, boolean>;
}) =>
createActionV2({
name: ({ t }) => t("Revoke link"),
analyticsName: "Revoke share",
section: ShareSection,
icon: <TrashIcon />,
dangerous: true,
visible: !!can.revoke,
perform: async ({ t, stores }) => {
try {
await stores.shares.revoke(share);
toast.message(t("Share link revoked"));
} catch (err) {
toast.error(err.message);
}
},
});
+22 -19
View File
@@ -5,20 +5,24 @@ import RootStore from "~/stores/RootStore";
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo";
import { createAction } from "~/actions";
import { ActionContext } from "~/types";
import {
createActionV2,
createActionV2WithChildren,
createExternalLinkActionV2,
} from "~/actions";
import { ActionContext, ExternalLinkActionV2 } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map((session) => ({
id: `switch-${session.id}`,
name: session.name,
analyticsName: "Switch workspace",
section: TeamSection,
keywords: "change switch workspace organization team",
icon: function _Icon() {
return (
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) =>
createExternalLinkActionV2({
id: `switch-${session.id}`,
name: session.name,
analyticsName: "Switch workspace",
section: TeamSection,
keywords: "change switch workspace organization team",
icon: (
<StyledTeamLogo
alt={session.name}
model={{
@@ -29,16 +33,15 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
}}
size={24}
/>
);
},
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
to: {
),
visible: ({ currentTeamId }: ActionContext) =>
currentTeamId !== session.id,
url: session.url,
target: "_self",
},
})) ?? [];
})
) ?? [];
export const switchTeam = createAction({
export const switchTeam = createActionV2WithChildren({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
analyticsName: "Switch workspace",
@@ -49,7 +52,7 @@ export const switchTeam = createAction({
children: switchTeamsList,
});
export const createTeam = createAction({
export const createTeam = createActionV2({
name: ({ t }) => `${t("New workspace")}`,
analyticsName: "New workspace",
keywords: "create change switch workspace organization team",
@@ -71,7 +74,7 @@ export const createTeam = createAction({
},
});
export const desktopLoginTeam = createAction({
export const desktopLoginTeam = createActionV2({
name: ({ t }) => t("Login to workspace"),
analyticsName: "Login to workspace",
keywords: "change switch workspace organization team",
+3 -3
View File
@@ -8,7 +8,7 @@ import {
UserChangeRoleDialog,
UserDeleteDialog,
} from "~/components/UserDialogs";
import { createAction } from "~/actions";
import { createAction, createActionV2 } from "~/actions";
import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
@@ -28,7 +28,7 @@ export const inviteUser = createAction({
});
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
createAction({
createActionV2({
name: ({ t }) =>
UserRoleHelper.isRoleHigher(role, user!.role)
? `${t("Promote to {{ role }}", {
@@ -63,7 +63,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
});
export const deleteUserActionFactory = (userId: string) =>
createAction({
createActionV2({
name: ({ t }) => `${t("Delete user")}`,
analyticsName: "Delete user",
keywords: "leave",
+96 -9
View File
@@ -1,3 +1,4 @@
import { LocationDescriptor } from "history";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import { Optional } from "utility-types";
@@ -10,7 +11,6 @@ import {
ActionV2Separator as TActionV2Separator,
ActionV2Variant,
ActionV2WithChildren,
CommandBarAction,
ExternalLinkActionV2,
InternalLinkActionV2,
MenuExternalLink,
@@ -21,8 +21,9 @@ import {
} from "~/types";
import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
import { Action as KbarAction } from "kbar";
function resolve<T>(value: any, context: ActionContext): T {
export function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
}
@@ -111,7 +112,7 @@ export function actionToMenuItem(
export function actionToKBar(
action: Action,
context: ActionContext
): CommandBarAction[] {
): KbarAction[] {
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
@@ -267,10 +268,11 @@ export function actionV2ToMenuItem(
switch (action.type) {
case "action": {
const title = resolve<string>(action.name, context);
const visible = resolve<boolean>(action.visible, context);
const visible = resolve<boolean>(action.visible, context) ?? true;
const disabled = resolve<boolean>(action.disabled, context);
const icon =
!!action.icon && action.iconInContextMenu !== false
? action.icon
? resolve<React.ReactNode>(action.icon, context)
: undefined;
switch (action.variant) {
@@ -280,18 +282,24 @@ export function actionV2ToMenuItem(
title,
icon,
visible,
disabled,
tooltip: resolve<React.ReactChild>(action.tooltip, context),
selected: resolve<boolean>(action.selected, context),
dangerous: action.dangerous,
onClick: () => performActionV2(action, context),
};
case "internal_link":
case "internal_link": {
const to = resolve<LocationDescriptor>(action.to, context);
return {
type: "route",
title,
icon,
visible,
to: action.to,
disabled,
to,
};
}
case "external_link":
return {
@@ -299,6 +307,7 @@ export function actionV2ToMenuItem(
title,
icon,
visible,
disabled,
href: action.target
? { url: action.url, target: action.target }
: action.url,
@@ -316,6 +325,7 @@ export function actionV2ToMenuItem(
title,
icon,
items: subMenuItems,
disabled,
visible: visible && hasVisibleItems(subMenuItems),
};
}
@@ -342,11 +352,88 @@ export function actionV2ToMenuItem(
}
}
export function actionV2ToKBar(
action: ActionV2Variant,
context: ActionContext
): KbarAction[] {
const visible = resolve<boolean>(action.visible, context);
if (visible === false) {
return [];
}
const name = resolve<string>(action.name, context);
const icon = resolve<React.ReactElement>(action.icon, context);
const section = resolve<string>(action.section, context);
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? ((action.section.priority as number) ?? 0)
: 0;
const priority = (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0));
switch (action.variant) {
case "action":
case "internal_link":
case "external_link": {
return [
{
id: action.id,
name,
section,
keywords: action.keywords,
shortcut: action.shortcut,
icon,
priority,
perform: () => performActionV2(action, context),
},
];
}
case "action_with_children": {
const resolvedChildren = resolve<ActionV2Variant[]>(
action.children,
context
);
const children = resolvedChildren
.map((a) => actionV2ToKBar(a, context))
.flat()
.filter(Boolean);
return [
{
id: action.id,
name,
section,
keywords: action.keywords,
shortcut: action.shortcut,
icon,
priority,
},
...children.map((child) => ({
...child,
parent: child.parent ?? action.id,
})),
];
}
default:
throw Error("invalid action variant");
}
}
export async function performActionV2(
action: ActionV2,
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
context: ActionContext
) {
const result = action.perform(context);
const perform =
action.variant === "action"
? () => action.perform(context)
: action.variant === "internal_link"
? () => history.push(resolve<LocationDescriptor>(action.to, context))
: () => window.open(action.url, action.target);
const result = perform();
if (result instanceof Promise) {
return result.catch((err: Error) => {
+4
View File
@@ -36,10 +36,14 @@ export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const GroupSection = ({ t }: ActionContext) => t("Groups");
export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
export const ShareSection = ({ t }: ActionContext) => t("Share");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
+15 -7
View File
@@ -1,9 +1,14 @@
/* eslint-disable react/prop-types */
/* oxlint-disable react/prop-types */
import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction } from "~/actions";
import { performAction, performActionV2, resolve } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import { Action, ActionContext } from "~/types";
import {
Action,
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
} from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -11,7 +16,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Action;
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
/** Context of action, must be provided with action */
context?: ActionContext;
/** If tooltip props are provided the button will be wrapped in a tooltip */
@@ -40,8 +45,8 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
const actionContext = { ...context, isButton: true };
if (
action?.visible &&
!action.visible(actionContext) &&
action.visible &&
!resolve<boolean>(action.visible, actionContext) &&
hideOnActionDisabled
) {
return null;
@@ -63,7 +68,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const response = performAction(action, actionContext);
const response =
"variant" in action
? performActionV2(action, actionContext)
: performAction(action, actionContext);
if (response?.finally) {
setExecuting(true);
void response.finally(
+1 -1
View File
@@ -1,4 +1,4 @@
/* eslint-disable prefer-rest-params */
/* oxlint-disable prefer-rest-params */
/* global ga */
import escape from "lodash/escape";
import * as React from "react";
+2 -11
View File
@@ -27,7 +27,6 @@ import {
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
@@ -39,9 +38,7 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const DocumentInsights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
type Props = {
@@ -98,12 +95,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
!!matchPath(location.pathname, {
path: matchDocumentHistory,
}) && can.listRevisions;
const showInsights =
!!matchPath(location.pathname, {
path: matchDocumentInsights,
}) && can.listViews;
const showComments =
!showInsights &&
!showHistory &&
can.comment &&
ui.activeDocumentId &&
@@ -115,12 +107,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showInsights || showComments) && (
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showInsights && <DocumentInsights />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
+65 -31
View File
@@ -6,55 +6,89 @@ import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { MenuInternalLink } from "~/types";
import { InternalLinkActionV2, MenuInternalLink } from "~/types";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useComputed } from "~/hooks/useComputed";
type TopLevelAction =
| InternalLinkActionV2
| { type: "menu"; actions: InternalLinkActionV2[] };
type Props = React.PropsWithChildren<{
items: MenuInternalLink[];
actions: InternalLinkActionV2[];
max?: number;
highlightFirstItem?: boolean;
}>;
function Breadcrumb(
{ items, highlightFirstItem, children, max = 2 }: Props,
{ actions, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems;
const actionContext = useActionContext({ isContextMenu: true });
const visibleActions = useComputed(
() =>
actions.filter((action) =>
typeof action.visible === "function"
? action.visible(actionContext)
: (action.visible ?? true)
),
[actions, actionContext]
);
const totalVisibleActions = visibleActions.length;
const topLevelActions: TopLevelAction[] = [...visibleActions];
// chop middle breadcrumbs and present a "..." menu instead
if (totalItems > max) {
if (totalVisibleActions > max) {
const halfMax = Math.floor(max / 2);
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
const menuActions = topLevelActions.splice(
halfMax,
totalVisibleActions - max
) as InternalLinkActionV2[];
topLevelItems.splice(halfMax, 0, {
to: "",
type: "route",
title: <BreadcrumbMenu items={overflowItems as MenuInternalLink[]} />,
topLevelActions.splice(halfMax, 0, {
type: "menu",
actions: menuActions,
});
}
const toBreadcrumb = React.useCallback(
(action: TopLevelAction, index: number) => {
if (action.type === "menu") {
return <BreadcrumbMenu key="menu" actions={action.actions} />;
}
const item = actionV2ToMenuItem(
action,
actionContext
) as MenuInternalLink;
return (
<>
{item.icon}
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
{item.title}
</Item>
</>
);
},
[actionContext, highlightFirstItem]
);
return (
<Flex justify="flex-start" align="center" ref={ref}>
{topLevelItems.map((item, index) => (
<React.Fragment
key={
(typeof item.to === "string" ? item.to : item.to.pathname) || 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}
{topLevelActions.map((action, index) => (
<React.Fragment key={action.type === "menu" ? "menu" : `item-${index}`}>
{toBreadcrumb(action, index)}
{index !== topLevelActions.length - 1 || !!children ? (
<Slash />
) : null}
</React.Fragment>
))}
{children}
+5 -1
View File
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
{...rest}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
@@ -148,6 +148,10 @@ function Collaborators(props: Props) {
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
);
if (!document.insightsEnabled) {
return null;
}
return (
<Popover>
<PopoverTrigger>
+21 -28
View File
@@ -3,9 +3,10 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { MenuInternalLink } from "~/types";
import { archivePath, collectionPath } from "~/utils/routeHelpers";
import Breadcrumb from "./Breadcrumb";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveCollectionSection } from "~/actions/sections";
type Props = {
collection: Collection;
@@ -14,32 +15,24 @@ type Props = {
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
const { t } = useTranslation();
const items = React.useMemo(() => {
const collectionNode: MenuInternalLink = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
};
const actions = React.useMemo(
() => [
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: collection.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
}),
],
[collection, t]
);
const category: MenuInternalLink | undefined = collection.isArchived
? {
type: "route",
icon: <ArchiveIcon />,
title: t("Archive"),
to: archivePath(),
}
: undefined;
const output = [];
if (category) {
output.push(category);
}
output.push(collectionNode);
return output;
}, [collection, t]);
return <Breadcrumb items={items} highlightFirstItem />;
return <Breadcrumb actions={actions} highlightFirstItem />;
};
+1 -1
View File
@@ -119,7 +119,7 @@ const ContextMenu: React.FC<Props> = ({
>
{(props) => (
<InnerContextMenu
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
+79 -90
View File
@@ -11,8 +11,9 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
type Props = {
children?: React.ReactNode;
@@ -27,46 +28,12 @@ type Props = {
maxDepth?: number;
};
function useCategory(document: Document): MenuInternalLink | null {
const { t } = useTranslation();
if (document.isDeleted) {
return {
type: "route",
icon: <TrashIcon />,
title: t("Trash"),
to: trashPath(),
};
}
if (document.isArchived) {
return {
type: "route",
icon: <ArchiveIcon />,
title: t("Archive"),
to: archivePath(),
};
}
if (document.template) {
return {
type: "route",
icon: <ShapesIcon />,
title: t("Templates"),
to: settingsPath("templates"),
};
}
return null;
}
function DocumentBreadcrumb(
{ document, children, onlyText, reverse = false, maxDepth }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
const sidebarContext = useLocationSidebarContext();
const collection = document.collectionId
? collections.get(document.collectionId)
@@ -78,69 +45,91 @@ function DocumentBreadcrumb(
void document.loadRelations({ withoutPolicies: true });
}, [document]);
let collectionNode: MenuInternalLink | undefined;
if (collection && can.readDocument) {
collectionNode = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: {
pathname: collection.path,
state: { sidebarContext },
},
};
} else if (document.isCollectionDeleted) {
collectionNode = {
type: "route",
title: t("Deleted Collection"),
icon: undefined,
to: "",
};
}
const path = document.pathTo.slice(0, -1);
const items = React.useMemo(() => {
const output: MenuInternalLink[] = [];
const actions = React.useMemo(() => {
if (depth === 0) {
return output;
return [];
}
if (category) {
output.push(category);
}
if (collectionNode) {
output.push(collectionNode);
}
path.forEach((node: NavigationNode) => {
const title = node.title || t("Untitled");
output.push({
type: "route",
title: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
title
),
to: {
pathname: node.url,
state: { sidebarContext },
},
});
});
const outputActions = [
createInternalLinkActionV2({
name: t("Trash"),
section: ActiveDocumentSection,
icon: <TrashIcon />,
visible: document.isDeleted,
to: trashPath(),
}),
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkActionV2({
name: collection?.name,
section: ActiveDocumentSection,
icon: collection ? (
<CollectionIcon collection={collection} expanded />
) : undefined,
visible: !!(collection && can.readDocument),
to: collection
? {
pathname: collection.path,
state: { sidebarContext },
}
: "",
}),
createInternalLinkActionV2({
name: t("Deleted Collection"),
section: ActiveDocumentSection,
visible: document.isCollectionDeleted,
to: "",
}),
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkActionV2({
name: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
title
),
section: ActiveDocumentSection,
to: {
pathname: node.url,
state: { sidebarContext },
},
});
}),
];
return reverse
? depth !== undefined
? output.slice(-depth)
: output
? outputActions.slice(-depth)
: outputActions
: depth !== undefined
? output.slice(0, depth)
: output;
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
? outputActions.slice(0, depth)
: outputActions;
}, [
t,
document,
collection,
can.readDocument,
sidebarContext,
path,
reverse,
depth,
]);
if (!collections.isLoaded) {
return null;
@@ -176,7 +165,7 @@ function DocumentBreadcrumb(
}
return (
<Breadcrumb items={items} ref={ref} highlightFirstItem>
<Breadcrumb actions={actions} ref={ref} highlightFirstItem>
{children}
</Breadcrumb>
);
+32 -34
View File
@@ -1,4 +1,3 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -11,7 +10,6 @@ import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { flattenTree } from "~/utils/tree";
import Switch from "./Switch";
import Text from "./Text";
@@ -32,7 +30,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
);
const items = React.useMemo(() => {
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
const nodes = collectionTrees.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
@@ -78,34 +76,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
<OptionsContainer>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</>
)}
</OptionsContainer>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
@@ -127,9 +123,11 @@ function DocumentCopy({ document, onSubmit }: Props) {
}
const OptionsContainer = styled.div`
margin: 16px 0 8px 0;
padding-left: 24px;
padding-right: 24px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding: 16px 24px 0;
margin-bottom: -1px;
background: ${(props) => props.theme.modalBackground};
z-index: 1;
`;
export default observer(DocumentCopy);
+7 -9
View File
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode, NavigationNodeType } from "@shared/types";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
@@ -26,7 +26,8 @@ import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { ancestors, descendants } from "~/utils/tree";
import { ancestors, descendants, flattenTree } from "~/utils/tree";
import flatten from "lodash/flatten";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
@@ -80,7 +81,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const searchIndex = React.useMemo(
() =>
new FuzzySearch(items, ["title"], {
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
caseSensitive: false,
}),
[items]
@@ -125,11 +126,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.flatMap(includeDescendants);
: items.flatMap(includeDescendants);
}
const nodes = getNodes();
@@ -137,6 +134,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -310,7 +308,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={(node.depth ?? 0) - baseDepth}
depth={(node.depth ?? 0) - normalizedBaseDepth}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
-4
View File
@@ -33,7 +33,6 @@ type Props = {
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
@@ -68,7 +67,6 @@ function DocumentListItem(
showParentDocuments,
showCollection,
showPublished,
showPin,
showDraft = true,
showTemplate,
highlight,
@@ -156,10 +154,8 @@ function DocumentListItem(
<Actions>
<DocumentMenu
document={document}
showPin={showPin}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
modal={false}
/>
</Actions>
</DocumentLink>
+89 -15
View File
@@ -13,6 +13,9 @@ import Text from "~/components/Text";
import env from "~/env";
import Logger from "~/utils/Logger";
import isCloudHosted from "~/utils/isCloudHosted";
import Storage from "@shared/utils/Storage";
import { deleteAllDatabases } from "~/utils/developer";
import Flex from "./Flex";
type Props = WithTranslation & {
/** Whether to reload the page if a chunk fails to load. */
@@ -23,6 +26,9 @@ type Props = WithTranslation & {
component?: React.ComponentType | string;
};
const ERROR_TRACKING_KEY = "error-boundary-tracking";
const ERROR_TRACKING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
@observer
class ErrorBoundary extends React.Component<Props> {
@observable
@@ -31,6 +37,13 @@ class ErrorBoundary extends React.Component<Props> {
@observable
showDetails = false;
@observable
isRepeatedError = false;
componentDidMount() {
this.checkForPreviousErrors();
}
componentDidCatch(error: Error) {
this.error = error;
@@ -46,9 +59,47 @@ class ErrorBoundary extends React.Component<Props> {
return;
}
this.trackError();
Logger.error("ErrorBoundary", error);
}
private checkForPreviousErrors = () => {
try {
const stored = Storage.get(ERROR_TRACKING_KEY);
if (!stored) {
return;
}
const errors: number[] = JSON.parse(stored);
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
const recentErrors = errors.filter((timestamp) => timestamp > cutoff);
this.isRepeatedError = recentErrors.length > 0;
} catch (err) {
Logger.warn("Failed to parse stored errors for error boundary", { err });
}
};
private trackError = () => {
try {
const stored = Storage.get(ERROR_TRACKING_KEY);
const errors: number[] = stored ? JSON.parse(stored) : [];
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
// Filter out old errors and add current one
const updatedErrors = [
...errors.filter((timestamp) => timestamp > cutoff),
Date.now(),
];
Storage.set(ERROR_TRACKING_KEY, JSON.stringify(updatedErrors));
this.isRepeatedError = updatedErrors.length > 1;
} catch (err) {
Logger.warn("Failed to track error in error boundary", { err });
}
};
handleReload = () => {
window.location.reload();
};
@@ -61,6 +112,12 @@ class ErrorBoundary extends React.Component<Props> {
window.open(isCloudHosted ? UrlHelper.contact : UrlHelper.github);
};
handleClearCache = async () => {
await deleteAllDatabases();
Storage.clear();
window.location.reload();
};
render() {
const { t, component: Component = CenteredContent, showTitle } = this.props;
@@ -107,29 +164,46 @@ class ErrorBoundary extends React.Component<Props> {
</Heading>
</>
)}
<Text as="p" type="secondary">
<Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
</Text>
{this.showDetails && <Pre>{error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
{this.isRepeatedError ? (
<Text as="p" type="secondary">
<Trans>
An error has occurred multiple times recently. If it continues
please try clearing the cache or using a different browser.
</Trans>
</Text>
) : (
<Text as="p" type="secondary">
<Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
</Text>
)}
{this.showDetails && <Pre>{error.stack}</Pre>}
<Flex gap={8} wrap>
{this.isRepeatedError && (
<Button onClick={this.handleClearCache}>
<Trans>Clear cache + reload</Trans>
</Button>
)}
<Button onClick={this.handleReload} neutral={this.isRepeatedError}>
{t("Reload")}
</Button>
{this.showDetails ? (
<Button onClick={this.handleReportBug} neutral>
<Trans>Report a bug</Trans>
<Trans>Report a bug</Trans>
</Button>
) : (
<Button onClick={this.handleShowDetails} neutral>
<Trans>Show detail</Trans>
</Button>
)}
</p>
</Flex>
</Component>
);
}
@@ -30,10 +30,15 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
) {
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
let service;
if (urlObj.hostname === "github.com") {
service = IntegrationService.GitHub;
} else if (urlObj.hostname === "gitlab.com") {
service = IntegrationService.GitLab;
} else {
service = IntegrationService.Linear;
}
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -1,14 +1,17 @@
import { useMemo, useCallback } from "react";
import { useMemo, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuButton, MenuItem } from "reakit";
import styled from "styled-components";
import { depths, s, hover } from "@shared/styles";
import { s, hover } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { useMenuState } from "~/hooks/useMenuState";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/primitives/Popover";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
@@ -19,62 +22,61 @@ const SkinTonePicker = ({
onChange: (skin: EmojiSkinTone) => void;
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const handEmojiVariants = useMemo(() => getEmojiVariants({ id: "hand" }), []);
const menu = useMenuState({
placement: "bottom-end",
});
const handleSkinClick = useCallback(
(emojiSkin) => {
menu.hide();
(emojiSkin: EmojiSkinTone) => {
setOpen(false);
onChange(emojiSkin);
},
[menu, onChange]
[onChange]
);
const menuItems = useMemo(
() =>
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
<MenuItem {...menu} key={emoji.value}>
{(menuprops) => (
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
Object.values(EmojiSkinTone)
.map((skinTone) => {
const emoji = handEmojiVariants[skinTone];
return emoji ? (
<IconButton
key={emoji.value}
onClick={() => handleSkinClick(skinTone)}
>
<Emoji width={24} height={24}>
{emoji.value}
</Emoji>
</IconButton>
)}
</MenuItem>
)),
[menu, handEmojiVariants, handleSkinClick]
) : null;
})
.filter(Boolean),
[handEmojiVariants, handleSkinClick]
);
return (
<>
<MenuButton {...menu}>
{(props) => (
<StyledMenuButton
{...props}
aria-label={t("Choose default skin tone")}
>
{handEmojiVariants[skinTone]!.value}
</StyledMenuButton>
)}
</MenuButton>
<Menu {...menu} aria-label={t("Choose default skin tone")}>
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
</Menu>
</>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<StyledMenuButton aria-label={t("Choose default skin tone")}>
{handEmojiVariants[skinTone]?.value}
</StyledMenuButton>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
aria-label={t("Choose default skin tone")}
width={208}
scrollable={false}
shrink
>
<Emojis>{menuItems}</Emojis>
</PopoverContent>
</Popover>
);
};
const MenuContainer = styled(Flex)`
z-index: ${depths.menu};
padding: 4px;
border-radius: 4px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
const Emojis = styled(Flex)`
padding: 0 8px;
`;
const StyledMenuButton = styled(NudeButton)`
+26 -31
View File
@@ -1,16 +1,14 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import { useMenuState } from "~/hooks/useMenuState";
import lazyWithRetry from "~/utils/lazyWithRetry";
import ContextMenu from "./ContextMenu";
import DelayedMount from "./DelayedMount";
import Input, { Props as InputProps } from "./Input";
import NudeButton from "./NudeButton";
import Relative from "./Sidebar/components/Relative";
import Text from "./Text";
import { Popover, PopoverContent, PopoverTrigger } from "./primitives/Popover";
type Props = Omit<InputProps, "onChange"> & {
value: string | undefined;
@@ -19,10 +17,6 @@ type Props = Omit<InputProps, "onChange"> & {
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
return (
<Relative>
@@ -33,30 +27,26 @@ const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
maxLength={7}
{...rest}
/>
<MenuButton {...menu}>
{(props) => (
<SwatchButton
aria-label={t("Show menu")}
{...props}
$background={value}
/>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Select a color")}>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={value}
onChange={(color) => onChange(color.hex)}
/>
</React.Suspense>
</ContextMenu>
<Popover modal={true}>
<PopoverTrigger>
<SwatchButton aria-label={t("Show menu")} $background={value} />
</PopoverTrigger>
<StyledContent aria-label={t("Select a color")} align="end">
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={value}
onChange={(color) => onChange(color.hex)}
/>
</React.Suspense>
</StyledContent>
</Popover>
</Relative>
);
};
@@ -70,6 +60,11 @@ const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
right: 6px;
`;
const StyledContent = styled(PopoverContent)`
width: auto;
padding: 8px;
`;
const ColorPicker = lazyWithRetry(
() => import("react-color/lib/components/chrome/Chrome")
);
+2 -2
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<any>> {
export interface LazyComponent<T extends React.ComponentType<unknown>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
@@ -34,7 +34,7 @@ interface LazyLoadOptions {
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
export function createLazyComponent<T extends React.ComponentType<unknown>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
+161 -74
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import styled from "styled-components";
import Scrollable from "~/components/Scrollable";
import {
@@ -15,92 +16,155 @@ import {
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { ActionV2Variant, ActionV2WithChildren, MenuItem } from "~/types";
import {
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
MenuItem,
MenuItemWithChildren,
} from "~/types";
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
/** Action context to use - new context will be created if not provided */
context?: ActionContext;
/** Trigger for the menu */
children: React.ReactNode;
/** Alignment w.r.t trigger - defaults to start */
align?: "start" | "end";
/** ARIA label for the menu */
ariaLabel: string;
contentAriaLabel?: string;
};
/** Additional component to display at the bottom of the top-level menu */
append?: React.ReactNode;
/** Callback when menu is opened */
onOpen?: () => void;
/** Callback when menu is closed */
onClose?: () => void;
// TODO: Invert the dependency chain by forwarding dropdown ref and props to Tooltip component
} & React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>;
export function DropdownMenu({
action,
children,
align = "start",
ariaLabel,
contentAriaLabel,
}: Props) {
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
const context = useActionContext({
isContextMenu: true,
});
const menuItems = (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, context)
);
export const DropdownMenu = observer(
React.forwardRef<React.ElementRef<typeof TooltipPrimitive.Trigger>, Props>(
(
{
action,
context,
children,
align = "start",
ariaLabel,
append,
onOpen,
onClose,
...rest
},
ref
) => {
const [open, setOpen] = React.useState(false);
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
return [];
}
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
setOpen(open);
if (open) {
onOpen?.();
} else {
onClose?.();
}
},
[onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
const handleCloseAutoFocus = React.useCallback(
(e: Event) => e.preventDefault(),
[]
);
if (isMobile) {
return (
<MobileDropdown
open={open}
onOpenChange={handleOpenChange}
items={menuItems}
trigger={children}
ariaLabel={ariaLabel}
append={append}
/>
);
}
const content = toDropdownMenuItems(menuItems);
return (
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
{append}
</DropdownMenuContent>
</DropdownMenuRoot>
);
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
if (isMobile) {
return (
<MobileDropdown
items={menuItems}
trigger={children}
ariaLabel={ariaLabel}
contentAriaLabel={contentAriaLabel}
/>
);
}
const content = toDropdownMenuItems(menuItems);
if (!content) {
return null;
}
return (
<DropdownMenuRoot>
<DropdownMenuTrigger aria-label={ariaLabel}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={contentAriaLabel ?? ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{content}
</DropdownMenuContent>
</DropdownMenuRoot>
);
}
)
);
type MobileDropdownProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
items: MenuItem[];
trigger: React.ReactNode;
} & Pick<Props, "ariaLabel" | "contentAriaLabel">;
} & Pick<Props, "ariaLabel" | "append">;
function MobileDropdown({
open,
onOpenChange,
items,
trigger,
ariaLabel,
contentAriaLabel,
append,
}: MobileDropdownProps) {
const [open, setOpen] = React.useState(false);
const [submenuName, setSubmenuName] = React.useState<string>();
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const enablePointerEvents = React.useCallback(() => {
@@ -116,29 +180,52 @@ function MobileDropdown({
}, []);
const closeDrawer = React.useCallback(() => {
setOpen(false);
onOpenChange(false);
setTimeout(() => setSubmenuName(undefined), 500); // needed for a Vaul bug where 'onAnimationEnd' is not called for controlled state.
}, [onOpenChange]);
const resetSubmenu = React.useCallback((isOpen: boolean) => {
if (!isOpen) {
setSubmenuName(undefined);
}
}, []);
const content = toMobileMenuItems(items, closeDrawer);
const menuItems = React.useMemo(() => {
if (!items.length || !submenuName) {
return items;
}
if (!content) {
return null;
}
const submenu = items.find(
(item) =>
item.type === "submenu" && (item.title as string) === submenuName
)! as MenuItemWithChildren;
return submenu.items;
}, [items, submenuName]);
const content = toMobileMenuItems(menuItems, closeDrawer, setSubmenuName);
return (
<Drawer open={open} onOpenChange={setOpen}>
<Drawer
open={open}
onOpenChange={onOpenChange}
onAnimationEnd={resetSubmenu}
>
<DrawerTrigger aria-label={ariaLabel} asChild>
{trigger}
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={contentAriaLabel ?? ariaLabel}
aria-label={ariaLabel}
aria-describedby={undefined}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle>{ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>{content}</StyledScrollable>
<StyledScrollable hiddenScrollbars>
{content}
{!submenuName ? append : null}
</StyledScrollable>
</DrawerContent>
</Drawer>
);
+10 -5
View File
@@ -1,19 +1,24 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import Button from "~/components/Button";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Trigger
> & {
neutral?: boolean;
className?: string;
};
export const OverflowMenuButton = React.forwardRef<HTMLButtonElement, Props>(
({ className, ...rest }, ref) => (
<NudeButton ref={ref} className={className} {...rest}>
<MoreIcon />
</NudeButton>
)
({ neutral, className, ...rest }, ref) =>
neutral ? (
<Button ref={ref} icon={<MoreIcon />} neutral borderOnHover {...rest} />
) : (
<NudeButton ref={ref} className={className} {...rest}>
<MoreIcon />
</NudeButton>
)
);
OverflowMenuButton.displayName = "OverflowMenuButton";
+68 -2
View File
@@ -1,3 +1,4 @@
import { CheckmarkIcon } from "outline-icons";
import {
DropdownMenuButton,
DropdownMenuExternalLink,
@@ -5,6 +6,9 @@ import {
DropdownMenuInternalLink,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
} from "~/components/primitives/DropdownMenu";
import {
MenuButton,
@@ -13,6 +17,8 @@ import {
MenuExternalLink,
MenuLabel,
MenuSeparator,
MenuDisclosure,
SelectedIconWrapper,
} from "~/components/primitives/components/Menu";
import { MenuItem } from "~/types";
@@ -46,6 +52,8 @@ export function toDropdownMenuItems(items: MenuItem[]) {
label={item.title as string}
icon={icon}
disabled={item.disabled}
tooltip={item.tooltip}
selected={item.selected}
dangerous={item.dangerous}
onClick={item.onClick}
/>
@@ -76,6 +84,25 @@ export function toDropdownMenuItems(items: MenuItem[]) {
/>
);
case "submenu": {
const submenuItems = toDropdownMenuItems(item.items);
if (!submenuItems?.length) {
return null;
}
return (
<DropdownSubMenu key={`${item.type}-${item.title}-${index}`}>
<DropdownSubMenuTrigger
label={item.title as string}
icon={icon}
disabled={item.disabled}
/>
<DropdownSubMenuContent>{submenuItems}</DropdownSubMenuContent>
</DropdownSubMenu>
);
}
case "group": {
const groupItems = toDropdownMenuItems(item.items);
@@ -101,7 +128,11 @@ export function toDropdownMenuItems(items: MenuItem[]) {
});
}
export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
export function toMobileMenuItems(
items: MenuItem[],
closeMenu: () => void,
openSubmenu: (submenuName: string) => void
) {
const filteredItems = filterMenuItems(items);
if (!filteredItems.length) {
@@ -137,6 +168,11 @@ export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
{item.selected !== undefined && (
<SelectedIconWrapper aria-hidden>
{item.selected ? <CheckmarkIcon /> : null}
</SelectedIconWrapper>
)}
</MenuButton>
);
@@ -169,8 +205,38 @@ export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
</MenuExternalLink>
);
case "submenu": {
const submenuItems = toMobileMenuItems(
item.items,
closeMenu,
openSubmenu
);
if (!submenuItems?.length) {
return null;
}
return (
<MenuButton
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
onClick={() => {
openSubmenu(item.title as string);
}}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
<MenuDisclosure />
</MenuButton>
);
}
case "group": {
const groupItems = toMobileMenuItems(item.items, closeMenu);
const groupItems = toMobileMenuItems(
item.items,
closeMenu,
openSubmenu
);
if (!groupItems?.length) {
return null;
@@ -35,7 +35,7 @@ function Notifications(
const context = useActionContext();
const { notifications } = useStores();
const { t } = useTranslation();
const isEmpty = notifications.orderedData.length === 0;
const isEmpty = notifications.active.length === 0;
// Update the notification count in the dock icon, if possible.
React.useEffect(() => {
@@ -80,7 +80,7 @@ function Notifications(
<PaginatedList<Notification>
fetch={notifications.fetchPage}
options={{ archived: false }}
items={notifications.orderedData}
items={notifications.active}
renderItem={(item) => (
<NotificationListItem
key={item.id}
@@ -20,7 +20,7 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
const scrollableRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
void notifications.fetchPage({});
void notifications.fetchPage({ archived: false });
}, [notifications]);
const handleRequestClose = React.useCallback(() => {
-1
View File
@@ -46,7 +46,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
<DocumentListItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
+1 -1
View File
@@ -14,7 +14,7 @@ describe("PaginatedList", () => {
i18n,
tReady: true,
t: ((key: string) => key) as TFunction,
} as any;
} as unknown;
it("with no items renders nothing", () => {
const result = render(
+6 -4
View File
@@ -34,11 +34,11 @@ interface Props<T extends PaginatedItem>
* @param options Pagination and other query options
*/
fetch?: (
options: Record<string, any> | undefined
options: Record<string, unknown> | undefined
) => Promise<unknown[] | undefined> | undefined;
/** Additional options to pass to the fetch function */
options?: Record<string, any>;
options?: Record<string, unknown>;
/** Optional header content to display above the list */
heading?: React.ReactNode;
@@ -77,7 +77,9 @@ interface Props<T extends PaginatedItem>
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
renderHeading?: (
name: React.ReactElement<unknown> | string
) => React.ReactNode;
/**
* Handler for escape key press
@@ -206,7 +208,7 @@ const PaginatedList = <T extends PaginatedItem>({
if (fetch) {
void fetchResults();
}
}, [fetch]);
}, [fetch, fetchResults]);
// Handle updates to fetch or options
React.useEffect(() => {
+25 -28
View File
@@ -25,7 +25,7 @@ import HistoryNavigation from "./components/HistoryNavigation";
import Section from "./components/Section";
import SharedWithMe from "./components/SharedWithMe";
import SidebarAction from "./components/SidebarAction";
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
import SidebarButton from "./components/SidebarButton";
import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import ToggleButton from "./components/ToggleButton";
@@ -63,34 +63,31 @@ function AppSidebar() {
<DragPlaceholder />
<TeamMenu>
{(props: SidebarButtonProps) => (
<SidebarButton
{...props}
title={team.name}
image={
<TeamLogo
model={team}
size={24}
alt={t("Logo")}
style={{ marginLeft: 4 }}
/>
}
<SidebarButton
title={team.name}
image={
<TeamLogo
model={team}
size={24}
alt={t("Logo")}
style={{ marginLeft: 4 }}
/>
}
>
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
>
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
</SidebarButton>
)}
<ToggleButton
position="bottom"
image={<SidebarIcon />}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
</SidebarButton>
</TeamMenu>
<Overflow>
<Section>
+18 -24
View File
@@ -19,7 +19,7 @@ import NotificationIcon from "../Notifications/NotificationIcon";
import NotificationsPopover from "../Notifications/NotificationsPopover";
import { TooltipProvider } from "../TooltipContext";
import ResizeBorder from "./components/ResizeBorder";
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
const ANIMATION_MS = 250;
@@ -231,29 +231,23 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{user && (
<AccountMenu>
{(props: SidebarButtonProps) => (
<SidebarButton
{...props}
showMoreMenu
title={user.name}
position="bottom"
image={
<Avatar
alt={user.name}
model={user}
size={24}
style={{ marginLeft: 4 }}
/>
}
>
<NotificationsPopover>
<SidebarButton
position="bottom"
image={<NotificationIcon />}
/>
</NotificationsPopover>
</SidebarButton>
)}
<SidebarButton
showMoreMenu
title={user.name}
position="bottom"
image={
<Avatar
alt={user.name}
model={user}
size={24}
style={{ marginLeft: 4 }}
/>
}
>
<NotificationsPopover>
<SidebarButton position="bottom" image={<NotificationIcon />} />
</NotificationsPopover>
</SidebarButton>
</AccountMenu>
)}
<ResizeBorder
@@ -33,10 +33,13 @@ import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import UserMembership from "~/models/UserMembership";
import GroupMembership from "~/models/GroupMembership";
type Props = {
node: NavigationNode;
collection?: Collection;
membership?: UserMembership | GroupMembership;
activeDocument: Document | null | undefined;
prefetchDocument?: (documentId: string) => Promise<Document | void>;
isDraft?: boolean;
@@ -49,6 +52,7 @@ function InnerDocumentLink(
{
node,
collection,
membership,
activeDocument,
prefetchDocument,
isDraft,
@@ -87,20 +91,27 @@ function InnerDocumentLink(
isActiveDocument,
]);
const showChildren = React.useMemo(
() =>
!!(
hasChildDocuments &&
activeDocument &&
collection &&
(collection
.pathToDocument(activeDocument.id)
.map((entry) => entry.id)
.includes(node.id) ||
isActiveDocument)
),
[hasChildDocuments, activeDocument, isActiveDocument, node, collection]
);
const showChildren = React.useMemo(() => {
if (!hasChildDocuments || !activeDocument) {
return false;
}
const pathToDocument =
collection?.pathToDocument(activeDocument.id) ??
membership?.pathToDocument(activeDocument.id);
return !!(
pathToDocument?.map((entry) => entry.id).includes(node.id) ||
isActiveDocument
);
}, [
hasChildDocuments,
activeDocument,
isActiveDocument,
node,
collection,
membership,
]);
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
@@ -404,6 +415,7 @@ function InnerDocumentLink(
<DocumentLink
key={childNode.id}
collection={collection}
membership={membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
@@ -421,7 +433,7 @@ function InnerDocumentLink(
const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
transition: opacity 250ms ease;
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
pointer-events: ${(props) => (props.$isMoving ? "none" : "inherit")};
`;
const DocumentLink = observer(React.forwardRef(InnerDocumentLink));
@@ -135,7 +135,7 @@ function DraggableCollectionLink({
const Draggable = styled("div")<{ $isDragging: boolean }>`
transition: opacity 250ms ease;
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
pointer-events: ${(props) => (props.$isDragging ? "none" : "auto")};
pointer-events: ${(props) => (props.$isDragging ? "none" : "inherit")};
`;
export default observer(DraggableCollectionLink);
@@ -18,13 +18,17 @@ import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext from "./SidebarContext";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { useHistory } from "react-router-dom";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
function SharedWithMe() {
const { userMemberships, groupMemberships } = useStores();
const { ui, userMemberships, groupMemberships } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const history = useHistory();
const locationSidebarContext = useLocationSidebarContext();
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
@@ -44,6 +48,54 @@ function SharedWithMe() {
}
}, [error, t]);
useEffect(() => {
const isContextInSharedSection =
locationSidebarContext === "shared" ||
locationSidebarContext?.startsWith("group");
if (!ui.activeDocumentId || isContextInSharedSection) {
return;
}
const isActiveDocSharedDirectly = user.documentMemberships.find(
(m) => m.pathToDocument(ui.activeDocumentId!).length > 0
);
if (isActiveDocSharedDirectly) {
history.push({
...history.location,
state: {
...(history.location.state as Record<string, unknown>),
sidebarContext: "shared",
},
});
return;
}
const groupWithActiveDocument = user.groupsWithDocumentMemberships.find(
(group) =>
group.documentMemberships.some(
(m) => m.pathToDocument(ui.activeDocumentId!).length > 0
)
);
if (groupWithActiveDocument) {
history.push({
...history.location,
state: {
...(history.location.state as Record<string, unknown>),
sidebarContext: groupSidebarContext(groupWithActiveDocument.id),
},
});
}
}, [
ui.activeDocumentId,
locationSidebarContext,
user.documentMemberships,
user.groupsWithDocumentMemberships,
]);
if (
!user.documentMemberships.length &&
!user.groupsWithDocumentMemberships.length
@@ -40,21 +40,20 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
const sidebarContext = useSidebarContext();
const document = documentId ? documents.get(documentId) : undefined;
const isActiveDocumentInPath = ui.activeDocumentId
? membership.pathToDocument(ui.activeDocumentId).length > 0
: false;
const [expanded, setExpanded, setCollapsed] = useBoolean(
membership.documentId === ui.activeDocumentId &&
locationSidebarContext === sidebarContext
isActiveDocumentInPath && locationSidebarContext === sidebarContext
);
React.useEffect(() => {
if (
membership.documentId === ui.activeDocumentId &&
locationSidebarContext === sidebarContext
) {
if (isActiveDocumentInPath && locationSidebarContext === sidebarContext) {
setExpanded();
}
}, [
membership.documentId,
ui.activeDocumentId,
isActiveDocumentInPath,
sidebarContext,
locationSidebarContext,
setExpanded,
@@ -63,6 +62,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
React.useEffect(() => {
if (documentId) {
void documents.fetch(documentId);
void membership.fetchDocuments();
}
}, [documentId, documents]);
@@ -118,9 +118,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
? collections.get(document.collectionId)
: undefined;
const node = document.asNavigationNode;
const childDocuments = node.children;
const hasChildDocuments = childDocuments.length > 0;
const childDocuments = membership.documents ?? [];
return (
<>
@@ -139,7 +137,9 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
state: { sidebarContext },
}}
expanded={
hasChildDocuments && !isDragging ? expanded : undefined
childDocuments.length > 0 && !isDragging
? expanded
: undefined
}
onDisclosureClick={handleDisclosureClick}
icon={icon}
@@ -180,8 +180,9 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
key={childNode.id}
node={childNode}
collection={collection}
membership={membership}
activeDocument={documents.active}
isDraft={node.isDraft}
isDraft={childNode.isDraft}
depth={2}
index={index}
/>
@@ -3,7 +3,7 @@ import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { s, truncateMultiline } from "@shared/styles";
import { isMobile } from "@shared/utils/browser";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
@@ -272,8 +272,8 @@ const Link = styled(NavLink)<{
const Label = styled.div`
position: relative;
width: 100%;
max-height: 4.8em;
line-height: 24px;
${truncateMultiline(3)}
* {
unicode-bidi: plaintext;
+1 -1
View File
@@ -9,7 +9,7 @@ function Toasts() {
return (
<StyledToaster
theme={ui.resolvedTheme as any}
theme={ui.resolvedTheme as unknown}
closeButton
toastOptions={{
duration: 5000,
+19 -6
View File
@@ -6,6 +6,8 @@ import { depths, s } from "@shared/styles";
import Flex from "../Flex";
import Text from "../Text";
import { Overlay } from "./components/Overlay";
import { m } from "framer-motion";
import useMeasure from "react-use-measure";
/** Root Drawer component - all the other components are rendered inside it. */
const Drawer = (props: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
@@ -22,15 +24,25 @@ const DrawerContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>((props, ref) => {
const { children, ...rest } = props;
const [measureRef, bounds] = useMeasure();
return (
<DrawerPrimitive.Portal>
<DrawerPrimitive.Overlay asChild>
<Overlay />
</DrawerPrimitive.Overlay>
<StyledContent ref={ref} {...rest}>
{children}
</StyledContent>
<DrawerPrimitive.Content ref={ref} asChild>
<StyledContent
animate={{
height: bounds.height,
transition: { bounce: 0, duration: 0.2 },
}}
>
<StyledInnerContent ref={measureRef} {...rest}>
{children}
</StyledInnerContent>
</StyledContent>
</DrawerPrimitive.Content>
</DrawerPrimitive.Portal>
);
});
@@ -64,7 +76,7 @@ const DrawerTitle = React.forwardRef<
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
/** Styled components. */
const StyledContent = styled(DrawerPrimitive.Content)`
const StyledContent = styled(m.div)`
z-index: ${depths.menu};
position: fixed;
left: 0;
@@ -75,12 +87,13 @@ const StyledContent = styled(DrawerPrimitive.Content)`
min-height: 44px;
max-height: 90vh;
padding: 6px;
border-radius: 6px;
background: ${s("menuBackground")};
`;
animation-duration: 0.3s;
const StyledInnerContent = styled.div`
padding: 6px;
`;
const TitleWrapper = styled(Flex)`
+91 -7
View File
@@ -4,18 +4,25 @@ import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import Tooltip from "~/components/Tooltip";
import { fadeAndScaleIn } from "~/styles/animations";
import {
MenuButton,
MenuDisclosure,
MenuExternalLink,
MenuHeader,
MenuInternalLink,
MenuLabel,
MenuSeparator,
MenuSubTrigger,
SelectedIconWrapper,
} from "./components/Menu";
import { CheckmarkIcon } from "outline-icons";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownSubMenu = DropdownMenuPrimitive.Sub;
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
@@ -37,7 +44,13 @@ const DropdownMenuContent = React.forwardRef<
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content ref={ref} {...rest} asChild>
<DropdownMenuPrimitive.Content
ref={ref}
{...rest}
sideOffset={4}
collisionPadding={6}
asChild
>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
@@ -45,6 +58,50 @@ const DropdownMenuContent = React.forwardRef<
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
type DropdownSubMenuTriggerProps = BaseDropdownItemProps &
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>;
const DropdownSubMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
DropdownSubMenuTriggerProps
>((props, ref) => {
const { label, icon, disabled, ...rest } = props;
return (
<DropdownMenuPrimitive.SubTrigger ref={ref} {...rest} asChild>
<MenuSubTrigger disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
<MenuDisclosure />
</MenuSubTrigger>
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownSubMenuTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownSubMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={ref}
{...rest}
collisionPadding={6}
asChild
>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DropdownMenuPrimitive.SubContent>
</DropdownMenuPrimitive.Portal>
);
});
DropdownSubMenuContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
type DropdownMenuGroupProps = {
label: string;
items: React.ReactNode[];
@@ -76,6 +133,8 @@ type BaseDropdownItemProps = {
type DropdownMenuButtonProps = BaseDropdownItemProps & {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
tooltip?: React.ReactChild;
selected?: boolean;
dangerous?: boolean;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
@@ -86,16 +145,38 @@ const DropdownMenuButton = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuButtonProps
>((props, ref) => {
const { label, icon, disabled, dangerous, onClick, ...rest } = props;
const {
label,
icon,
tooltip,
disabled,
selected,
dangerous,
onClick,
...rest
} = props;
return (
<DropdownMenuPrimitive.Item ref={ref} {...rest} asChild>
const button = (
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
<MenuButton disabled={disabled} $dangerous={dangerous} onClick={onClick}>
{icon}
<MenuLabel>{label}</MenuLabel>
{selected !== undefined && (
<SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : null}
</SelectedIconWrapper>
)}
</MenuButton>
</DropdownMenuPrimitive.Item>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
<div>{button}</div>
</Tooltip>
) : (
<>{button}</>
);
});
DropdownMenuButton.displayName = "DropdownMenuButton";
@@ -113,7 +194,7 @@ const DropdownMenuInternalLink = React.forwardRef<
const { label, icon, disabled, to, ...rest } = props;
return (
<DropdownMenuPrimitive.Item ref={ref} {...rest} asChild>
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
<MenuInternalLink to={to} disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
@@ -138,7 +219,7 @@ const DropdownMenuExternalLink = React.forwardRef<
const { label, icon, disabled, href, target, ...rest } = props;
return (
<DropdownMenuPrimitive.Item ref={ref} {...rest} asChild>
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
<MenuExternalLink href={href} target={target} disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
@@ -174,7 +255,7 @@ const StyledScrollable = styled(Scrollable)`
min-width: 180px;
max-width: 276px;
min-height: 44px;
max-height: 75vh;
max-height: min(85vh, var(--radix-dropdown-menu-content-available-height));
font-weight: normal;
background: ${s("menuBackground")};
@@ -204,4 +285,7 @@ export {
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownSubMenu,
DropdownSubMenuTrigger,
DropdownSubMenuContent,
};
@@ -1,3 +1,4 @@
import { ExpandedIcon } from "outline-icons";
import { ellipsis } from "polished";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
@@ -37,6 +38,10 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
&:focus-visible {
outline: 0; // Disable default outline on Firefox
}
${(props) =>
!props.disabled &&
`
@@ -82,6 +87,10 @@ export const MenuExternalLink = styled.a`
${BaseMenuItemCSS}
`;
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
${BaseMenuItemCSS}
`;
export const MenuSeparator = styled.hr`
margin: 6px 0;
`;
@@ -103,6 +112,13 @@ export const MenuHeader = styled.h3`
margin: 1em 12px 0.5em;
`;
export const MenuDisclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
color: ${s("textTertiary")};
`;
export const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
@@ -111,3 +127,11 @@ export const MenuIconWrapper = styled.span`
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export const SelectedIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: -6px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
+1 -1
View File
@@ -333,7 +333,7 @@ export default function FindAndReplace({
setShowReplace(false);
editor.commands.clearSearch();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [localOpen]);
const disabled = totalResults === 0;
+24 -18
View File
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
}, [query, documents.add])
);
useEffect(() => {
@@ -79,6 +79,22 @@ const LinkEditor: React.FC<Props> = ({
}
}, [trimmedQuery, request]);
const save = React.useCallback(
(href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
},
[onSelectLink, from, to]
);
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
@@ -107,20 +123,7 @@ const LinkEditor: React.FC<Props> = ({
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
};
}, [trimmedQuery, initialValue, handleRemoveLink, save]);
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
@@ -195,7 +198,7 @@ const LinkEditor: React.FC<Props> = ({
}
};
const handleRemoveLink = () => {
const handleRemoveLink = React.useCallback(() => {
discardRef.current = true;
const { state, dispatch } = view;
@@ -203,9 +206,12 @@ const LinkEditor: React.FC<Props> = ({
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
if (onRemoveLink) {
onRemoveLink();
}
view.focus();
};
}, [view, mark, from, to, onRemoveLink]);
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
+10 -1
View File
@@ -184,7 +184,16 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
collections,
]);
const handleSelect = useCallback(
async (item: MentionItem) => {
+1 -1
View File
@@ -87,7 +87,7 @@ function useIsActive(state: EditorState) {
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
const nodes = (fragment as unknown).content;
return some(nodes, (n) => n.content.size);
}
+1 -1
View File
@@ -356,7 +356,7 @@ export default class PasteHandler extends Extension {
simplifyDiff: true,
}).mapping;
} catch (err) {
// eslint-disable-next-line no-console
// oxlint-disable-next-line no-console
console.warn("Failed to recreate transform: ", err);
}
}
+14 -2
View File
@@ -448,7 +448,7 @@ export class Editor extends React.PureComponent<
step.mark.type.name === this.schema.marks.comment.name
);
const self = this; // eslint-disable-line
const self = this; // oxlint-disable-line
const view = new EditorView(this.elementRef.current, {
handleDOMEvents: {
blur: this.handleEditorBlur,
@@ -504,12 +504,24 @@ export class Editor extends React.PureComponent<
return;
}
function isVisible(element: HTMLElement | null) {
for (let e = element; e; e = e.parentElement) {
const s = getComputedStyle(e);
if (s.display === "none" || s.opacity === "0") {
return false;
}
}
return true;
}
try {
this.mutationObserver?.disconnect();
this.mutationObserver = observe(
hash,
(element) => {
element.scrollIntoView();
if (isVisible(element)) {
element.scrollIntoView();
}
},
this.elementRef.current || undefined
);
+2 -2
View File
@@ -73,13 +73,13 @@ export default function useCollectionTrees(): NavigationNode[] {
parent: null,
};
return addParent(addCollectionId(addDepth(addType(collectionNode))));
return addParent(addCollectionId(addDepth(addType(collectionNode), 1)));
};
const key = collections.orderedData.map((o) => o.documents?.length).join("-");
const collectionTrees = useMemo(
() => collections.orderedData.map(getCollectionTree),
// eslint-disable-next-line react-hooks/exhaustive-deps
// oxlint-disable-next-line react-hooks/exhaustive-deps
[collections.orderedData, key]
);
+8 -4
View File
@@ -1,8 +1,8 @@
import { useRegisterActions } from "kbar";
import flattenDeep from "lodash/flattenDeep";
import { useLocation } from "react-router-dom";
import { actionToKBar } from "~/actions";
import { Action } from "~/types";
import { actionToKBar, actionV2ToKBar } from "~/actions";
import { Action, ActionV2Variant } from "~/types";
import useActionContext from "./useActionContext";
/**
@@ -12,7 +12,7 @@ import useActionContext from "./useActionContext";
* @param actions actions to make available
*/
export default function useCommandBarActions(
actions: Action[],
actions: (Action | ActionV2Variant)[],
additionalDeps: React.DependencyList = []
) {
const location = useLocation();
@@ -21,7 +21,11 @@ export default function useCommandBarActions(
});
const registerable = flattenDeep(
actions.map((action) => actionToKBar(action, context))
actions.map((action) =>
"variant" in action
? actionV2ToKBar(action, context)
: actionToKBar(action, context)
)
);
useRegisterActions(registerable, [
+16
View File
@@ -0,0 +1,16 @@
import { formatNumber } from "~/utils/language";
import useUserLocale from "./useUserLocale";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
/**
* Hook that returns a function to format numbers based on the user's locale.
*
* @returns A function that formats numbers
*/
export function useFormatNumber() {
const language = useUserLocale();
return (input: number) =>
language
? formatNumber(input, unicodeCLDRtoBCP47(language))
: input.toString();
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const useLocaleTime = ({
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const [, setMinutesMounted] = useState(0);
const callback = useRef<() => void>();
useEffect(() => {
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import {
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
useMenuState as reakitUseMenuState,
MenuStateReturn,
} from "reakit/Menu";
+28 -25
View File
@@ -13,6 +13,7 @@ import { DocumentsSection } from "~/actions/sections";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { ActionV2 } from "~/types";
import { useComputed } from "./useComputed";
type Props = {
/** The document to which the templates will be applied */
@@ -22,7 +23,7 @@ type Props = {
};
/**
* This hook provides a memoized list of menu items for both collection-specific
* This hook provides a memoized list of actions for both collection-specific
* templates and workspace-wide templates. It filters templates based on whether
* they are published and organizes them into appropriate sections.
*
@@ -56,32 +57,34 @@ export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
[user, onSelectTemplate]
);
const templates = documents.templates.filter(
(template) => template.publishedAt
);
return useComputed(() => {
if (!onSelectTemplate) {
return [];
}
const collectionTemplatesActions = templates
.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === document.collectionId
)
.map(templateToAction);
const templates = documents.templates.filter(
(template) => template.publishedAt
);
const workspaceTemplatesActions = templates
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToAction);
const collectionTemplatesActions = templates
.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === document.collectionId
)
.map(templateToAction);
if (!onSelectTemplate) {
return [];
}
const workspaceTemplatesActions = templates
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToAction);
return [
...collectionTemplatesActions,
ActionV2Separator,
createActionV2Group({
name: t("Workspace"),
actions: workspaceTemplatesActions,
}),
];
return [
...collectionTemplatesActions,
ActionV2Separator,
createActionV2Group({
name: t("Workspace"),
actions: workspaceTemplatesActions,
}),
];
}, []);
}
-88
View File
@@ -1,88 +0,0 @@
import { DocumentIcon } from "outline-icons";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
type Props = {
/** The document to which the templates will be applied */
document: Document;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Document) => void;
};
/**
* This hook provides a memoized list of menu items for both collection-specific
* templates and workspace-wide templates. It filters templates based on whether
* they are published and organizes them into appropriate sections.
*
* Collection-specific templates are displayed first, followed by workspace templates
* with a separator in between (if both types exist).
*
* @returns An array of MenuItem objects representing templates that can be applied
* to the current document. Returns an empty array if no callback is provided.
*/
export function useTemplateMenuItems({ document, onSelectTemplate }: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const templateToMenuItem = useCallback(
(template: Document): MenuItem => ({
type: "button",
title: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
user
),
icon: template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<DocumentIcon />
),
onClick: () => onSelectTemplate?.(template),
}),
[user, onSelectTemplate]
);
const templates = documents.templates.filter(
(template) => template.publishedAt
);
const collectionItems = templates
.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === document.collectionId
)
.map(templateToMenuItem);
const workspaceTemplates = templates
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToMenuItem);
const workspaceItems: MenuItem[] = useMemo(
() =>
workspaceTemplates.length
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
: [],
[t, workspaceTemplates]
);
if (!onSelectTemplate) {
return [];
}
return collectionItems
? workspaceItems.length
? [
...collectionItems,
{ type: "separator" } as MenuItem,
...workspaceItems,
]
: collectionItems
: workspaceItems;
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function useThrottledCallback<T extends (...args: any[]) => any>(
) {
const handler = React.useMemo(
() => throttle<T>(fn, wait, options),
// eslint-disable-next-line react-hooks/exhaustive-deps
// oxlint-disable-next-line react-hooks/exhaustive-deps
dependencies
);
+1 -1
View File
@@ -1,4 +1,4 @@
// eslint-disable-next-line import/no-unresolved
// oxlint-disable-next-line import/no-unresolved
import "vite/modulepreload-polyfill";
import { LazyMotion } from "framer-motion";
import { KBarProvider } from "kbar";
+10 -28
View File
@@ -1,9 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import {
navigateToProfileSettings,
navigateToAccountPreferences,
@@ -16,56 +13,41 @@ import {
logout,
} from "~/actions/definitions/navigation";
import { changeTheme } from "~/actions/definitions/settings";
import { useMenuState } from "~/hooks/useMenuState";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import separator from "~/menus/separator";
import { ActionV2Separator } from "~/actions";
import { useMenuAction } from "~/hooks/useMenuAction";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
type Props = {
children?: React.ReactNode;
};
const AccountMenu: React.FC = ({ children }: Props) => {
const menu = useMenuState({
placement: "bottom-end",
modal: true,
});
const { ui } = useStores();
const { theme } = ui;
const previousTheme = usePrevious(theme);
const { t } = useTranslation();
React.useEffect(() => {
if (theme !== previousTheme) {
menu.hide();
}
}, [menu, theme, previousTheme]);
const actions = React.useMemo(
() => [
openKeyboardShortcuts,
openDocumentation,
openAPIDocumentation,
separator(),
ActionV2Separator,
openChangelog,
openFeedbackUrl,
openBugReportUrl,
changeTheme,
navigateToProfileSettings,
navigateToAccountPreferences,
separator(),
ActionV2Separator,
logout,
],
[]
);
const rootAction = useMenuAction(actions);
return (
<>
<MenuButton {...menu}>{children}</MenuButton>
<ContextMenu {...menu} aria-label={t("Account")}>
<Template {...menu} items={undefined} actions={actions} />
</ContextMenu>
</>
<DropdownMenu action={rootAction} align="end" ariaLabel={t("Account")}>
{children}
</DropdownMenu>
);
};
+11 -17
View File
@@ -1,27 +1,21 @@
import { useTranslation } from "react-i18next";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
import { MenuInternalLink } from "~/types";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useMenuAction } from "~/hooks/useMenuAction";
import { InternalLinkActionV2 } from "~/types";
type Props = {
items: MenuInternalLink[];
actions: InternalLinkActionV2[];
};
export default function BreadcrumbMenu({ items }: Props) {
export default function BreadcrumbMenu({ actions }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom",
});
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
<ContextMenu {...menu} aria-label={t("Path to document")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Show path to document")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
-46
View File
@@ -1,46 +0,0 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
type Props = {
onMembers: () => void;
onRemove: () => void;
};
function CollectionGroupMemberMenu({ onMembers, onRemove }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Group member options")}>
<Template
{...menu}
items={[
{
type: "button",
title: t("Members"),
onClick: onMembers,
},
{
type: "separator",
},
{
type: "button",
title: t("Remove"),
dangerous: true,
onClick: onRemove,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(CollectionGroupMemberMenu);
+103 -162
View File
@@ -1,9 +1,7 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { observer } from "mobx-react";
import {
NewDocumentIcon,
ImportIcon,
ExportIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
ManualSortIcon,
@@ -12,16 +10,17 @@ import {
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { toast } from "sonner";
import { SubscriptionType } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import ContextMenu, { Placement } from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import ExportDialog from "~/components/ExportDialog";
import { actionToMenuItem } from "~/actions";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import {
ActionV2Separator,
createActionV2,
createActionV2WithChildren,
} from "~/actions";
import {
deleteCollection,
editCollection,
@@ -34,21 +33,20 @@ import {
restoreCollection,
subscribeCollection,
unsubscribeCollection,
createDocument,
exportCollection,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { useMenuState } from "~/hooks/useMenuState";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
import { ActiveCollectionSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
collection: Collection;
placement?: Placement;
modal?: boolean;
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
align?: "start" | "end";
neutral?: boolean;
onRename?: () => void;
onOpen?: () => void;
onClose?: () => void;
@@ -56,19 +54,13 @@ type Props = {
function CollectionMenu({
collection,
label,
modal = true,
placement,
align,
neutral,
onRename,
onOpen,
onClose,
}: Props) {
const menu = useMenuState({
modal,
placement,
});
const team = useCurrentTeam();
const { documents, dialogs, subscriptions } = useStores();
const { documents, subscriptions } = useStores();
const { t } = useTranslation();
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
@@ -90,42 +82,16 @@ function CollectionMenu({
}
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
const handleExport = React.useCallback(() => {
dialogs.openModal({
title: t("Export collection"),
content: (
<ExportDialog
collection={collection}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [collection, dialogs, t]);
const handleNewDocument = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
history.push(newDocumentPath(collection.id));
},
[history, collection.id]
);
const stopPropagation = React.useCallback((ev: React.SyntheticEvent) => {
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
// simulate a click on the file upload input element
if (file.current) {
file.current.click();
}
},
[file]
);
const handleImportDocument = React.useCallback(() => {
// simulate a click on the file upload input element
if (file.current) {
file.current.click();
}
}, [file]);
const handleFilePicked = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
@@ -153,18 +119,17 @@ function CollectionMenu({
);
const handleChangeSort = React.useCallback(
(field: string, direction = "asc") => {
menu.hide();
return collection.save({
(field: string, direction = "asc") =>
collection.save({
sort: {
field,
direction,
},
});
},
[collection, menu]
}),
[collection]
);
const can = usePolicy(collection);
const context = useActionContext({
isContextMenu: true,
activeCollectionId: collection.id,
@@ -172,48 +137,12 @@ function CollectionMenu({
const sortAlphabetical = collection.sort.field === "title";
const sortDir = collection.sort.direction;
const can = usePolicy(collection);
const canUserInTeam = usePolicy(team);
const items: MenuItem[] = React.useMemo(
() => [
actionToMenuItem(restoreCollection, context),
actionToMenuItem(starCollection, context),
actionToMenuItem(unstarCollection, context),
actionToMenuItem(subscribeCollection, context),
actionToMenuItem(unsubscribeCollection, context),
{
type: "separator",
},
{
type: "button",
title: t("New document"),
visible: can.createDocument,
onClick: handleNewDocument,
icon: <NewDocumentIcon />,
},
{
type: "button",
title: t("Import document"),
visible: can.createDocument,
onClick: handleImportDocument,
icon: <ImportIcon />,
},
{
type: "separator",
},
{
type: "button",
title: `${t("Rename")}`,
visible: !!can.update && !!onRename,
onClick: () => onRename?.(),
icon: <InputIcon />,
},
actionToMenuItem(editCollection, context),
actionToMenuItem(editCollectionPermissions, context),
actionToMenuItem(createTemplate, context),
{
type: "submenu",
title: t("Sort in sidebar"),
const sortAction = React.useMemo(
() =>
createActionV2WithChildren({
name: t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: can.update,
icon: sortAlphabetical ? (
sortDir === "asc" ? (
@@ -224,61 +153,79 @@ function CollectionMenu({
) : (
<ManualSortIcon />
),
items: [
{
type: "button",
title: t("A-Z sort"),
onClick: () => handleChangeSort("title", "asc"),
children: [
createActionV2({
name: t("A-Z sort"),
section: ActiveCollectionSection,
visible: can.update,
selected: sortAlphabetical && sortDir === "asc",
},
{
type: "button",
title: t("Z-A sort"),
onClick: () => handleChangeSort("title", "desc"),
perform: () => handleChangeSort("title", "asc"),
}),
createActionV2({
name: t("Z-A sort"),
section: ActiveCollectionSection,
visible: can.update,
selected: sortAlphabetical && sortDir === "desc",
},
{
type: "button",
title: t("Manual sort"),
onClick: () => handleChangeSort("index"),
perform: () => handleChangeSort("title", "desc"),
}),
createActionV2({
name: t("Manual sort"),
section: ActiveCollectionSection,
visible: can.update,
selected: !sortAlphabetical,
},
perform: () => handleChangeSort("index"),
}),
],
},
{
type: "button",
title: `${t("Export")}`,
visible: !!(collection && canUserInTeam.createExport && can.export),
onClick: handleExport,
icon: <ExportIcon />,
},
actionToMenuItem(archiveCollection, context),
actionToMenuItem(searchInCollection, context),
{
type: "separator",
},
actionToMenuItem(deleteCollection, context),
}),
[t, can.update, sortAlphabetical, sortDir, handleChangeSort]
);
const actions = React.useMemo(
() => [
restoreCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
ActionV2Separator,
createDocument,
createActionV2({
name: t("Import document"),
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: can.createDocument,
perform: handleImportDocument,
}),
ActionV2Separator,
createActionV2({
name: `${t("Rename")}`,
section: ActiveCollectionSection,
icon: <InputIcon />,
visible: !!can.update && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
editCollection,
editCollectionPermissions,
createTemplate,
sortAction,
exportCollection,
archiveCollection,
searchInCollection,
ActionV2Separator,
deleteCollection,
],
[
t,
onRename,
collection,
can.createDocument,
can.update,
can.export,
handleNewDocument,
sortAction,
handleImportDocument,
context,
sortAlphabetical,
canUserInTeam.createExport,
handleExport,
handleChangeSort,
onRename,
]
);
if (!items.length) {
return null;
}
const rootAction = useMenuAction(actions);
return (
<>
@@ -295,25 +242,19 @@ function CollectionMenu({
/>
</label>
</VisuallyHidden.Root>
{label ? (
<MenuButton {...menu} onPointerEnter={handlePointerEnter}>
{label}
</MenuButton>
) : (
<OverflowMenuButton
aria-label={t("Show menu")}
{...menu}
onPointerEnter={handlePointerEnter}
/>
)}
<ContextMenu
{...menu}
<DropdownMenu
action={rootAction}
context={context}
align={align}
onOpen={onOpen}
onClose={onClose}
aria-label={t("Collection")}
ariaLabel={t("Collection menu")}
>
<Template {...menu} items={items} />
</ContextMenu>
<OverflowMenuButton
neutral={neutral}
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
</>
);
}
+47 -70
View File
@@ -1,26 +1,24 @@
import copy from "copy-to-clipboard";
import { observer } from "mobx-react";
import { CopyIcon, EditIcon } from "outline-icons";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import EventBoundary from "@shared/components/EventBoundary";
import Comment from "~/models/Comment";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import {
deleteCommentFactory,
resolveCommentFactory,
unresolveCommentFactory,
viewCommentReactionsFactory,
} from "~/actions/definitions/comments";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { commentPath, urlify } from "~/utils/routeHelpers";
import { ActionV2Separator, createActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
/** The comment to associate with the menu */
@@ -42,13 +40,9 @@ function CommentMenu({
onUpdate,
className,
}: Props) {
const menu = useMenuState({
modal: true,
});
const { documents } = useStores();
const { t } = useTranslation();
const can = usePolicy(comment);
const context = useActionContext({ isContextMenu: true });
const document = documents.get(comment.documentId);
const handleCopyLink = useCallback(() => {
@@ -58,65 +52,48 @@ function CommentMenu({
}
}, [t, document, comment]);
const actions = useMemo(
() => [
createActionV2({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: ActiveDocumentSection,
visible: can.update && !comment.isResolved,
perform: onEdit,
}),
resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
}),
unresolveCommentFactory({
comment,
onUnresolve: () => onUpdate({ resolved: false }),
}),
viewCommentReactionsFactory({
comment,
}),
createActionV2({
name: t("Copy link"),
icon: <CopyIcon />,
section: ActiveDocumentSection,
perform: handleCopyLink,
}),
ActionV2Separator,
deleteCommentFactory({ comment, onDelete }),
],
[t, comment, can.update, onEdit, onUpdate, onDelete, handleCopyLink]
);
const rootAction = useMenuAction(actions);
return (
<>
<EventBoundary>
<OverflowMenuButton
aria-label={t("Show menu")}
className={className}
{...menu}
/>
</EventBoundary>
{menu.visible && (
<ContextMenu {...menu} aria-label={t("Comment options")}>
<Template
{...menu}
items={[
{
type: "button",
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: onEdit,
visible: can.update && !comment.isResolved,
},
actionToMenuItem(
resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
}),
context
),
actionToMenuItem(
unresolveCommentFactory({
comment,
onUnresolve: () => onUpdate({ resolved: false }),
}),
context
),
actionToMenuItem(
viewCommentReactionsFactory({
comment,
}),
context
),
{
type: "button",
icon: <CopyIcon />,
title: t("Copy link"),
onClick: handleCopyLink,
},
{
type: "separator",
},
actionToMenuItem(
deleteCommentFactory({ comment, onDelete }),
context
),
]}
/>
</ContextMenu>
)}
</>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Comment options")}
>
<OverflowMenuButton className={className} />
</DropdownMenu>
);
}
+185 -384
View File
@@ -1,33 +1,17 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import capitalize from "lodash/capitalize";
import isEmpty from "lodash/isEmpty";
import noop from "lodash/noop";
import { observer } from "mobx-react";
import {
EditIcon,
InputIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
} from "outline-icons";
import { InputIcon, SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { toast } from "sonner";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { SubscriptionType, UserPreference } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import Template from "~/components/ContextMenu/Template";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import Switch from "~/components/Switch";
import { actionToMenuItem } from "~/actions";
import { ActionV2Separator, createActionV2 } from "~/actions";
import {
pinDocument,
createTemplateFromDocument,
@@ -55,35 +39,36 @@ import {
searchInDocument,
leaveDocument,
moveTemplate,
restoreDocument,
restoreDocumentToCollection,
editDocument,
applyTemplateFactory,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useMenuState } from "~/hooks/useMenuState";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
import { MenuItem, MenuItemButton } from "~/types";
import { documentEditPath } from "~/utils/routeHelpers";
import { MenuContext, useMenuContext } from "./MenuContext";
import { ActiveDocumentSection } from "~/actions/sections";
import { useTemplateMenuActions } from "~/hooks/useTemplateMenuActions";
import { useMenuAction } from "~/hooks/useMenuAction";
import { MenuSeparator } from "~/components/primitives/components/Menu";
type Props = {
/** Document for which the menu is to be shown */
document: Document;
isRevision?: boolean;
/** Alignment w.r.t trigger - defaults to start */
align?: "start" | "end";
/** Trigger's variant - renders nude variant if unset */
neutral?: boolean;
/** Pass true if the document is currently being displayed */
showDisplayOptions?: boolean;
/** Whether to display menu as a modal */
modal?: boolean;
/** Whether to include the option of toggling embeds as menu item */
showToggleEmbeds?: boolean;
showPin?: boolean;
/** Label for menu button */
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Document) => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
@@ -93,16 +78,24 @@ type Props = {
onClose?: () => void;
};
type MenuTriggerProps = {
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
onTrigger: () => void;
};
const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
function DocumentMenu({
document,
align,
neutral,
showToggleEmbeds,
showDisplayOptions,
onSelectTemplate,
onRename,
onOpen,
onClose,
onFindAndReplace,
}: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const isMobile = useMobile();
const can = usePolicy(document);
const { subscriptions, pins } = useStores();
const { model: document, menuState } = useMenuContext<Document>();
const {
loading: auxDataLoading,
@@ -134,106 +127,6 @@ const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
}
}, [auxDataLoading, auxDataLoaded, auxDataRequest, document]);
return label ? (
<MenuButton
{...menuState}
onPointerEnter={handlePointerEnter}
onClick={onTrigger}
>
{label}
</MenuButton>
) : (
<OverflowMenuButton
aria-label={t("Show document menu")}
onPointerEnter={handlePointerEnter}
onClick={onTrigger}
{...menuState}
/>
);
};
type MenuContentProps = {
onOpen?: () => void;
onClose?: () => void;
onFindAndReplace?: () => void;
onSelectTemplate?: (template: Document) => void;
onRename?: () => void;
showDisplayOptions?: boolean;
showToggleEmbeds?: boolean;
};
const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
onOpen,
onClose,
onFindAndReplace,
onSelectTemplate,
onRename,
showDisplayOptions,
showToggleEmbeds,
}) {
const user = useCurrentUser();
const { model: document, menuState } = useMenuContext<Document>();
const can = usePolicy(document);
const { t } = useTranslation();
const { policies, collections } = useStores();
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
activeCollectionId: document.collectionId ?? undefined,
});
const isMobile = useMobile();
const handleRestore = React.useCallback(
async (
ev: React.SyntheticEvent,
options?: {
collectionId: string;
}
) => {
await document.restore(options);
toast.success(
t("{{ documentName }} restored", {
documentName: capitalize(document.noun),
})
);
},
[t, document]
);
const restoreItems = React.useMemo(
() => [
...collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
const can = policies.abilities(collection.id);
if (can.createDocument) {
filtered.push({
type: "button",
onClick: (ev) =>
handleRestore(ev, {
collectionId: collection.id,
}),
icon: <CollectionIcon collection={collection} />,
title: collection.name,
});
}
return filtered;
}, []),
],
[collections.orderedData, handleRestore, policies]
);
const templateMenuItems = useTemplateMenuItems({
document,
onSelectTemplate,
});
const handleEmbedsToggle = React.useCallback(
(checked: boolean) => {
if (checked) {
@@ -255,255 +148,163 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
[user, document]
);
return !isEmpty(can) ? (
<ContextMenu
{...menuState}
aria-label={t("Document options")}
onOpen={onOpen}
onClose={onClose}
>
<Template
{...menuState}
items={[
{
type: "button",
title: t("Restore"),
visible:
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive),
onClick: (ev) => handleRestore(ev),
icon: <RestoreIcon />,
},
{
type: "submenu",
title: t("Restore"),
visible:
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive) &&
restoreItems.length !== 0,
style: {
left: -170,
position: "relative",
top: -40,
},
icon: <RestoreIcon />,
hover: true,
items: [
{
type: "heading",
title: t("Choose a collection"),
},
...restoreItems,
],
},
actionToMenuItem(starDocument, context),
actionToMenuItem(unstarDocument, context),
{
...actionToMenuItem(subscribeDocument, context),
disabled: collection?.isSubscribed,
tooltip: collection?.isSubscribed
? t("Subscription inherited from collection")
: undefined,
} as MenuItemButton,
{
...actionToMenuItem(unsubscribeDocument, context),
disabled: collection?.isSubscribed,
tooltip: collection?.isSubscribed
? t("Subscription inherited from collection")
: undefined,
} as MenuItemButton,
{
type: "button",
title: `${t("Find and replace")}`,
visible: !!onFindAndReplace && isMobile,
onClick: () => onFindAndReplace?.(),
icon: <SearchIcon />,
},
{
type: "separator",
},
{
type: "route",
title: t("Edit"),
to: documentEditPath(document),
visible:
!!can.update && user.separateEditMode && !document.template,
icon: <EditIcon />,
},
{
type: "button",
title: `${t("Rename")}`,
visible: !!can.update && !user.separateEditMode && !!onRename,
onClick: () => onRename?.(),
icon: <InputIcon />,
},
actionToMenuItem(shareDocument, context),
actionToMenuItem(createNestedDocument, context),
actionToMenuItem(importDocument, context),
actionToMenuItem(createTemplateFromDocument, context),
actionToMenuItem(duplicateDocument, context),
actionToMenuItem(publishDocument, context),
actionToMenuItem(unpublishDocument, context),
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(moveTemplate, context),
{
type: "submenu",
title: t("Apply template"),
icon: <ShapesIcon />,
items: templateMenuItems,
},
actionToMenuItem(pinDocument, context),
actionToMenuItem(createDocumentFromTemplate, context),
{
type: "separator",
},
actionToMenuItem(openDocumentComments, context),
actionToMenuItem(openDocumentHistory, context),
actionToMenuItem(openDocumentInsights, context),
actionToMenuItem(downloadDocument, context),
actionToMenuItem(copyDocument, context),
actionToMenuItem(printDocument, context),
actionToMenuItem(searchInDocument, context),
{
type: "separator",
},
actionToMenuItem(deleteDocument, context),
actionToMenuItem(permanentlyDeleteDocument, context),
actionToMenuItem(leaveDocument, context),
]}
/>
{(showDisplayOptions || showToggleEmbeds) && can.update && (
<>
<Separator />
<DisplayOptions>
{showToggleEmbeds && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Enable embeds")}
labelPosition="left"
checked={!document.embedsDisabled}
onChange={handleEmbedsToggle}
/>
</Style>
)}
{showDisplayOptions && !isMobile && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Full width")}
labelPosition="left"
checked={document.fullWidth}
onChange={handleFullWidthToggle}
/>
</Style>
)}
</DisplayOptions>
</>
)}
</ContextMenu>
) : null;
});
function DocumentMenu({
document,
modal = true,
showToggleEmbeds,
showDisplayOptions,
onSelectTemplate,
label,
onRename,
onOpen,
onClose,
}: Props) {
const { collections, documents } = useStores();
const menuState = useMenuState({
modal,
unstable_preventOverflow: true,
unstable_fixed: true,
unstable_flip: true,
});
const history = useHistory();
const { t } = useTranslation();
const [isMenuVisible, showMenu] = useBoolean(false);
const file = React.useRef<HTMLInputElement>(null);
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const stopPropagation = React.useCallback((ev: React.SyntheticEvent) => {
ev.stopPropagation();
}, []);
const handleFilePicked = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
// Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty
if (!files.length) {
return;
}
if (!collection) {
return;
}
try {
const file = files[0];
const importedDocument = await documents.import(
file,
document.id,
collection.id,
{
publish: true,
}
);
history.push(importedDocument.url);
} catch (err) {
toast.error(err.message);
throw err;
} finally {
ev.target.value = "";
}
const handleInsightsToggle = React.useCallback(
(checked: boolean) => {
void document.save({ insightsEnabled: checked });
},
[history, collection, documents, document.id]
[document]
);
const templateMenuActions = useTemplateMenuActions({
document,
onSelectTemplate,
});
const actions = React.useMemo(
() => [
restoreDocument,
restoreDocumentToCollection,
starDocument,
unstarDocument,
subscribeDocument,
unsubscribeDocument,
createActionV2({
name: `${t("Find and replace")}`,
section: ActiveDocumentSection,
icon: <SearchIcon />,
visible: !!onFindAndReplace && isMobile,
perform: () => onFindAndReplace?.(),
}),
ActionV2Separator,
editDocument,
createActionV2({
name: `${t("Rename")}`,
section: ActiveDocumentSection,
icon: <InputIcon />,
visible: !!can.update && !user.separateEditMode && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
shareDocument,
createNestedDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
publishDocument,
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory({ actions: templateMenuActions }),
pinDocument,
createDocumentFromTemplate,
ActionV2Separator,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
downloadDocument,
copyDocument,
printDocument,
searchInDocument,
ActionV2Separator,
deleteDocument,
permanentlyDeleteDocument,
leaveDocument,
],
[
t,
isMobile,
templateMenuActions,
can.update,
user.separateEditMode,
onFindAndReplace,
onRename,
]
);
const rootAction = useMenuAction(actions);
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
activeCollectionId: document.collectionId ?? undefined,
});
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
if (!can.update || !(showDisplayOptions || showToggleEmbeds)) {
return;
}
return (
<>
<MenuSeparator />
<DisplayOptions>
{can.updateInsights && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Enable viewer insights")}
labelPosition="left"
checked={document.insightsEnabled}
onChange={handleInsightsToggle}
/>
</Style>
)}
{showToggleEmbeds && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Enable embeds")}
labelPosition="left"
checked={!document.embedsDisabled}
onChange={handleEmbedsToggle}
/>
</Style>
)}
{showDisplayOptions && !isMobile && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Full width")}
labelPosition="left"
checked={document.fullWidth}
onChange={handleFullWidthToggle}
/>
</Style>
)}
</DisplayOptions>
</>
);
}, [
t,
can.update,
document.embedsDisabled,
document.fullWidth,
document.insightsEnabled,
isMobile,
showDisplayOptions,
showToggleEmbeds,
handleEmbedsToggle,
handleFullWidthToggle,
]);
return (
<>
<VisuallyHidden.Root>
<label>
{t("Import document")}
<input
type="file"
ref={file}
onChange={handleFilePicked}
onClick={stopPropagation}
accept={documents.importFileTypes.join(", ")}
tabIndex={-1}
/>
</label>
</VisuallyHidden.Root>
<MenuContext.Provider value={{ model: document, menuState }}>
<MenuTrigger label={label} onTrigger={showMenu} />
{isMenuVisible ? (
<MenuContent
onOpen={onOpen}
onClose={onClose}
onRename={onRename}
onSelectTemplate={onSelectTemplate}
showDisplayOptions={showDisplayOptions}
showToggleEmbeds={showToggleEmbeds}
/>
) : null}
</MenuContext.Provider>
</>
<DropdownMenu
action={rootAction}
context={context}
align={align}
onOpen={onOpen}
onClose={onClose}
ariaLabel={t("Document options")}
append={toggleSwitches}
>
<OverflowMenuButton
neutral={neutral}
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
);
}
+24 -23
View File
@@ -1,9 +1,11 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useMenuAction } from "~/hooks/useMenuAction";
import { useMemo } from "react";
import { createActionV2 } from "~/actions";
import { GroupSection } from "~/actions/sections";
type Props = {
onRemove: () => void;
@@ -11,26 +13,25 @@ type Props = {
function GroupMemberMenu({ onRemove }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
const actions = useMemo(
() => [
createActionV2({
name: t("Remove"),
section: GroupSection,
dangerous: true,
perform: onRemove,
}),
],
[t, onRemove]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Group member options")}>
<Template
{...menu}
items={[
{
type: "button",
dangerous: true,
title: t("Remove"),
onClick: onRemove,
},
]}
/>
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Group member options")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
+65 -53
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Group from "~/models/Group";
import {
@@ -8,12 +8,17 @@ import {
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import {
ActionV2Separator,
createActionV2,
createExternalLinkActionV2,
} from "~/actions";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
group: Group;
@@ -22,9 +27,6 @@ type Props = {
function GroupMenu({ group }: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const menu = useMenuState({
modal: true,
});
const can = usePolicy(group);
const handleViewMembers = useCallback(() => {
@@ -52,52 +54,62 @@ function GroupMenu({ group }: Props) {
});
}, [t, group, dialogs]);
const actions = useMemo(
() => [
createActionV2({
name: `${t("Members")}`,
icon: <GroupIcon />,
section: GroupSection,
visible: !!(group && can.read),
perform: handleViewMembers,
}),
ActionV2Separator,
createActionV2({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: GroupSection,
visible: !!(group && can.update),
perform: handleEditGroup,
}),
createActionV2({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: GroupSection,
visible: !!(group && can.delete),
dangerous: true,
perform: handleDeleteGroup,
}),
ActionV2Separator,
createExternalLinkActionV2({
name: group.externalId ?? "",
section: GroupSection,
visible: !!group.externalId,
disabled: true,
url: "",
}),
],
[
t,
group,
can.read,
can.update,
can.delete,
handleViewMembers,
handleEditGroup,
handleDeleteGroup,
]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Group options")}>
<Template
{...menu}
items={[
{
type: "button",
title: `${t("Members")}`,
icon: <GroupIcon />,
onClick: handleViewMembers,
visible: !!(group && can.read),
},
{
type: "separator",
},
{
type: "button",
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: handleEditGroup,
visible: !!(group && can.update),
},
{
type: "button",
title: `${t("Delete")}`,
icon: <TrashIcon />,
dangerous: true,
onClick: handleDeleteGroup,
visible: !!(group && can.delete),
},
{
type: "separator",
},
{
type: "link",
href: "",
title: group.externalId,
disabled: true,
visible: !!group.externalId,
},
]}
/>
</ContextMenu>
</>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Group options")}
>
<OverflowMenuButton />
</DropdownMenu>
);
}
+31 -35
View File
@@ -3,12 +3,13 @@ import { CrossIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Import from "~/models/Import";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import usePolicy from "~/hooks/usePolicy";
import { MenuItem } from "~/types";
import { createActionV2 } from "~/actions";
import { useMenuAction } from "~/hooks/useMenuAction";
const Section = "Imports";
type Props = {
/** Import to which actions will be applied. */
@@ -23,40 +24,35 @@ export const ImportMenu = observer(
({ importModel, onCancel, onDelete }: Props) => {
const { t } = useTranslation();
const can = usePolicy(importModel);
const menu = useMenuState({
modal: true,
});
const items = React.useMemo(
() =>
[
{
type: "button",
title: t("Cancel"),
visible: can.cancel,
icon: <CrossIcon />,
dangerous: true,
onClick: onCancel,
},
{
type: "button",
title: t("Delete"),
visible: can.delete,
icon: <TrashIcon />,
dangerous: true,
onClick: onDelete,
},
] satisfies MenuItem[],
[t, can.delete, can.cancel, onCancel, onDelete]
const actions = React.useMemo(
() => [
createActionV2({
name: t("Cancel"),
section: Section,
visible: !!can.cancel,
icon: <CrossIcon />,
dangerous: true,
perform: onCancel,
}),
createActionV2({
name: t("Delete"),
section: Section,
visible: !!can.delete,
icon: <TrashIcon />,
dangerous: true,
perform: onDelete,
}),
],
[t, can.cancel, can.delete, onCancel, onDelete]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Import menu options")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Import menu options")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
);
-48
View File
@@ -1,48 +0,0 @@
import { t } from "i18next";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import NudeButton from "~/components/NudeButton";
import { actionToMenuItem } from "~/actions";
import { toggleViewerInsights } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import { MenuItem } from "~/types";
const InsightsMenu: React.FC = () => {
const menuRef = React.useRef<HTMLDivElement>(null);
const menu = useMenuState();
const context = useActionContext();
const items: MenuItem[] = [actionToMenuItem(toggleViewerInsights, context)];
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button {...props}>
<MoreIcon />
</Button>
)}
</MenuButton>
<ContextMenu {...menu} menuRef={menuRef} aria-label={t("Notification")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
};
const Button = styled(NudeButton)`
color: ${s("textSecondary")};
&:${hover},
&:active {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
`;
export default InsightsMenu;
-41
View File
@@ -1,41 +0,0 @@
import { useTranslation } from "react-i18next";
import User from "~/models/User";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useMenuState } from "~/hooks/useMenuState";
type Props = {
user: User;
onRemove: () => void;
};
function MemberMenu({ user, onRemove }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const menu = useMenuState({
modal: false,
});
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Member options")}>
<Template
{...menu}
items={[
{
type: "button",
title: currentUser.id === user.id ? t("Leave") : t("Remove"),
dangerous: true,
onClick: onRemove,
},
]}
/>
</ContextMenu>
</>
);
}
export default MemberMenu;
-19
View File
@@ -1,19 +0,0 @@
import * as React from "react";
import { MenuStateReturn } from "reakit";
import Model from "~/models/base/Model";
export type MenuContext<T extends Model> = {
/** Model for which the menu is to be designed. */
model: T;
/** Menu state */
menuState: MenuStateReturn;
};
export const MenuContext = React.createContext<MenuContext<Model>>(
{} as MenuContext<Model>
);
export const useMenuContext = <T extends Model>() =>
React.useContext<MenuContext<T>>(
MenuContext as unknown as React.Context<MenuContext<T>>
);
+58 -48
View File
@@ -1,40 +1,36 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath, newNestedDocumentPath } from "~/utils/routeHelpers";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import Tooltip from "~/components/Tooltip";
import Button from "~/components/Button";
import { PlusIcon } from "outline-icons";
type Props = {
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
document: Document;
};
function NewChildDocumentMenu({ document, label }: Props) {
const menu = useMenuState({
modal: true,
});
function NewChildDocumentMenu({ document }: Props) {
const { t } = useTranslation();
const canCollection = usePolicy(document.collectionId);
const { collections } = useStores();
const items: MenuItem[] = [];
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const collectionName = collection ? collection.name : t("collection");
if (canCollection.createDocument) {
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const collectionName = collection ? collection.name : t("collection");
items.push({
type: "route",
title: (
<span>
const actions = React.useMemo(
() => [
createInternalLinkActionV2({
name: (
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
@@ -44,37 +40,51 @@ function NewChildDocumentMenu({ document, label }: Props) {
em: <strong />,
}}
/>
</span>
),
to: newDocumentPath(document.collectionId),
});
}
),
section: ActiveDocumentSection,
visible: !!canCollection.createDocument,
to: newDocumentPath(document.collectionId),
}),
createInternalLinkActionV2({
name: (
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
),
section: ActiveDocumentSection,
visible: true,
to: newNestedDocumentPath(document.id),
}),
],
[
collectionName,
canCollection.createDocument,
document.id,
document.titleWithDefault,
document.collectionId,
]
);
items.push({
type: "route",
title: (
<span>
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName: document.title,
}}
components={{
em: <strong />,
}}
/>
</span>
),
to: newNestedDocumentPath(document.id),
});
const rootAction = useMenuAction(actions);
return (
<>
<MenuButton {...menu}>{label}</MenuButton>
<ContextMenu {...menu} aria-label={t("New child document")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
<Tooltip content={t("New document")} shortcut="n" placement="bottom">
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("New child document")}
>
<Button icon={<PlusIcon />} neutral>
{t("New doc")}
</Button>
</DropdownMenu>
</Tooltip>
);
}
+14 -45
View File
@@ -1,61 +1,29 @@
import { t } from "i18next";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import NudeButton from "~/components/NudeButton";
import { actionToMenuItem, performAction } from "~/actions";
import { navigateToNotificationSettings } from "~/actions/definitions/navigation";
import { markNotificationsAsArchived } from "~/actions/definitions/notifications";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import { MenuItem } from "~/types";
import { useMemo } from "react";
import { useMenuAction } from "~/hooks/useMenuAction";
const NotificationMenu: React.FC = () => {
const menuRef = React.useRef<HTMLDivElement>(null);
const menu = useMenuState();
const context = useActionContext();
const items: MenuItem[] = React.useMemo(
() => [
actionToMenuItem(markNotificationsAsArchived, context),
{
type: "button",
title: t("Notification settings"),
onClick: () => performAction(navigateToNotificationSettings, context),
},
],
[context]
const actions = useMemo(
() => [markNotificationsAsArchived, navigateToNotificationSettings],
[]
);
useOnClickOutside(
menuRef,
(event) => {
if (menu.visible) {
event.stopPropagation();
event.preventDefault();
menu.hide();
}
},
{ capture: true }
);
const rootAction = useMenuAction(actions);
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button {...props}>
<MoreIcon />
</Button>
)}
</MenuButton>
<ContextMenu {...menu} menuRef={menuRef} aria-label={t("Notification")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Notifications")}>
<Button>
<MoreIcon />
</Button>
</DropdownMenu>
);
};
@@ -63,7 +31,8 @@ const Button = styled(NudeButton)`
color: ${s("textSecondary")};
&:${hover},
&:active {
&:active,
&[data-state="open"] {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
+22 -16
View File
@@ -1,13 +1,13 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import useStores from "~/hooks/useStores";
import { createActionV2 } from "~/actions";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
/** The OAuthAuthentication to associate with the menu */
@@ -15,9 +15,6 @@ type Props = {
};
function OAuthAuthenticationMenu({ oauthAuthentication }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
@@ -42,15 +39,24 @@ function OAuthAuthenticationMenu({ oauthAuthentication }: Props) {
});
}, [t, dialogs, oauthAuthentication]);
const actions = useMemo(
() => [
createActionV2({
name: t("Revoke"),
section: "OAuth",
dangerous: true,
perform: handleRevoke,
}),
],
[t, handleRevoke]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<MenuItem {...menu} onClick={handleRevoke} dangerous>
{t("Revoke")}
</MenuItem>
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Show menu")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
+35 -33
View File
@@ -1,14 +1,20 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import OAuthClient from "~/models/oauth/OAuthClient";
import OAuthClientDeleteDialog from "~/scenes/Settings/components/OAuthClientDeleteDialog";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import {
ActionV2Separator,
createActionV2,
createInternalLinkActionV2,
} from "~/actions";
import { useMenuAction } from "~/hooks/useMenuAction";
const Section = "OAuth";
type Props = {
/** The oauthClient to associate with the menu */
@@ -18,9 +24,6 @@ type Props = {
};
function OAuthClientMenu({ oauthClient, showEdit }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
@@ -36,32 +39,31 @@ function OAuthClientMenu({ oauthClient, showEdit }: Props) {
});
}, [t, dialogs, oauthClient]);
const actions = useMemo(
() => [
createInternalLinkActionV2({
name: `${t("Edit")}`,
section: Section,
visible: showEdit,
to: settingsPath("applications", oauthClient.id),
}),
ActionV2Separator,
createActionV2({
name: `${t("Delete")}`,
section: Section,
dangerous: true,
perform: handleDelete,
}),
],
[t, showEdit, oauthClient.id, handleDelete]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<Template
{...menu}
items={[
{
type: "route",
title: `${t("Edit")}`,
visible: showEdit,
to: settingsPath("applications", oauthClient.id),
},
{
type: "separator",
},
{
type: "button",
dangerous: true,
title: `${t("Delete")}`,
onClick: handleDelete,
},
]}
/>
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Show menu")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
+22 -28
View File
@@ -1,51 +1,45 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { ActionV2Separator } from "~/actions";
import {
copyLinkToRevision,
restoreRevision,
} from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import separator from "./separator";
import { useMemo } from "react";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
document: Document;
revisionId: string;
className?: string;
};
function RevisionMenu({ document, className }: Props) {
const menu = useMenuState({
modal: true,
});
function RevisionMenu({ document }: Props) {
const { t } = useTranslation();
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
});
const actions = useMemo(
() => [restoreRevision, ActionV2Separator, copyLinkToRevision],
[]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton
className={className}
aria-label={t("Show menu")}
{...menu}
/>
<ContextMenu {...menu} aria-label={t("Revision options")}>
<Template
{...menu}
items={[
actionToMenuItem(restoreRevision, context),
separator(),
actionToMenuItem(copyLinkToRevision, context),
]}
/>
</ContextMenu>
</>
<DropdownMenu
action={rootAction}
context={context}
align="end"
ariaLabel={t("Revision options")}
>
<OverflowMenuButton />
</DropdownMenu>
);
}
+25 -66
View File
@@ -1,87 +1,46 @@
import { observer } from "mobx-react";
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import Share from "~/models/Share";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import CopyToClipboard from "~/components/CopyToClipboard";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { ActionV2Separator } from "~/actions";
import {
copyShareUrlFactory,
goToShareSourceFactory,
revokeShareFactory,
} from "~/actions/definitions/shares";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
share: Share;
};
function ShareMenu({ share }: Props) {
const menu = useMenuState({
modal: true,
});
const { shares } = useStores();
const { t } = useTranslation();
const history = useHistory();
const can = usePolicy(share);
const handleGoToSource = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
history.push({
pathname: share.sourcePathWithFallback,
state: { sidebarContext: "collections" }, // optimistic preference of "collections"
});
},
[history, share]
const actions = React.useMemo(
() => [
copyShareUrlFactory({ share }),
goToShareSourceFactory({ share }),
ActionV2Separator,
revokeShareFactory({ share, can }),
],
[share, can]
);
const handleRevoke = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
try {
await shares.revoke(share);
toast.message(t("Share link revoked"));
} catch (err) {
toast.error(err.message);
}
},
[t, shares, share]
);
const handleCopy = React.useCallback(() => {
toast.success(t("Share link copied"));
}, [t]);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Share options")}>
<CopyToClipboard text={share.url} onCopy={handleCopy}>
<MenuItem {...menu} icon={<CopyIcon />}>
{t("Copy link")}
</MenuItem>
</CopyToClipboard>
<MenuItem {...menu} onClick={handleGoToSource} icon={<ArrowIcon />}>
{share.collectionId ? t("Go to collection") : t("Go to document")}
</MenuItem>
{can.revoke && (
<>
<hr />
<MenuItem
{...menu}
onClick={handleRevoke}
icon={<TrashIcon />}
dangerous
>
{t("Revoke link")}
</MenuItem>
</>
)}
</ContextMenu>
</>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Share options")}
>
<OverflowMenuButton />
</DropdownMenu>
);
}
+57 -58
View File
@@ -2,87 +2,86 @@ import { observer } from "mobx-react";
import { TableOfContentsIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { createActionV2, createActionV2Group } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { useDocumentContext } from "~/components/DocumentContext";
import { useMenuState } from "~/hooks/useMenuState";
import { MenuItem } from "~/types";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { useMenuAction } from "~/hooks/useMenuAction";
function TableOfContentsMenu() {
const { headings } = useDocumentContext();
const menu = useMenuState({
modal: true,
unstable_preventOverflow: true,
unstable_fixed: true,
unstable_flip: true,
});
const { t } = useTranslation();
const minHeading = headings.reduce(
(memo, heading) => (heading.level < memo ? heading.level : memo),
Infinity
);
const items: MenuItem[] = useMemo(() => {
const i = [
{
type: "heading",
title: t("Contents"),
},
...headings.map((heading) => ({
type: "button",
onClick: () =>
requestAnimationFrame(() =>
requestAnimationFrame(
() => (window.location.hash = `#${heading.id}`)
)
),
title: <HeadingWrapper>{t(heading.title)}</HeadingWrapper>,
level: heading.level - minHeading,
})),
] as MenuItem[];
if (i.length === 1) {
i.push({
type: "link",
href: "#",
title: (
<HeadingWrapper>
{t("Headings you add to the document will appear here")}
</HeadingWrapper>
const headingActions = useMemo(
() =>
headings
.filter((heading) => heading.level < 4)
.map((heading) =>
createActionV2({
name: (
<HeadingWrapper $level={heading.level - minHeading}>
{t(heading.title)}
</HeadingWrapper>
),
section: ActiveDocumentSection,
perform: () =>
requestAnimationFrame(() =>
requestAnimationFrame(
() => (window.location.hash = `#${heading.id}`)
)
),
})
),
disabled: true,
});
[t, headings, minHeading]
);
const actions = useMemo(() => {
let childActions = headingActions;
if (!childActions.length) {
childActions = [
createActionV2({
name: (
<HeadingWrapper>
{t("Headings you add to the document will appear here")}
</HeadingWrapper>
),
section: ActiveDocumentSection,
disabled: true,
perform: () => {},
}),
];
}
return i;
}, [t, headings, minHeading]);
return [
createActionV2Group({
name: t("Contents"),
actions: childActions,
}),
];
}, [t, headingActions]);
const rootAction = useMenuAction(actions);
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button
{...props}
icon={<TableOfContentsIcon />}
borderOnHover
neutral
/>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Table of contents")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Table of contents")}>
<Button icon={<TableOfContentsIcon />} borderOnHover neutral />
</DropdownMenu>
);
}
const HeadingWrapper = styled.div`
const HeadingWrapper = styled.div<{ $level?: number }>`
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
margin-left: ${({ $level }) => `${12 * ($level ?? 0)}px`};
`;
export default observer(TableOfContentsMenu);
+9 -28
View File
@@ -1,9 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import {
navigateToWorkspaceSettings,
logout,
@@ -14,33 +11,18 @@ import {
desktopLoginTeam,
} from "~/actions/definitions/teams";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import separator from "~/menus/separator";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { ActionV2Separator } from "~/actions";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
children?: React.ReactNode;
};
const TeamMenu: React.FC = ({ children }: Props) => {
const menu = useMenuState({
unstable_offset: [4, -4],
placement: "bottom-start",
modal: true,
});
const stores = useStores();
const { theme } = stores.ui;
const previousTheme = usePrevious(theme);
const { t } = useTranslation();
const context = useActionContext({ isContextMenu: true });
React.useEffect(() => {
if (theme !== previousTheme) {
menu.hide();
}
}, [menu, theme, previousTheme]);
// NOTE: it's useful to memoize on the team id and session because the action
// menu is not cached at all.
const actions = React.useMemo(
@@ -48,20 +30,19 @@ const TeamMenu: React.FC = ({ children }: Props) => {
...switchTeamsList(context),
createTeam,
desktopLoginTeam,
separator(),
ActionV2Separator,
navigateToWorkspaceSettings,
logout,
],
[context]
);
const rootAction = useMenuAction(actions);
return (
<>
<MenuButton {...menu}>{children}</MenuButton>
<ContextMenu {...menu} aria-label={t("Account")}>
<Template {...menu} items={undefined} actions={actions} />
</ContextMenu>
</>
<DropdownMenu action={rootAction} align="start" ariaLabel={t("Account")}>
{children}
</DropdownMenu>
);
};
+132 -148
View File
@@ -4,21 +4,24 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import User from "~/models/User";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import {
UserSuspendDialog,
UserChangeNameDialog,
UserChangeEmailDialog,
} from "~/components/UserDialogs";
import { actionToMenuItem } from "~/actions";
import {
ActionV2Separator,
createActionV2,
createActionV2WithChildren,
} from "~/actions";
import {
deleteUserActionFactory,
updateUserRoleActionFactory,
} from "~/actions/definitions/users";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import { UserSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -29,156 +32,137 @@ type Props = {
function UserMenu({ user }: Props) {
const { users, dialogs } = useStores();
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
const can = usePolicy(user);
const context = useActionContext({
isContextMenu: true,
});
const handleChangeName = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
dialogs.openModal({
title: t("Change name"),
content: (
<UserChangeNameDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
},
[dialogs, t, user]
const handleChangeName = React.useCallback(() => {
dialogs.openModal({
title: t("Change name"),
content: (
<UserChangeNameDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
}, [dialogs, t, user]);
const handleChangeEmail = React.useCallback(() => {
dialogs.openModal({
title: t("Change email"),
content: (
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
}, [dialogs, t, user]);
const handleSuspend = React.useCallback(() => {
dialogs.openModal({
title: t("Suspend user"),
content: (
<UserSuspendDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
}, [dialogs, t, user]);
const handleRevoke = React.useCallback(async () => {
await users.delete(user);
}, [users, user]);
const handleResendInvite = React.useCallback(async () => {
try {
await users.resendInvite(user);
toast.success(t(`Invite was resent to ${user.name}`));
} catch (err) {
toast.error(
err.message ?? t(`An error occurred while sending the invite`)
);
}
}, [users, user, t]);
const handleActivate = React.useCallback(async () => {
await users.activate(user);
}, [users, user]);
const changeRoleActions = React.useMemo(
() =>
[UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
updateUserRoleActionFactory(user, role)
),
[user]
);
const handleChangeEmail = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
dialogs.openModal({
title: t("Change email"),
content: (
<UserChangeEmailDialog
user={user}
onSubmit={dialogs.closeAllModals}
/>
),
});
},
[dialogs, t, user]
const actions = React.useMemo(
() => [
createActionV2WithChildren({
name: t("Change role"),
section: UserSection,
visible: can.demote || can.promote,
children: changeRoleActions,
}),
createActionV2({
name: `${t("Change name")}`,
section: UserSection,
visible: can.update,
perform: handleChangeName,
}),
createActionV2({
name: `${t("Change email")}`,
section: UserSection,
visible: can.update,
perform: handleChangeEmail,
}),
createActionV2({
name: t("Resend invite"),
section: UserSection,
visible: can.resendInvite,
perform: handleResendInvite,
}),
ActionV2Separator,
createActionV2({
name: `${t("Revoke invite")}`,
section: UserSection,
visible: user.isInvited,
dangerous: true,
perform: handleRevoke,
}),
createActionV2({
name: t("Activate user"),
section: UserSection,
visible: !user.isInvited && user.isSuspended,
perform: handleActivate,
}),
createActionV2({
name: `${t("Suspend user")}`,
section: UserSection,
visible: !user.isInvited && !user.isSuspended,
dangerous: true,
perform: handleSuspend,
}),
ActionV2Separator,
deleteUserActionFactory(user.id),
],
[
t,
can.demote,
can.promote,
can.update,
can.resendInvite,
user.id,
user.isInvited,
user.isSuspended,
changeRoleActions,
handleChangeName,
handleChangeEmail,
handleResendInvite,
handleRevoke,
handleActivate,
handleSuspend,
]
);
const handleSuspend = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
dialogs.openModal({
title: t("Suspend user"),
content: (
<UserSuspendDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
},
[dialogs, t, user]
);
const handleRevoke = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
await users.delete(user);
},
[users, user]
);
const handleResendInvite = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
try {
await users.resendInvite(user);
toast.success(t(`Invite was resent to ${user.name}`));
} catch (err) {
toast.error(
err.message ?? t(`An error occurred while sending the invite`)
);
}
},
[users, user, t]
);
const handleActivate = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
await users.activate(user);
},
[users, user]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("User options")}>
<Template
{...menu}
items={[
{
type: "submenu",
title: t("Change role"),
visible: can.demote || can.promote,
items: [UserRole.Admin, UserRole.Member, UserRole.Viewer].map(
(role) =>
actionToMenuItem(
updateUserRoleActionFactory(user, role),
context
)
),
},
{
type: "button",
title: `${t("Change name")}`,
onClick: handleChangeName,
visible: can.update,
},
{
type: "button",
title: `${t("Change email")}`,
onClick: handleChangeEmail,
visible: can.update,
},
{
type: "button",
title: t("Resend invite"),
onClick: handleResendInvite,
visible: can.resendInvite,
},
{
type: "separator",
},
{
type: "button",
title: `${t("Revoke invite")}`,
dangerous: true,
onClick: handleRevoke,
visible: user.isInvited,
},
{
type: "button",
title: t("Activate user"),
onClick: handleActivate,
visible: !user.isInvited && user.isSuspended,
},
{
type: "button",
title: `${t("Suspend user")}`,
dangerous: true,
onClick: handleSuspend,
visible: !user.isInvited && !user.isSuspended,
},
{
type: "separator",
},
actionToMenuItem(deleteUserActionFactory(user.id), context),
]}
/>
</ContextMenu>
</>
<DropdownMenu action={rootAction} align="end" ariaLabel={t("User options")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
/* eslint-disable */
/* oxlint-disable */
import stores from "~/stores";
describe("Collection model", () => {
+29 -1
View File
@@ -665,6 +665,28 @@ export default class Document extends ArchivableModel implements Searchable {
};
}
/**
* Returns all children of the document.
* This is determined by the collection structure, or the user/group memberships in case it's a shared document.
*
* @returns An array of NavigationNode objects.
*/
@computed
get children(): NavigationNode[] {
const { userMemberships, groupMemberships } = this.store.rootStore;
const collection = this.collection;
const membership =
userMemberships.getByDocumentId(this.id) ??
groupMemberships.getByDocumentId(this.id);
return (
collection?.getChildrenForDocument(this.id) ??
membership?.getChildrenForDocument(this.id) ??
[]
);
}
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
*
@@ -677,7 +699,13 @@ export default class Document extends ArchivableModel implements Searchable {
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const markdown = serializer.serialize(Node.fromJSON(schema, this.data), {
const doc = Node.fromJSON(
schema,
ProsemirrorHelper.attachmentsToAbsoluteUrls(this.data)
);
const markdown = serializer.serialize(doc, {
softBreak: true,
});
return markdown;
+19 -2
View File
@@ -3,14 +3,14 @@ import { CollectionPermission, DocumentPermission } from "@shared/types";
import Collection from "./Collection";
import Document from "./Document";
import Group from "./Group";
import Model from "./base/Model";
import { AfterRemove } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
import NavigableModel from "./base/NavigableModel";
/**
* Represents a groups's membership to a collection or document.
*/
class GroupMembership extends Model {
class GroupMembership extends NavigableModel {
static modelName = "GroupMembership";
/** The group ID that this membership is granted to. */
@@ -45,6 +45,23 @@ class GroupMembership extends Model {
@observable
permission: CollectionPermission | DocumentPermission;
// methods
/**
* Fetches the child documents structure from the server.
*/
async fetchDocuments(options: { force?: boolean } = {}) {
if (!this.documentId) {
return;
}
await super.fetchDocuments({
path: "/documents.documents",
params: { id: this.documentId },
...options,
});
}
// hooks
@AfterRemove
+12 -2
View File
@@ -1,6 +1,6 @@
import { TFunction } from "i18next";
import { action, computed, observable } from "mobx";
import { NotificationEventType } from "@shared/types";
import { NotificationData, NotificationEventType } from "@shared/types";
import {
collectionPath,
commentPath,
@@ -73,6 +73,11 @@ class Notification extends Model {
*/
event: NotificationEventType;
/**
* Additional data associated with the notification.
*/
data: NotificationData;
/**
* Mark the notification as read or unread
*
@@ -121,6 +126,10 @@ class Notification extends Model {
return t("left a comment on");
case NotificationEventType.ResolveComment:
return t("resolved a comment on");
case NotificationEventType.ReactionsCreate:
return t("reacted {{ emoji }} to your comment on", {
emoji: this.data.emoji,
});
case NotificationEventType.AddUserToDocument:
return t("shared");
case NotificationEventType.AddUserToCollection:
@@ -173,7 +182,8 @@ class Notification extends Model {
}
case NotificationEventType.MentionedInComment:
case NotificationEventType.ResolveComment:
case NotificationEventType.CreateComment: {
case NotificationEventType.CreateComment:
case NotificationEventType.ReactionsCreate: {
return this.document && this.comment
? commentPath(this.document, this.comment)
: this.document?.path;
+19 -2
View File
@@ -3,12 +3,12 @@ import { DocumentPermission } from "@shared/types";
import type UserMembershipsStore from "~/stores/UserMembershipsStore";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterRemove } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
import NavigableModel from "./base/NavigableModel";
class UserMembership extends Model {
class UserMembership extends NavigableModel {
static modelName = "UserMembership";
/** The sort order of the membership (In users sidebar) */
@@ -50,6 +50,23 @@ class UserMembership extends Model {
store: UserMembershipsStore;
// methods
/**
* Fetches the child documents structure from the server.
*/
async fetchDocuments(options: { force?: boolean } = {}) {
if (!this.documentId) {
return;
}
await super.fetchDocuments({
path: "/documents.documents",
params: { id: this.documentId },
...options,
});
}
/**
* Returns the next membership for the same user in the list, or undefined if this is the last.
*/
+1 -1
View File
@@ -208,7 +208,7 @@ export default abstract class Model {
for (const property in this) {
if (
// eslint-disable-next-line no-prototype-builtins
// oxlint-disable-next-line no-prototype-builtins
this.hasOwnProperty(property) &&
!["persistedAttributes", "store", "isSaving", "isNew"].includes(
property
+112
View File
@@ -0,0 +1,112 @@
import { computed, observable, runInAction } from "mobx";
import { JSONObject, type NavigationNode } from "@shared/types";
import { client } from "~/utils/ApiClient";
import ParanoidModel from "./ParanoidModel";
export default abstract class NavigableModel extends ParanoidModel {
private isFetching = false;
@observable
node?: NavigationNode;
/**
* Fetches the child documents structure from the server.
*/
async fetchDocuments(options: {
path: string;
params: JSONObject;
force?: boolean;
}) {
if (this.isFetching) {
return;
}
if (this.documents && options.force !== true) {
return;
}
try {
this.isFetching = true;
const res = await client.post(options.path, options.params);
runInAction(`${NavigableModel.modelName}#fetchDocuments`, () => {
this.node = res.data;
});
} finally {
this.isFetching = false;
}
}
/**
* Child documents structure of the document shared with this membership.
*/
@computed
get documents(): NavigationNode[] | undefined {
return this.node?.children;
}
/**
* Returns the document path from the original document shared with this membership.
*/
pathToDocument(documentId: string) {
let path: NavigationNode[] | undefined = [];
const document = this.store.rootStore.documents.get(documentId);
if (!document) {
return path;
}
const travelNodes = (
nodes: NavigationNode[],
previousPath: NavigationNode[]
) => {
nodes.forEach((node) => {
const newPath = [...previousPath, node];
if (node.id === documentId) {
path = newPath;
return;
}
if (
document.parentDocumentId &&
node.id === document.parentDocumentId
) {
path = [...newPath, document.asNavigationNode];
return;
}
return travelNodes(node.children, newPath);
});
};
if (this.node) {
travelNodes([this.node], path);
}
return path;
}
/**
* Returns the child documents structure for the document.
*/
getChildrenForDocument(documentId: string) {
let result: NavigationNode[] = [];
const travelNodes = (nodes: NavigationNode[]) => {
nodes.forEach((node) => {
if (node.id === documentId) {
result = node.children;
return;
}
return travelNodes(node.children);
});
};
if (this.node) {
travelNodes([this.node]);
}
return result;
}
}

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