Compare commits

...

108 Commits

Author SHA1 Message Date
Tom Moor bd0111785a test: Fix flaky availableTeams test 2025-02-26 22:13:08 -05:00
Hemachandar 5c46bd13ed fix: Show diff from document when revision has not been created yet (#8567)
* fix: Show diff from document when revision has not been created yet

* fast equals
2025-02-26 15:59:48 -08:00
Hemachandar e51f93f9a0 chore: Add API error handler middleware (#8572) 2025-02-26 05:02:21 -08:00
Tom Moor d4fe240808 fix: If multiple authentication providers match, choose the enabled one with priority (#8566) 2025-02-25 19:06:53 -08:00
Tom Moor 61c76e62ef fix: Smart text fraction replacements lose preceding space (#8564)
* chore: Ensure no cache of root index page

* fix: Fractional smart text replacements also lose preceding space
2025-02-25 17:10:19 -08:00
Tom Moor 499392c114 feat: allow sending text parameter to comments.create (#8544)
* fix: Do not size last table column by default

* feat: Allow text param for comments.create

* Support images in comment text
2025-02-24 17:52:39 -08:00
Hemachandar af0651f243 Assorted fixes/improvs in FindAndReplace popover (#8560)
* enable/disable find options for keyboard shortcuts

* fix replace all keyboard shortcut

* tooltip for replace and replace all buttons

* uppercase tooltips

* trap cmd+f inside popover

* direct findandreplace popover

* focus replace text
2025-02-24 17:24:58 -08:00
dependabot[bot] 7a54d5bb84 chore(deps): bump the aws group with 5 updates (#8554)
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.749.0` | `3.750.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.749.0` | `3.750.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.749.0` | `3.750.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.749.0` | `3.750.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.749.0` | `3.750.0` |


Updates `@aws-sdk/client-s3` from 3.749.0 to 3.750.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.750.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.749.0 to 3.750.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.750.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.749.0 to 3.750.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.750.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.749.0 to 3.750.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.750.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.749.0 to 3.750.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.750.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  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-02-24 16:59:13 -08:00
dependabot[bot] cb4a978a87 chore(deps): bump semver from 7.6.3 to 7.7.1 (#8558)
Bumps [semver](https://github.com/npm/node-semver) from 7.6.3 to 7.7.1.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.6.3...v7.7.1)

---
updated-dependencies:
- dependency-name: semver
  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-02-24 16:59:03 -08:00
dependabot[bot] 66a5055e73 chore(deps): bump randomstring from 1.3.0 to 1.3.1 (#8555)
Bumps [randomstring](https://github.com/klughammer/node-randomstring) from 1.3.0 to 1.3.1.
- [Changelog](https://github.com/klughammer/node-randomstring/blob/master/CHANGELOG.md)
- [Commits](https://github.com/klughammer/node-randomstring/commits)

---
updated-dependencies:
- dependency-name: randomstring
  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-02-24 16:58:48 -08:00
dependabot[bot] a9ddbde02c chore(deps-dev): bump typescript from 5.7.2 to 5.7.3 (#8556)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.7.2 to 5.7.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.2...v5.7.3)

---
updated-dependencies:
- dependency-name: typescript
  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-02-24 16:57:46 -08:00
Hemachandar 6b25000adb fix: Horizontal scroll in workspace details page (#8548) 2025-02-24 05:34:14 -08:00
Hemachandar 0fb8971628 chore: Use stores from context in collection actions (#8549) 2025-02-24 05:19:03 -08:00
Tom Moor 9f277a8f7b fix: Do not size last table column by default 2025-02-23 22:57:50 -05:00
Tom Moor 97e91eb06b test: Fix race condition 2025-02-23 22:29:40 -05:00
Tom Moor a87bda82b1 fix: Incorrect position of floating toolbar due to measurement post-transform 2025-02-23 22:17:42 -05:00
Tom Moor 36a92d5393 chore: Special-case database validation as it is used before server env can be validated 2025-02-23 22:00:09 -05:00
Tom Moor 50f9f414bf fix: Slim stop-word list, related #8395 2025-02-23 17:26:57 -05:00
Tom Moor 0d6026c21f fix: Cannot export images without captions 2025-02-23 13:39:11 -05:00
Tom Moor eea0e28630 feat: Show recently viewed documents in Move dialog
closes #8422
2025-02-23 12:06:32 -05:00
Tom Moor 4ad58b4ccd fix: Incorrect menu position when notice is first item in doc, closes #8539 2025-02-23 11:00:42 -05:00
Tom Moor 17f4dc58f1 chore: Fix cascade team_domains -> users 2025-02-23 09:37:00 -05:00
Tom Moor fe839acaeb fix: Backport redirect fix 2025-02-23 09:14:36 -05:00
Tom Moor 210aefaf8f fix: Image caption in HTML exports, closes #8515 2025-02-22 22:57:54 -05:00
Tom Moor 90f7a4272e chore: cleanup 2025-02-22 21:57:07 -05:00
Tom Moor 978773ee27 fix: Translation of notice menu 2025-02-22 20:11:12 -05:00
YouLL 59abc3355c feat: change notice type (#8533)
* feat: change notice type

* Apply suggestions from code review

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

* refactor: change enum naming

* fix: notice creation

* fix: menu name

* refactor: put notice type in the menu label

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-02-22 17:07:02 -08:00
Tom Moor 72aa4cde3f chore: Selective test runner, fix bundle-size 2025-02-22 19:59:07 -05:00
Tom Moor 42dfe7027f chore: CircleCI -> GitHub (#8534) 2025-02-22 07:54:15 -08:00
Translate-O-Tron 3d4299bc60 New Crowdin updates (#8440) 2025-02-22 06:57:43 -08:00
Hemachandar 6a1f2399db feat: Collection subscription (#8392)
* feat: Collection subscription

* refactor to use latest impl

* load subscriptions only once

* tests, type rename, migration index

* all users in publish flow

* tsc

* remove SubscriptionType.Collection enum

* review
2025-02-22 06:53:19 -08:00
Hemachandar ae2fafac0e Wrap headings on mobile ToC (#8531)
* Truncate heading overflow on mobile ToC

* wrap
2025-02-22 06:29:53 -08:00
Tom Moor 0efe6393a3 fix: Match spacing between internal and published docs, closes #8507 2025-02-22 00:03:07 -05:00
Tom Moor 2e3c19ff88 fix: Remove numbered list detection for markdown, closes #8523 2025-02-21 23:46:58 -05:00
Hemachandar 87fcf35956 fix: Allow downloading exported file (#8524)
* fix: Allow downloading exported file

* tests
2025-02-21 05:10:10 -08:00
Tom Moor 540683d896 chore: Cleanup 2025-02-20 09:04:52 -05:00
Tom Moor a8cbdf061d fix: Hardcoded max collection description length, closes #8510 2025-02-20 08:52:03 -05:00
Tom Moor 171433e984 tsc 2025-02-19 23:59:02 -05:00
Hemachandar f61046ec22 fix: Prevent double pinning of documents (#8503)
* fix: Prevent double pinning of documents

* tests

* review

* policy

* schema
2025-02-19 20:44:21 -08:00
Tom Moor 78ff3af801 fix: Initials are unreadable on light colored avatar background 2025-02-19 23:39:55 -05:00
Tom Moor 83da38afd5 fix: Use store models in history sidebar 2025-02-19 23:35:43 -05:00
Tom Moor b2da166dd6 fix: Prevent last user/group with collection manage permission being removed (#8499)
* fix: Prevent removal of last manage UserMembership in collection

* fix: Check last GroupMembership with manage permission

* Cover permission update case

* save
2025-02-19 18:55:28 -08:00
Hemachandar 33c7560b3d fix: Update local storage when creating/deleting pins (#8504)
* fix: Update local storage when creating/deleting pins

* reuse

* use urlId
2025-02-19 18:55:03 -08:00
Tom Moor db78fb7111 Improvements to history styling (#8496) 2025-02-19 04:44:29 -08:00
Tom Moor cbca7f60fe Move document history to revisions.list API (#8497)
* Revert "Revert "Move document history to `revisions.list` API (#8458)" (#8495)"

This reverts commit 2116041cd5.

* fix: check all events for latest ad-hoc revision

* view revision list for deleted docs

* rename

---------

Co-authored-by: hmacr <hmac.devo@gmail.com>
2025-02-19 04:44:15 -08:00
Hemachandar c89589e86c fix: Don't remove data from store when fetching archived docs (#8484) 2025-02-19 04:41:30 -08:00
Tom Moor 878a27b7c6 chore: Upgrade vite 2025-02-18 23:03:31 -05:00
Tom Moor a05c965be2 fix: Markdown import with relative path image not imported correctly 2025-02-18 22:38:41 -05:00
Tom Moor 2116041cd5 Revert "Move document history to revisions.list API (#8458)" (#8495)
This reverts commit 839ce889ad.
2025-02-18 20:25:52 -05:00
Tom Moor 7144536eb3 fix: Visible scrollbars on LaTeX on Windows, closes #8488 2025-02-18 20:21:39 -05:00
Tom Moor 4cd2ee6291 fix: Path with query string does not work with scope restrictions, closes #8489 2025-02-18 20:16:54 -05:00
Tom Moor 1749ffe20d feat: Redirect to previous subdomains (#8477)
* Migration

* Store previous subdomains

* Redirect previous subdomains at service layer

* refactor

* refactor

* change index

* Guard logic to hosted only
2025-02-18 16:53:18 -08:00
dependabot[bot] b9c6f9c9e6 chore(deps): bump @octokit/request from 8.4.0 to 8.4.1 (#8493)
Bumps [@octokit/request](https://github.com/octokit/request.js) from 8.4.0 to 8.4.1.
- [Release notes](https://github.com/octokit/request.js/releases)
- [Commits](https://github.com/octokit/request.js/compare/v8.4.0...v8.4.1)

---
updated-dependencies:
- dependency-name: "@octokit/request"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-18 18:30:34 -05:00
Hemachandar 14777145e9 fix: Set sidebar context for archive section (#8485) 2025-02-18 15:24:09 -08:00
Tom Moor c6ae6e0c36 chore: Update popperjs/core closes #8277 2025-02-17 17:43:39 -05:00
Tom Moor 84542874c4 fix: RTL list nesting – this is about the limit of what CSS-alone can achieve. closes #8459 2025-02-17 17:24:15 -05:00
Tom Moor e90a86737f Add task to cleanup old events, change strategies to hourly (#8446) 2025-02-17 13:34:30 -08:00
dependabot[bot] 4373dad309 chore(deps): bump the aws group with 5 updates (#8464)
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.744.0` | `3.749.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.744.0` | `3.749.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.744.0` | `3.749.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.744.0` | `3.749.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.744.0` | `3.749.0` |


Updates `@aws-sdk/client-s3` from 3.744.0 to 3.749.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.749.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.744.0 to 3.749.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.749.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.744.0 to 3.749.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.749.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.744.0 to 3.749.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.749.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.744.0 to 3.749.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.749.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  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-02-17 13:15:02 -08:00
Hemachandar 839ce889ad Move document history to revisions.list API (#8458)
* Move document history to `revisions.list` API

* deprecate name

* tests

* review
2025-02-17 13:14:51 -08:00
dependabot[bot] 27322d62f8 chore(deps-dev): bump eslint-import-resolver-typescript (#8466)
Bumps [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) from 3.7.0 to 3.8.0.
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v3.7.0...v3.8.0)

---
updated-dependencies:
- dependency-name: eslint-import-resolver-typescript
  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-02-17 11:54:31 -08:00
dependabot[bot] a9b41b3f17 chore(deps-dev): bump @types/emoji-regex from 9.2.0 to 9.2.2 (#8467)
Bumps [@types/emoji-regex](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/emoji-regex) from 9.2.0 to 9.2.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/emoji-regex)

---
updated-dependencies:
- dependency-name: "@types/emoji-regex"
  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-02-17 11:54:23 -08:00
Tom Moor f46921275d fix: copy pasting the content from some medium into outline does not get the images (#8472)
* fix: Files from local storage provider sometimes returned with incorrect content type

* fix: attachments.createFromUrl response values incorrect for successful upload

* fix: Reduce liklihood of image download requests being blocked on server

* fix: Content with HTML images should never be considered as markdown

* fix: Image caption sometimes uncentered

* test
2025-02-17 11:54:13 -08:00
dependabot[bot] 433c3b299d chore(deps): bump form-data from 4.0.1 to 4.0.2 (#8468)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.1 to 4.0.2.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.1...v4.0.2)

---
updated-dependencies:
- dependency-name: form-data
  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-02-17 09:57:31 -08:00
dependabot[bot] c44872b4cc chore(deps): bump react-medium-image-zoom from 5.2.10 to 5.2.13 (#8465)
Bumps [react-medium-image-zoom](https://github.com/rpearce/react-medium-image-zoom) from 5.2.10 to 5.2.13.
- [Release notes](https://github.com/rpearce/react-medium-image-zoom/releases)
- [Changelog](https://github.com/rpearce/react-medium-image-zoom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rpearce/react-medium-image-zoom/compare/v5.2.10...v5.2.13)

---
updated-dependencies:
- dependency-name: react-medium-image-zoom
  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-02-17 09:57:22 -08:00
Hemachandar 5132c5814b Skip auxiliary data load when viewing revisions (#8460) 2025-02-17 05:45:06 -08:00
Tom Moor acc825b554 perf: Add trigram index for doc title search (#8454) 2025-02-16 17:44:00 -08:00
Hemachandar bef4292146 Enable dragging a document into drafts (#8411)
* Enable dragging a document into drafts

* unpublish by detaching from collection

* websocket events
2025-02-15 18:45:05 -08:00
Tom Moor 0b13698998 Add command menu action to create draft
closes #8423
2025-02-15 21:27:35 -05:00
Tom Moor ac45e3c0db fix: Allow tsv import, closes #8445 2025-02-15 21:11:38 -05:00
Translate-O-Tron 6a633f5a4c New Crowdin updates (#8372) 2025-02-15 07:45:25 -08:00
dependabot[bot] 67c114e6ed chore(deps): bump the babel group across 1 directory with 2 updates (#8437)
Bumps the babel group with 2 updates in the / directory: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) and [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env).


Updates `@babel/core` from 7.26.8 to 7.26.9
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.9/packages/babel-core)

Updates `@babel/preset-env` from 7.26.8 to 7.26.9
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.9/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:35:18 -08:00
dependabot[bot] 483fe95856 chore(deps): bump the babel group with 2 updates (#8374)
Bumps the babel group with 2 updates: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) and [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env).


Updates `@babel/core` from 7.26.7 to 7.26.8
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.8/packages/babel-core)

Updates `@babel/preset-env` from 7.26.7 to 7.26.8
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.8/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:27:20 -08:00
dependabot[bot] 06f1f0431f chore(deps): bump @octokit/request-error from 5.1.0 to 5.1.1 (#8429)
Bumps [@octokit/request-error](https://github.com/octokit/request-error.js) from 5.1.0 to 5.1.1.
- [Release notes](https://github.com/octokit/request-error.js/releases)
- [Commits](https://github.com/octokit/request-error.js/compare/v5.1.0...v5.1.1)

---
updated-dependencies:
- dependency-name: "@octokit/request-error"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:27:10 -08:00
dependabot[bot] ca38523d9b chore(deps): bump @octokit/endpoint from 9.0.5 to 9.0.6 (#8435)
Bumps [@octokit/endpoint](https://github.com/octokit/endpoint.js) from 9.0.5 to 9.0.6.
- [Release notes](https://github.com/octokit/endpoint.js/releases)
- [Commits](https://github.com/octokit/endpoint.js/compare/v9.0.5...v9.0.6)

---
updated-dependencies:
- dependency-name: "@octokit/endpoint"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:27:01 -08:00
dependabot[bot] c725302701 chore(deps): bump dompurify from 3.2.3 to 3.2.4 (#8434)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.2.3 to 3.2.4.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.2.3...3.2.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:26:50 -08:00
Tom Moor 7bc687b6bf v0.82.0 2025-02-15 09:33:07 -05:00
Hemachandar bb397b8625 fix: Guard templates dropdown menu (#8410) 2025-02-13 18:21:22 -08:00
Tom Moor edd413fba3 fix: Guard new doc button on collections, supercedes #8400 2025-02-13 19:30:46 -05:00
dependabot[bot] 3f8fb66be1 chore(deps): bump the aws group with 5 updates (#8375)
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.740.0` | `3.744.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.740.0` | `3.744.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.740.0` | `3.744.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.740.0` | `3.744.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.740.0` | `3.744.0` |


Updates `@aws-sdk/client-s3` from 3.740.0 to 3.744.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.744.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.740.0 to 3.744.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.744.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.740.0 to 3.744.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.744.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.740.0 to 3.744.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.744.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.740.0 to 3.744.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.744.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  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-02-13 16:14:48 -08:00
Tom Moor 4b379a4dc4 Updates new tables to start with fixed column widths (#8396)
* fix: Updates table creation to start with fixed columns

* tsc
2025-02-13 16:14:37 -08:00
Tom Moor 0bcff545e7 fix: Notifications sent for insignificant changes (#8397)
* fix: Notifications sent for insignificant changes

* doc

* Reduce work on larger documents
2025-02-13 16:14:28 -08:00
Tom Moor 93e8cbb541 fix: Support forward slash in mention search, closes #8406 2025-02-13 19:12:41 -05:00
Tom Moor 9e8b4a3269 chore: Clarify copy in user account deletion email 2025-02-13 19:00:45 -05:00
Tom Moor d48386797e fix: Remove hardcoded mention of Markdown on export settings 2025-02-13 18:40:52 -05:00
Tom Moor 898e11b424 fix: Improve validation of document and collection IDs, closes #8401 2025-02-13 18:34:15 -05:00
Tom Moor ac48767132 fix: Umami CSP with url including port, closes #8371 2025-02-12 23:23:31 -05:00
Tom Moor 854fbca420 fix: Improve matching on quoted queries 2025-02-12 21:32:30 -05:00
Tom Moor 82539cc348 Increase max length of collection overview 2025-02-12 20:51:13 -05:00
dependabot[bot] 027522350f chore(deps): bump koa from 2.15.3 to 2.15.4 (#8394)
Bumps [koa](https://github.com/koajs/koa) from 2.15.3 to 2.15.4.
- [Release notes](https://github.com/koajs/koa/releases)
- [Changelog](https://github.com/koajs/koa/blob/2.15.4/History.md)
- [Commits](https://github.com/koajs/koa/compare/2.15.3...2.15.4)

---
updated-dependencies:
- dependency-name: koa
  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-02-12 16:27:35 -08:00
Tom Moor eb92a206fb Update version.ts
Force editor reload for any clients without knowledge of new mentions
2025-02-12 14:45:55 -08:00
Tom Moor dfc3c05c40 chore: Upgrade docker publisher to larger box 2025-02-12 14:38:25 -08:00
Tom Moor 59fa91413d fix: Heading anchors sometimes do not scroll to correct location.
I don't know why moving this below the editor works, but it does – very reliably
closes #8296
2025-02-12 00:03:55 -05:00
Tom Moor 205ca03ced fix: Mentions matching find and replace not correctly highlighted 2025-02-11 23:28:19 -05:00
Tom Moor 0432144d1e chore: Add developer action to type automatically, useful for recreating multiplayer-editing scenarios 2025-02-11 20:43:50 -05:00
Hemachandar c81802b3bb Fetch subscription data using 'subscriptions.info' API (#8368)
* Fetch subscription data using 'subscriptions.info' API

* use getByDocumentId

* throw 404

* unnecessary notfound error
2025-02-10 18:32:51 -08:00
dependabot[bot] 6ecf9ca9c3 chore(deps-dev): bump @relative-ci/agent from 4.2.13 to 4.2.14 (#8378)
Bumps [@relative-ci/agent](https://github.com/relative-ci/agent) from 4.2.13 to 4.2.14.
- [Release notes](https://github.com/relative-ci/agent/releases)
- [Commits](https://github.com/relative-ci/agent/compare/v4.2.13...v4.2.14)

---
updated-dependencies:
- dependency-name: "@relative-ci/agent"
  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-02-10 18:32:41 -08:00
dependabot[bot] b788b95880 chore(deps): bump nodemailer from 6.9.16 to 6.10.0 (#8376)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 6.9.16 to 6.10.0.
- [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.9.16...v6.10.0)

---
updated-dependencies:
- dependency-name: nodemailer
  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-02-10 18:32:16 -08:00
dependabot[bot] ff0bebaf63 chore(deps): bump form-data from 4.0.0 to 4.0.1 (#8377)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.0...v4.0.1)

---
updated-dependencies:
- dependency-name: form-data
  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-02-10 18:32:02 -08:00
Tom Moor f53c2828ef fix: Copying out of code blocks and inline code marks should not include markdown formatting 2025-02-10 21:28:36 -05:00
Translate-O-Tron 926a4e2224 New Crowdin updates (#8341) 2025-02-10 05:51:15 -08:00
Tom Moor 12efdf4e50 chore: Remove A1 label from stale workflow 2025-02-10 04:53:24 -08:00
Tom Moor 49b2fad6ce fix: Image should be selected before zoomable in edit mode, closes #8367 2025-02-09 20:52:59 -05:00
Tom Moor 2edd48ab84 Merge 2025-02-09 19:32:49 -05:00
Tom Moor 146cf56bce fix: Starred child documents do not preload 2025-02-09 19:31:23 -05:00
Hemachandar d37e21645c Manage document subscription when a group is added to (or) removed from a document (#8354) 2025-02-09 16:09:29 -08:00
Tom Moor fe2c9b5817 feat: Inline document creation in sidebar (#8364)
* wip

* Untitled document name missing in breadcrumb

* Add inline creation for collection

* fix: autoFocus new docs

* refactor
2025-02-09 15:51:18 -08:00
Tom Moor ec86e80edb PR feedback 2025-02-09 18:41:50 -05:00
Hemachandar fa19b278a4 Allow creation of nested document from command bar (#8365) 2025-02-09 12:56:48 -08:00
208 changed files with 5531 additions and 1912 deletions
-183
View File
@@ -1,183 +0,0 @@
version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
resource_class: large
environment:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
types:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
test-app:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate
- run:
name: test
command: |
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
<<: *defaults
environment:
NODE_ENV: production
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: build-vite
command: yarn vite:build
- run:
name: Send bundle stats to RelativeCI
command: npx relative-ci-agent
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker
- run:
name: Install Docker buildx
command: |
mkdir -p ~/.docker/cli-plugins
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
chmod a+x ~/.docker/cli-plugins/docker-buildx
- run:
name: Enable Docker buildx
command: export DOCKER_CLI_EXPERIMENTAL=enabled
- run:
name: Initialize Docker buildx
command: |
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch
- run:
name: Build base image
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
- run:
name: Login to Docker Hub
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- run:
name: Publish base Docker Image to Docker Hub
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi
workflows:
version: 2
all:
jobs:
- build
- lint:
requires:
- build
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
- types:
requires:
- build
- bundle-size:
requires:
- build
- types
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
curl --user ${CIRCLE_TOKEN}: \
--request POST \
--form revision=<ENTER COMMIT SHA HERE>\
--form config=@config.yml \
--form notify=false \
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
+163
View File
@@ -0,0 +1,163 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
REDIS_URL: redis://127.0.0.1:6379
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
SMTP_USERNAME: localhost
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.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
lint:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint
types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn tsc
changes:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
server:
- 'server/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
app:
- 'app/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
test:
needs: build
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' }}
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14.2
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: outline_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:5.0
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
+52
View File
@@ -0,0 +1,52 @@
name: Docker
on:
push:
tags:
- 'v*'
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.base
push: true
tags: ${{ env.BASE_IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build and push main image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
+1 -1
View File
@@ -24,6 +24,6 @@ jobs:
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned"
exempt-issue-labels: "security,pinned,A1"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.81.0
The Licensed Work is (c) 2024 General Outline, Inc.
Licensed Work: Outline 0.82.0
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
Service.
@@ -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: 2028-11-11
Change Date: 2029-02-15
Change License: Apache License, Version 2.0
+77 -14
View File
@@ -8,12 +8,13 @@ import {
SearchIcon,
ShapesIcon,
StarredIcon,
SubscribeIcon,
TrashIcon,
UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
@@ -60,7 +61,7 @@ export const createCollection = createAction({
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event }) => {
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
@@ -76,10 +77,10 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -102,10 +103,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -133,7 +134,7 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -158,7 +159,7 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -168,7 +169,7 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId }) => {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -185,7 +186,7 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -195,7 +196,7 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId }) => {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -205,6 +206,66 @@ export const unstarCollection = createAction({
},
});
export const subscribeCollection = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
export const unsubscribeCollection = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
},
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
@@ -277,13 +338,13 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t }) => {
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -311,7 +372,7 @@ export const createTemplate = createAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
@@ -331,5 +392,7 @@ export const rootCollectionActions = [
createCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection,
];
+34
View File
@@ -2,6 +2,7 @@ import copy from "copy-to-clipboard";
import {
BeakerIcon,
CopyIcon,
EditIcon,
ToolsIcon,
TrashIcon,
UserIcon,
@@ -83,6 +84,38 @@ export const copyId = createAction({
},
});
function generateRandomText() {
const characters =
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
let text = "";
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
text += characters.charAt(Math.floor(Math.random() * characters.length));
}
return text;
}
export const startTyping = createAction({
name: "Start automatic typing",
icon: <EditIcon />,
section: DeveloperSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && env.ENVIRONMENT === "development",
perform: () => {
const intervalId = setInterval(() => {
const text = generateRandomText();
document.execCommand("insertText", false, text);
}, 250);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
intervalId && clearInterval(intervalId);
}
});
toast.info("Automatic typing started, press Escape to stop");
},
});
export const clearIndexedDB = createAction({
name: ({ t }) => t("Clear IndexedDB cache"),
icon: <TrashIcon />,
@@ -169,6 +202,7 @@ export const developer = createAction({
createToast,
createTestUsers,
clearIndexedDB,
startTyping,
],
});
+21 -3
View File
@@ -125,6 +125,20 @@ export const createDocument = createAction({
}),
});
export const createDraftDocument = createAction({
name: ({ t }) => t("New draft"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create document",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
perform: ({ sidebarContext }) =>
history.push(newDocumentPath(), {
sidebarContext,
}),
});
export const createDocumentFromTemplate = createAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
@@ -319,6 +333,7 @@ export const subscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
@@ -347,8 +362,9 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe)
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -358,7 +374,7 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
await document?.unsubscribe(currentUserId);
await document?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
@@ -1179,6 +1195,8 @@ export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createDraftDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
importDocument,
+5 -1
View File
@@ -1,3 +1,4 @@
import { getLuminance } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
@@ -15,7 +16,10 @@ const Initials = styled(Flex)<{
border-radius: 50%;
width: 100%;
height: 100%;
color: ${s("white75")};
color: ${(props) =>
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
? s("black50")
: s("white75")};
background-color: ${(props) => props.color ?? props.theme.textTertiary};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
+2 -1
View File
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
@@ -61,7 +62,7 @@ function CollectionDescription({ collection }: Props) {
onChange={handleSave}
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={1000}
maxLength={CollectionValidation.maxDescriptionLength}
canUpdate={can.update}
readOnly={!can.update}
editorStyle={editorStyle}
+10 -1
View File
@@ -20,6 +20,7 @@ import {
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
@@ -167,7 +168,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
if (item.type === "button") {
return (
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
@@ -182,6 +183,14 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip content={item.tooltip} placement={"bottom"}>
<div>{menuItem}</div>
</Tooltip>
) : (
<>{menuItem}</>
);
}
if (item.type === "submenu") {
+14 -3
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 } from "@shared/types";
import { NavigationNode, NavigationNodeType } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
@@ -78,6 +78,10 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const recentlyViewedItemIds = documents.recentlyViewed
.slice(0, 5)
.map((item) => item.id);
const searchIndex = React.useMemo(
() =>
new FuzzySearch(items, ["title"], {
@@ -126,11 +130,18 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => item.type === "collection")
.filter((item) => recentlyViewedItemIds.includes(item.id))
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.flatMap(includeDescendants);
}
const nodes = getNodes();
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -304,7 +315,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={node.depth as number}
depth={(node.depth ?? 0) - baseDepth}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
+2 -2
View File
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
) {
const { t } = useTranslation();
const OFFSET = 12;
const ICON_SIZE = 24;
const DISCLOSURE = 20;
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
return (
<Node
+8 -9
View File
@@ -199,15 +199,14 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom &&
(!props.readOnly || props.shareId) && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
</>
</ErrorBoundary>
);
+150 -77
View File
@@ -7,6 +7,9 @@ import {
PublishIcon,
MoveIcon,
UnpublishIcon,
RestoreIcon,
UserIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -14,32 +17,61 @@ import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Event from "~/models/Event";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
import Text from "./Text";
export type RevisionEvent = {
name: "revisions.create";
latest: boolean;
};
export type DocumentEvent = {
name:
| "documents.publish"
| "documents.unpublish"
| "documents.archive"
| "documents.unarchive"
| "documents.delete"
| "documents.restore"
| "documents.add_user"
| "documents.remove_user"
| "documents.move";
userId: string;
};
export type Event = { id: string; actorId: string; createdAt: string } & (
| RevisionEvent
| DocumentEvent
);
type Props = {
document: Document;
event: Event<Document>;
latest?: boolean;
event: Event;
};
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const EventListItem = ({ event, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const { revisions, users } = useStores();
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
const user = "userId" in event ? users.get(event.userId) : undefined;
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = React.useRef(false);
const opts = {
userName: event.actor.name,
userName: actor?.name,
};
const isRevision = event.name === "revisions.create";
const isDerivedFromDocument =
event.id === RevisionHelper.latestId(document.id);
let meta, icon, to: LocationDescriptor | undefined;
const ref = React.useRef<HTMLAnchorElement>(null);
@@ -50,23 +82,32 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
};
const prefetchRevision = async () => {
if (event.name === "revisions.create" && event.modelId) {
await revisions.fetch(event.modelId);
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
await revisions.fetch(event.id, { force: true });
revisionLoadedRef.current = true;
}
};
switch (event.name) {
case "revisions.create":
icon = <EditIcon size={16} />;
meta = latest ? (
meta = event.latest ? (
<>
{t("Current version")} &middot; {event.actor.name}
{t("Current version")} &middot; {actor?.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(document, event.modelId || "latest"),
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
@@ -75,47 +116,51 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
break;
case "documents.archive":
icon = <ArchiveIcon size={16} />;
icon = <ArchiveIcon />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
icon = <RestoreIcon />;
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon size={16} />;
icon = <TrashIcon />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.add_user":
icon = <UserIcon />;
meta = t("{{userName}} added {{addedUserName}}", {
...opts,
addedUserName: event.user?.name ?? t("a user"),
addedUserName: user?.name ?? t("a user"),
});
break;
case "documents.remove_user":
icon = <CrossIcon />;
meta = t("{{userName}} removed {{removedUserName}}", {
...opts,
removedUserName: event.user?.name ?? t("a user"),
removedUserName: user?.name ?? t("a user"),
});
break;
case "documents.restore":
icon = <RestoreIcon />;
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon size={16} />;
icon = <PublishIcon />;
meta = t("{{userName}} published", opts);
break;
case "documents.unpublish":
icon = <UnpublishIcon size={16} />;
icon = <UnpublishIcon />;
meta = t("{{userName}} unpublished", opts);
break;
case "documents.move":
icon = <MoveIcon size={16} />;
icon = <MoveIcon />;
meta = t("{{userName}} moved", opts);
break;
@@ -136,8 +181,8 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
to = undefined;
}
return (
<BaseItem
return event.name === "revisions.create" ? (
<RevisionItem
small
exact
to={to}
@@ -153,17 +198,12 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
onClick={handleTimeClick}
/>
}
image={<Avatar model={event.actor} size={AvatarSize.Large} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
image={<Avatar model={actor} size={AvatarSize.Large} />}
subtitle={meta}
actions={
isRevision && isActive && event.modelId && !latest ? (
isRevision && isActive && !event.latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.modelId} />
<RevisionMenu document={document} revisionId={event.id} />
</StyledEventBoundary>
) : undefined
}
@@ -171,63 +211,100 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
ref={ref}
{...rest}
/>
) : (
<EventItem>
<IconWrapper size="xsmall" type="secondary">
{icon}
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time dateTime={event.createdAt} relative shorten addSuffix />
</Text>
</EventItem>
);
};
const BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement>
) {
return <ListItem to={to} ref={ref} {...rest} />;
});
const lineStyle = css`
&::before {
content: "";
display: block;
position: absolute;
top: -8px;
left: 22px;
width: 1px;
height: calc(50% - 14px + 8px);
background: ${s("divider")};
mix-blend-mode: multiply;
z-index: 1;
}
&:first-child::before {
display: none;
}
&:nth-child(2)::before {
display: none;
}
&::after {
content: "";
display: block;
position: absolute;
top: calc(50% + 14px);
left: 22px;
width: 1px;
height: calc(50% - 14px);
background: ${s("divider")};
mix-blend-mode: multiply;
z-index: 1;
}
&:last-child::after {
display: none;
}
h3 + &::before {
display: none;
}
`;
const IconWrapper = styled(Text)`
height: 24px;
`;
const EventItem = styled.li`
display: flex;
align-items: center;
gap: 8px;
list-style: none;
margin: 8px 0;
padding: 4px 10px;
white-space: nowrap;
position: relative;
time {
white-space: nowrap;
}
svg {
flex-shrink: 0;
}
${lineStyle}
`;
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ItemStyle = css`
const RevisionItem = styled(Item)`
border: 0;
position: relative;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${s("textSecondary")};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: auto;
bottom: -4px;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${lineStyle}
${Actions} {
opacity: 0.5;
@@ -238,8 +315,4 @@ const ItemStyle = css`
}
`;
const ListItem = styled(Item)`
${ItemStyle}
`;
export default observer(EventListItem);
@@ -27,7 +27,7 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt="" /> : null}
<Card>
<CardContent>
<Flex column>
+5 -2
View File
@@ -176,6 +176,7 @@ function Input(
if (ev.key === "Enter" && ev.metaKey) {
if (props.onRequestSubmit) {
props.onRequestSubmit(ev);
return;
}
}
@@ -230,10 +231,11 @@ function Input(
])}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
/>
) : (
<NativeInput
@@ -243,11 +245,12 @@ function Input(
])}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
type={type}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
/>
)}
{children}
+12 -2
View File
@@ -48,7 +48,8 @@ export type Props = Omit<ButtonProps<any>, "onChange"> & {
options: Option[];
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
onChange?: (value: string | null) => void;
/** Callback function that is called when the value changes. Return false to cancel the change. */
onChange?: (value: string | null) => void | Promise<boolean | void>;
style?: React.CSSProperties;
/**
* Set to true if this component is rendered inside a Modal.
@@ -165,9 +166,18 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
if (previousValue.current === select.selectedValue) {
return;
}
const previous = previousValue.current;
previousValue.current = select.selectedValue;
onChange?.(select.selectedValue);
const response = onChange?.(select.selectedValue);
if (response && response instanceof Promise) {
void response.then((success) => {
if (success === false) {
select.selectedValue = previous;
select.setSelectedValue(previous);
}
});
}
}, [onChange, select.selectedValue]);
React.useLayoutEffect(() => {
+5 -13
View File
@@ -1,16 +1,13 @@
import * as React from "react";
import styled from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import PaginatedList from "~/components/PaginatedList";
import EventListItem from "./EventListItem";
import EventListItem, { type Event } from "./EventListItem";
type Props = {
events: Event<Document>[];
events: Event[];
document: Document;
fetch: (
options: Record<string, any> | undefined
) => Promise<Event<Document>[]>;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -32,13 +29,8 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event<Document>, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
/>
renderItem={(item: Event) => (
<EventListItem key={item.id} event={item} document={document} />
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
+8 -3
View File
@@ -60,7 +60,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
fetchCounter = 0;
@observable
renderCount = 15;
renderCount = Pagination.defaultLimit;
@observable
offset = 0;
@@ -108,13 +108,16 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
...this.props.options,
});
if (this.offset !== 0) {
this.renderCount += limit;
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
this.renderCount += limit;
this.isFetchingInitial = false;
} catch (err) {
this.error = err;
@@ -248,7 +251,9 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
<div style={{ height: "1px" }}>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
</div>
)}
</>
);
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
@@ -167,18 +168,24 @@ export const AccessControlList = observer(
| CollectionPermission
| typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
try {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
@@ -215,18 +222,24 @@ export const AccessControlList = observer(
| CollectionPermission
| typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
try {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
+4 -26
View File
@@ -1,26 +1,25 @@
import { observer } from "mobx-react";
import { DraftsIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
import { homePath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import { DraftsLink } from "./components/DraftsLink";
import DragPlaceholder from "./components/DragPlaceholder";
import HistoryNavigation from "./components/HistoryNavigation";
import Section from "./components/Section";
@@ -107,24 +106,7 @@ function AppSidebar() {
label={t("Search")}
exact={false}
/>
{can.createDocument && (
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25
? "25+"
: documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
/>
)}
{can.createDocument && <DraftsLink />}
</Section>
</Overflow>
<Scrollable flex shadow>
@@ -158,8 +140,4 @@ const Overflow = styled.div`
flex-shrink: 0;
`;
const Drafts = styled(Text)`
margin: 0 4px;
`;
export default observer(AppSidebar);
@@ -14,6 +14,7 @@ import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
import { StyledError } from "./Collections";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
function ArchiveLink() {
@@ -64,38 +65,40 @@ function ArchiveLink() {
useDropToArchive();
return (
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
<SidebarContext.Provider value="archive">
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
</Relative>
) : null}
</Flex>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
/>
</Relative>
) : null}
</Flex>
</SidebarContext.Provider>
);
}
@@ -101,9 +101,12 @@ const CollectionLink: React.FC<Props> = ({
collection?.addDocument(newDocument);
closeAddingNewChild();
history.replace(documentEditPath(newDocument));
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[user, closeAddingNewChild, history, collection, documents]
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
);
return (
@@ -144,16 +147,18 @@ const CollectionLink: React.FC<Props> = ({
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
{can.createDocument && (
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
)}
<CollectionMenu
collection={collection}
onRename={handleRename}
@@ -240,9 +240,21 @@ function InnerDocumentLink(
collection?.addDocument(newDocument, node.id);
closeAddingNewChild();
history.replace(documentEditPath(newDocument));
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[documents, collection, user, node, doc, history, closeAddingNewChild]
[
documents,
collection,
sidebarContext,
user,
node,
doc,
history,
closeAddingNewChild,
]
);
return (
@@ -0,0 +1,41 @@
import { observer } from "mobx-react";
import { DraftsIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { draftsPath } from "~/utils/routeHelpers";
import { useDropToUnpublish } from "../hooks/useDragAndDrop";
import SidebarLink from "./SidebarLink";
export const DraftsLink = observer(() => {
const { t } = useTranslation();
const { documents } = useStores();
const [{ isOver, canDrop }, dropRef] = useDropToUnpublish();
return (
<div ref={dropRef}>
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25 ? "25+" : documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
isActiveDrop={isOver && canDrop}
/>
</div>
);
});
const Drafts = styled(Text)`
margin: 0 4px;
`;
@@ -5,6 +5,7 @@ import User from "~/models/User";
export type SidebarContextType =
| "collections"
| "shared"
| "archive"
| `group-${string}`
| `starred-${string}`
| undefined;
@@ -41,7 +42,7 @@ export const determineSidebarContext = ({
}
if (document.collection) {
return "collections";
return document.collection.isArchived ? "archive" : "collections";
} else if (
user.documentMemberships.find((m) => m.documentId === document.id)
) {
@@ -86,6 +86,11 @@ function StarredLink({ star }: Props) {
[]
);
const handlePrefetch = React.useCallback(
() => documentId && documents.prefetchDocument(documentId),
[documents, documentId]
);
const getIndex = () => {
const next = star?.next();
return fractionalIndex(star?.index || null, next?.index || null);
@@ -142,6 +147,7 @@ function StarredLink({ star }: Props) {
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
icon={icon}
isActive={(
match,
@@ -172,6 +178,7 @@ function StarredLink({ star }: Props) {
node={node}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
@@ -586,3 +586,45 @@ export function useDropToArchive() {
}),
});
}
export function useDropToUnpublish() {
const { t } = useTranslation();
const { policies, documents } = useStores();
return useDrop<
DragObject,
Promise<void>,
{ isOver: boolean; canDrop: boolean }
>({
accept: "document",
drop: async (item) => {
const document = documents.get(item.id);
if (!document) {
return;
}
try {
await document.unpublish({ detach: true });
toast.success(
t("Unpublished {{ documentName }}", {
documentName: document.noun,
})
);
} catch (err) {
toast.error(err.message);
}
},
canDrop: (item) => {
const policy = policies.abilities(item.id);
if (!policy) {
return true; // optimistic, let the server check for the necessary permission.
}
return policy.unpublish;
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
}
+26
View File
@@ -225,6 +225,32 @@ class WebsocketProvider extends React.Component<Props> {
})
);
this.socket.on(
"documents.unpublish",
action(
(event: {
document: PartialExcept<Document, "id">;
collectionId: string;
}) => {
const document = event.document;
// When document is detached as part of unpublishing, only the owner should be able to view it.
if (
!document.collectionId &&
document.createdBy?.id !== currentUserId
) {
documents.remove(document.id);
} else {
documents.add(document);
}
policies.remove(document.id);
const collection = collections.get(event.collectionId);
collection?.removeDocument(document.id);
}
)
);
this.socket.on(
"documents.archive",
action((event: PartialExcept<Document, "id">) => {
+3 -1
View File
@@ -1,6 +1,7 @@
import React from "react";
import useDictionary from "~/hooks/useDictionary";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
@@ -11,6 +12,7 @@ type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
return (
<SuggestionsMenu
@@ -26,7 +28,7 @@ function BlockMenu(props: Props) {
shortcut={item.shortcut}
/>
)}
items={getMenuItems(dictionary)}
items={getMenuItems(dictionary, elementRef)}
/>
);
}
+132 -52
View File
@@ -24,6 +24,54 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import Desktop from "~/utils/Desktop";
import { useEditor } from "./EditorContext";
type KeyboardShortcutsProps = {
popover: ReturnType<typeof usePopoverState>;
handleOpen: ({ withReplace }: { withReplace: boolean }) => void;
handleCaseSensitive: () => void;
handleRegex: () => void;
};
function useKeyboardShortcuts({
popover,
handleOpen,
handleCaseSensitive,
handleRegex,
}: KeyboardShortcutsProps) {
// Open popover
useKeyDown(
(ev) =>
isModKey(ev) &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
(ev) => {
ev.preventDefault();
handleOpen({ withReplace: ev.altKey });
},
{ allowInInput: true }
);
// Enable/disable case sensitive search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => {
ev.preventDefault();
handleCaseSensitive();
},
{ allowInInput: true }
);
// Enable/disable regex search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => {
ev.preventDefault();
handleRegex();
},
{ allowInInput: true }
);
}
type Props = {
/** Whether the find and replace popover is open */
open: boolean;
@@ -89,42 +137,48 @@ export default function FindAndReplace({
}
}, [show]);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
// Callbacks
const selectInputText = React.useCallback(() => {
inputRef.current?.focus();
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
}, []);
const selectInputReplaceText = React.useCallback(() => {
setTimeout(() => {
inputReplaceRef.current?.focus();
inputReplaceRef.current?.setSelectionRange(
0,
inputReplaceRef.current?.value.length
);
}, 100);
}, []);
const handleOpen = React.useCallback(
({ withReplace }: { withReplace: boolean }) => {
const shouldShowReplace = !readOnly && withReplace;
// If already open, switch focus to corresponding input text.
if (popover.visible) {
if (shouldShowReplace) {
setShowReplace(true);
selectInputReplaceText();
} else {
selectInputText();
}
return;
}
// Keyboard shortcuts
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
(ev) => {
ev.preventDefault();
selectionRef.current = window.getSelection()?.toString();
popover.show();
}
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => {
ev.preventDefault();
setRegex((state) => !state);
if (shouldShowReplace) {
setShowReplace(true);
}
},
{ allowInInput: true }
[popover, readOnly, selectInputText, selectInputReplaceText]
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => {
ev.preventDefault();
setCaseSensitive((state) => !state);
},
{ allowInInput: true }
);
// Callbacks
const handleMore = React.useCallback(() => {
setShowReplace((state) => !state);
setTimeout(() => inputReplaceRef.current?.focus(), 100);
@@ -132,68 +186,65 @@ export default function FindAndReplace({
const handleCaseSensitive = React.useCallback(() => {
setCaseSensitive((state) => {
const caseSensitive = !state;
const isCaseSensitive = !state;
editor.commands.find({
text: searchTerm,
caseSensitive,
caseSensitive: isCaseSensitive,
regexEnabled,
});
return caseSensitive;
return isCaseSensitive;
});
}, [regexEnabled, editor.commands, searchTerm]);
const handleRegex = React.useCallback(() => {
setRegex((state) => {
const regexEnabled = !state;
const isRegexEnabled = !state;
editor.commands.find({
text: searchTerm,
caseSensitive,
regexEnabled,
regexEnabled: isRegexEnabled,
});
return regexEnabled;
return isRegexEnabled;
});
}, [caseSensitive, editor.commands, searchTerm]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
function nextPrevious(ev: React.KeyboardEvent<HTMLInputElement>) {
function nextPrevious() {
if (ev.shiftKey) {
editor.commands.prevSearchMatch();
} else {
editor.commands.nextSearchMatch();
}
}
function selectInputText() {
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
}
switch (ev.key) {
case "Enter": {
ev.preventDefault();
nextPrevious(ev);
nextPrevious();
return;
}
case "g": {
if (ev.metaKey) {
ev.preventDefault();
nextPrevious(ev);
nextPrevious();
selectInputText();
}
return;
}
case "F3": {
ev.preventDefault();
nextPrevious(ev);
nextPrevious();
selectInputText();
return;
}
}
},
[editor.commands]
[editor.commands, selectInputText]
);
const handleReplace = React.useCallback(
@@ -243,6 +294,15 @@ export default function FindAndReplace({
[handleReplace]
);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
useKeyboardShortcuts({
popover,
handleOpen,
handleCaseSensitive,
handleRegex,
});
const style: React.CSSProperties = React.useMemo(
() => ({
position: "fixed",
@@ -285,7 +345,7 @@ export default function FindAndReplace({
<>
<Tooltip
content={t("Previous match")}
shortcut="shift+enter"
shortcut="Shift+Enter"
placement="bottom"
>
<ButtonLarge
@@ -295,7 +355,7 @@ export default function FindAndReplace({
<CaretUpIcon />
</ButtonLarge>
</Tooltip>
<Tooltip content={t("Next match")} shortcut="enter" placement="bottom">
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.nextSearchMatch()}
@@ -354,7 +414,11 @@ export default function FindAndReplace({
</StyledInput>
{navigation}
{!readOnly && (
<Tooltip content={t("Replace options")} placement="bottom">
<Tooltip
content={t("Replace options")}
shortcut={`${altDisplay}+${metaDisplay}+f`}
placement="bottom"
>
<ButtonLarge onClick={handleMore}>
<ReplaceIcon color={theme.textSecondary} />
</ButtonLarge>
@@ -376,12 +440,28 @@ export default function FindAndReplace({
onRequestSubmit={handleReplaceAll}
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
/>
<Button onClick={handleReplace} disabled={disabled} neutral>
{t("Replace")}
</Button>
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
{t("Replace all")}
</Button>
<Tooltip
content={t("Replace")}
shortcut="Enter"
placement="bottom"
>
<Button onClick={handleReplace} disabled={disabled} neutral>
{t("Replace")}
</Button>
</Tooltip>
<Tooltip
content={t("Replace all")}
shortcut={`${metaDisplay}+Enter`}
placement="bottom"
>
<Button
onClick={handleReplaceAll}
disabled={disabled}
neutral
>
{t("Replace all")}
</Button>
</Tooltip>
</Flex>
)}
</ResizingHeightContainer>
+21 -9
View File
@@ -6,7 +6,6 @@ import styled, { css } from "styled-components";
import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
@@ -41,7 +40,8 @@ function usePosition({
}) {
const { view } = useEditor();
const { selection } = view.state;
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
return defaultPosition;
@@ -78,13 +78,24 @@ function usePosition({
// position at the top right of code blocks
const codeBlock = findParentNode(isCode)(view.state.selection);
const noticeBlock = findParentNode(
(node) => node.type.name === "container_notice"
)(view.state.selection);
if (codeBlock && view.state.selection.empty) {
const element = view.nodeDOM(codeBlock.pos);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
const position = codeBlock
? codeBlock.pos
: noticeBlock
? noticeBlock.pos
: null;
if (position !== null) {
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
}
}
// tables are an oddity, and need their own positioning logic
@@ -188,7 +199,8 @@ function usePosition({
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
blockSelection: codeBlock || isColSelection || isRowSelection,
blockSelection:
codeBlock || isColSelection || isRowSelection || noticeBlock,
visible: true,
};
}
@@ -4,6 +4,7 @@ import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
@@ -18,6 +19,7 @@ import getCodeMenuItems from "../menus/code";
import getDividerMenuItems from "../menus/divider";
import getFormattingMenuItems from "../menus/formatting";
import getImageMenuItems from "../menus/image";
import getNoticeMenuItems from "../menus/notice";
import getReadOnlyMenuItems from "../menus/readOnly";
import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
@@ -55,6 +57,10 @@ function useIsActive(state: EditorState) {
return true;
}
if (isInNotice(state) && selection.from > 0) {
return true;
}
if (!selection || selection.empty) {
return false;
}
@@ -184,6 +190,7 @@ export default function SelectionToolbar(props: Props) {
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
let items: MenuItem[] = [];
@@ -203,6 +210,8 @@ export default function SelectionToolbar(props: Props) {
items = getDividerMenuItems(state, dictionary);
} else if (readOnly) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
} else {
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
}
@@ -1,5 +1,6 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
/**
* A plugin that allows overriding the default behavior of the editor to allow
@@ -11,16 +12,34 @@ export default class ClipboardTextSerializer extends Extension {
}
get plugins() {
const serializer = this.editor.extensions.serializer();
const mdSerializer = this.editor.extensions.serializer();
return [
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice) =>
serializer.serialize(slice.content, {
softBreak: true,
}),
clipboardTextSerializer: (slice) => {
const isMultiline = slice.content.childCount > 1;
// This is a cheap way to determine if the content is "complex",
// aka it has multiple marks or formatting. In which case we'll use
// markdown formatting
const copyAsMarkdown =
isMultiline ||
slice.content.content.some(
(node) => node.content.content.length > 1
);
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
softBreak: true,
})
: slice.content.content
.map((node) =>
ProsemirrorHelper.toPlainText(node, this.editor.schema)
)
.join("");
},
},
}),
];
+61 -48
View File
@@ -25,52 +25,6 @@ import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
*
* @param html The HTML string to check.
* @returns True if the HTML string is likely coming from Dropbox Paper.
*/
function isDropboxPaper(html: string): boolean {
return html?.includes("usually-unique-id");
}
function sliceSingleNode(slice: Slice) {
return slice.openStart === 0 &&
slice.openEnd === 0 &&
slice.content.childCount === 1
? slice.content.firstChild
: null;
}
/**
* Parses the text contents of an HTML string and returns the src of the first
* iframe if it exists.
*
* @param text The HTML string to parse.
* @returns The src of the first iframe if it exists, or undefined.
*/
function parseSingleIframeSrc(html: string) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
if (
doc.body.children.length === 1 &&
doc.body.firstElementChild?.tagName === "IFRAME"
) {
const iframe = doc.body.firstElementChild;
const src = iframe.getAttribute("src");
if (src) {
return src;
}
}
} catch (e) {
// Ignore the million ways parsing could fail.
}
return undefined;
}
export default class PasteHandler extends Extension {
state: {
open: boolean;
@@ -261,9 +215,12 @@ export default class PasteHandler extends Extension {
// If the text on the clipboard looks like Markdown OR there is no
// html on the clipboard then try to parse content as Markdown
if (
(isMarkdown(text) && !isDropboxPaper(html)) ||
(isMarkdown(text) &&
!isDropboxPaper(html) &&
!isContainingImage(html)) ||
pasteCodeLanguage === "markdown" ||
this.shiftKey
this.shiftKey ||
!html
) {
event.preventDefault();
@@ -475,3 +432,59 @@ export default class PasteHandler extends Extension {
/>
);
}
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
*
* @param html The HTML string to check.
* @returns True if the HTML string is likely coming from Dropbox Paper.
*/
function isDropboxPaper(html: string): boolean {
return html?.includes("usually-unique-id");
}
/**
* Checks if the HTML string contains an image.
*
* @param html The HTML string to check.
* @returns True if the HTML string contains an image.
*/
function isContainingImage(html: string): boolean {
return html?.includes("<img");
}
function sliceSingleNode(slice: Slice) {
return slice.openStart === 0 &&
slice.openEnd === 0 &&
slice.content.childCount === 1
? slice.content.firstChild
: null;
}
/**
* Parses the text contents of an HTML string and returns the src of the first
* iframe if it exists.
*
* @param text The HTML string to parse.
* @returns The src of the first iframe if it exists, or undefined.
*/
function parseSingleIframeSrc(html: string) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
if (
doc.body.children.length === 1 &&
doc.body.firstElementChild?.tagName === "IFRAME"
) {
const iframe = doc.body.firstElementChild;
const src = iframe.getAttribute("src");
if (src) {
return src;
}
}
} catch (e) {
// Ignore the million ways parsing could fail.
}
return undefined;
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
const emdash = new InputRule(/--$/, "—");
const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
const registered = new InputRule(/\(r\)$/, "®️");
const trademarked = new InputRule(/\(tm\)$/, "™️");
+3 -1
View File
@@ -19,7 +19,9 @@ export default class Suggestion extends Extension {
super(options);
this.openRegex = new RegExp(
`(?:^|\\s|\\()${escapeRegExp(this.options.trigger)}(${`[\\p{L}\\p{M}\\d${
`(?:^|\\s|\\()${escapeRegExp(
this.options.trigger
)}(${`[\\p{L}\/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
-2
View File
@@ -13,13 +13,11 @@ export default function attachmentMenuItems(
name: "replaceAttachment",
tooltip: dictionary.replaceAttachment,
icon: <ReplaceIcon />,
visible: true,
},
{
name: "deleteAttachment",
tooltip: dictionary.deleteAttachment,
icon: <TrashIcon />,
visible: true,
},
{
name: "separator",
+11 -2
View File
@@ -38,7 +38,12 @@ const Img = styled(Image)`
height: 18px;
`;
export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
export default function blockMenuItems(
dictionary: Dictionary,
documentRef: React.RefObject<HTMLDivElement>
): MenuItem[] {
const documentWidth = documentRef.current?.clientWidth ?? 0;
return [
{
name: "heading",
@@ -119,7 +124,11 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
name: "table",
title: dictionary.table,
icon: <TableIcon />,
attrs: { rowsCount: 3, colsCount: 3 },
attrs: {
rowsCount: 3,
colsCount: 3,
colWidth: documentWidth / 3,
},
},
{
name: "blockquote",
-7
View File
@@ -33,14 +33,12 @@ export default function imageMenuItems(
name: "alignLeft",
tooltip: dictionary.alignLeft,
icon: <AlignImageLeftIcon />,
visible: true,
active: isLeftAligned,
},
{
name: "alignCenter",
tooltip: dictionary.alignCenter,
icon: <AlignImageCenterIcon />,
visible: true,
active: (state) =>
isNodeActive(schema.nodes.image)(state) &&
!isLeftAligned(state) &&
@@ -51,19 +49,16 @@ export default function imageMenuItems(
name: "alignRight",
tooltip: dictionary.alignRight,
icon: <AlignImageRightIcon />,
visible: true,
active: isRightAligned,
},
{
name: "alignFullWidth",
tooltip: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
visible: true,
active: isFullWidthAligned,
},
{
name: "separator",
visible: true,
},
{
name: "downloadImage",
@@ -75,13 +70,11 @@ export default function imageMenuItems(
name: "replaceImage",
tooltip: dictionary.replaceImage,
icon: <ReplaceIcon />,
visible: true,
},
{
name: "deleteImage",
tooltip: dictionary.deleteImage,
icon: <TrashIcon />,
visible: true,
},
];
}
+63
View File
@@ -0,0 +1,63 @@
import {
DoneIcon,
ExpandedIcon,
InfoIcon,
StarredIcon,
WarningIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import { NoticeTypes } from "@shared/editor/nodes/Notice";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function noticeMenuItems(
state: EditorState,
readOnly: boolean | undefined,
dictionary: Dictionary
): MenuItem[] {
const node = state.selection.$from.node(-1);
const currentStyle = node?.attrs.style as NoticeTypes;
const mapping = {
[NoticeTypes.Info]: dictionary.infoNotice,
[NoticeTypes.Warning]: dictionary.warningNotice,
[NoticeTypes.Success]: dictionary.successNotice,
[NoticeTypes.Tip]: dictionary.tipNotice,
};
return [
{
name: "container_notice",
visible: !readOnly,
label: mapping[currentStyle],
icon: <ExpandedIcon />,
children: [
{
name: NoticeTypes.Info,
icon: <InfoIcon />,
label: dictionary.infoNotice,
active: () => currentStyle === NoticeTypes.Info,
},
{
name: NoticeTypes.Success,
icon: <DoneIcon />,
label: dictionary.successNotice,
active: () => currentStyle === NoticeTypes.Success,
},
{
name: NoticeTypes.Warning,
icon: <WarningIcon />,
label: dictionary.warningNotice,
active: () => currentStyle === NoticeTypes.Warning,
},
{
name: NoticeTypes.Tip,
icon: <StarredIcon />,
label: dictionary.tipNotice,
active: () => currentStyle === NoticeTypes.Tip,
},
],
},
];
}
+6 -5
View File
@@ -2,13 +2,14 @@ import * as React from "react";
import usePersistedState from "~/hooks/usePersistedState";
import useStores from "./useStores";
export function usePinnedDocuments(
urlId: "home" | string,
collectionId?: string
) {
type UrlId = "home" | string;
export const pinsCacheKey = (urlId: UrlId) => `pins-${urlId}`;
export function usePinnedDocuments(urlId: UrlId, collectionId?: string) {
const { pins } = useStores();
const [pinsCacheCount, setPinsCacheCount] = usePersistedState<number>(
`pins-${urlId}`,
pinsCacheKey(urlId),
0
);
+5 -1
View File
@@ -8,6 +8,8 @@ type RequestResponse<T> = {
error: unknown;
/** Whether the request is currently in progress. */
loading: boolean;
/** Whether the request has completed - useful to check if the request has completed at least once. */
loaded: boolean;
/** Function to start the request. */
request: () => Promise<T | undefined>;
};
@@ -26,6 +28,7 @@ export default function useRequest<T = unknown>(
const isMounted = useIsMounted();
const [data, setData] = React.useState<T>();
const [loading, setLoading] = React.useState<boolean>(false);
const [loaded, setLoaded] = React.useState<boolean>(false);
const [error, setError] = React.useState();
const request = React.useCallback(async () => {
@@ -36,6 +39,7 @@ export default function useRequest<T = unknown>(
if (isMounted()) {
setData(response);
setError(undefined);
setLoaded(true);
}
return response;
} catch (err) {
@@ -57,5 +61,5 @@ export default function useRequest<T = unknown>(
}
}, [request, makeRequestOnMount]);
return { data, loading, error, request };
return { data, loading, loaded, error, request };
}
+32 -3
View File
@@ -14,6 +14,7 @@ import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
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";
@@ -31,10 +32,13 @@ import {
createTemplate,
archiveCollection,
restoreCollection,
subscribeCollection,
unsubscribeCollection,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -63,11 +67,28 @@ function CollectionMenu({
placement,
});
const team = useCurrentTeam();
const { documents, dialogs } = useStores();
const { documents, dialogs, subscriptions } = useStores();
const { t } = useTranslation();
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const {
loading: subscriptionLoading,
loaded: subscriptionLoaded,
request: loadSubscription,
} = useRequest(() =>
subscriptions.fetchOne({
collectionId: collection.id,
event: SubscriptionType.Document,
})
);
const handlePointerEnter = React.useCallback(() => {
if (!subscriptionLoading && !subscriptionLoaded) {
void loadSubscription();
}
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
const handleExport = React.useCallback(() => {
dialogs.openModal({
title: t("Export collection"),
@@ -157,6 +178,8 @@ function CollectionMenu({
actionToMenuItem(restoreCollection, context),
actionToMenuItem(starCollection, context),
actionToMenuItem(unstarCollection, context),
actionToMenuItem(subscribeCollection, context),
actionToMenuItem(unsubscribeCollection, context),
{
type: "separator",
},
@@ -272,9 +295,15 @@ function CollectionMenu({
</label>
</VisuallyHidden>
{label ? (
<MenuButton {...menu}>{label}</MenuButton>
<MenuButton {...menu} onPointerEnter={handlePointerEnter}>
{label}
</MenuButton>
) : (
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<OverflowMenuButton
aria-label={t("Show menu")}
{...menu}
onPointerEnter={handlePointerEnter}
/>
)}
<ContextMenu
{...menu}
+42 -14
View File
@@ -1,6 +1,6 @@
import capitalize from "lodash/capitalize";
import isEmpty from "lodash/isEmpty";
import isUndefined from "lodash/isUndefined";
import noop from "lodash/noop";
import { observer } from "mobx-react";
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
import * as React from "react";
@@ -12,7 +12,7 @@ import { toast } from "sonner";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { SubscriptionType, UserPreference } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
@@ -57,7 +57,7 @@ import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { MenuItem, MenuItemButton } from "~/types";
import { documentEditPath } from "~/utils/routeHelpers";
import { MenuContext, useMenuContext } from "./MenuContext";
@@ -92,22 +92,38 @@ type MenuTriggerProps = {
const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
const { t } = useTranslation();
const { subscriptions } = useStores();
const { subscriptions, pins } = useStores();
const { model: document, menuState } = useMenuContext<Document>();
const { data, loading, error, request } = useRequest(() =>
subscriptions.fetchPage({
documentId: document.id,
event: "documents.update",
})
const {
loading: auxDataLoading,
loaded: auxDataLoaded,
request: auxDataRequest,
} = useRequest(() =>
Promise.all([
subscriptions.fetchOne({
documentId: document.id,
event: SubscriptionType.Document,
}),
document.collectionId
? subscriptions.fetchOne({
collectionId: document.collectionId,
event: SubscriptionType.Document,
})
: noop,
pins.fetchOne({
documentId: document.id,
collectionId: document.collectionId ?? null,
}),
])
);
const handlePointerEnter = React.useCallback(() => {
if (isUndefined(data ?? error) && !loading) {
void request();
if (!auxDataLoading && !auxDataLoaded) {
void auxDataRequest();
void document.loadRelations();
}
}, [data, error, loading, request, document]);
}, [auxDataLoading, auxDataLoaded, auxDataRequest, document]);
return label ? (
<MenuButton
@@ -245,8 +261,20 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
},
actionToMenuItem(starDocument, context),
actionToMenuItem(unstarDocument, context),
actionToMenuItem(subscribeDocument, context),
actionToMenuItem(unsubscribeDocument, 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")}`,
-1
View File
@@ -24,7 +24,6 @@ const NotificationMenu: React.FC = () => {
{
type: "button",
title: t("Notification settings"),
visible: true,
onClick: () => performAction(navigateToNotificationSettings, context),
},
],
+14 -6
View File
@@ -3,6 +3,7 @@ import { TableOfContentsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
@@ -23,28 +24,29 @@ function TableOfContentsMenu() {
Infinity
);
// @ts-expect-error check
const items: MenuItem[] = React.useMemo(() => {
const i = [
{
type: "heading",
visible: true,
title: t("Contents"),
},
...headings.map((heading) => ({
type: "link",
href: `#${heading.id}`,
title: t(heading.title),
title: <HeadingWrapper>{t(heading.title)}</HeadingWrapper>,
level: heading.level - minHeading,
})),
];
] as MenuItem[];
if (i.length === 1) {
i.push({
type: "link",
href: "#",
title: t("Headings you add to the document will appear here"),
// @ts-expect-error check
title: (
<HeadingWrapper>
{t("Headings you add to the document will appear here")}
</HeadingWrapper>
),
disabled: true,
});
}
@@ -71,4 +73,10 @@ function TableOfContentsMenu() {
);
}
const HeadingWrapper = styled.div`
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
`;
export default observer(TableOfContentsMenu);
-2
View File
@@ -5,8 +5,6 @@ import Field from "./decorators/Field";
class AuthenticationProvider extends Model {
static modelName = "AuthenticationProvider";
id: string;
displayName: string;
name: string;
+26
View File
@@ -129,6 +129,16 @@ export default class Collection extends ParanoidModel {
);
}
/**
* Returns whether there is a subscription for this collection in the store.
*
* @returns True if there is a subscription, false otherwise.
*/
@computed
get isSubscribed(): boolean {
return !!this.store.rootStore.subscriptions.getByCollectionId(this.id);
}
@computed
get isManualSort(): boolean {
return this.sort.field === "index";
@@ -376,6 +386,22 @@ export default class Collection extends ParanoidModel {
@action
unstar = async () => this.store.unstar(this);
/**
* Subscribes the current user to this collection.
*
* @returns A promise that resolves when the subscription is created.
*/
@action
subscribe = () => this.store.subscribe(this);
/**
* Unsubscribes the current user from this collection.
*
* @returns A promise that resolves when the subscription is destroyed.
*/
@action
unsubscribe = () => this.store.unsubscribe(this);
archive = () => this.store.archive(this);
restore = () => this.store.restore(this);
+15 -7
View File
@@ -27,6 +27,7 @@ import { client } from "~/utils/ApiClient";
import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import Notification from "./Notification";
import Pin from "./Pin";
import View from "./View";
import ArchivableModel from "./base/ArchivableModel";
import Field from "./decorators/Field";
@@ -307,9 +308,7 @@ export default class Document extends ArchivableModel implements Searchable {
*/
@computed
get isSubscribed(): boolean {
return !!this.store.rootStore.subscriptions.orderedData.find(
(subscription) => subscription.documentId === this.id
);
return !!this.store.rootStore.subscriptions.getByDocumentId(this.id);
}
/**
@@ -450,7 +449,11 @@ export default class Document extends ArchivableModel implements Searchable {
restore = (options?: { revisionId?: string; collectionId?: string }) =>
this.store.restore(this, options);
unpublish = () => this.store.unpublish(this);
unpublish = (
options: { detach?: boolean } = {
detach: false,
}
) => this.store.unpublish(this, options);
@action
enableEmbeds = () => {
@@ -463,12 +466,17 @@ export default class Document extends ArchivableModel implements Searchable {
};
@action
pin = (collectionId?: string | null) =>
this.store.rootStore.pins.create({
pin = async (collectionId?: string | null) => {
const pin = new Pin({}, this.store.rootStore.pins);
await pin.save({
documentId: this.id,
...(collectionId ? { collectionId } : {}),
});
return pin;
};
@action
unpin = (collectionId?: string) => {
const pin = this.store.rootStore.pins.orderedData.find(
@@ -501,7 +509,7 @@ export default class Document extends ArchivableModel implements Searchable {
* @returns A promise that resolves when the subscription is destroyed.
*/
@action
unsubscribe = (userId: string) => this.store.unsubscribe(userId, this);
unsubscribe = () => this.store.unsubscribe(this);
@action
view = () => {
-2
View File
@@ -7,8 +7,6 @@ import Relation from "./decorators/Relation";
class Event<T extends Model> extends Model {
static modelName = "Event";
id: string;
name: string;
modelId: string | undefined;
-2
View File
@@ -11,8 +11,6 @@ import Model from "./base/Model";
class FileOperation extends Model {
static modelName = "FileOperation";
id: string;
@observable
state: FileOperationState;
-2
View File
@@ -12,8 +12,6 @@ import Relation from "~/models/decorators/Relation";
class Integration<T = unknown> extends Model {
static modelName = "Integration";
id: string;
type: IntegrationType;
service: IntegrationService;
-2
View File
@@ -9,8 +9,6 @@ import Relation from "./decorators/Relation";
class Membership extends Model {
static modelName = "Membership";
id: string;
userId: string;
@Relation(() => User, { onDelete: "cascade" })
+31 -1
View File
@@ -1,15 +1,21 @@
import { observable } from "mobx";
import PinsStore from "~/stores/PinsStore";
import { setPersistedState } from "~/hooks/usePersistedState";
import { pinsCacheKey } from "~/hooks/usePinnedDocuments";
import Collection from "./Collection";
import Document from "./Document";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterCreate, AfterDelete, AfterRemove } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
class Pin extends Model {
static modelName = "Pin";
store: PinsStore;
/** The collection ID that the document is pinned to. If empty the document is pinned to home. */
collectionId: string;
collectionId: string | null;
/** The collection that the document is pinned to. If empty the document is pinned to home. */
@Relation(() => Collection, { onDelete: "cascade" })
@@ -26,6 +32,30 @@ class Pin extends Model {
@observable
@Field
index: string;
@AfterCreate
@AfterDelete
@AfterRemove
static updateCache(model: Pin) {
const pins = model.store;
// Pinned to home
if (!model.collectionId) {
setPersistedState(pinsCacheKey("home"), pins.home.length);
return;
}
// Pinned to collection
const collection = pins.rootStore.collections.get(model.collectionId);
if (!collection) {
return;
}
setPersistedState(
pinsCacheKey(collection.urlId),
pins.inCollection(collection.id).length
);
}
}
export default Pin;
+8
View File
@@ -1,4 +1,5 @@
import { observable } from "mobx";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
@@ -25,6 +26,13 @@ class Subscription extends Model {
@Relation(() => Document, { onDelete: "cascade" })
document?: Document;
/** The collection ID being subscribed to */
collectionId: string;
/** The collection being subscribed to */
@Relation(() => Collection, { onDelete: "cascade" })
collection?: Collection;
/** The event being subscribed to */
@Field
@observable
-2
View File
@@ -7,8 +7,6 @@ import Relation from "./decorators/Relation";
class View extends Model {
static modelName = "View";
id: string;
documentId: string;
@Relation(() => Document)
+4 -1
View File
@@ -73,10 +73,13 @@ const CollectionScene = observer(function _CollectionScene() {
const sidebarContext = useLocationSidebarContext();
const id = params.id || "";
const urlId = id.split("-").pop() ?? "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(id, collection?.id);
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
`collection-tab:${collection?.id}`,
collection?.hasDescription
+12 -2
View File
@@ -183,7 +183,7 @@ function DataLoader({ match, children }: Props) {
// Prevents unauthorized request to load share information for the document
// when viewing a public share link
if (can.read && !document.isDeleted) {
if (can.read && !document.isDeleted && !revisionId) {
if (team.getPreference(TeamPreference.Commenting)) {
void comments.fetchAll({
documentId: document.id,
@@ -199,7 +199,17 @@ function DataLoader({ match, children }: Props) {
});
}
}
}, [can.read, can.update, document, isEditRoute, comments, team, shares, ui]);
}, [
can.read,
can.update,
document,
isEditRoute,
comments,
team,
shares,
ui,
revisionId,
]);
if (error) {
return error instanceof OfflineError ? (
+8 -8
View File
@@ -542,14 +542,6 @@ class DocumentScene extends React.Component<Props> {
</RevisionContainer>
) : (
<>
{showContents && (
<ContentsContainer
docFullWidth={document.fullWidth}
position={tocPos}
>
<Contents />
</ContentsContainer>
)}
<MeasuredContainer
name="document"
as={EditorContainer}
@@ -600,6 +592,14 @@ class DocumentScene extends React.Component<Props> {
) : null}
</Editor>
</MeasuredContainer>
{showContents && (
<ContentsContainer
docFullWidth={document.fullWidth}
position={tocPos}
>
<Contents />
</ContentsContainer>
)}
</>
)}
</React.Suspense>
+12 -9
View File
@@ -281,15 +281,18 @@ function DocumentHeader({
limit={isCompact ? 3 : undefined}
/>
)}
{(isEditing || !user?.separateEditMode) && !isTemplate && isNew && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{(isEditing || !user?.separateEditMode) &&
!isTemplate &&
isNew &&
can.update && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{!isEditing && !isRevision && !isTemplate && can.update && (
<Action>
<ShareButton document={document} />
+147 -36
View File
@@ -1,12 +1,17 @@
import isEqual from "fast-deep-equal";
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { Pagination } from "@shared/constants";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Event from "~/models/Event";
import EventModel from "~/models/Event";
import Revision from "~/models/Revision";
import Empty from "~/components/Empty";
import { DocumentEvent, type Event } from "~/components/EventListItem";
import PaginatedEventList from "~/components/PaginatedEventList";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
@@ -14,21 +19,153 @@ import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
const EMPTY_ARRAY: Event<Document>[] = [];
const DocumentEvents = [
"documents.publish",
"documents.unpublish",
"documents.archive",
"documents.unarchive",
"documents.delete",
"documents.restore",
"documents.add_user",
"documents.remove_user",
"documents.move",
];
function History() {
const { events, documents } = useStores();
const { events, documents, revisions } = useStores();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
? events.filter({ documentId: document.id })
: EMPTY_ARRAY;
const [, setForceRender] = React.useState(0);
const offset = React.useMemo(() => ({ revisions: 0, events: 0 }), []);
const onCloseHistory = () => {
const toEvent = React.useCallback(
(data: Revision | EventModel<Document>): Event => {
if (data instanceof Revision) {
return {
id: data.id,
name: "revisions.create",
actorId: data.createdBy.id,
createdAt: data.createdAt,
latest: false,
} satisfies Event;
}
return {
id: data.id,
name: data.name as DocumentEvent["name"],
actorId: data.actorId,
userId: data.userId,
createdAt: data.createdAt,
} satisfies Event;
},
[]
);
const fetchHistory = React.useCallback(async () => {
if (!document) {
return [];
}
const [revisionsArr, eventsArr] = await Promise.all([
revisions.fetchPage({
documentId: document.id,
offset: offset.revisions,
limit: Pagination.defaultLimit,
}),
events.fetchPage({
events: DocumentEvents,
documentId: document.id,
offset: offset.events,
limit: Pagination.defaultLimit,
}),
]);
const pageEvents = orderBy(
[...revisionsArr, ...eventsArr].map(toEvent),
"createdAt",
"desc"
).slice(0, Pagination.defaultLimit);
const revisionsCount = pageEvents.filter(
(event) => event.name === "revisions.create"
).length;
offset.revisions += revisionsCount;
offset.events += pageEvents.length - revisionsCount;
// needed to re-render after mobx store and offset is updated
setForceRender((s) => ++s);
return pageEvents;
}, [document, revisions, events, toEvent, offset]);
const revisionEvents = React.useMemo(() => {
if (!document) {
return [];
}
const latestRevisionId = RevisionHelper.latestId(document.id);
return revisions
.filter(
(revision: Revision) =>
revision.id !== latestRevisionId &&
revision.documentId === document.id
)
.slice(0, offset.revisions)
.map(toEvent);
}, [document, revisions, offset.revisions, toEvent]);
const nonRevisionEvents = React.useMemo(
() =>
document
? events
.filter({ documentId: document.id })
.slice(0, offset.events)
.map(toEvent)
: [],
[document, events, offset.events, toEvent]
);
const mergedEvents = React.useMemo(() => {
const merged = orderBy(
[...revisionEvents, ...nonRevisionEvents],
"createdAt",
"desc"
);
const latestRevisionEvent = merged.find(
(event) => event.name === "revisions.create"
);
if (latestRevisionEvent && document) {
const latestRevision = revisions.get(latestRevisionEvent.id);
const isDocUpdated =
latestRevision?.title !== document.title ||
!isEqual(latestRevision.data, document.data);
if (isDocUpdated) {
revisions.remove(RevisionHelper.latestId(document.id));
merged.unshift({
id: RevisionHelper.latestId(document.id),
name: "revisions.create",
createdAt: document.updatedAt,
actorId: document.updatedBy?.id ?? "",
latest: true,
});
} else if (latestRevisionEvent) {
latestRevisionEvent.latest = true;
}
}
return merged;
}, [revisions, document, revisionEvents, nonRevisionEvents]);
const onCloseHistory = React.useCallback(() => {
if (document) {
history.push({
pathname: documentPath(document),
@@ -37,30 +174,7 @@ function History() {
} else {
history.goBack();
}
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event(
{
id: RevisionHelper.latestId(document.id),
name: "revisions.create",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
},
events
)
);
}
return eventsInDocument;
}, [eventsInDocument, events, document]);
}, [history, document, sidebarContext]);
useKeyDown("Escape", onCloseHistory);
@@ -69,11 +183,8 @@ function History() {
{document ? (
<PaginatedEventList
aria-label={t("History")}
fetch={events.fetchPage}
events={items}
options={{
documentId: document.id,
}}
fetch={fetchHistory}
events={mergedEvents}
document={document}
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
/>
+1 -1
View File
@@ -105,7 +105,7 @@ function Message({ notice }: { notice: string }) {
case "authentication-provider-disabled":
return (
<Trans>
Authentication failed this login method was disabled by a team
Authentication failed this login method was disabled by a workspace
admin.
</Trans>
);
+1 -1
View File
@@ -35,7 +35,7 @@ function Export() {
<Heading>{t("Export")}</Heading>
<Text as="p" type="secondary">
<Trans
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete."
defaults="A full export might take some time, consider exporting a single document or collection. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete."
values={{
userEmail: user.email,
}}
+8 -2
View File
@@ -1,5 +1,6 @@
import { transparentize } from "polished";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
/**
@@ -8,8 +9,9 @@ import { s } from "@shared/styles";
export const ActionRow = styled.div`
position: sticky;
bottom: 0;
padding: 16px 50vw;
margin: 0 -50vw;
width: 100vw;
padding: 16px 12px;
margin-left: -12px;
background: ${s("background")};
@@ -17,4 +19,8 @@ export const ActionRow = styled.div`
backdrop-filter: blur(20px);
background: ${(props) => transparentize(0.2, props.theme.background)};
}
${breakpoint("tablet")`
width: auto;
`}
`;
+15
View File
@@ -8,6 +8,7 @@ import {
CollectionPermission,
CollectionStatusFilter,
FileOperationFormat,
SubscriptionType,
} from "@shared/types";
import Collection from "~/models/Collection";
import { PaginationParams, Properties } from "~/types";
@@ -213,6 +214,20 @@ export default class CollectionsStore extends Store<Collection> {
await star?.delete();
};
subscribe = (collection: Collection) =>
this.rootStore.subscriptions.create({
collectionId: collection.id,
event: SubscriptionType.Document,
});
unsubscribe = (collection: Collection) => {
const subscription = this.rootStore.subscriptions.getByCollectionId(
collection.id
);
return subscription?.delete();
};
@computed
get navigationNodes() {
return this.orderedData.map((collection) => collection.asNavigationNode);
+29 -24
View File
@@ -5,11 +5,12 @@ import find from "lodash/find";
import omitBy from "lodash/omitBy";
import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
import type {
DateFilter,
NavigationNode,
PublicTeam,
StatusFilter,
import {
SubscriptionType,
type DateFilter,
type NavigationNode,
type PublicTeam,
type StatusFilter,
} from "@shared/types";
import { subtractDate } from "@shared/utils/date";
import { bytesToHumanReadable } from "@shared/utils/files";
@@ -63,6 +64,7 @@ export default class DocumentsStore extends Store<Document> {
".md",
".doc",
".docx",
".tsv",
"text/csv",
"text/markdown",
"text/plain",
@@ -342,18 +344,8 @@ export default class DocumentsStore extends Store<Document> {
};
@action
fetchArchived = async (options?: PaginationParams): Promise<Document[]> => {
const archivedInResponse = await this.fetchNamedPage("archived", options);
const archivedInMemory = this.archived;
archivedInMemory.forEach((docInMemory) => {
!archivedInResponse.find(
(docInResponse) => docInResponse.id === docInMemory.id
) && this.remove(docInMemory.id);
});
return archivedInResponse;
};
fetchArchived = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("archived", options);
@action
fetchDeleted = async (options?: PaginationParams): Promise<Document[]> =>
@@ -775,17 +767,30 @@ export default class DocumentsStore extends Store<Document> {
};
@action
unpublish = async (document: Document) => {
unpublish = async (
document: Document,
options: { detach?: boolean } = {
detach: false,
}
) => {
const res = await client.post("/documents.unpublish", {
id: document.id,
...options,
});
runInAction("Document#unpublish", () => {
invariant(res?.data, "Data should be available");
// unpublishing could sometimes detach the document from the collection.
// so, get the collection id before data is updated.
const collectionId = document.collectionId;
document.updateData(res.data);
this.addPolicies(res.policies);
const collection = this.getCollectionForDocument(document);
void collection?.fetchDocuments({ force: true });
if (collectionId) {
const collection = this.rootStore.collections.get(collectionId);
collection?.removeDocument(document.id);
}
});
};
@@ -813,12 +818,12 @@ export default class DocumentsStore extends Store<Document> {
subscribe = (document: Document) =>
this.rootStore.subscriptions.create({
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
});
unsubscribe = (userId: string, document: Document) => {
const subscription = this.rootStore.subscriptions.orderedData.find(
(s) => s.documentId === document.id && s.userId === userId
unsubscribe = (document: Document) => {
const subscription = this.rootStore.subscriptions.getByDocumentId(
document.id
);
return subscription?.delete();
+36
View File
@@ -3,6 +3,7 @@ import { action, runInAction, computed } from "mobx";
import Pin from "~/models/Pin";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import RootStore from "./RootStore";
import Store from "./base/Store";
@@ -13,6 +14,41 @@ export default class PinsStore extends Store<Pin> {
super(rootStore, Pin);
}
@action
async fetchOne({
documentId,
collectionId,
}: {
documentId: string;
collectionId: string | null;
}) {
const pin = this.orderedData.find(
(p) => p.documentId === documentId && p.collectionId === collectionId
);
if (pin) {
return pin;
}
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, {
documentId,
collectionId,
});
invariant(res?.data, "Data should be available");
return this.add(res.data);
} catch (err) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
return;
}
throw err;
} finally {
this.isFetching = false;
}
}
@action
fetchPage = async (params?: FetchParams | undefined): Promise<Pin[]> => {
this.isFetching = true;
+1 -1
View File
@@ -58,7 +58,7 @@ export default class RevisionsStore extends Store<Revision> {
@action
fetchPage = async (
options: PaginationParams | undefined
options: { documentId: string } & (PaginationParams | undefined)
): Promise<Revision[]> => {
this.isFetching = true;
+43
View File
@@ -1,4 +1,9 @@
import invariant from "invariant";
import { action } from "mobx";
import { SubscriptionType } from "@shared/types";
import Subscription from "~/models/Subscription";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
@@ -8,4 +13,42 @@ export default class SubscriptionsStore extends Store<Subscription> {
constructor(rootStore: RootStore) {
super(rootStore, Subscription);
}
@action
async fetchOne(
options: { event: SubscriptionType } & (
| { documentId: string }
| { collectionId: string }
)
) {
const subscription =
"collectionId" in options
? this.getByCollectionId(options.collectionId)
: this.getByDocumentId(options.documentId);
if (subscription) {
return subscription;
}
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, options);
invariant(res?.data, "Data should be available");
return this.add(res.data);
} catch (err) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
return;
}
throw err;
} finally {
this.isFetching = false;
}
}
getByDocumentId = (documentId: string): Subscription | undefined =>
this.find({ documentId });
getByCollectionId = (collectionId: string): Subscription | undefined =>
this.find({ collectionId });
}
+1
View File
@@ -27,6 +27,7 @@ export type MenuItemButton = {
selected?: boolean;
disabled?: boolean;
icon?: React.ReactNode;
tooltip?: React.ReactChild;
};
export type MenuItemWithChildren = {
+20 -19
View File
@@ -48,17 +48,17 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.740.0",
"@aws-sdk/lib-storage": "3.740.0",
"@aws-sdk/s3-presigned-post": "3.740.0",
"@aws-sdk/s3-request-presigner": "3.740.0",
"@aws-sdk/signature-v4-crt": "^3.740.0",
"@babel/core": "^7.26.7",
"@aws-sdk/client-s3": "3.750.0",
"@aws-sdk/lib-storage": "3.750.0",
"@aws-sdk/s3-presigned-post": "3.750.0",
"@aws-sdk/s3-request-presigner": "3.750.0",
"@aws-sdk/signature-v4-crt": "^3.750.0",
"@babel/core": "^7.26.9",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
"@babel/plugin-transform-destructuring": "^7.25.9",
"@babel/plugin-transform-regenerator": "^7.25.9",
"@babel/preset-env": "^7.26.7",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^4.2.2",
@@ -118,7 +118,7 @@
"fast-deep-equal": "^3.1.3",
"fetch-retry": "^5.0.6",
"fetch-with-proxy": "^3.0.1",
"form-data": "^4.0.0",
"form-data": "^4.0.2",
"fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
"fs-extra": "^11.2.0",
@@ -136,7 +136,7 @@
"jszip": "^3.10.1",
"katex": "^0.16.21",
"kbar": "0.1.0-beta.41",
"koa": "^2.15.3",
"koa": "^2.15.4",
"koa-body": "^6.0.1",
"koa-compress": "^5.1.1",
"koa-helmet": "^6.1.0",
@@ -159,7 +159,7 @@
"mobx-utils": "^4.0.1",
"natural-sort": "^1.0.0",
"node-fetch": "2.7.0",
"nodemailer": "^6.9.16",
"nodemailer": "^6.10.0",
"octokit": "^3.2.1",
"outline-icons": "^3.10.0",
"oy-vey": "^0.12.1",
@@ -188,7 +188,7 @@
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.37.1",
"query-string": "^7.1.3",
"randomstring": "1.3.0",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
"react": "^17.0.2",
"react-avatar-editor": "^13.0.2",
@@ -201,7 +201,7 @@
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.10",
"react-medium-image-zoom": "5.2.13",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
@@ -217,7 +217,7 @@
"rfc6902": "^5.1.1",
"sanitize-filename": "^1.6.3",
"scroll-into-view-if-needed": "^3.1.0",
"semver": "^7.6.2",
"semver": "^7.7.1",
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"sequelize-encrypted": "^1.0.0",
@@ -237,11 +237,12 @@
"tiny-cookie": "^2.5.1",
"tmp": "^0.2.3",
"turndown": "^7.2.0",
"ukkonen": "^2.1.0",
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vite": "^5.4.12",
"vite": "^5.4.14",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -256,14 +257,14 @@
"@babel/cli": "^7.26.4",
"@babel/preset-typescript": "^7.26.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.13",
"@relative-ci/agent": "^4.2.14",
"@testing-library/react": "^12.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
"@types/crypto-js": "^4.2.2",
"@types/diff": "^5.0.9",
"@types/dotenv": "^8.2.3",
"@types/emoji-regex": "^9.2.0",
"@types/emoji-regex": "^9.2.2",
"@types/escape-html": "^1.0.4",
"@types/express-useragent": "^1.0.5",
"@types/formidable": "^2.0.6",
@@ -333,7 +334,7 @@
"discord-api-types": "^0.37.102",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-import-resolver-typescript": "^3.8.0",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
@@ -355,7 +356,7 @@
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.1",
"terser": "^5.37.0",
"typescript": "^5.7.2",
"typescript": "^5.7.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
@@ -369,5 +370,5 @@
"qs": "6.9.7",
"rollup": "^4.5.1"
},
"version": "0.81.0"
"version": "0.82.0"
}
+58 -3
View File
@@ -4,9 +4,15 @@ import path from "path";
import FormData from "form-data";
import { ensureDirSync } from "fs-extra";
import { v4 as uuidV4 } from "uuid";
import { FileOperationState, FileOperationType } from "@shared/types";
import env from "@server/env";
import { Buckets } from "@server/models/helpers/AttachmentHelper";
import FileStorage from "@server/storage/files";
import { buildAttachment, buildUser } from "@server/test/factories";
import {
buildAttachment,
buildFileOperation,
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
@@ -236,7 +242,16 @@ describe("#files.get", () => {
it("should succeed with status 200 ok when file is requested using signature", async () => {
const user = await buildUser();
const fileName = "images.docx";
const key = path.join("uploads", user.id, uuidV4(), fileName);
const { key } = await buildAttachment(
{
teamId: user.teamId,
userId: user.id,
contentType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
acl: "private",
},
fileName
);
const signedUrl = await FileStorage.getSignedUrl(key);
ensureDirSync(
@@ -262,6 +277,13 @@ describe("#files.get", () => {
it("should succeed with status 200 ok when avatar is requested using key", async () => {
const user = await buildUser();
const key = path.join("avatars", user.id, uuidV4());
await buildAttachment({
key,
teamId: user.teamId,
userId: user.id,
contentType: "image/jpg",
acl: "public-read",
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
@@ -274,7 +296,40 @@ describe("#files.get", () => {
const res = await server.get(`/api/files.get?key=${key}`);
expect(res.status).toEqual(200);
expect(res.headers.get("Content-Type")).toEqual("application/octet-stream");
expect(res.headers.get("Content-Type")).toEqual("image/jpg");
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
});
it("should succeed with status 200 ok when exported file is requested using signature", async () => {
const user = await buildUser();
const fileName = "export-markdown.zip";
const key = `${Buckets.uploads}/${user.teamId}/${uuidV4()}/${fileName}`;
await buildFileOperation({
userId: user.id,
teamId: user.teamId,
type: FileOperationType.Export,
state: FileOperationState.Complete,
key,
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
);
copyFileSync(
path.resolve(__dirname, "..", "test", "fixtures", fileName),
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
);
const signedUrl = await FileStorage.getSignedUrl(key);
const url = new URL(signedUrl);
const res = await server.get(url.pathname + url.search);
expect(res.status).toEqual(200);
expect(res.headers.get("Content-Type")).toEqual("application/zip");
expect(res.headers.get("Content-Disposition")).toEqual(
'attachment; filename="export-markdown.zip"'
);
});
});
+15 -8
View File
@@ -5,6 +5,7 @@ import env from "@server/env";
import {
AuthenticationError,
AuthorizationError,
NotFoundError,
ValidationError,
} from "@server/errors";
import auth from "@server/middlewares/authentication";
@@ -77,19 +78,25 @@ router.get(
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
const skipAuthorize = isPublicBucket || isSignedRequest;
const cacheHeader = "max-age=604800, immutable";
let contentType =
(fileName ? mime.lookup(fileName) : undefined) ||
"application/octet-stream";
const attachment = await Attachment.findOne({
where: { key },
});
// Attachment is requested with a key, but it was not found
if (!attachment && !!ctx.input.query.key) {
throw NotFoundError();
}
if (!skipAuthorize) {
const attachment = await Attachment.findOne({
where: { key },
rejectOnEmpty: true,
});
authorize(actor, "read", attachment);
contentType = attachment.contentType;
}
const contentType =
attachment?.contentType ||
(fileName ? mime.lookup(fileName) : undefined) ||
"application/octet-stream";
ctx.set("Accept-Ranges", "bytes");
ctx.set("Cache-Control", cacheHeader);
ctx.set("Content-Type", contentType);
Binary file not shown.
@@ -10,7 +10,7 @@ import BaseTask, {
type Props = Record<string, never>;
export default class CleanupWebhookDeliveriesTask extends BaseTask<Props> {
static cron = TaskSchedule.Daily;
static cron = TaskSchedule.Day;
public async perform() {
Logger.info("task", `Deleting WebhookDeliveries older than one week…`);
@@ -190,6 +190,56 @@ describe("accountProvisioner", () => {
expect(error).toBeTruthy();
});
it("should prioritize enabled authentication provider", async () => {
const existingTeam = await buildTeam();
const existingProviders = await existingTeam.$get(
"authenticationProviders"
);
const team2 = await buildTeam();
const providers = await team2.$get("authenticationProviders");
const authenticationProvider = providers[0];
await authenticationProvider.update({
enabled: false,
providerId: existingProviders[0].providerId,
});
const existing = await buildUser({
teamId: existingTeam.id,
});
const authentications = await existing.$get("authentications");
const authentication = authentications[0];
const { isNewUser, isNewTeam } = await accountProvisioner({
ip,
user: {
name: existing.name,
email: existing.email!,
avatarUrl: existing.avatarUrl,
},
team: {
name: existingTeam.name,
avatarUrl: existingTeam.avatarUrl,
subdomain: faker.internet.domainWord(),
},
authenticationProvider: {
name: authenticationProvider.name,
providerId: authenticationProvider.providerId,
},
authentication: {
providerId: authentication.providerId,
accessToken: "123",
scopes: ["read"],
},
});
const auth = await UserAuthentication.findByPk(authentication.id);
expect(auth?.accessToken).toEqual("123");
expect(auth?.scopes.length).toEqual(1);
expect(auth?.scopes[0]).toEqual("read");
expect(isNewTeam).toEqual(false);
expect(isNewUser).toEqual(false);
});
it("should throw an error when the domain is not allowed", async () => {
const existingTeam = await buildTeam();
const admin = await buildAdmin({ teamId: existingTeam.id });
+2 -1
View File
@@ -102,7 +102,7 @@ async function accountProvisioner({
if (err.id === "invalid_authentication") {
const authenticationProvider = await AuthenticationProvider.findOne({
where: {
name: authenticationProviderParams.name, // example: "google"
name: authenticationProviderParams.name,
teamId: teamParams.teamId,
},
include: [
@@ -112,6 +112,7 @@ async function accountProvisioner({
required: true,
},
],
order: [["enabled", "DESC"]],
});
if (authenticationProvider) {
+7 -6
View File
@@ -62,12 +62,13 @@ export default async function pinCreator({
index = fractionalIndex(pins.length ? pins[0].index : null, null);
}
const pin = await Pin.createWithCtx(ctx, {
createdById: user.id,
teamId: user.teamId,
collectionId,
documentId,
index,
const [pin] = await Pin.findOrCreateWithCtx(ctx, {
where: {
collectionId: collectionId ?? null,
documentId,
teamId: user.teamId,
},
defaults: { index, createdById: user.id },
});
return pin;
+41 -3
View File
@@ -1,14 +1,51 @@
import { SubscriptionType } from "@shared/types";
import { createContext } from "@server/context";
import { Subscription, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
import { buildDocument, buildUser } from "@server/test/factories";
import {
buildCollection,
buildDocument,
buildUser,
} from "@server/test/factories";
import subscriptionCreator from "./subscriptionCreator";
describe("subscriptionCreator", () => {
const ip = "127.0.0.1";
const subscribedEvent = "documents.update";
const subscribedEvent = SubscriptionType.Document;
it("should create a subscription", async () => {
it("should create a document subscription for the whole collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const subscription = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
ctx: createContext({ user, transaction, ip }),
collectionId: collection.id,
event: SubscriptionType.Document,
})
);
const event = await Event.findOne({
where: {
teamId: user.teamId,
},
});
expect(subscription.collectionId).toEqual(collection.id);
expect(subscription.documentId).toBeNull();
expect(subscription.userId).toEqual(user.id);
expect(event?.name).toEqual("subscriptions.create");
expect(event?.modelId).toEqual(subscription.id);
expect(event?.actorId).toEqual(subscription.userId);
expect(event?.userId).toEqual(subscription.userId);
expect(event?.collectionId).toEqual(subscription.collectionId);
});
it("should create a document subscription", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -31,6 +68,7 @@ describe("subscriptionCreator", () => {
});
expect(subscription.documentId).toEqual(document.id);
expect(subscription.collectionId).toBeNull();
expect(subscription.userId).toEqual(user.id);
expect(event?.name).toEqual("subscriptions.create");
expect(event?.modelId).toEqual(subscription.id);
+22 -7
View File
@@ -1,3 +1,5 @@
import { WhereOptions } from "sequelize";
import { SubscriptionType } from "@shared/types";
import { createContext } from "@server/context";
import { Subscription, Document } from "@server/models";
import { sequelize } from "@server/storage/database";
@@ -8,8 +10,10 @@ type Props = {
ctx: APIContext;
/** The document to subscribe to */
documentId?: string;
/** The collection to subscribe to */
collectionId?: string;
/** Event to subscribe to */
event: string;
event: SubscriptionType;
/** Whether the subscription should be restored if it exists in a deleted state */
resubscribe?: boolean;
};
@@ -22,16 +26,27 @@ type Props = {
export default async function subscriptionCreator({
ctx,
documentId,
collectionId,
event,
resubscribe = true,
}: Props): Promise<Subscription> {
const { user } = ctx.context.auth;
const where: WhereOptions<Subscription> = {
userId: user.id,
event,
};
if (documentId) {
where.documentId = documentId;
}
if (collectionId) {
where.collectionId = collectionId;
}
const [subscription] = await Subscription.findOrCreateWithCtx(ctx, {
where: {
userId: user.id,
documentId,
event,
},
where,
paranoid: false, // Previous subscriptions are soft-deleted, we want to know about them here.
});
@@ -68,7 +83,7 @@ export const createSubscriptionsForDocument = async (
transaction,
}),
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
resubscribe: false,
});
}
+1
View File
@@ -61,6 +61,7 @@ async function teamProvisioner({
paranoid: false,
},
],
order: [["enabled", "DESC"]],
});
// This authentication provider already exists which means we have a team and
+1 -1
View File
@@ -63,7 +63,7 @@ export default async function userProvisioner({
const auth = authentication
? await UserAuthentication.findOne({
where: {
providerId: "" + authentication.providerId,
providerId: String(authentication.providerId),
},
include: [
{
@@ -11,6 +11,8 @@ import Heading from "./components/Heading";
type Props = EmailProps & {
deleteConfirmationCode: string;
teamName: string;
teamUrl: string;
};
/**
@@ -29,15 +31,15 @@ export default class ConfirmUserDeleteEmail extends BaseEmail<Props> {
return `Your requested account deletion code`;
}
protected renderAsText({ deleteConfirmationCode }: Props): string {
protected renderAsText({ teamName, deleteConfirmationCode }: Props): string {
return `
You requested to permanently delete your ${env.APP_NAME} account. Please enter the code below to confirm your account deletion.
You requested to permanently delete your ${env.APP_NAME} user account in the ${teamName} workspace. Please enter the code below to confirm your account deletion.
Code: ${deleteConfirmationCode}
`;
}
protected render({ deleteConfirmationCode }: Props) {
protected render({ teamUrl, teamName, deleteConfirmationCode }: Props) {
return (
<EmailTemplate previewText={this.preview()}>
<Header />
@@ -45,8 +47,9 @@ Code: ${deleteConfirmationCode}
<Body>
<Heading>Your account deletion request</Heading>
<p>
You requested to permanently delete your {env.APP_NAME} account.
Please enter the code below to confirm your account deletion.
You requested to permanently delete your {env.APP_NAME} user account
in the <a href={teamUrl}>{teamName}</a> workspace. Please enter the
code below to confirm your account deletion.
</p>
<EmptySpace height={5} />
<p>
@@ -0,0 +1,44 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn(
"subscriptions",
"collectionId",
{
type: Sequelize.UUID,
allowNull: true,
onDelete: "cascade",
references: {
model: "collections",
},
},
{ transaction }
);
await queryInterface.addIndex(
"subscriptions",
["userId", "collectionId", "event"],
{
name: "subscriptions_user_id_collection_id_event",
type: "UNIQUE",
transaction,
}
);
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeIndex(
"subscriptions",
["userId", "collectionId", "event"],
{ transaction }
);
await queryInterface.removeColumn("subscriptions", "collectionId", {
transaction,
});
});
},
};
@@ -0,0 +1,19 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface) {
await queryInterface.sequelize.query(
`CREATE EXTENSION IF NOT EXISTS "pg_trgm";`,
);
await queryInterface.sequelize.query(
`CREATE INDEX CONCURRENTLY documents_title_idx ON documents USING GIN (title gin_trgm_ops);`,
);
},
async down (queryInterface) {
await queryInterface.sequelize.query(
`DROP INDEX CONCURRENTLY documents_title_idx;`,
);
}
};
@@ -0,0 +1,27 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn("teams", "previousSubdomains", {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: true,
}, { transaction });
await queryInterface.sequelize.query(
`CREATE INDEX teams_previous_subdomains ON teams USING GIN ("previousSubdomains");`,
{ transaction }
);
});
},
async down(queryInterface) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.sequelize.query(
`DROP INDEX teams_previous_subdomains;`,
{ transaction }
);
await queryInterface.removeColumn("teams", "previousSubdomains", { transaction });
});
},
};
@@ -0,0 +1,49 @@
const tableName = "team_domains";
// because of this issue in Sequelize the foreign key constraint may be named differently depending
// on when the previous migrations were ran https://github.com/sequelize/sequelize/pull/9890
const constraintNames = [
"team_domains_createdById_fkey",
"createdById_foreign_idx"
];
module.exports = {
up: async (queryInterface, Sequelize) => {
let error;
for (const constraintName of constraintNames) {
try {
await queryInterface.sequelize.query(
`alter table "${tableName}" drop constraint "${constraintName}"`
);
await queryInterface.sequelize.query(`alter table "${tableName}"
add constraint "${constraintName}" foreign key("createdById") references "users" ("id")
on delete set null`);
return;
} catch (err) {
error = err;
}
}
throw error;
},
down: async (queryInterface, Sequelize) => {
let error;
for (const constraintName of constraintNames) {
try {
await queryInterface.sequelize.query(
`alter table "${tableName}" drop constraint "${constraintName}"`
);
await queryInterface.sequelize.query(`alter table "${tableName}"\
add constraint "${constraintName}" foreign key("createdById") references "users" ("id")
on delete no action`);
return;
} catch (err) {
error = err;
}
}
throw error;
},
};
+9
View File
@@ -46,6 +46,15 @@ describe("#ApiKey", () => {
});
describe("canAccess", () => {
it("should account for query string", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/documents.info"],
});
expect(apiKey.canAccess("/api/documents.info?foo=bar")).toBe(true);
});
it("should return true for all resources if no scope", async () => {
const apiKey = await buildApiKey({
name: "Dev",
+3
View File
@@ -174,6 +174,9 @@ class ApiKey extends ParanoidModel<
return true;
}
// strip any query string from the path
path = path.split("?")[0];
const resource = path.split("/").pop() ?? "";
const [namespace, method] = resource.split(".");
+12 -1
View File
@@ -981,7 +981,13 @@ class Document extends ArchivableModel<
return false;
};
unpublish = async (user: User) => {
/**
*
* @param user User who is performing the action
* @param options.detach Whether to detach the document from the containing collection
* @returns Updated document
*/
unpublish = async (user: User, options: { detach: boolean }) => {
// If the document is already a draft then calling unpublish should act like save
if (!this.publishedAt) {
return this.save();
@@ -1010,6 +1016,11 @@ class Document extends ArchivableModel<
this.createdBy = user;
this.updatedBy = user;
this.publishedAt = null;
if (options.detach) {
this.collectionId = null;
}
return this.save();
};
+62
View File
@@ -19,13 +19,17 @@ import {
AfterCreate,
AfterUpdate,
AfterDestroy,
BeforeDestroy,
BeforeUpdate,
} from "sequelize-typescript";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import { ValidationError } from "@server/errors";
import { APIContext } from "@server/types";
import Collection from "./Collection";
import Document from "./Document";
import Group from "./Group";
import User from "./User";
import UserMembership from "./UserMembership";
import { type HookContext } from "./base/Model";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
@@ -247,6 +251,36 @@ class GroupMembership extends ParanoidModel<
}
}
@BeforeUpdate
static async checkLastAdminBeforeUpdate(
model: GroupMembership,
ctx: APIContext["context"]
) {
if (
model.permission === CollectionPermission.Admin ||
model.previous("permission") !== CollectionPermission.Admin ||
!model.collectionId
) {
return;
}
await this.validateLastAdminPermission(model, ctx);
}
@BeforeDestroy
static async checkLastAdminBeforeDestroy(
model: GroupMembership,
ctx: APIContext["context"]
) {
// Only check for last admin permission if this permission is admin
if (
model.permission !== CollectionPermission.Admin ||
!model.collectionId
) {
return;
}
await this.validateLastAdminPermission(model, ctx);
}
@AfterUpdate
static async publishAddGroupEventAfterUpdate(
model: GroupMembership,
@@ -366,6 +400,34 @@ class GroupMembership extends ParanoidModel<
await Document.insertEvent(name, this, hookContext);
}
}
private static async validateLastAdminPermission(
model: GroupMembership,
{ transaction }: APIContext["context"]
) {
const [userMemberships, groupMemberships] = await Promise.all([
UserMembership.count({
where: {
collectionId: model.collectionId,
permission: CollectionPermission.Admin,
},
transaction,
}),
this.count({
where: {
collectionId: model.collectionId,
permission: CollectionPermission.Admin,
},
transaction,
}),
]);
if (userMemberships === 0 && groupMemberships === 1) {
throw ValidationError(
"At least one user or group must have manage permissions"
);
}
}
}
export default GroupMembership;
+10 -1
View File
@@ -8,6 +8,8 @@ import {
IsIn,
Scopes,
} from "sequelize-typescript";
import { SubscriptionType } from "@shared/types";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
@@ -42,7 +44,14 @@ class Subscription extends ParanoidModel<
@Column(DataType.UUID)
documentId: string | null;
@IsIn([["documents.update"]])
@BelongsTo(() => Collection, "collectionId")
collection: Collection | null;
@ForeignKey(() => Document)
@Column(DataType.UUID)
collectionId: string | null;
@IsIn([Object.values(SubscriptionType)])
@Column(DataType.STRING)
event: string;
}
+37 -14
View File
@@ -1,20 +1,43 @@
import { buildTeam, buildCollection } from "@server/test/factories";
describe("collectionIds", () => {
it("should return non-private collection ids", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
describe("Team", () => {
describe("collectionIds", () => {
it("should return non-private collection ids", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
});
// build a collection in another team
await buildCollection();
// build a private collection
await buildCollection({
teamId: team.id,
permission: null,
});
const response = await team.collectionIds();
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
// build a collection in another team
await buildCollection();
// build a private collection
await buildCollection({
teamId: team.id,
permission: null,
});
describe("previousSubdomains", () => {
it("should list the previous subdomains", async () => {
const team = await buildTeam({
subdomain: "example",
});
const subdomain = "updated";
await team.update({ subdomain });
expect(team.subdomain).toEqual(subdomain);
expect(team.previousSubdomains?.length).toEqual(1);
expect(team.previousSubdomains?.[0]).toEqual("example");
const subdomain2 = "another";
await team.update({ subdomain: subdomain2 });
expect(team.subdomain).toEqual(subdomain2);
expect(team.previousSubdomains?.length).toEqual(2);
expect(team.previousSubdomains?.[0]).toEqual("example");
expect(team.previousSubdomains?.[1]).toEqual(subdomain);
});
const response = await team.collectionIds();
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
});
+57
View File
@@ -171,6 +171,9 @@ class Team extends ParanoidModel<
@Column
lastActiveAt: Date | null;
@Column(DataType.ARRAY(DataType.STRING))
previousSubdomains: string[] | null;
// getters
/**
@@ -369,6 +372,25 @@ class Team extends ParanoidModel<
return model;
}
@BeforeUpdate
static async savePreviousSubdomain(model: Team) {
const previousSubdomain = model.previous("subdomain");
if (previousSubdomain && previousSubdomain !== model.subdomain) {
model.previousSubdomains = model.previousSubdomains || [];
if (!model.previousSubdomains.includes(previousSubdomain)) {
// Add the previous subdomain to the list of previous subdomains
// upto a maximum of 3 previous subdomains
model.previousSubdomains.push(previousSubdomain);
if (model.previousSubdomains.length > 3) {
model.previousSubdomains.shift();
}
}
}
return model;
}
@AfterUpdate
static deletePreviousAvatar = async (model: Team) => {
const previousAvatarUrl = model.previous("avatarUrl");
@@ -393,6 +415,41 @@ class Team extends ParanoidModel<
}
}
};
/**
* Find a team by its current or previous subdomain.
*
* @param subdomain - The subdomain to search for.
* @returns The team with the given or previous subdomain, or null if not found.
*/
static async findBySubdomain(subdomain: string) {
// Preference is always given to the team with the subdomain currently
// otherwise we can try and find a team that previously used the subdomain.
return (
(await this.findOne({
where: {
subdomain,
},
})) || (await this.findByPreviousSubdomain(subdomain))
);
}
/**
* Find a team by its previous subdomain.
*
* @param previousSubdomain - The previous subdomain to search for.
* @returns The team with the given previous subdomain, or null if not found.
*/
static async findByPreviousSubdomain(previousSubdomain: string) {
return this.findOne({
where: {
previousSubdomains: {
[Op.contains]: [previousSubdomain],
},
},
order: [["updatedAt", "DESC"]],
});
}
}
export default Team;
+1 -1
View File
@@ -1,7 +1,7 @@
import { buildAdmin, buildTeam } from "@server/test/factories";
import TeamDomain from "./TeamDomain";
describe("team domain model", () => {
describe("TeamDomain", () => {
describe("create", () => {
it("should allow creation of domains", async () => {
const team = await buildTeam();
+3
View File
@@ -79,6 +79,7 @@ describe("user model", () => {
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
it("should return read collections", async () => {
const team = await buildTeam();
const user = await buildUser({
@@ -92,6 +93,7 @@ describe("user model", () => {
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
it("should not return private collections", async () => {
const team = await buildTeam();
const user = await buildUser({
@@ -104,6 +106,7 @@ describe("user model", () => {
const response = await user.collectionIds();
expect(response.length).toEqual(0);
});
it("should not return private collection with membership", async () => {
const team = await buildTeam();
const user = await buildUser({
+1
View File
@@ -614,6 +614,7 @@ class User extends ParanoidModel<
where: { email: this.email },
},
],
order: [["createdAt", "ASC"]],
});
// hooks

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