Compare commits

..

47 Commits

Author SHA1 Message Date
tommoor 33307c636a chore: Compressed inefficient images automatically 2025-09-01 09:13:04 +00:00
github-actions[bot] 7fb8706c30 chore: Compressed inefficient images automatically (#10063)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-09-01 05:12:38 -04:00
Tom Moor 617504d8bb feat: Add group admin role (#10030)
* Add admin role to GroupUser

This change adds an admin role to GroupUser that allows group admins to:
1. Administer other users in the group
2. Change the group name

Changes include:
- Database migration to add isAdmin field to group_users table
- Updated GroupUser model to include isAdmin field
- Added isGroupAdmin policy utility function
- Updated group policy to allow group admins to update groups
- Added API endpoints for managing admin status
- Updated GroupUsersStore to handle admin functionality
- Added tests for the new functionality

* Replace isAdmin with role-based approach for GroupUser

- Added role field to GroupUser model using UserRole.Admin and UserRole.Member
- Created migration to convert isAdmin boolean to role enum
- Updated policies to be synchronous and require pre-loaded relationships
- Updated API endpoints to support both role and legacy isAdmin parameters
- Updated GroupUsersStore to handle role-based functionality
- Updated tests to use role instead of isAdmin
- Maintained backward compatibility with isAdmin in presenters

* Remove isAdmin logic from GroupUser implementation

- Removed isAdmin parameter from GroupUsersStore methods
- Removed isAdmin field from presenter output
- Removed isAdmin from API schemas
- Removed isAdmin parameter handling in API endpoints
- Updated tests to use role instead of isAdmin
- Simplified role handling throughout the codebase

* lint

* tests

* role -> permission

* fe

* test

* Change permission label from 'Admin' to 'Manage'

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-01 05:10:32 -04:00
github-actions[bot] 95537af5f3 chore: Compressed inefficient images automatically (#10060)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-09-01 05:10:11 -04:00
Tom Moor 1765a19aab v0.87.1 2025-08-31 18:44:59 +02:00
Tom Moor a73a8626c5 fix: Allow access to private IP address for OIDC (#10059) 2025-08-31 12:44:37 -04:00
Tom Moor 88054a3899 fix: Include base url as script domain (#10058)
* fix: Include base url as script domain

* Update server/middlewares/csp.ts

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-08-31 12:43:57 -04:00
Tom Moor 409313639d v0.87.0 2025-08-31 15:52:23 +02:00
Tom Moor 78ad61c9fb Bump editor version 2025-08-31 15:52:08 +02:00
Translate-O-Tron 2d9de26041 New Crowdin updates (#9910)
* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Hungarian translations from Crowdin [ci skip]
2025-08-31 09:31:42 -04:00
Tom Moor 0a9bd39aac Add CSRF middleware (#10051)
ref OUT-Q325-03
2025-08-31 06:35:35 -04:00
vlad f614f3dd3f chore(queues): use Second from @shared/utils/time (#10031) 2025-08-31 06:23:43 -04:00
Hemachandar 7f818c7329 chore: Replace custom toPlainText serialization with leafText (#10039) 2025-08-30 20:26:07 +05:30
Hemachandar 27d116c8e2 Implement right-click context menu (#9883)
* base context menu

* extract document actions to `useDocumentMenuAction` hook

* context menu for DocumentListItem

* common menu component

* use Menu in dropdown and context

* menu context

* remove DropdownMenu and ContextMenu primitives

* update yarn.lock

* permissions recent bug fix
2025-08-30 20:25:03 +05:30
codegen-sh[bot] 7e962d36e6 feat: Add expiresAt column to IntegrationAuthentication (#10046)
This adds an expiresAt column to the IntegrationAuthentication model to store token expiration dates.

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-08-30 02:26:47 -04:00
Tom Moor f09450e7ea fix: Update unique db constraint to account for revoked share links (#10022)
* fix: Update unique db constraint to account for revoked share links

closes #10017

* Remove pointless try/catch

* Switch order of migrations to ensure no 'dead zone' without constraint
2025-08-30 01:52:05 -04:00
Hemachandar 05b9c69da8 fix: Show api-key creator name in settings page (#10041) 2025-08-29 19:03:47 +05:30
Hemachandar ac55ad55dd fix documents.import permission checks for shared parent (#9996) 2025-08-28 05:37:42 -04:00
Tom Moor 8c11b6cfc8 Map export endpoint to read permissions (#10019) 2025-08-27 04:29:11 -04:00
Tom Moor d858289159 fix: Image caption parsed as sep paragraph on copy/paste (#10020)
closes #9985
2025-08-27 04:29:01 -04:00
Tom Moor 52d420bd98 chore: Suppress lint warnings in CI (confuses AI) (#10021)
Auto fix where possible in pre-commit hook
2025-08-27 03:03:14 -04:00
dependabot[bot] 386eebb117 chore(deps): bump tmp from 0.2.4 to 0.2.5 (#10023)
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.4 to 0.2.5.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.4...v0.2.5)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 03:03:00 -04:00
dependabot[bot] d0993c3393 chore(deps): bump mermaid from 11.10.0 to 11.10.1 (#10024)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.10.0 to 11.10.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.10.0...mermaid@11.10.1)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 03:02:52 -04:00
dependabot[bot] 54d17503bf chore(deps): bump @css-inline/css-inline-wasm from 0.14.3 to 0.17.0 (#10025)
Bumps [@css-inline/css-inline-wasm](https://github.com/Stranger6667/css-inline) from 0.14.3 to 0.17.0.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/v0.14.3...v0.17.0)

---
updated-dependencies:
- dependency-name: "@css-inline/css-inline-wasm"
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 03:02:41 -04:00
Hemachandar 0de2a3dc98 Skip Notion linked database views (#10018) 2025-08-26 16:14:44 -04:00
dependabot[bot] 73ac18bbde chore(deps): bump the aws group with 5 updates (#10006)
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.864.0` | `3.873.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.864.0` | `3.873.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.864.0` | `3.873.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.864.0` | `3.873.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.864.0` | `3.873.0` |


Updates `@aws-sdk/client-s3` from 3.864.0 to 3.873.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.873.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.864.0 to 3.873.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.873.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.864.0 to 3.873.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.873.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.864.0 to 3.873.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.873.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.864.0 to 3.873.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.873.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 10:51:05 -04:00
Hemachandar 18dcef8ce4 Include collection attachments in json export (#10010) 2025-08-26 10:50:55 -04:00
Hemachandar 7458228df0 Use leafText when converting mention nodes to its text content (#10011) 2025-08-26 10:45:27 -04:00
dependabot[bot] 7c93f8a039 chore(deps): bump @linear/sdk from 39.0.0 to 39.2.1 (#10012)
Bumps [@linear/sdk](https://github.com/linear/linear) from 39.0.0 to 39.2.1.
- [Release notes](https://github.com/linear/linear/releases)
- [Commits](https://github.com/linear/linear/compare/@linear/sdk@39.0.0...@linear/sdk@39.2.1)

---
updated-dependencies:
- dependency-name: "@linear/sdk"
  dependency-version: 39.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 10:39:49 -04:00
dependabot[bot] d6a126d974 chore(deps): bump the radix-ui group with 8 updates (#10013)
Bumps the radix-ui group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives) | `1.1.11` | `1.1.12` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives) | `1.1.14` | `1.1.15` |
| [@radix-ui/react-one-time-password-field](https://github.com/radix-ui/primitives) | `0.1.7` | `0.1.8` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives) | `1.1.14` | `1.1.15` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives) | `2.2.5` | `2.2.6` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives) | `1.2.5` | `1.2.6` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives) | `1.1.12` | `1.1.13` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives) | `1.2.7` | `1.2.8` |


Updates `@radix-ui/react-collapsible` from 1.1.11 to 1.1.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-dialog` from 1.1.14 to 1.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-one-time-password-field` from 0.1.7 to 0.1.8
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-popover` from 1.1.14 to 1.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-select` from 2.2.5 to 2.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-switch` from 1.2.5 to 1.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tabs` from 1.1.12 to 1.1.13
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tooltip` from 1.2.7 to 1.2.8
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-collapsible"
  dependency-version: 1.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-one-time-password-field"
  dependency-version: 0.1.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-26 10:39:38 -04:00
dependabot[bot] 779fb1d568 chore(deps): bump core-js from 3.41.0 to 3.45.1 (#10007)
Bumps [core-js](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js) from 3.41.0 to 3.45.1.
- [Release notes](https://github.com/zloirock/core-js/releases)
- [Changelog](https://github.com/zloirock/core-js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zloirock/core-js/commits/v3.45.1/packages/core-js)

---
updated-dependencies:
- dependency-name: core-js
  dependency-version: 3.45.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 03:04:29 -04:00
dependabot[bot] a0ce14f2a2 chore(deps): bump @dotenvx/dotenvx from 1.48.4 to 1.49.0 (#10008)
Bumps [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) from 1.48.4 to 1.49.0.
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.48.4...v1.49.0)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 03:04:21 -04:00
dependabot[bot] 091abf0b9d chore(deps): bump @fortawesome/react-fontawesome (#10009) 2025-08-26 02:43:17 -04:00
dependabot[bot] 342c42194e chore(deps): bump @radix-ui/react-popover from 1.1.14 to 1.1.15 (#10004) 2025-08-26 02:02:05 -04:00
Hemachandar 8383a0ee1e fix: Sync draft comment from local storage when navigating between documents (#9997) 2025-08-26 02:15:43 +05:30
Hemachandar 19a696942e fix: Use event keycode for determining ToC shortcut keys (#10002) 2025-08-26 02:15:28 +05:30
Hemachandar f1a5e95f77 chore: Dependabot group for radix-ui (#10001) 2025-08-26 01:41:15 +05:30
dependabot[bot] 99fedfa354 chore(deps): bump mermaid from 11.9.0 to 11.10.0 (#9983)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.9.0 to 11.10.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.9.0...mermaid@11.10.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.10.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-21 07:56:00 -04:00
codegen-sh[bot] 9da73202c7 chore: upgrade vite-plugin-pwa to v1.0.3 and rollup-plugin-webpack-stats to v2.1.3 (#9982)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-08-21 07:55:13 -04:00
Tom Moor 30db7bc554 chore: Remove focused comment state from router (#9962)
* chore: Refactor comment state from router

* Handle edge cases

* refactor

* feedback
2025-08-19 10:51:08 -04:00
Tom Moor b40eaf4184 refactor: getByUrl (#9975) 2025-08-19 08:30:05 -04:00
Tom Moor 3aff344501 fix: Unable to use DATABASE_HOST env (#9977) 2025-08-19 08:29:53 -04:00
dependabot[bot] 0f812d70c1 chore(deps): bump @radix-ui/react-dropdown-menu from 2.1.15 to 2.1.16 (#9969)
Bumps [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives) from 2.1.15 to 2.1.16.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 20:03:53 -04:00
dependabot[bot] 125e9c2e0b chore(deps): bump the babel group with 4 updates (#9968)
Bumps the babel group with 4 updates: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core), [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator), [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) and [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli).


Updates `@babel/core` from 7.28.0 to 7.28.3
- [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.28.3/packages/babel-core)

Updates `@babel/plugin-transform-regenerator` from 7.28.1 to 7.28.3
- [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.28.3/packages/babel-plugin-transform-regenerator)

Updates `@babel/preset-env` from 7.28.0 to 7.28.3
- [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.28.3/packages/babel-preset-env)

Updates `@babel/cli` from 7.28.0 to 7.28.3
- [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.28.3/packages/babel-cli)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.28.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-version: 7.28.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.28.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-version: 7.28.3
  dependency-type: direct:development
  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-08-18 19:32:55 -04:00
dependabot[bot] 95402b4b52 chore(deps): bump dd-trace from 5.62.0 to 5.63.0 (#9966)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.62.0 to 5.63.0.
- [Release notes](https://github.com/DataDog/dd-trace-js/releases)
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.62.0...v5.63.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 19:32:42 -04:00
dependabot[bot] d01e3ad09c chore(deps): bump ukkonen from 2.1.0 to 2.2.0 (#9967)
Bumps [ukkonen](https://github.com/sunesimonsen/ukkonen) from 2.1.0 to 2.2.0.
- [Changelog](https://github.com/sunesimonsen/ukkonen/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sunesimonsen/ukkonen/compare/v2.1.0...v2.2.0)

---
updated-dependencies:
- dependency-name: ukkonen
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 19:32:17 -04:00
dependabot[bot] edb6d44bdc chore(deps-dev): bump discord-api-types from 0.37.119 to 0.38.20 (#9965)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.37.119 to 0.38.20.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.37.119...0.38.20)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-version: 0.38.20
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 19:31:50 -04:00
222 changed files with 3017 additions and 3012 deletions
-4
View File
@@ -211,10 +211,6 @@ GITHUB_APP_PRIVATE_KEY=
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# The GitLab integration allows previewing issue and merge request links as rich mentions
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
+3
View File
@@ -26,3 +26,6 @@ updates:
aws:
patterns:
- "@aws-sdk/*"
radix-ui:
patterns:
- "@radix-ui/*"
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint
- run: yarn lint --quiet
types:
needs: build
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.86.1
Licensed Work: Outline 0.87.1
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-08-09
Change Date: 2029-08-31
Change License: Apache License, Version 2.0
-21
View File
@@ -3,7 +3,6 @@ import { toast } from "sonner";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createActionV2 } from "..";
import { ActiveDocumentSection } from "../sections";
@@ -50,16 +49,6 @@ export const resolveCommentFactory = ({
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onResolve();
toast.success(t("Thread resolved"));
},
@@ -82,16 +71,6 @@ export const unresolveCommentFactory = ({
stores.policies.abilities(comment.documentId).update,
perform: async () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onUnresolve();
},
});
+1 -1
View File
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
key={collaborator.id}
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
+11
View File
@@ -11,9 +11,15 @@ class DocumentContext {
/** The editor instance for this document */
editor?: Editor;
/** The ID of the currently focused comment, or null if no comment is focused */
@observable
focusedCommentId: string | null = null;
/** Whether the editor has been initialized */
@observable
isEditorInitialized: boolean = false;
/** The headings in the document */
@observable
headings: Heading[] = [];
@@ -39,6 +45,11 @@ class DocumentContext {
this.isEditorInitialized = initialized;
};
@action
setFocusedCommentId = (commentId: string | null) => {
this.focusedCommentId = commentId;
};
@action
updateState = () => {
this.updateHeadings();
+100 -73
View File
@@ -25,6 +25,10 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
import useActionContext from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
import useStores from "~/hooks/useStores";
type Props = {
document: Document;
@@ -50,6 +54,7 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const { userMemberships, groupMemberships } = useStores();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
@@ -78,87 +83,109 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
groupMemberships.getByDocumentId(document.id)
);
const sidebarContext = determineSidebarContext({
document,
user,
currentContext: locationSidebarContext,
});
return (
<DocumentLink
ref={itemRef}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
const actionContext = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId ? document.collectionId : undefined,
});
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
const contextMenuAction = useDocumentMenuAction({ document });
return (
<ContextMenu
action={contextMenuAction}
context={actionContext}
ariaLabel={t("Document options")}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
>
<DocumentLink
ref={itemRef}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
</ContextMenu>
);
}
@@ -30,15 +30,10 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
) {
const authorName = author.name;
const urlObj = new URL(url);
let service;
if (urlObj.hostname === "github.com") {
service = IntegrationService.GitHub;
} else if (urlObj.hostname === "gitlab.com") {
service = IntegrationService.GitLab;
} else {
service = IntegrationService.Linear;
}
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
+2 -2
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<unknown>> {
export interface LazyComponent<T extends React.ComponentType<any>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
@@ -34,7 +34,7 @@ interface LazyLoadOptions {
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<unknown>>(
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
+98
View File
@@ -0,0 +1,98 @@
import * as React from "react";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { ActionContext, ActionV2Variant, ActionV2WithChildren } from "~/types";
import { toMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
/** Action context to use - new context will be created if not provided */
context?: ActionContext;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
ariaLabel: string;
/** Callback when menu is opened */
onOpen?: () => void;
/** Callback when menu is closed */
onClose?: () => void;
};
export const ContextMenu = observer(
({ action, children, ariaLabel, context, onOpen, onClose }: Props) => {
const isMobile = useMobile();
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
return [];
}
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (open) {
onOpen?.();
} else {
onClose?.();
}
},
[onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
const handleCloseAutoFocus = React.useCallback(
(e: Event) => e.preventDefault(),
[]
);
if (isMobile) {
return <>{children}</>;
}
const content = toMenuItems(menuItems);
return (
<MenuProvider variant={"context"}>
<Menu onOpenChange={handleOpenChange}>
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
<MenuContent
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
</MenuContent>
</Menu>
</MenuProvider>
);
}
);
+22 -23
View File
@@ -8,11 +8,8 @@ import {
DrawerTitle,
DrawerTrigger,
} from "~/components/primitives/Drawer";
import {
DropdownMenu as DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuContent,
} from "~/components/primitives/DropdownMenu";
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
@@ -23,7 +20,7 @@ import {
MenuItem,
MenuItemWithChildren,
} from "~/types";
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
import { toMenuItems, toMobileMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
@@ -66,7 +63,7 @@ export const DropdownMenu = observer(
const [open, setOpen] = React.useState(false);
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext =
context ??
@@ -126,24 +123,26 @@ export const DropdownMenu = observer(
);
}
const content = toDropdownMenuItems(menuItems);
const content = toMenuItems(menuItems);
return (
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
{append}
</DropdownMenuContent>
</DropdownMenuRoot>
<MenuProvider variant={"dropdown"}>
<Menu open={open} onOpenChange={handleOpenChange}>
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</MenuTrigger>
<MenuContent
align={align}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
{append}
</MenuContent>
</Menu>
</MenuProvider>
);
}
)
+39 -49
View File
@@ -1,28 +1,18 @@
import { CheckmarkIcon } from "outline-icons";
import {
DropdownMenuButton,
DropdownMenuExternalLink,
DropdownMenuGroup,
DropdownMenuInternalLink,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
} from "~/components/primitives/DropdownMenu";
import {
MenuButton,
MenuIconWrapper,
MenuInternalLink,
MenuExternalLink,
MenuLabel,
MenuSeparator,
MenuDisclosure,
SelectedIconWrapper,
} from "~/components/primitives/components/Menu";
SubMenu,
SubMenuTrigger,
SubMenuContent,
MenuGroup,
} from "~/components/primitives/Menu";
import * as Components from "~/components/primitives/components/Menu";
import { MenuItem } from "~/types";
export function toDropdownMenuItems(items: MenuItem[]) {
export function toMenuItems(items: MenuItem[]) {
const filteredItems = filterMenuItems(items);
if (!filteredItems.length) {
@@ -39,15 +29,15 @@ export function toDropdownMenuItems(items: MenuItem[]) {
return filteredItems.map((item, index) => {
const icon = showIcon ? (
<MenuIconWrapper aria-hidden>
<Components.MenuIconWrapper aria-hidden>
{"icon" in item ? item.icon : null}
</MenuIconWrapper>
</Components.MenuIconWrapper>
) : undefined;
switch (item.type) {
case "button":
return (
<DropdownMenuButton
<MenuButton
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -61,7 +51,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
case "route":
return (
<DropdownMenuInternalLink
<MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -72,7 +62,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
case "link":
return (
<DropdownMenuExternalLink
<MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -85,33 +75,33 @@ export function toDropdownMenuItems(items: MenuItem[]) {
);
case "submenu": {
const submenuItems = toDropdownMenuItems(item.items);
const submenuItems = toMenuItems(item.items);
if (!submenuItems?.length) {
return null;
}
return (
<DropdownSubMenu key={`${item.type}-${item.title}-${index}`}>
<DropdownSubMenuTrigger
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenuTrigger
label={item.title as string}
icon={icon}
disabled={item.disabled}
/>
<DropdownSubMenuContent>{submenuItems}</DropdownSubMenuContent>
</DropdownSubMenu>
<SubMenuContent>{submenuItems}</SubMenuContent>
</SubMenu>
);
}
case "group": {
const groupItems = toDropdownMenuItems(item.items);
const groupItems = toMenuItems(item.items);
if (!groupItems?.length) {
return null;
}
return (
<DropdownMenuGroup
<MenuGroup
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
items={groupItems}
@@ -120,7 +110,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
}
case "separator":
return <DropdownMenuSeparator key={`${item.type}-${index}`} />;
return <MenuSeparator key={`${item.type}-${index}`} />;
default:
return null;
@@ -149,15 +139,15 @@ export function toMobileMenuItems(
return filteredItems.map((item, index) => {
const icon = showIcon ? (
<MenuIconWrapper aria-hidden>
<Components.MenuIconWrapper aria-hidden>
{"icon" in item ? item.icon : null}
</MenuIconWrapper>
</Components.MenuIconWrapper>
) : undefined;
switch (item.type) {
case "button":
return (
<MenuButton
<Components.MenuButton
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
$dangerous={item.dangerous}
@@ -167,31 +157,31 @@ export function toMobileMenuItems(
}}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
<Components.MenuLabel>{item.title}</Components.MenuLabel>
{item.selected !== undefined && (
<SelectedIconWrapper aria-hidden>
<Components.SelectedIconWrapper aria-hidden>
{item.selected ? <CheckmarkIcon /> : null}
</SelectedIconWrapper>
</Components.SelectedIconWrapper>
)}
</MenuButton>
</Components.MenuButton>
);
case "route":
return (
<MenuInternalLink
<Components.MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
to={item.to}
disabled={item.disabled}
onClick={closeMenu}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
</MenuInternalLink>
<Components.MenuLabel>{item.title}</Components.MenuLabel>
</Components.MenuInternalLink>
);
case "link":
return (
<MenuExternalLink
<Components.MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
@@ -201,8 +191,8 @@ export function toMobileMenuItems(
onClick={closeMenu}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
</MenuExternalLink>
<Components.MenuLabel>{item.title}</Components.MenuLabel>
</Components.MenuExternalLink>
);
case "submenu": {
@@ -217,7 +207,7 @@ export function toMobileMenuItems(
}
return (
<MenuButton
<Components.MenuButton
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
onClick={() => {
@@ -225,9 +215,9 @@ export function toMobileMenuItems(
}}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
<MenuDisclosure />
</MenuButton>
<Components.MenuLabel>{item.title}</Components.MenuLabel>
<Components.MenuDisclosure />
</Components.MenuButton>
);
}
@@ -244,14 +234,14 @@ export function toMobileMenuItems(
return (
<div key={`${item.type}-${item.title}-${index}`}>
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
<Components.MenuHeader>{item.title}</Components.MenuHeader>
{groupItems}
</div>
);
}
case "separator":
return <MenuSeparator key={`${item.type}-${index}`} />;
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
default:
return null;
+1 -1
View File
@@ -14,7 +14,7 @@ describe("PaginatedList", () => {
i18n,
tReady: true,
t: ((key: string) => key) as TFunction,
} as unknown;
} as any;
it("with no items renders nothing", () => {
const result = render(
+4 -6
View File
@@ -34,11 +34,11 @@ interface Props<T extends PaginatedItem>
* @param options Pagination and other query options
*/
fetch?: (
options: Record<string, unknown> | undefined
options: Record<string, any> | undefined
) => Promise<unknown[] | undefined> | undefined;
/** Additional options to pass to the fetch function */
options?: Record<string, unknown>;
options?: Record<string, any>;
/** Optional header content to display above the list */
heading?: React.ReactNode;
@@ -77,9 +77,7 @@ interface Props<T extends PaginatedItem>
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (
name: React.ReactElement<unknown> | string
) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
/**
* Handler for escape key press
@@ -208,7 +206,7 @@ const PaginatedList = <T extends PaginatedItem>({
if (fetch) {
void fetchResults();
}
}, [fetch, fetchResults]);
}, [fetch]);
// Handle updates to fetch or options
React.useEffect(() => {
+1 -1
View File
@@ -9,7 +9,7 @@ function Toasts() {
return (
<StyledToaster
theme={ui.resolvedTheme as unknown}
theme={ui.resolvedTheme as any}
closeButton
toastOptions={{
duration: 5000,
-291
View File
@@ -1,291 +0,0 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { LocationDescriptor } from "history";
import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import Tooltip from "~/components/Tooltip";
import { fadeAndScaleIn } from "~/styles/animations";
import {
MenuButton,
MenuDisclosure,
MenuExternalLink,
MenuHeader,
MenuInternalLink,
MenuLabel,
MenuSeparator,
MenuSubTrigger,
SelectedIconWrapper,
} from "./components/Menu";
import { CheckmarkIcon } from "outline-icons";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownSubMenu = DropdownMenuPrimitive.Sub;
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DropdownMenuPrimitive.Trigger ref={ref} {...rest} asChild>
{children}
</DropdownMenuPrimitive.Trigger>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
{...rest}
sideOffset={4}
collisionPadding={6}
asChild
>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
type DropdownSubMenuTriggerProps = BaseDropdownItemProps &
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>;
const DropdownSubMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
DropdownSubMenuTriggerProps
>((props, ref) => {
const { label, icon, disabled, ...rest } = props;
return (
<DropdownMenuPrimitive.SubTrigger ref={ref} {...rest} asChild>
<MenuSubTrigger disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
<MenuDisclosure />
</MenuSubTrigger>
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownSubMenuTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownSubMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={ref}
{...rest}
collisionPadding={6}
asChild
>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DropdownMenuPrimitive.SubContent>
</DropdownMenuPrimitive.Portal>
);
});
DropdownSubMenuContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
type DropdownMenuGroupProps = {
label: string;
items: React.ReactNode[];
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
"children" | "asChild"
>;
const DropdownMenuGroup = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Group>,
DropdownMenuGroupProps
>((props, ref) => {
const { label, items, ...rest } = props;
return (
<DropdownMenuPrimitive.Group ref={ref} {...rest}>
<DropdownMenuLabel>{label}</DropdownMenuLabel>
{items}
</DropdownMenuPrimitive.Group>
);
});
DropdownMenuGroup.displayName = DropdownMenuPrimitive.Group.displayName;
type BaseDropdownItemProps = {
label: string;
icon?: React.ReactElement;
disabled?: boolean;
};
type DropdownMenuButtonProps = BaseDropdownItemProps & {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
tooltip?: React.ReactChild;
selected?: boolean;
dangerous?: boolean;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const DropdownMenuButton = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuButtonProps
>((props, ref) => {
const {
label,
icon,
tooltip,
disabled,
selected,
dangerous,
onClick,
...rest
} = props;
const button = (
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
<MenuButton disabled={disabled} $dangerous={dangerous} onClick={onClick}>
{icon}
<MenuLabel>{label}</MenuLabel>
{selected !== undefined && (
<SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : null}
</SelectedIconWrapper>
)}
</MenuButton>
</DropdownMenuPrimitive.Item>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
<div>{button}</div>
</Tooltip>
) : (
<>{button}</>
);
});
DropdownMenuButton.displayName = "DropdownMenuButton";
type DropdownMenuInternalLinkProps = BaseDropdownItemProps & {
to: LocationDescriptor;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const DropdownMenuInternalLink = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuInternalLinkProps
>((props, ref) => {
const { label, icon, disabled, to, ...rest } = props;
return (
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
<MenuInternalLink to={to} disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
</MenuInternalLink>
</DropdownMenuPrimitive.Item>
);
});
DropdownMenuInternalLink.displayName = "DropdownMenuInternalLink";
type DropdownMenuExternalLinkProps = BaseDropdownItemProps & {
href: string;
target?: string;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const DropdownMenuExternalLink = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuExternalLinkProps
>((props, ref) => {
const { label, icon, disabled, href, target, ...rest } = props;
return (
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
<MenuExternalLink href={href} target={target} disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
</MenuExternalLink>
</DropdownMenuPrimitive.Item>
);
});
DropdownMenuExternalLink.displayName = "DropdownMenuExternalLink";
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>((props, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} {...props} asChild>
<MenuSeparator />
</DropdownMenuPrimitive.Separator>
));
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.Label ref={ref} {...props} asChild>
<MenuHeader>{children}</MenuHeader>
</DropdownMenuPrimitive.Label>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
/** Styled components */
const StyledScrollable = styled(Scrollable)`
z-index: ${depths.menu};
min-width: 180px;
max-width: 276px;
min-height: 44px;
max-height: min(85vh, var(--radix-dropdown-menu-content-available-height));
font-weight: normal;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
outline: none;
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
&[data-state="open"] {
animation: ${fadeAndScaleIn} 150ms ease-out;
}
@media print {
display: none;
}
`;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuButton,
DropdownMenuInternalLink,
DropdownMenuExternalLink,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownSubMenu,
DropdownSubMenuTrigger,
DropdownSubMenuContent,
};
+19
View File
@@ -0,0 +1,19 @@
import { CSRF } from "@shared/constants";
import { useCsrfToken } from "~/hooks/useCsrfToken";
/**
* Form component that automatically includes a CSRF token as a hidden input field.
*/
export const Form = ({
children,
...props
}: React.FormHTMLAttributes<HTMLFormElement>) => {
const token = useCsrfToken();
return (
<form {...props}>
{token && <input type="hidden" name={CSRF.fieldName} value={token} />}
{children}
</form>
);
};
@@ -0,0 +1,23 @@
import { createContext, useContext, useMemo } from "react";
type MenuVariant = "dropdown" | "context";
const MenuContext = createContext<{
variant: MenuVariant;
}>({
variant: "dropdown",
});
export function MenuProvider({
variant,
children,
}: {
variant: MenuVariant;
children: React.ReactNode;
}) {
const ctx = useMemo(() => ({ variant }), [variant]);
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
}
export const useMenuContext = () => useContext(MenuContext);
+435
View File
@@ -0,0 +1,435 @@
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import * as Components from "../components/Menu";
import { LocationDescriptor } from "history";
import * as React from "react";
import Tooltip from "~/components/Tooltip";
import { CheckmarkIcon } from "outline-icons";
import { useMenuContext } from "./MenuContext";
type MenuProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Root
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
const Menu = ({ children, ...rest }: MenuProps) => {
const { variant } = useMenuContext();
const Root =
variant === "dropdown"
? DropdownMenuPrimitive.Root
: ContextMenuPrimitive.Root;
return <Root {...rest}>{children}</Root>;
};
type SubMenuProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Sub
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Sub>;
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
const { variant } = useMenuContext();
const Sub =
variant === "dropdown"
? DropdownMenuPrimitive.Sub
: ContextMenuPrimitive.Sub;
return <Sub {...rest}>{children}</Sub>;
};
type TriggerProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Trigger
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Trigger>;
const MenuTrigger = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Trigger>
| React.ElementRef<typeof ContextMenuPrimitive.Trigger>,
TriggerProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Trigger =
variant === "dropdown"
? DropdownMenuPrimitive.Trigger
: ContextMenuPrimitive.Trigger;
return (
<Trigger ref={ref} {...rest} asChild>
{children}
</Trigger>
);
});
MenuTrigger.displayName = "MenuTrigger";
type ContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Content
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
const MenuContent = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
ContentProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Portal =
variant === "dropdown"
? DropdownMenuPrimitive.Portal
: ContextMenuPrimitive.Portal;
const Content =
variant === "dropdown"
? DropdownMenuPrimitive.Content
: ContextMenuPrimitive.Content;
const offsetProp =
variant === "dropdown" ? { sideOffset: 4 } : { alignOffset: 4 };
const contentProps = {
maxHeightVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-available-height"
: "--radix-context-menu-content-available-height",
transformOriginVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-transform-origin"
: "--radix-context-menu-content-transform-origin",
};
return (
<Portal>
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
<Components.MenuContent {...contentProps} hiddenScrollbars>
{children}
</Components.MenuContent>
</Content>
</Portal>
);
});
MenuContent.displayName = "MenuContent";
type SubMenuTriggerProps = BaseItemProps &
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger>;
const SubMenuTrigger = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
SubMenuTriggerProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, ...rest } = props;
const Trigger =
variant === "dropdown"
? DropdownMenuPrimitive.SubTrigger
: ContextMenuPrimitive.SubTrigger;
return (
<Trigger ref={ref} {...rest} asChild>
<Components.MenuSubTrigger disabled={disabled}>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
<Components.MenuDisclosure />
</Components.MenuSubTrigger>
</Trigger>
);
});
SubMenuTrigger.displayName = "SubMenuTrigger";
type SubMenuContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubContent
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>;
const SubMenuContent = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
SubMenuContentProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Portal =
variant === "dropdown"
? DropdownMenuPrimitive.Portal
: ContextMenuPrimitive.Portal;
const Content =
variant === "dropdown"
? DropdownMenuPrimitive.SubContent
: ContextMenuPrimitive.SubContent;
const contentProps = {
maxHeightVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-available-height"
: "--radix-context-menu-content-available-height",
transformOriginVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-transform-origin"
: "--radix-context-menu-content-transform-origin",
};
return (
<Portal>
<Content ref={ref} {...rest} collisionPadding={6} asChild>
<Components.MenuContent {...contentProps} hiddenScrollbars>
{children}
</Components.MenuContent>
</Content>
</Portal>
);
});
SubMenuContent.displayName = "SubMenuContent";
type MenuGroupProps = {
label: string;
items: React.ReactNode[];
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
"children" | "asChild"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Group>,
"children" | "asChild"
>;
const MenuGroup = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
MenuGroupProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, items, ...rest } = props;
const Group =
variant === "dropdown"
? DropdownMenuPrimitive.Group
: ContextMenuPrimitive.Group;
return (
<Group ref={ref} {...rest}>
<MenuLabel>{label}</MenuLabel>
{items}
</Group>
);
});
MenuGroup.displayName = "MenuGroup";
type BaseItemProps = {
label: string;
icon?: React.ReactElement;
disabled?: boolean;
};
type MenuButtonProps = BaseItemProps & {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
tooltip?: React.ReactChild;
selected?: boolean;
dangerous?: boolean;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const MenuButton = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuButtonProps
>((props, ref) => {
const { variant } = useMenuContext();
const {
label,
icon,
tooltip,
disabled,
selected,
dangerous,
onClick,
...rest
} = props;
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
const button = (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuButton
disabled={disabled}
$dangerous={dangerous}
onClick={onClick}
>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
{selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : null}
</Components.SelectedIconWrapper>
)}
</Components.MenuButton>
</Item>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
<div>{button}</div>
</Tooltip>
) : (
<>{button}</>
);
});
MenuButton.displayName = "MenuButton";
type MenuInternalLinkProps = BaseItemProps & {
to: LocationDescriptor;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const MenuInternalLink = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuInternalLinkProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, to, ...rest } = props;
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
return (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuInternalLink to={to} disabled={disabled}>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
</Components.MenuInternalLink>
</Item>
);
});
MenuInternalLink.displayName = "MenuInternalLink";
type MenuExternalLinkProps = BaseItemProps & {
href: string;
target?: string;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const MenuExternalLink = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuExternalLinkProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, href, target, ...rest } = props;
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
return (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuExternalLink
href={href}
target={target}
disabled={disabled}
>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
</Components.MenuExternalLink>
</Item>
);
});
MenuExternalLink.displayName = "MenuExternalLink";
type MenuSeparatorProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Separator
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>;
const MenuSeparator = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
MenuSeparatorProps
>((props, ref) => {
const { variant } = useMenuContext();
const Separator =
variant === "dropdown"
? DropdownMenuPrimitive.Separator
: ContextMenuPrimitive.Separator;
return (
<Separator ref={ref} {...props} asChild>
<Components.MenuSeparator />
</Separator>
);
});
MenuSeparator.displayName = "MenuSeparator";
type MenuLabelProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Label
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label>;
const MenuLabel = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Label>
| React.ElementRef<typeof ContextMenuPrimitive.Label>,
MenuLabelProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Label =
variant === "dropdown"
? DropdownMenuPrimitive.Label
: ContextMenuPrimitive.Label;
return (
<Label ref={ref} {...rest} asChild>
<Components.MenuHeader>{children}</Components.MenuHeader>
</Label>
);
});
MenuLabel.displayName = "MenuLabel";
export {
Menu,
MenuTrigger,
MenuContent,
MenuButton,
MenuInternalLink,
MenuExternalLink,
MenuSeparator,
MenuGroup,
MenuLabel,
SubMenu,
SubMenuTrigger,
SubMenuContent,
};
+31 -1
View File
@@ -3,7 +3,9 @@ import { ellipsis } from "polished";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import { fadeAndScaleIn } from "~/styles/animations";
type BaseMenuItemProps = {
disabled?: boolean;
@@ -135,3 +137,31 @@ export const SelectedIconWrapper = styled.span`
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export const MenuContent = styled(Scrollable)<{
maxHeightVar: string;
transformOriginVar: string;
}>`
z-index: ${depths.menu};
min-width: 180px;
max-width: 276px;
min-height: 44px;
max-height: ${({ maxHeightVar }) => `min(85vh, var(${maxHeightVar}))`};
font-weight: normal;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
outline: none;
transform-origin: ${({ transformOriginVar }) => `var(${transformOriginVar})`};
&[data-state="open"] {
animation: ${fadeAndScaleIn} 150ms ease-out;
}
@media print {
display: none;
}
`;
+18 -24
View File
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query, documents.add])
}, [query])
);
useEffect(() => {
@@ -79,22 +79,6 @@ const LinkEditor: React.FC<Props> = ({
}
}, [trimmedQuery, request]);
const save = React.useCallback(
(href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
},
[onSelectLink, from, to]
);
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
@@ -123,7 +107,20 @@ const LinkEditor: React.FC<Props> = ({
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue, handleRemoveLink, save]);
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
};
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
@@ -198,7 +195,7 @@ const LinkEditor: React.FC<Props> = ({
}
};
const handleRemoveLink = React.useCallback(() => {
const handleRemoveLink = () => {
discardRef.current = true;
const { state, dispatch } = view;
@@ -206,12 +203,9 @@ const LinkEditor: React.FC<Props> = ({
dispatch(state.tr.removeMark(from, to, mark));
}
if (onRemoveLink) {
onRemoveLink();
}
onRemoveLink?.();
view.focus();
}, [view, mark, from, to, onRemoveLink]);
};
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
+1 -10
View File
@@ -184,16 +184,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
collections,
]);
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
const handleSelect = useCallback(
async (item: MentionItem) => {
+1 -1
View File
@@ -87,7 +87,7 @@ function useIsActive(state: EditorState) {
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as unknown).content;
const nodes = (fragment as any).content;
return some(nodes, (n) => n.content.size);
}
@@ -44,9 +44,7 @@ export default class ClipboardTextSerializer extends Extension {
softBreak: true,
})
: slice.content.content
.map((node) =>
ProsemirrorHelper.toPlainText(node, this.editor.schema)
)
.map((node) => ProsemirrorHelper.toPlainText(node))
.join("");
},
},
+2 -5
View File
@@ -35,7 +35,6 @@ import Extension, {
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween";
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
import Mark from "@shared/editor/marks/Mark";
import { basicExtensions as extensions } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node";
@@ -627,8 +626,7 @@ export class Editor extends React.PureComponent<
*
* @returns A list of headings in the document
*/
public getHeadings = () =>
ProsemirrorHelper.getHeadings(this.view.state.doc, this.schema);
public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc);
/**
* Return the images in the current editor.
@@ -721,9 +719,8 @@ export class Editor extends React.PureComponent<
*/
public getPlainText = () => {
const { doc } = this.view.state;
const textSerializers = getTextSerializers(this.schema);
return textBetween(doc, 0, doc.content.size, textSerializers);
return textBetween(doc, 0, doc.content.size);
};
private dispatchThemeChanged = (event: CustomEvent) => {
+30
View File
@@ -0,0 +1,30 @@
import { CSRF } from "@shared/constants";
import { useState, useEffect } from "react";
import { getCookie } from "tiny-cookie";
/**
* React hook for accessing CSRF tokens in components
*
* @returns The CSRF token string or null if not found
*/
export function useCsrfToken() {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
const updateToken = () => {
const currentToken = getCookie(CSRF.cookieName);
setToken(currentToken);
};
// Initial load
updateToken();
// Listen for cookie changes (when navigating or refreshing)
const interval = setInterval(updateToken, 1000);
return () => clearInterval(interval);
}, []);
return token;
}
+135
View File
@@ -0,0 +1,135 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { InputIcon, SearchIcon } from "outline-icons";
import { ActionV2Separator, createActionV2 } from "~/actions";
import {
restoreDocument,
unsubscribeDocument,
subscribeDocument,
restoreDocumentToCollection,
starDocument,
unstarDocument,
editDocument,
shareDocument,
createNestedDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
publishDocument,
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory,
pinDocument,
createDocumentFromTemplate,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
downloadDocument,
copyDocument,
printDocument,
searchInDocument,
deleteDocument,
leaveDocument,
permanentlyDeleteDocument,
} from "~/actions/definitions/documents";
import { ActiveDocumentSection } from "~/actions/sections";
import useMobile from "./useMobile";
import Document from "~/models/Document";
import usePolicy from "./usePolicy";
import useCurrentUser from "./useCurrentUser";
import { useTemplateMenuActions } from "./useTemplateMenuActions";
import { useMenuAction } from "./useMenuAction";
type Props = {
/** Document for which the actions are generated */
document: Document;
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Document) => void;
};
export function useDocumentMenuAction({
document,
onFindAndReplace,
onRename,
onSelectTemplate,
}: Props) {
const { t } = useTranslation();
const isMobile = useMobile();
const user = useCurrentUser();
const can = usePolicy(document);
const templateMenuActions = useTemplateMenuActions({
document,
onSelectTemplate,
});
const actions = useMemo(
() => [
restoreDocument,
restoreDocumentToCollection,
starDocument,
unstarDocument,
subscribeDocument,
unsubscribeDocument,
createActionV2({
name: `${t("Find and replace")}`,
section: ActiveDocumentSection,
icon: <SearchIcon />,
visible: !!onFindAndReplace && isMobile,
perform: () => onFindAndReplace?.(),
}),
ActionV2Separator,
editDocument,
createActionV2({
name: `${t("Rename")}`,
section: ActiveDocumentSection,
icon: <InputIcon />,
visible: !!can.update && !user.separateEditMode && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
shareDocument,
createNestedDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
publishDocument,
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory({ actions: templateMenuActions }),
pinDocument,
createDocumentFromTemplate,
ActionV2Separator,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
downloadDocument,
copyDocument,
printDocument,
searchInDocument,
ActionV2Separator,
deleteDocument,
permanentlyDeleteDocument,
leaveDocument,
],
[
t,
isMobile,
templateMenuActions,
can.update,
user.separateEditMode,
onFindAndReplace,
onRename,
]
);
return useMenuAction(actions);
}
+1 -1
View File
@@ -57,7 +57,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
}
if (isDocumentUrl(navigateTo)) {
const document = documents.getByUrl(navigateTo);
const document = documents.get(navigateTo);
if (document) {
navigateTo = document.path;
}
+35 -4
View File
@@ -1,13 +1,44 @@
import { useLocation } from "react-router-dom";
import useQuery from "~/hooks/useQuery";
import useStores from "./useStores";
import { useDocumentContext } from "~/components/DocumentContext";
import { useEffect } from "react";
import { useHistory } from "react-router-dom";
export default function useFocusedComment() {
/**
* Custom hook to retrieve the currently focused comment in a document.
* It checks both the document context and the query string for the comment ID.
* If a comment is focused, it returns the comment itself or the parent thread if it exists
*/
export function useFocusedComment() {
const { comments } = useStores();
const location = useLocation<{ commentId?: string }>();
const context = useDocumentContext();
const query = useQuery();
const focusedCommentId = location.state?.commentId || query.get("commentId");
const focusedCommentId = context.focusedCommentId || query.get("commentId");
const comment = focusedCommentId ? comments.get(focusedCommentId) : undefined;
const history = useHistory();
// Move the query string into context
useEffect(() => {
if (focusedCommentId && context.focusedCommentId !== focusedCommentId) {
context.setFocusedCommentId(focusedCommentId);
}
}, [focusedCommentId, context]);
// Clear query string from location
useEffect(() => {
if (focusedCommentId) {
const params = new URLSearchParams(history.location.search);
if (params.get("commentId") === focusedCommentId) {
params.delete("commentId");
history.replace({
pathname: history.location.pathname,
search: params.toString(),
state: history.location.state,
});
}
}
}, [focusedCommentId, history]);
return comment?.parentCommentId
? comments.get(comment.parentCommentId)
+10 -1
View File
@@ -1,9 +1,10 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { Primitive } from "utility-types";
import Storage from "@shared/utils/Storage";
import { isBrowser } from "@shared/utils/browser";
import Logger from "~/utils/Logger";
import useEventListener from "./useEventListener";
import usePrevious from "./usePrevious";
type Options = {
/* Whether to listen and react to changes in the value from other tabs */
@@ -41,6 +42,7 @@ export default function usePersistedState<T extends Primitive | object>(
defaultValue: T,
options?: Options
): [T, (value: T) => void] {
const previousKey = usePrevious(key);
const [storedValue, setStoredValue] = useState(() => {
if (!isBrowser) {
return defaultValue;
@@ -65,6 +67,13 @@ export default function usePersistedState<T extends Primitive | object>(
[key, storedValue]
);
// Sync state when key changes
useEffect(() => {
if (previousKey !== key) {
setStoredValue(Storage.get(key) ?? defaultValue);
}
}, [previousKey, key, defaultValue]);
// Listen to the key changing in other tabs so we can keep UI in sync
useEventListener("storage", (event: StorageEvent) => {
if (options?.listen !== false && event.key === key && event.newValue) {
+15 -103
View File
@@ -1,6 +1,5 @@
import noop from "lodash/noop";
import { observer } from "mobx-react";
import { InputIcon, SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -11,49 +10,14 @@ import Document from "~/models/Document";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import Switch from "~/components/Switch";
import { ActionV2Separator, createActionV2 } from "~/actions";
import {
pinDocument,
createTemplateFromDocument,
subscribeDocument,
unsubscribeDocument,
moveDocument,
deleteDocument,
permanentlyDeleteDocument,
downloadDocument,
importDocument,
starDocument,
unstarDocument,
duplicateDocument,
archiveDocument,
openDocumentHistory,
openDocumentInsights,
publishDocument,
unpublishDocument,
printDocument,
openDocumentComments,
createDocumentFromTemplate,
createNestedDocument,
shareDocument,
copyDocument,
searchInDocument,
leaveDocument,
moveTemplate,
restoreDocument,
restoreDocumentToCollection,
editDocument,
applyTemplateFactory,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { ActiveDocumentSection } from "~/actions/sections";
import { useTemplateMenuActions } from "~/hooks/useTemplateMenuActions";
import { useMenuAction } from "~/hooks/useMenuAction";
import { MenuSeparator } from "~/components/primitives/components/Menu";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
type Props = {
/** Document for which the menu is to be shown */
@@ -95,7 +59,13 @@ function DocumentMenu({
const isMobile = useMobile();
const can = usePolicy(document);
const { subscriptions, pins } = useStores();
const { userMemberships, groupMemberships, subscriptions, pins } =
useStores();
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
groupMemberships.getByDocumentId(document.id)
);
const {
loading: auxDataLoading,
@@ -155,78 +125,18 @@ function DocumentMenu({
[document]
);
const templateMenuActions = useTemplateMenuActions({
const rootAction = useDocumentMenuAction({
document,
onFindAndReplace,
onRename,
onSelectTemplate,
});
const actions = React.useMemo(
() => [
restoreDocument,
restoreDocumentToCollection,
starDocument,
unstarDocument,
subscribeDocument,
unsubscribeDocument,
createActionV2({
name: `${t("Find and replace")}`,
section: ActiveDocumentSection,
icon: <SearchIcon />,
visible: !!onFindAndReplace && isMobile,
perform: () => onFindAndReplace?.(),
}),
ActionV2Separator,
editDocument,
createActionV2({
name: `${t("Rename")}`,
section: ActiveDocumentSection,
icon: <InputIcon />,
visible: !!can.update && !user.separateEditMode && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
shareDocument,
createNestedDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
publishDocument,
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory({ actions: templateMenuActions }),
pinDocument,
createDocumentFromTemplate,
ActionV2Separator,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
downloadDocument,
copyDocument,
printDocument,
searchInDocument,
ActionV2Separator,
deleteDocument,
permanentlyDeleteDocument,
leaveDocument,
],
[
t,
isMobile,
templateMenuActions,
can.update,
user.separateEditMode,
onFindAndReplace,
onRename,
]
);
const rootAction = useMenuAction(actions);
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
activeCollectionId: document.collectionId ?? undefined,
activeCollectionId:
!isShared && document.collectionId ? document.collectionId : undefined,
});
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
@@ -280,6 +190,7 @@ function DocumentMenu({
}, [
t,
can.update,
can.updateInsights,
document.embedsDisabled,
document.fullWidth,
document.insightsEnabled,
@@ -288,6 +199,7 @@ function DocumentMenu({
showToggleEmbeds,
handleEmbedsToggle,
handleFullWidthToggle,
handleInsightsToggle,
]);
return (
-38
View File
@@ -1,38 +0,0 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useMenuAction } from "~/hooks/useMenuAction";
import { useMemo } from "react";
import { createActionV2 } from "~/actions";
import { GroupSection } from "~/actions/sections";
type Props = {
onRemove: () => void;
};
function GroupMemberMenu({ onRemove }: Props) {
const { t } = useTranslation();
const actions = useMemo(
() => [
createActionV2({
name: t("Remove"),
section: GroupSection,
dangerous: true,
perform: onRemove,
}),
],
[t, onRemove]
);
const rootAction = useMenuAction(actions);
return (
<DropdownMenu action={rootAction} ariaLabel={t("Group member options")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
export default observer(GroupMemberMenu);
+6
View File
@@ -2,6 +2,8 @@ import { isPast } from "date-fns";
import { computed, observable } from "mobx";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import User from "./User";
import Relation from "./decorators/Relation";
class ApiKey extends ParanoidModel {
static modelName = "ApiKey";
@@ -25,6 +27,10 @@ class ApiKey extends ParanoidModel {
@observable
lastActiveAt?: string;
/** The user who this API key belongs to. */
@Relation(() => User)
user: User;
/** The user ID that the API key belongs to. */
userId: string;
+1 -2
View File
@@ -723,8 +723,7 @@ export default class Document extends ArchivableModel implements Searchable {
marks: extensionManager.marks,
});
const text = ProsemirrorHelper.toPlainText(
Node.fromJSON(schema, this.data),
schema
Node.fromJSON(schema, this.data)
);
return text;
};
+6
View File
@@ -1,7 +1,9 @@
import { GroupPermission } from "@shared/types";
import Group from "./Group";
import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
import Field from "./decorators/Field";
/**
* Represents a user's membership to a group.
@@ -22,6 +24,10 @@ class GroupUser extends Model {
/** The group that the user belongs to. */
@Relation(() => Group, { onDelete: "cascade" })
group: Group;
/** The permission of the user in the group. */
@Field
permission: GroupPermission;
}
export default GroupUser;
+1 -2
View File
@@ -75,8 +75,7 @@ const CollectionScene = observer(function _CollectionScene() {
const id = params.id || "";
const urlId = id.split("-").pop() ?? "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const collection: Collection | null | undefined = collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
@@ -1,23 +1,22 @@
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { InputSelect, Option } from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useQuery from "~/hooks/useQuery";
import { CommentSortType } from "~/types";
const CommentSortMenu = () => {
type Props = {
/** Callback when the sort type changes */
onChange?: (sortType: CommentSortType | "resolved") => void;
/** Whether resolved comments are being viewed */
viewingResolved?: boolean;
};
const CommentSortMenu = ({ viewingResolved, onChange }: Props) => {
const { t } = useTranslation();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const history = useHistory();
const user = useCurrentUser();
const params = useQuery();
const preferredSortType = user.getPreference(
UserPreference.SortCommentsByOrderInDocument
@@ -25,42 +24,23 @@ const CommentSortMenu = () => {
? CommentSortType.OrderInDocument
: CommentSortType.MostRecent;
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved ? "resolved" : preferredSortType;
const handleChange = React.useCallback(
(val: string) => {
if (val === "resolved") {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
return;
(val: CommentSortType | "resolved") => {
if (val !== "resolved") {
if (val !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
val === CommentSortType.OrderInDocument
);
void user.save();
}
}
const sortType = val as CommentSortType;
if (sortType !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
sortType === CommentSortType.OrderInDocument
);
void user.save();
}
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
onChange?.(val);
},
[history, location, sidebarContext, user, preferredSortType]
[user, onChange, preferredSortType]
);
const options: Option[] = React.useMemo(
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -17,7 +16,6 @@ import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -51,14 +49,11 @@ function CommentThread({
collapseNumDisplayed = 3,
}: Props) {
const [scrollOnMount] = React.useState(focused && !window.location.hash);
const { editor } = useDocumentContext();
const { editor, setFocusedCommentId } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
const replyRef = React.useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
const user = useCurrentUser();
@@ -102,14 +97,7 @@ function CommentThread({
!(event.target as HTMLElement).classList.contains("comment") &&
event.defaultPrevented === false
) {
history.replace({
search: location.search,
pathname: location.pathname,
state: {
commentId: undefined,
sidebarContext,
},
});
setFocusedCommentId(null);
}
});
@@ -118,15 +106,7 @@ function CommentThread({
}, [editor, thread.id]);
const handleClickThread = () => {
history.replace({
// Clear any commentId from the URL when explicitly focusing a thread
search: thread.isResolved ? "resolved=" : "",
pathname: location.pathname.replace(/\/history$/, ""),
state: {
commentId: thread.id,
sidebarContext,
},
});
setFocusedCommentId(thread.id);
};
const handleClickExpand = (ev: React.SyntheticEvent) => {
@@ -30,6 +30,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
import CommentEditor from "./CommentEditor";
import { HighlightedText } from "./HighlightText";
import { useDocumentContext } from "~/components/DocumentContext";
/**
* Hook to calculate if we should display a timestamp on a comment
@@ -111,6 +112,7 @@ function CommentThreadItem({
onEditStart,
onEditEnd,
}: Props) {
const { setFocusedCommentId } = useDocumentContext();
const { t } = useTranslation();
const user = useCurrentUser();
const [data, setData] = React.useState(comment.data);
@@ -154,6 +156,9 @@ function CommentThreadItem({
const handleUpdate = React.useCallback(
(attrs: { resolved: boolean }) => {
onUpdate?.(comment.id, attrs);
if ("resolved" in attrs) {
setFocusedCommentId(null);
}
},
[comment.id, onUpdate]
);
+19 -6
View File
@@ -13,7 +13,7 @@ import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import { useFocusedComment } from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -31,11 +31,13 @@ function Comments() {
const { editor, isEditorInitialized } = useDocumentContext();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const params = useQuery();
const document = documents.getByUrl(match.params.documentSlug);
const document = documents.get(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
const query = useQuery();
const [viewingResolved, setViewingResolved] = useState(
query.get("resolved") !== null || focusedComment?.isResolved || false
);
const scrollableRef = useRef<HTMLDivElement | null>(null);
const prevThreadCount = useRef(0);
const isAtBottom = useRef(true);
@@ -43,6 +45,13 @@ function Comments() {
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
// Account for the resolved status of the comment changing
useEffect(() => {
if (focusedComment && focusedComment.isResolved !== viewingResolved) {
setViewingResolved(focusedComment.isResolved);
}
}, [focusedComment, viewingResolved]);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document?.id}-new`,
undefined
@@ -57,7 +66,6 @@ function Comments() {
}
: { type: CommentSortType.MostRecent };
const viewingResolved = params.get("resolved") === "";
const threads = !document
? []
: viewingResolved
@@ -124,7 +132,12 @@ function Comments() {
title={
<Flex align="center" justify="space-between" auto>
<span>{t("Comments")}</span>
<CommentSortMenu />
<CommentSortMenu
viewingResolved={viewingResolved}
onChange={(val) => {
setViewingResolved(val === "resolved");
}}
/>
</Flex>
}
onClose={() => ui.set({ commentsExpanded: false })}
@@ -67,9 +67,7 @@ function DataLoader({ match, children }: Props) {
const { revisionId, documentSlug } = match.params;
// Allows loading by /doc/slug-<urlId> or /doc/<id>
const document =
documents.getByUrl(match.params.documentSlug) ??
documents.get(match.params.documentSlug);
const document = documents.get(match.params.documentSlug);
if (document) {
setDocument(document);
+9 -30
View File
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useHistory, useRouteMatch } from "react-router-dom";
import { useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import Text from "@shared/components/Text";
import { richExtensions, withComments } from "@shared/editor/nodes";
@@ -19,7 +19,7 @@ import Time from "~/components/Time";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import { useFocusedComment } from "~/hooks/useFocusedComment";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
@@ -55,15 +55,15 @@ type Props = Omit<EditorProps, "editorStyle"> & {
* The main document editor includes an editable title with metadata below it,
* and support for commenting.
*/
function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const titleRef = React.useRef<RefHandle>(null);
const { t } = useTranslation();
const match = useRouteMatch();
const { setFocusedCommentId } = useDocumentContext();
const focusedComment = useFocusedComment();
const { ui, comments } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const team = useCurrentTeam({ rejectOnEmpty: false });
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const params = useQuery();
const {
@@ -95,18 +95,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
(focusedComment.isResolved && !viewingResolved) ||
(!focusedComment.isResolved && viewingResolved)
) {
history.replace({
search: focusedComment.isResolved ? "resolved=" : "",
pathname: location.pathname,
state: {
commentId: focusedComment.id,
sidebarContext,
},
});
setFocusedCommentId(focusedComment.id);
}
ui.set({ commentsExpanded: true });
}
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
}, [focusedComment, ui, document.id, params]);
// Save document when blurring title, but delay so that if clicking on a
// button this is allowed to execute first.
@@ -127,16 +120,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
[focusAtStart, ref]
);
const handleClickComment = React.useCallback(
(commentId: string) => {
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId, sidebarContext },
});
},
[history, sidebarContext]
);
// Create a Comment model in local store when a comment mark is created, this
// acts as a local draft before submission.
const handleDraftComment = React.useCallback(
@@ -156,13 +139,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
);
comment.id = commentId;
comments.add(comment);
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId, sidebarContext },
});
setFocusedCommentId(commentId);
},
[comments, user?.id, props.id, history, sidebarContext]
[comments, user?.id, props.id]
);
// Soft delete the Comment model when associated mark is totally removed.
@@ -258,7 +237,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
userId={user?.id}
focusedCommentId={focusedComment?.id}
onClickCommentMark={
commentingEnabled && can.comment ? handleClickComment : undefined
commentingEnabled && can.comment ? setFocusedCommentId : undefined
}
onCreateCommentMark={
commentingEnabled && can.comment ? handleDraftComment : undefined
+1 -1
View File
@@ -166,7 +166,7 @@ function DocumentHeader({
);
useKeyDown(
(event) => event.ctrlKey && event.altKey && event.key === "˙",
(event) => event.ctrlKey && event.altKey && event.code === "KeyH",
handleToggle,
{
allowInInput: true,
+1 -1
View File
@@ -34,7 +34,7 @@ function History() {
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const document = documents.getByUrl(match.params.documentSlug);
const document = documents.get(match.params.documentSlug);
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
const [eventsOffset, setEventsOffset] = React.useState(0);
@@ -51,10 +51,7 @@ type MessageEvent = {
};
};
function MultiplayerEditor(
{ onSynced, ...props }: Props,
ref: React.Ref<unknown>
) {
function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const documentId = props.id;
const history = useHistory();
const { t } = useTranslation();
+5 -5
View File
@@ -1,4 +1,3 @@
import * as React from "react";
import { useState, useRef, useEffect } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -27,6 +26,7 @@ import { Background } from "./components/Background";
import { Centered } from "./components/Centered";
import { ConnectHeader } from "./components/ConnectHeader";
import { TeamSwitcher } from "./components/TeamSwitcher";
import { Form } from "~/components/primitives/Form";
export default function OAuthAuthorize() {
const team = useCurrentTeam({ rejectOnEmpty: false });
@@ -133,10 +133,10 @@ function Authorize() {
{t("Required OAuth parameters are missing")}
<Pre>
{missingParams.map((param) => (
<React.Fragment key={param}>
<>
{param}
<br />
</React.Fragment>
</>
))}
</Pre>
</Text>
@@ -204,7 +204,7 @@ function Authorize() {
</li>
))}
</ul>
<form
<Form
method="POST"
action="/oauth/authorize"
style={{ width: "100%" }}
@@ -237,7 +237,7 @@ function Authorize() {
{t("Authorize")}
</Button>
</Flex>
</form>
</Form>
</Centered>
</Background>
);
@@ -12,15 +12,17 @@ import { detectLanguage } from "~/utils/language";
import { BackButton } from "./BackButton";
import { Background } from "./Background";
import { Centered } from "./Centered";
import { Form } from "~/components/primitives/Form";
const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
const { t } = useTranslation();
return (
<Background>
<BackButton onBack={onBack} />
<ChangeLanguage locale={detectLanguage()} />
<Centered
as="form"
as={Form}
action="/api/installation.create"
method="POST"
gap={12}
@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import { CopyIcon } from "outline-icons";
import * as React from "react";
import { useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -33,7 +32,7 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
{apiKey.userId === user.id
? ""
: t(`by {{ name }}`, { name: user.name })}{" "}
: t(`by {{ name }}`, { name: apiKey.user.name })}{" "}
&middot;{" "}
</Text>
{apiKey.lastActiveAt && (
@@ -51,10 +50,10 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
{apiKey.scope && (
<Tooltip
content={apiKey.scope.map((s) => (
<React.Fragment key={s}>
<>
{s}
<br />
</React.Fragment>
</>
))}
>
<Text type="tertiary">{t("Restricted scope")}</Text>
@@ -25,7 +25,10 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import GroupMemberMenu from "~/menus/GroupMemberMenu";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import { GroupPermission } from "@shared/types";
import { EmptySelectValue, Permission } from "~/types";
import GroupUser from "~/models/GroupUser";
type Props = {
group: Group;
@@ -248,6 +251,7 @@ export const ViewGroupMembersDialog = observer(function ({
</Button>
</span>
)}
<br />
</>
) : (
<Text as="p" type="secondary">
@@ -262,7 +266,6 @@ export const ViewGroupMembersDialog = observer(function ({
/>
</Text>
)}
<br />
<PaginatedList<User>
items={users.inGroup(group.id)}
fetch={groupUsers.fetchPage}
@@ -274,6 +277,10 @@ export const ViewGroupMembersDialog = observer(function ({
<GroupMemberListItem
key={user.id}
user={user}
group={group}
groupUser={groupUsers.orderedData.find(
(gu) => gu.userId === user.id && gu.groupId === group.id
)}
onRemove={can.update ? () => handleRemoveUser(user) : undefined}
/>
)}
@@ -390,6 +397,8 @@ const AddPeopleToGroupDialog = observer(function ({
<GroupMemberListItem
key={item.id}
user={item}
group={group}
groupUser={undefined}
onAdd={() => handleAddUser(item)}
/>
)}
@@ -401,16 +410,41 @@ const AddPeopleToGroupDialog = observer(function ({
type GroupMemberListItemProps = {
user: User;
group: Group;
groupUser: GroupUser | undefined;
onAdd?: () => Promise<void>;
onRemove?: () => Promise<void>;
};
const GroupMemberListItem = observer(function ({
user,
onRemove,
group,
groupUser,
onAdd,
}: GroupMemberListItemProps) {
const { t } = useTranslation();
const { groupUsers } = useStores();
const can = usePolicy(group);
const permissions = React.useMemo(
() =>
[
{
label: t("Manage"),
value: GroupPermission.Admin,
},
{
label: t("Member"),
value: GroupPermission.Member,
},
{
divider: true,
label: t("Remove"),
value: EmptySelectValue,
},
] as Permission[],
[t]
);
return (
<ListItem
@@ -431,11 +465,40 @@ const GroupMemberListItem = observer(function ({
image={<Avatar model={user} size={AvatarSize.Large} />}
actions={
<Flex align="center">
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
{onAdd && (
{onAdd ? (
<Button onClick={onAdd} neutral>
{t("Add")}
</Button>
) : (
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission: GroupPermission | typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await groupUsers.delete({
userId: user.id,
groupId: group.id,
});
} else {
await groupUsers.update({
userId: user.id,
groupId: group.id,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={groupUser?.permission}
/>
</div>
)}
</Flex>
}
+1 -6
View File
@@ -1,5 +1,4 @@
import invariant from "invariant";
import find from "lodash/find";
import isEmpty from "lodash/isEmpty";
import orderBy from "lodash/orderBy";
import sortBy from "lodash/sortBy";
@@ -186,7 +185,7 @@ export default class CollectionsStore extends Store<Collection> {
statusFilter: [CollectionStatusFilter.Archived],
});
get(id: string): Collection | undefined {
get(id: string = ""): Collection | undefined {
return (
this.data.get(id) ??
this.orderedData.find((collection) => id.endsWith(collection.urlId))
@@ -242,10 +241,6 @@ export default class CollectionsStore extends Store<Collection> {
return this.orderedData.map((collection) => collection.asNavigationNode);
}
getByUrl(url: string): Collection | null | undefined {
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
}
async delete(collection: Collection) {
await super.delete(collection);
await this.rootStore.documents.fetchRecentlyUpdated();
+1 -8
View File
@@ -1,7 +1,6 @@
import invariant from "invariant";
import compact from "lodash/compact";
import filter from "lodash/filter";
import find from "lodash/find";
import omitBy from "lodash/omitBy";
import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
@@ -460,7 +459,7 @@ export default class DocumentsStore extends Store<Document> {
@action
prefetchDocument = async (id: string) => {
if (!this.data.get(id) && !this.getByUrl(id)) {
if (!this.get(id)) {
return this.fetch(id, {
prefetch: true,
});
@@ -746,12 +745,6 @@ export default class DocumentsStore extends Store<Document> {
return subscription?.delete();
};
getByUrl = (url = ""): Document | undefined =>
find(
this.orderedData,
(doc) => url.endsWith(doc.urlId) || url.endsWith(doc.id)
);
getCollectionForDocument(document: Document) {
return document.collectionId
? this.rootStore.collections.get(document.collectionId)
+35 -2
View File
@@ -3,6 +3,7 @@ import filter from "lodash/filter";
import { action, runInAction } from "mobx";
import GroupUser from "~/models/GroupUser";
import { PaginationParams } from "~/types";
import { GroupPermission } from "@shared/types";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store, {
@@ -12,7 +13,7 @@ import Store, {
} from "./base/Store";
export default class GroupUsersStore extends Store<GroupUser> {
actions = [RPCAction.Create, RPCAction.Delete];
actions = [RPCAction.Create, RPCAction.Update, RPCAction.Delete];
constructor(rootStore: RootStore) {
super(rootStore, GroupUser);
@@ -43,10 +44,19 @@ export default class GroupUsersStore extends Store<GroupUser> {
};
@action
async create({ groupId, userId }: { groupId: string; userId: string }) {
async create({
groupId,
userId,
permission = GroupPermission.Member,
}: {
groupId: string;
userId: string;
permission?: GroupPermission;
}) {
const res = await client.post("/groups.add_user", {
id: groupId,
userId,
permission,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
@@ -70,6 +80,29 @@ export default class GroupUsersStore extends Store<GroupUser> {
});
}
@action
async update({
groupId,
userId,
permission,
}: {
groupId: string;
userId: string;
permission?: GroupPermission;
}) {
const res = await client.post("/groups.update_user", {
id: groupId,
userId,
permission,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
}
@action
removeGroupMemberships = (groupId: string) => {
this.data.forEach((_, key) => {
-7
View File
@@ -34,13 +34,6 @@ class IntegrationsStore extends Store<Integration> {
(integration) => integration.service === IntegrationService.Linear
);
}
@computed
get gitlab(): Integration<IntegrationType.Embed>[] {
return this.orderedData.filter(
(integration) => integration.service === IntegrationService.GitLab
);
}
}
export default IntegrationsStore;
+8 -3
View File
@@ -4,6 +4,7 @@ import {
JSONValue,
CollectionPermission,
DocumentPermission,
GroupPermission,
} from "@shared/types";
import RootStore from "~/stores/RootStore";
import { SidebarContextType } from "./components/Sidebar/components/SidebarContext";
@@ -125,7 +126,7 @@ export type Action = {
* Perform the action note this should generally not be called directly, use `performAction`
* instead. Errors will be caught and displayed to the user as a toast message.
*/
perform?: (context: ActionContext) => unknown;
perform?: (context: ActionContext) => any;
to?: string | { url: string; target?: string };
children?: ((context: ActionContext) => Action[]) | Action[];
};
@@ -154,7 +155,7 @@ export type ActionV2 = BaseActionV2 & {
tooltip?:
| ((context: ActionContext) => React.ReactChild | undefined)
| React.ReactChild;
perform: (context: ActionContext) => unknown;
perform: (context: ActionContext) => any;
};
export type InternalLinkActionV2 = BaseActionV2 & {
@@ -311,7 +312,11 @@ export const EmptySelectValue = "__empty__";
export type Permission = {
label: string;
value: CollectionPermission | DocumentPermission | typeof EmptySelectValue;
value:
| CollectionPermission
| DocumentPermission
| GroupPermission
| typeof EmptySelectValue;
divider?: boolean;
};
+28 -5
View File
@@ -2,7 +2,7 @@ import retry from "fetch-retry";
import trim from "lodash/trim";
import queryString from "query-string";
import EDITOR_VERSION from "@shared/editor/version";
import { JSONObject } from "@shared/types";
import { JSONObject, Scope } from "@shared/types";
import stores from "~/stores";
import Logger from "./Logger";
import download from "./download";
@@ -20,6 +20,9 @@ import {
UnprocessableEntityError,
UpdateRequiredError,
} from "./errors";
import { getCookie } from "tiny-cookie";
import { CSRF } from "@shared/constants";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
type Options = {
baseUrl?: string;
@@ -47,7 +50,7 @@ class ApiClient {
this.shareId = shareId;
};
fetch = async <T = unknown>(
fetch = async <T = any>(
path: string,
method: string,
data: JSONObject | FormData | undefined,
@@ -105,6 +108,20 @@ class ApiClient {
...options?.headers,
};
// Add CSRF token to headers for mutating requests
const isModifyingRequest = ["POST", "PUT", "PATCH", "DELETE"].includes(
method
);
const canAccessWithReadOnly = AuthenticationHelper.canAccess(path, [
Scope.Read,
]);
if (isModifyingRequest && !canAccessWithReadOnly) {
const csrfToken = getCookie(CSRF.cookieName);
if (csrfToken) {
headerOptions[CSRF.headerName] = csrfToken;
}
}
// for multipart forms or other non JSON requests fetch
// populates the Content-Type without needing to explicitly
// set it.
@@ -180,7 +197,7 @@ class ApiClient {
const error: {
message?: string;
error?: string;
data?: Record<string, unknown>;
data?: Record<string, any>;
} = {};
try {
@@ -213,6 +230,12 @@ class ApiClient {
});
}
if (error.error === "csrf_error") {
throw new AuthorizationError(
"CSRF token invalid, please try reloading."
);
}
throw new AuthorizationError(error.message);
}
@@ -244,13 +267,13 @@ class ApiClient {
throw err;
};
get = <T = unknown>(
get = <T = any>(
path: string,
data: JSONObject | undefined,
options?: FetchOptions
) => this.fetch<T>(path, "GET", data, options);
post = <T = unknown>(
post = <T = any>(
path: string,
data?: JSONObject | FormData | undefined,
options?: FetchOptions
+1 -1
View File
@@ -13,7 +13,7 @@ type LogCategory =
| "plugins"
| "policies";
type Extra = Record<string, unknown>;
type Extra = Record<string, any>;
class Logger {
/**
+3 -3
View File
@@ -1,6 +1,6 @@
import * as React from "react";
type ComponentPromise<T extends React.ComponentType<unknown>> = Promise<{
type ComponentPromise<T extends React.ComponentType<any>> = Promise<{
default: T;
}>;
@@ -12,7 +12,7 @@ type ComponentPromise<T extends React.ComponentType<unknown>> = Promise<{
* @param interval The interval between retries in milliseconds, defaults to 1000.
* @returns A lazy component.
*/
export default function lazyWithRetry<T extends React.ComponentType<unknown>>(
export default function lazyWithRetry<T extends React.ComponentType<any>>(
component: () => ComponentPromise<T>,
retries?: number,
interval?: number
@@ -20,7 +20,7 @@ export default function lazyWithRetry<T extends React.ComponentType<unknown>>(
return React.lazy(() => retry(component, retries, interval));
}
function retry<T extends React.ComponentType<unknown>>(
function retry<T extends React.ComponentType<any>>(
fn: () => ComponentPromise<T>,
retriesLeft = 3,
interval = 1000
-21
View File
@@ -37,17 +37,6 @@ export const isURLMentionable = ({
);
}
case IntegrationService.GitLab: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "gitlab.com" &&
settings.gitlab?.project.path_with_namespace ===
pathParts.slice(1, -2).join("/") // ensure installed project path matches with the provided url.
);
}
default:
return false;
}
@@ -78,16 +67,6 @@ export const determineMentionType = ({
return type === "issue" ? MentionType.Issue : undefined;
}
case IntegrationService.GitLab: {
const type = pathParts[pathParts.length - 2];
if (type === "issues") {
return MentionType.Issue;
} else if (type === "merge_requests") {
return MentionType.PullRequest;
}
return undefined;
}
default:
return;
}
+1 -1
View File
@@ -33,7 +33,7 @@ export function settingsPath(...args: string[]): string {
export function commentPath(document: Document, comment: Comment): string {
return `${documentPath(document)}?commentId=${comment.id}${
comment.isResolved ? "&resolved=" : ""
comment.isResolved ? "&resolved=1" : ""
}`;
}
+1 -1
View File
@@ -3,7 +3,7 @@ export default {
// TypeScript files
"**/*.[tj]s?(x)": [
(f) => `prettier --write ${f.join(" ")}`,
(f) => (f.length > 20 ? `yarn lint` : `oxlint ${f.join(" ")}`),
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix`),
() => `yarn build:i18n`,
() => "git add shared/i18n/locales/en_US/translation.json",
],
+32 -32
View File
@@ -51,32 +51,32 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.864.0",
"@aws-sdk/lib-storage": "3.864.0",
"@aws-sdk/s3-presigned-post": "3.864.0",
"@aws-sdk/s3-request-presigner": "3.864.0",
"@aws-sdk/signature-v4-crt": "^3.864.0",
"@babel/core": "^7.27.7",
"@aws-sdk/client-s3": "3.873.0",
"@aws-sdk/lib-storage": "3.873.0",
"@aws-sdk/s3-presigned-post": "3.873.0",
"@aws-sdk/s3-request-presigner": "3.873.0",
"@aws-sdk/signature-v4-crt": "^3.873.0",
"@babel/core": "^7.28.3",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0",
"@babel/plugin-transform-regenerator": "^7.28.1",
"@babel/preset-env": "^7.28.0",
"@babel/plugin-transform-regenerator": "^7.28.3",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.12.0",
"@css-inline/css-inline-wasm": "^0.14.3",
"@css-inline/css-inline-wasm": "^0.17.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dotenvx/dotenvx": "^1.48.4",
"@dotenvx/dotenvx": "^1.49.0",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.5",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.3",
"@fortawesome/react-fontawesome": "^0.2.6",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@hocuspocus/extension-redis": "1.1.2",
"@hocuspocus/extension-throttle": "1.1.2",
@@ -84,22 +84,23 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@linear/sdk": "^39.0.0",
"@linear/sdk": "^39.2.1",
"@node-oauth/oauth2-server": "^5.2.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.4",
"@octokit/webhooks": "^13.9.1",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-one-time-password-field": "^0.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-one-time-password-field": "^0.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.2",
"@sentry/node": "^7.120.4",
"@sentry/react": "^7.120.4",
@@ -123,11 +124,11 @@
"content-disposition": "^0.5.4",
"cookie": "^0.7.0",
"copy-to-clipboard": "^3.3.3",
"core-js": "^3.37.0",
"core-js": "^3.45.1",
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"date-fns": "^3.6.0",
"dd-trace": "^5.62.0",
"dd-trace": "^5.63.0",
"diff": "^5.2.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
@@ -170,7 +171,7 @@
"markdown-it": "^14.1.0",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^3.0.0",
"mermaid": "11.9.0",
"mermaid": "11.10.1",
"mime-types": "^3.0.1",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
@@ -255,17 +256,17 @@
"styled-normalize": "^8.1.1",
"throng": "^5.0.0",
"tiny-cookie": "^2.5.1",
"tmp": "^0.2.4",
"tmp": "^0.2.5",
"tunnel-agent": "^0.6.0",
"turndown": "^7.2.0",
"ukkonen": "^2.1.0",
"ukkonen": "^2.2.0",
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.15.15",
"vaul": "^1.1.2",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-pwa": "^1.0.2",
"vite-plugin-pwa": "1.0.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
@@ -276,7 +277,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@babel/cli": "^7.28.0",
"@babel/cli": "^7.28.3",
"@babel/preset-typescript": "^7.27.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
@@ -351,8 +352,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.37.119",
"eslint": "^9.33.0",
"discord-api-types": "^0.38.20",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"ioredis-mock": "^8.9.0",
@@ -366,7 +366,7 @@
"prettier": "^3.6.2",
"react-refresh": "^0.17.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.1.3",
"rollup-plugin-webpack-stats": "2.1.3",
"terser": "^5.43.1",
"typescript": "^5.9.2",
"yarn-deduplicate": "^6.0.2"
@@ -382,6 +382,6 @@
"qs": "6.9.7",
"prismjs": "1.30.0"
},
"version": "0.86.1",
"version": "0.87.1",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
-50
View File
@@ -1,50 +0,0 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L17.7 7.8L19.4 13.4L12 20.8Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L12 20.8Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.3"
/>
<path
d="M4.6 13.4L2.5 7.8L6.3 7.8L4.6 13.4Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.5"
/>
<path
d="M19.4 13.4L21.5 7.8L17.7 7.8L19.4 13.4Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.5"
/>
<path
d="M6.3 7.8L8.7 2.2L15.3 2.2L17.7 7.8L6.3 7.8Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.7"
/>
</svg>
);
}
-139
View File
@@ -1,139 +0,0 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import GitLabIcon from "./Icon";
import { GitLabConnectButton } from "./components/GitLabButton";
function GitLab() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const appName = env.APP_NAME;
React.useEffect(() => {
void integrations.fetchAll({
service: IntegrationService.GitLab,
withRelations: true,
});
}, [integrations]);
return (
<Scene title="GitLab" icon={<GitLabIcon />}>
<Heading>GitLab</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in GitLab to connect{" "}
{{ appName }} to your project. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{env.GITLAB_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Enable previews of GitLab issues and merge requests in documents
by connecting a GitLab project to {appName}.
</Trans>
</Text>
{integrations.gitlab.length ? (
<>
<Heading as="h2">
<Flex justify="space-between" auto>
{t("Connected")}
<GitLabConnectButton icon={<PlusIcon />} />
</Flex>
</Heading>
<List>
{integrations.gitlab.map((integration) => {
const gitlabProject = integration.settings?.gitlab?.project;
const integrationCreatedBy = integration.user
? integration.user.name
: undefined;
return (
<ListItem
key={gitlabProject?.id}
small
title={gitlabProject?.name}
subtitle={
integrationCreatedBy ? (
<>
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
&middot;{" "}
<Time
dateTime={integration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
) : (
<PlaceholderText />
)
}
image={
<TeamLogo
src={gitlabProject?.avatar_url}
size={AvatarSize.Large}
/>
}
actions={
<ConnectedButton
onClick={integration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?"
)}
/>
}
/>
);
})}
</List>
</>
) : (
<p>
<GitLabConnectButton icon={<GitLabIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The GitLab integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</Scene>
);
}
export default observer(GitLab);
@@ -1,23 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { GitLabUtils } from "../../shared/GitLabUtils";
export function GitLabConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() =>
redirectTo(GitLabUtils.authUrl({ state: { teamId: team.id } }))
}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}
-16
View File
@@ -1,16 +0,0 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
-7
View File
@@ -1,7 +0,0 @@
{
"id": "gitlab",
"name": "GitLab",
"priority": 16,
"description": "Adds a GitLab integration for link unfurling and converting links to mentions.",
"after": "linear"
}
-99
View File
@@ -1,99 +0,0 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import Logger from "@server/logging/Logger";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration } from "@server/models";
import { APIContext } from "@server/types";
import { GitLab } from "../gitlab";
import UploadGitLabProjectAvatarTask from "../tasks/UploadGitLabProjectAvatarTask";
import * as T from "./schema";
import { GitLabUtils } from "plugins/gitlab/shared/GitLabUtils";
const router = new Router();
router.get(
"gitlab.callback",
auth({
optional: true,
}),
validate(T.GitLabCallbackSchema),
apexAuthRedirect<T.GitLabCallbackReq>({
getTeamId: (ctx) => GitLabUtils.parseState(ctx.input.query.state)?.teamId,
getRedirectPath: (ctx, team) =>
GitLabUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => GitLabUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.GitLabCallbackReq>) => {
const { code, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(GitLabUtils.errorUrl(error));
return;
}
try {
// validation middleware ensures that code is non-null at this point.
const oauth = await GitLab.oauthAccess(code!);
const project = await GitLab.getInstalledProject(oauth.access_token);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.GitLab,
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
scopes: oauth.scope.split(" "),
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.Embed>
>(
{
service: IntegrationService.GitLab,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
gitlab: {
project: {
id: project.id,
name: project.name,
path_with_namespace: project.path_with_namespace,
avatar_url: project.avatar_url,
},
},
},
},
{ transaction }
);
transaction.afterCommit(async () => {
if (project.avatar_url) {
await new UploadGitLabProjectAvatarTask().schedule({
integrationId: integration.id,
avatarUrl: project.avatar_url,
});
}
});
ctx.redirect(GitLabUtils.successUrl());
} catch (err) {
Logger.error("Encountered error during GitLab OAuth callback", err);
ctx.redirect(GitLabUtils.errorUrl("unknown"));
}
}
);
export default router;
-17
View File
@@ -1,17 +0,0 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const GitLabCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
}),
});
export type GitLabCallbackReq = z.infer<typeof GitLabCallbackSchema>;
-6
View File
@@ -1,6 +0,0 @@
import env from "@server/env";
export default {
GITLAB_CLIENT_ID: env.GITLAB_CLIENT_ID,
GITLAB_CLIENT_SECRET: env.GITLAB_CLIENT_SECRET,
};
-273
View File
@@ -1,273 +0,0 @@
import { z } from "zod";
import {
IntegrationService,
IntegrationType,
UnfurlResourceType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import User from "@server/models/User";
import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
import { GitLabUtils } from "../shared/GitLabUtils";
import env from "./env";
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number(),
refresh_token: z.string(),
scope: z.string(),
created_at: z.number(),
});
const GitLabProjectSchema = z.object({
id: z.string(),
name: z.string(),
path_with_namespace: z.string(),
avatar_url: z.string().optional(),
});
const GitLabIssueSchema = z.object({
id: z.number(),
iid: z.number(),
title: z.string(),
description: z.string().nullable(),
state: z.string(),
created_at: z.string(),
author: z.object({
id: z.number(),
name: z.string(),
avatar_url: z.string().nullable(),
}),
labels: z.array(z.string()).optional(),
});
const GitLabMergeRequestSchema = z.object({
id: z.number(),
iid: z.number(),
title: z.string(),
description: z.string().nullable(),
state: z.string(),
created_at: z.string(),
author: z.object({
id: z.number(),
name: z.string(),
avatar_url: z.string().nullable(),
}),
labels: z.array(z.string()).optional(),
draft: z.boolean().optional(),
});
export class GitLab {
private static supportedUnfurls = [
UnfurlResourceType.Issue,
UnfurlResourceType.PR,
];
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("code", code);
body.set("client_id", env.GITLAB_CLIENT_ID!);
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
body.set("redirect_uri", GitLabUtils.callbackUrl());
body.set("grant_type", "authorization_code");
const res = await fetch(GitLabUtils.tokenUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error while exchanging oauth code from GitLab; status: ${res.status}`
);
}
return AccessTokenResponseSchema.parse(await res.json());
}
static async revokeAccess(accessToken: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("client_id", env.GITLAB_CLIENT_ID!);
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
body.set("token", accessToken);
await fetch(GitLabUtils.revokeUrl, {
method: "POST",
headers,
body,
});
}
static async getInstalledProject(accessToken: string) {
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
};
// Get the first project the user has access to
// In a real implementation, we would want to let the user select which project to connect
const res = await fetch(
"https://gitlab.com/api/v4/projects?membership=true&per_page=1",
{
headers,
}
);
if (res.status !== 200) {
throw new Error(
`Error while fetching GitLab projects; status: ${res.status}`
);
}
const projects = await res.json();
if (!projects.length) {
throw new Error("No GitLab projects found");
}
return GitLabProjectSchema.parse(projects[0]);
}
/**
*
* @param url GitLab resource url
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a GitLab issue or merge request details
*/
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
const resource = GitLab.parseUrl(url);
if (!resource) {
return;
}
const integration = (await Integration.scope("withAuthentication").findOne({
where: {
service: IntegrationService.GitLab,
teamId: actor.teamId,
"settings.gitlab.project.path_with_namespace": resource.projectPath,
},
})) as Integration<IntegrationType.Embed>;
if (!integration) {
return;
}
try {
const headers = {
Authorization: `Bearer ${integration.authentication.token}`,
Accept: "application/json",
};
let apiUrl: string;
let resourceSchema: z.ZodObject<z.ZodRawShape>;
let resourceType: UnfurlResourceType;
if (resource.type === "issues") {
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/issues/${resource.id}`;
resourceSchema = GitLabIssueSchema;
resourceType = UnfurlResourceType.Issue;
} else if (resource.type === "merge_requests") {
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/merge_requests/${resource.id}`;
resourceSchema = GitLabMergeRequestSchema;
resourceType = UnfurlResourceType.PR;
} else {
return;
}
const res = await fetch(apiUrl, { headers });
if (res.status !== 200) {
return { error: `Resource not found (${res.status})` };
}
const data = resourceSchema.parse(await res.json());
// Fetch labels if they exist
let labels = [];
if (data.labels && data.labels.length > 0) {
labels = data.labels.map((label) => ({
name: label,
color: "#428BCA", // Default GitLab blue
}));
}
return {
type: resourceType,
url,
id: `#${data.iid}`,
title: data.title,
description: data.description,
author: {
name: data.author.name,
avatarUrl: data.author.avatar_url || "",
},
labels,
state: {
name: data.state,
color: data.state === "opened" ? "#1aaa55" : "#db3b21", // Green for open, red for closed
draft:
resourceType === UnfurlResourceType.PR ? data.draft : undefined,
},
createdAt: data.created_at,
} satisfies UnfurlIssueOrPR;
} catch (err) {
Logger.warn("Failed to fetch resource from GitLab", err);
return { error: err.message || "Unknown error" };
}
};
/**
* Parses a given URL and returns resource identifiers for GitLab specific URLs
*
* @param url URL to parse
* @returns {object} Containing resource identifiers - `projectPath`, `type`, and `id`.
*/
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (hostname !== "gitlab.com") {
return;
}
const parts = pathname.split("/");
// Remove empty first element
parts.shift();
// GitLab URLs are in the format: /namespace/project/-/issues/1 or /namespace/project/-/merge_requests/1
// The namespace can have multiple levels (e.g., /group/subgroup/project/-/issues/1)
if (parts.length < 4) {
return;
}
// Find the index of "-" which separates project path from resource type
const separatorIndex = parts.indexOf("-");
if (separatorIndex === -1 || separatorIndex === parts.length - 1) {
return;
}
const projectPath = parts.slice(0, separatorIndex).join("/");
const type = parts[separatorIndex + 1];
const id = parts[separatorIndex + 2];
if (
!type ||
!id ||
!GitLab.supportedUnfurls.includes(type as UnfurlResourceType)
) {
return;
}
return { projectPath, type, id };
}
}
@@ -1,56 +0,0 @@
import { IntegrationType } from "@shared/types";
import BaseTask from "@server/queues/tasks/BaseTask";
import { Integration } from "@server/models";
import { FileOperation } from "@server/models";
import fetch from "node-fetch";
type Props = {
integrationId: string;
avatarUrl: string;
};
export default class UploadGitLabProjectAvatarTask extends BaseTask<Props> {
public async perform({ integrationId, avatarUrl }: Props) {
const integration = await Integration.findByPk(integrationId, {
rejectOnEmpty: true,
});
try {
const res = await fetch(avatarUrl);
const buffer = await res.buffer();
const name = avatarUrl.split("/").pop() || "avatar";
const contentType = res.headers.get("content-type") || "image/png";
const operation = await FileOperation.createFromBuffer({
buffer,
contentType,
name,
userId: integration.userId,
teamId: integration.teamId,
source: "gitlab",
});
await integration.update({
settings: {
...integration.settings,
gitlab: {
...(integration.settings as Integration<IntegrationType.Embed>)
.gitlab,
project: {
...(integration.settings as Integration<IntegrationType.Embed>)
.gitlab?.project,
avatar_url: operation.url,
},
},
},
});
} catch (err) {
// If the avatar upload fails, we don't need to fail the entire task
// as it's not critical to the integration's functionality.
// Just log the error and continue.
this.logger.error(
`Failed to upload GitLab project avatar: ${err.message}`
);
}
}
}
-51
View File
@@ -1,51 +0,0 @@
import queryString from "query-string";
import env from "@shared/env";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class GitLabUtils {
private static oauthScopes = "api read_api read_user read_repository";
public static tokenUrl = "https://gitlab.com/oauth/token";
public static revokeUrl = "https://gitlab.com/oauth/revoke";
private static authBaseUrl = "https://gitlab.com/oauth/authorize";
private static settingsUrl = integrationSettingsPath("gitlab");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl() {
return this.settingsUrl;
}
static errorUrl(error: string) {
return `${this.settingsUrl}?error=${error}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: env.URL,
params: undefined,
}
) {
return params
? `${baseUrl}/api/gitlab.callback?${params}`
: `${baseUrl}/api/gitlab.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.GITLAB_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
scope: this.oauthScopes,
response_type: "code",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
+1 -9
View File
@@ -55,15 +55,7 @@ export const Notion = observer(() => {
onClose: clearQueryParams,
});
}
}, [
t,
dialogs,
oauthSuccess,
service,
clearQueryParams,
handleSubmit,
integrationId,
]);
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
React.useEffect(() => {
if (!oauthError) {
@@ -52,15 +52,7 @@ export function ImportDialog({ integrationId, onSubmit }: Props) {
toast.error(err.message);
resetSubmitting();
}
}, [
permission,
onSubmit,
integrationId,
t,
imports,
resetSubmitting,
setSubmitting,
]);
}, [permission, onSubmit]);
return (
<Flex column gap={12}>
@@ -21,6 +21,11 @@ type ParsePageOutput = ImportTaskOutput[number] & {
};
export default class NotionAPIImportTask extends APIImportTask<IntegrationService.Notion> {
private skippableErrorMessages = [
"Database retrievals do not support linked databases",
"does not contain any data sources accessible by this API bot", // error msg for linked database views
];
/**
* Process the Notion import task.
* This fetches data from Notion and converts it to task output.
@@ -138,8 +143,8 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
if (
error.code === APIErrorCode.ObjectNotFound ||
error.code === APIErrorCode.Unauthorized ||
error.message.includes(
"Database retrievals do not support linked databases"
this.skippableErrorMessages.some((errorMsg) =>
error.message.includes(errorMsg)
)
) {
Logger.warn(
+8 -17
View File
@@ -1,17 +1,10 @@
import { HttpsProxyAgent } from "https-proxy-agent";
import * as OAuth2StrategyModule from "passport-oauth2";
import { Request } from "express";
const { Strategy } = OAuth2StrategyModule;
type OIDCOptions = Record<string, unknown> & {
originalQuery?: Record<string, unknown>;
};
import OAuth2Strategy, { Strategy } from "passport-oauth2";
export class OIDCStrategy extends Strategy {
constructor(
options: OAuth2StrategyModule.StrategyOptionsWithRequest,
verify: OAuth2StrategyModule.VerifyFunctionWithRequest
options: OAuth2Strategy.StrategyOptionsWithRequest,
verify: OAuth2Strategy.VerifyFunctionWithRequest
) {
super(options, verify);
@@ -21,16 +14,14 @@ export class OIDCStrategy extends Strategy {
}
}
authenticate(req: Request, options?: unknown) {
const opts = (options || {}) as OIDCOptions;
opts.originalQuery = req.query as Record<string, unknown>;
super.authenticate(req, opts);
authenticate(req: any, options: any) {
options.originalQuery = req.query;
super.authenticate(req, options);
}
authorizationParams(options: unknown) {
const opts = options as OIDCOptions;
authorizationParams(options: any) {
return {
...(opts.originalQuery ?? {}),
...options.originalQuery,
...super.authorizationParams?.(options),
};
}
+1
View File
@@ -52,6 +52,7 @@ export async function fetchOIDCConfiguration(
Accept: "application/json",
},
timeout: 10000, // 10 second timeout
allowPrivateIPAddress: true,
});
if (!response.ok) {
+2 -2
View File
@@ -6,7 +6,7 @@ import env from "./env";
const SLACK_API_URL = "https://slack.com/api";
export async function post(endpoint: string, body: Record<string, unknown>) {
export async function post(endpoint: string, body: Record<string, any>) {
let data;
const token = body.token;
@@ -30,7 +30,7 @@ export async function post(endpoint: string, body: Record<string, unknown>) {
return data;
}
export async function request(endpoint: string, body: Record<string, unknown>) {
export async function request(endpoint: string, body: Record<string, any>) {
let data;
try {
+1 -1
View File
@@ -16,7 +16,7 @@ export class SlackUtils {
static createState(
teamId: string,
type: IntegrationType,
data?: Record<string, unknown>
data?: Record<string, any>
) {
return JSON.stringify({ type, teamId, ...data });
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1003 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 994 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 B

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 698 B

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

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