Compare commits

...

144 Commits

Author SHA1 Message Date
Tom Moor 54a5deb6a5 fix: Mentions do not render in first frame 2025-01-16 23:50:29 -05:00
Tom Moor f2ebac066a fix: Various issues with copy/pasted mentions 2025-01-16 21:35:16 -05:00
Tom Moor c33fad966a fix: Ensure data- attributes on React mention nodes 2025-01-16 21:15:06 -05:00
Tom Moor 5e5a6ec189 Order by section priority rather than translated label 2025-01-16 21:00:16 -05:00
Tom Moor acae1aecd7 Merge branch 'main' into tom/document-mentions 2025-01-16 20:42:03 -05:00
Tom Moor 79df75e09d chore: Update bug_report.yml 2025-01-16 17:26:28 -08:00
Tom Moor 4517cd6ab1 Move bug report to form format 2025-01-16 20:22:51 -05:00
Hemachandar 3c86b48533 Convert GroupMembership mutations (#8242)
* Convert 'GroupMembership' mutations

* cleanup collectionGroupMemberships

* remove duplicate data
2025-01-16 15:23:09 -08:00
Tom Moor bcba35550a fix: NodeView does not re-render when editable prop changes (#8237) 2025-01-16 14:28:50 -08:00
Tom Moor 4af3ac98d1 fix: Improve styling with mixed RTL content (#8247)
* fix: Improve styling with mixed RTL content

* fix
2025-01-16 13:35:04 -08:00
Tom Moor 7421a9fbdc fix: Mentions should not be able to contain node content (#8246)
closes #8238
2025-01-16 03:49:38 -08:00
Tom Moor 56b9c60388 Revert "Updated ImportJsonTask file mapDocuments method to use unshift instea…" (#8241)
This reverts commit 9cab404194.
2025-01-15 07:16:41 -08:00
Tom Moor 8fec6758b8 fix: Move compression middleware to cover all /api and /auth routes 2025-01-14 19:01:51 -05:00
Tom Moor 1aaabf113b fix: Incorrect plain text serialization in exportTable (#8234) 2025-01-14 06:35:43 -08:00
Translate-O-Tron a0d78378d7 New Crowdin updates (#8204) 2025-01-13 19:35:08 -08:00
dependabot[bot] 78bf8fd641 chore(deps): bump @sentry/react from 7.119.0 to 7.120.3 (#8233)
Bumps [@sentry/react](https://github.com/getsentry/sentry-javascript) from 7.119.0 to 7.120.3.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.120.3/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.119.0...7.120.3)

---
updated-dependencies:
- dependency-name: "@sentry/react"
  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-01-13 19:34:24 -08:00
dependabot[bot] 5374d32801 chore(deps): bump @sentry/node from 7.120.0 to 7.120.3 (#8232)
Bumps [@sentry/node](https://github.com/getsentry/sentry-javascript) from 7.120.0 to 7.120.3.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.120.3/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.120.0...7.120.3)

---
updated-dependencies:
- dependency-name: "@sentry/node"
  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-01-13 18:52:28 -08:00
dependabot[bot] 68de78ead8 chore(deps): bump sonner from 1.4.3 to 1.7.1 (#8231)
Bumps [sonner](https://github.com/emilkowalski/sonner) from 1.4.3 to 1.7.1.
- [Release notes](https://github.com/emilkowalski/sonner/releases)
- [Commits](https://github.com/emilkowalski/sonner/compare/v1.4.3...v1.7.1)

---
updated-dependencies:
- dependency-name: sonner
  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-01-13 18:52:17 -08:00
Tom Moor d40a3a77e0 fix: Urls displayed in TOC if mention in heading
fix: Ordering of suggestion menu
2025-01-13 21:39:33 -05:00
Tom Moor dbcf23d3cd Fix shared pages prefetch loading 2025-01-13 20:06:06 -05:00
Tom Moor 2ee8167cdc fix: Filtering in suggestion menu 2025-01-13 19:21:37 -05:00
Tom Moor 8eb73b4079 Reprioritize items 2025-01-13 18:38:23 -05:00
Tom Moor 8dfe635427 fix: Prevent duplicate preloads 2025-01-13 09:58:58 -05:00
Tom Moor ec8521461d Enter on selected mention should navigate 2025-01-12 23:19:41 -05:00
Tom Moor d76cb3bd17 fix: Copying mention should paste full url 2025-01-12 22:15:04 -05:00
Tom Moor 05b3a39f4d preload 2025-01-12 21:58:19 -05:00
Tom Moor 8f3139da7a tsc 2025-01-11 17:56:27 -05:00
Tom Moor 741f9aa796 Add ability to create new docs from @ 2025-01-11 15:06:34 -05:00
Tom Moor 338b10658b test 2025-01-11 13:24:22 -05:00
Tom Moor 97284780f9 Add support for mentions in BacklinkProcessor 2025-01-11 12:51:52 -05:00
WEI-HUA CHIEN 3998a80ae9 fix: Handle nested collapsed headings in findCollapsedNodes (#8223) 2025-01-11 08:59:40 -08:00
Tom Moor d3683413af fix: Links should work correctly 2025-01-11 11:51:49 -05:00
Hemachandar e910ecf559 fix: Update counter cache when a user is deleted (or) suspended (#8222) 2025-01-10 19:36:39 -08:00
Tom Moor 47a067cd19 Remove onSearchLink prop 2025-01-10 10:05:40 -05:00
Tom Moor 11f744e7d6 Remove doc search from link editor 2025-01-10 09:54:23 -05:00
Tom Moor 1d2292d2a7 tsc 2025-01-09 23:58:27 -05:00
Tom Moor 5eaab0e14a Remove LinkToolbar 2025-01-09 23:11:08 -05:00
Tom Moor 22ff9d394b Add groups to mention menu 2025-01-09 22:34:36 -05:00
Tom Moor a2a84102a6 Merge main 2025-01-09 20:41:22 -05:00
Hemachandar e42b533b07 Move group management to table (#8212)
* convert to table

* refactor edit group modal

* refactor delete group modal

* refactor add people modal

* refactor create group modal

* rebased changes

* filter works

* empty group message

* retain group title click

* fade

* cleanup

* pre-filtered for determining isEmpty

* remove fade, unnecessary role check

* StickyFilters component

* createdAt column

* Remove DelayedMount
Add 'External ID' in menu when present

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-01-09 20:06:09 -05:00
Hemachandar 81d7492e5e Convert Comment and Reaction mutations (#8181)
* handle create, update, delete

* handle resolve, unresolve

* handle add_reaction, remove_reaction

* cleanup

* fix type

* afterDestroy hook

* remove unnecessary 'hooks:false' added in this PR

* tests
2025-01-09 15:48:09 -08:00
Tom Moor 908c147e3d lint 2025-01-09 09:30:45 -05:00
Tom Moor 6d0270ae37 Add paste handling 2025-01-09 09:18:10 -05:00
Tom Moor abf49bc04d wip 2025-01-08 23:45:25 -05:00
Tom Moor 58732ddd9b wip 2025-01-08 08:40:27 -05:00
Hemachandar 3c5ce8cb3d Publish event in withCtx flow only (#8188)
* api key

* attachment

* file operation

* group

* share

* star

* subscription

* publish events in withCtx flow only

* cleanup GroupUser hooks:false

* type and rename

* rename publish to create
2025-01-08 05:27:49 -08:00
Hemachandar cf3e29bbab Improve useTableRequest for better reactivity (#8206)
* use data from store directly

* load active users only when no filter is set

* return invited user email in users.invite response

* shares
2025-01-08 05:27:36 -08:00
Tom Moor 684d622754 Merge branch 'main' into tom/document-mentions 2025-01-07 19:58:27 -05:00
Translate-O-Tron 92a5954ec7 New Crowdin updates (#8193) 2025-01-07 05:29:21 -08:00
Hemachandar 4afa225967 Simplify email references determination (#8189)
* Simplify email references determination

* individual thread for comments

* use toPlainText
2025-01-07 05:29:04 -08:00
dependabot[bot] 48feaf9bc0 chore(deps): bump nodemailer and @types/nodemailer (#8197)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) and [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer). These dependencies needed to be updated together.

Updates `nodemailer` from 6.9.14 to 6.9.16
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.14...v6.9.16)

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-06 17:14:23 -08:00
dependabot[bot] 3f2ac2d23b chore(deps-dev): bump @babel/cli from 7.25.9 to 7.26.4 (#8198)
Bumps [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) from 7.25.9 to 7.26.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.4/packages/babel-cli)

---
updated-dependencies:
- dependency-name: "@babel/cli"
  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-01-06 17:14:06 -08:00
dependabot[bot] 38c12bd2a9 chore(deps-dev): bump @types/resolve-path from 1.4.2 to 1.4.3 (#8199)
Bumps [@types/resolve-path](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/resolve-path) from 1.4.2 to 1.4.3.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/resolve-path)

---
updated-dependencies:
- dependency-name: "@types/resolve-path"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-06 17:13:52 -08:00
Hemachandar fafaddf07f feat: Option to return anchor text for comments (#8196)
* feat: Option to return anchor text for comments

* cleanup anchorText presentation

* consolidated anchor text

* cleanup unused method
2025-01-06 17:13:37 -08:00
Hemachandar 25f264a763 fix: Notify previously mentioned users when new comment is added to a thread (#8194) 2025-01-05 18:23:16 -08:00
Tom Moor 085785a94c Remove yellow badge for guests 2025-01-05 19:40:30 -05:00
Tom Moor 9c71566d66 fix: Filter input divider in light theme 2025-01-05 19:40:30 -05:00
Hemachandar 4a64a767e1 Convert GroupUser mutations (#8187)
* Convert 'GroupUser' mutations

* cleanup commands
2025-01-05 16:33:51 -08:00
Hemachandar 9bc1788bc0 Upgrade and virtualize table component (#8157)
* Upgrade and virtualize table component

* width in column def

* container height

* share query options

* full page scroll

* change z-index and remove shrink

* non-modal menu
2025-01-05 04:55:05 -08:00
Tom Moor e93ef8b392 fix: shares.update written on every view, regressed in #8177 2025-01-04 21:10:18 -05:00
Tom Moor d11e15b360 suggestions 2025-01-04 21:03:11 -05:00
Tom Moor 8429c68b7a fix observability 2025-01-03 18:24:36 -05:00
Tom Moor c4e3786291 Merge main 2025-01-03 17:23:07 -05:00
Tom Moor db30d080ae fix: Highlight matching mentions in find and replace (#8184) 2025-01-02 19:06:42 -08:00
Translate-O-Tron 63d70c2cd5 New Crowdin updates (#8144) 2025-01-02 12:06:20 -08:00
Hemachandar 98fef1bb1f Convert Share mutations (#8177)
* Convert 'Share' mutations

* createContext

* name override in share.revoke method
2025-01-02 12:06:02 -08:00
Prasad Bandaru 9cab404194 Updated ImportJsonTask file mapDocuments method to use unshift instead of push for sorting order (#8183) 2025-01-02 12:05:50 -08:00
Tom Moor d6459150fe feat: Allow resizing of embed height (#8154)
* stash

* tsc

* remove console log

* Restore bottom bar on embeds

* fix: Cannot see selected state

* fix layout issue
2025-01-02 05:49:51 -08:00
Hemachandar 4789ddd947 Skip unnecessary update of comment sort preference (#8182) 2025-01-02 05:49:33 -08:00
Tom Moor 1c179a3c6b Move Group to model event writing (#8179)
* Move Group to model event writing

* cleanup type
2025-01-02 07:45:09 -05:00
Tom Moor b8c07eb298 chore: Cleanup unused pinDestroyer (#8180) 2025-01-01 14:02:45 -08:00
Tom Moor adfca1e5ca fix: Attempting to split undefined 2024-12-31 17:33:47 -05:00
Tom Moor 6ca3c25d35 fix: Do not report errors due to unsupported file types 2024-12-31 08:26:34 -05:00
Tom Moor 05a2c6ae1e fix: Zoom cursor shown while drag-resizing image 2024-12-31 08:26:34 -05:00
Hemachandar 234915f4a0 Convert Subscription mutations (#8166)
* createContext accepts object

* handle subscriptions

* use createContext

* should've done this on the initial attempt...
2024-12-31 05:25:43 -08:00
Tom Moor 538a1274ab fix: Scale width of caption with image (#8174) 2024-12-31 03:36:14 -08:00
Hemachandar 63422373ac Add teamId index on attachments table (#8175) 2024-12-31 03:36:05 -08:00
dependabot[bot] 708bd8a544 chore(deps): bump prosemirror-view from 1.36.0 to 1.37.1 (#8172)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.36.0 to 1.37.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.36.0...1.37.1)

---
updated-dependencies:
- dependency-name: prosemirror-view
  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>
2024-12-30 16:30:23 -08:00
dependabot[bot] 120191d4d7 chore(deps-dev): bump eslint-plugin-react from 7.35.0 to 7.37.3 (#8169)
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.35.0 to 7.37.3.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.35.0...v7.37.3)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  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>
2024-12-30 16:29:40 -08:00
dependabot[bot] 6a2ab299a8 chore(deps): bump @babel/preset-react from 7.25.9 to 7.26.3 (#8170)
Bumps [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) from 7.25.9 to 7.26.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.26.3/packages/babel-preset-react)

---
updated-dependencies:
- dependency-name: "@babel/preset-react"
  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>
2024-12-30 16:29:27 -08:00
dependabot[bot] 74dc7094e1 chore(deps): bump i18next-http-backend from 2.5.0 to 2.7.1 (#8171)
Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 2.5.0 to 2.7.1.
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/commits)

---
updated-dependencies:
- dependency-name: i18next-http-backend
  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>
2024-12-30 16:29:09 -08:00
Hemachandar 5dd993adf5 Convert WebhookSubscription mutations (#8161)
* Convert 'WebhookSubscription' mutations

* add tests

* remove unnecessary assignment
2024-12-30 16:11:32 -08:00
Hemachandar 41832bbaf1 fix: Use parent transaction for findOrCreate after-commit hook (#8173) 2024-12-30 16:11:11 -08:00
Tom Moor f448be5830 feat: Allow querying groups by externalId 2024-12-27 16:48:19 +00:00
Tom Moor f0fcb26b50 fix: Cannot read properties of undefined (reading 'replace'), closes #8123 2024-12-27 10:41:45 +00:00
Tom Moor ad237a619c fix: Avoid document scrolling behavior when auto-scrolling sidebar 2024-12-26 21:41:25 +00:00
Tom Moor 5f49938267 chore: Fix react key warning 2024-12-26 17:40:22 +00:00
Tom Moor 68a469daa7 Add externalId property on groups (#8127)
* Add 'externalId' property on groups

* Remove clientside Field decorator

* Allow querying by externalId
2024-12-26 08:44:04 -08:00
Tom Moor 3d5a167f7f fix: textBetween line breaks (#8145)
* fix: textBetween line breaks

* test
2024-12-26 03:31:12 -08:00
Tom Moor b58671cbd1 Exclude state column by default in document queries (#8139)
* Exclude state column by default in document queries

* restore withoutState scope
2024-12-26 03:30:48 -08:00
Tom Moor b3a3b0763f fix: Exported HTML does not include table column sizes (#8128) 2024-12-26 03:06:03 -08:00
Tom Moor a4becd66bd feat: Add 'Protobuf' highlighting, closes #8141 2024-12-26 11:05:25 +00:00
Tom Moor 3437bd3a6c fix: Additional Canva embed format, closes #8140 2024-12-25 11:28:17 +00:00
Tom Moor 86cfd62afa feat: Allow users to change email in-app (#8119) 2024-12-25 02:58:26 -08:00
Translate-O-Tron 85b62d3146 New Crowdin updates (#8132)
* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]
2024-12-24 09:38:02 -08:00
dependabot[bot] 1fa0a5ea98 chore(deps): bump i18next-fs-backend from 2.3.2 to 2.6.0 (#8136)
Bumps [i18next-fs-backend](https://github.com/i18next/i18next-fs-backend) from 2.3.2 to 2.6.0.
- [Changelog](https://github.com/i18next/i18next-fs-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-fs-backend/compare/v2.3.2...v2.6.0)

---
updated-dependencies:
- dependency-name: i18next-fs-backend
  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>
2024-12-24 09:30:57 -08:00
dependabot[bot] 2b4c8d981c chore(deps-dev): bump nodemon from 3.1.7 to 3.1.9 (#8135)
Bumps [nodemon](https://github.com/remy/nodemon) from 3.1.7 to 3.1.9.
- [Release notes](https://github.com/remy/nodemon/releases)
- [Commits](https://github.com/remy/nodemon/compare/v3.1.7...v3.1.9)

---
updated-dependencies:
- dependency-name: nodemon
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-24 09:30:23 -08:00
Tom Moor ce55719626 chore: Print emails to console when Ethereal unavailable (offline) 2024-12-19 17:07:10 +09:00
Tom Moor b9f0f67fb2 chore: Tidy mention menu, remove unneccessary component 2024-12-19 14:39:16 +09:00
Tom Moor 02aa4c2928 fix: Consider CDN urls to not be internal 2024-12-19 09:48:21 +09:00
Tom Moor 77e8dbefd6 fix: Ensure signed urls on shared documents are valid longer than 60s 2024-12-19 09:43:49 +09:00
Tom Moor 1e5d281870 chore: Improve warning for SMTP_FROM_EMAIL not set, closes #8125 2024-12-19 06:57:05 +09:00
Tom Moor 9b68e6835e fix: Reduce visual strength of collection in doc breadcrumb 2024-12-18 11:07:39 +09:00
Tom Moor f17926f912 fix: Update slate to WCAG AA compliant, closes #8113 2024-12-18 10:46:06 +09:00
Tom Moor 2397196be8 fix: Shared document header always in mobile styling, closes #8121 2024-12-18 10:27:26 +09:00
Tom Moor 133db9c22c Improve error message when database URI contains invalid characters, closes #8110 2024-12-18 10:18:09 +09:00
Translate-O-Tron 0dd14cdf1a New Crowdin updates (#8058) 2024-12-17 16:56:43 -08:00
dependabot[bot] cc8ec28a39 chore(deps-dev): bump typescript from 5.6.3 to 5.7.2 (#8118)
* chore(deps-dev): bump typescript from 5.6.3 to 5.7.2

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.6.3 to 5.7.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.6.3...v5.7.2)

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

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

* tsc

---------

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.moor@gmail.com>
2024-12-18 09:56:31 +09:00
Tom Moor c8cbb9ef9c Add HEIC to supported mimes, closes #8122 2024-12-18 09:49:28 +09:00
dependabot[bot] 4af07ab6c4 chore(deps-dev): bump eslint-plugin-import from 2.29.1 to 2.31.0 (#8116)
Bumps [eslint-plugin-import](https://github.com/import-js/eslint-plugin-import) from 2.29.1 to 2.31.0.
- [Release notes](https://github.com/import-js/eslint-plugin-import/releases)
- [Changelog](https://github.com/import-js/eslint-plugin-import/blob/main/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-plugin-import/compare/v2.29.1...v2.31.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-import
  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>
2024-12-17 16:22:49 -08:00
dependabot[bot] 742c138b3d chore(deps): bump mermaid from 11.4.0 to 11.4.1 (#8117)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.4.0 to 11.4.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Changelog](https://github.com/mermaid-js/mermaid/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.4.0...mermaid@11.4.1)

---
updated-dependencies:
- dependency-name: mermaid
  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>
2024-12-16 20:36:59 -08:00
Tom Moor ec1eacaeea fix: Cannot leave overlapping comments (#8107) 2024-12-16 20:36:32 -08:00
Tom Moor 8b15cc45b0 fix: Permissions checks on notification emails do not take into account shares (#8109)
* fix: Comment notifications not sent on drafts outside collection, shared docs

* fix: DocumentPublishedOrUpdatedEmail not sent for drafts

* tsc
2024-12-15 20:53:06 -08:00
Tom Moor e89c32424f fix: Subscribe to document automatically on share (#8108) 2024-12-15 17:37:20 -08:00
Tom Moor a458690bfc fix: Non-unique key parameter, closes #8104 2024-12-12 11:39:19 -05:00
Tom Moor df03a6da8c fix: Markdown escape characters left in titles on import (#8102) 2024-12-12 05:15:45 -08:00
Tom Moor 6dfe7d707a fix: Token type not supported by Markdown parser, closes #8101 2024-12-11 21:07:15 -05:00
Tom Moor c063709f1c Allow resizing final table column 2024-12-11 20:39:02 -05:00
Tom Moor dd8f6a987c perf: Avoid iterating child documents in documents.info when direct descendant 2024-12-09 22:13:04 -05:00
Tom Moor fa117870a2 perf: One less query in documents.info 2024-12-09 21:52:32 -05:00
dependabot[bot] 40b1e3c8c6 chore(deps): bump prosemirror-model from 1.23.0 to 1.24.0 (#8092)
* chore(deps): bump prosemirror-model from 1.23.0 to 1.24.0

Bumps [prosemirror-model](https://github.com/prosemirror/prosemirror-model) from 1.23.0 to 1.24.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-model/compare/1.23.0...1.24.0)

---
updated-dependencies:
- dependency-name: prosemirror-model
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

* tsc

* tsc

---------

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.moor@gmail.com>
2024-12-09 17:43:04 -08:00
Tom Moor e3b0f7db86 fix: Parsing of grist links with utm parameters, closes #8082 2024-12-09 20:42:36 -05:00
dependabot[bot] 6fddb29ff6 chore(deps): bump nanoid from 3.3.7 to 3.3.8 (#8098)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-09 17:37:36 -08:00
dependabot[bot] 569a7876ae chore(deps): bump utility-types from 3.10.0 to 3.11.0 (#8093)
Bumps [utility-types](https://github.com/piotrwitek/utility-types) from 3.10.0 to 3.11.0.
- [Release notes](https://github.com/piotrwitek/utility-types/releases)
- [Commits](https://github.com/piotrwitek/utility-types/compare/v3.10.0...v3.11.0)

---
updated-dependencies:
- dependency-name: utility-types
  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>
2024-12-09 17:31:03 -08:00
dependabot[bot] bea56159ec chore(deps-dev): bump eslint-import-resolver-typescript from 3.6.3 to 3.7.0 (#8096)
Bumps [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) from 3.6.3 to 3.7.0.
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v3.6.3...v3.7.0)

---
updated-dependencies:
- dependency-name: eslint-import-resolver-typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-09 17:30:41 -08:00
Tom Moor 908f053920 Add UI element to images that are externally hosted 2024-12-08 12:51:55 -05:00
Tom Moor 033c298bff fix: Retrieve image dimensions for all types, not only PNG 2024-12-07 17:15:25 -05:00
Tom Moor 22f02ad713 feat: Add 'Neon' highlighter color, increase presence of highlights 2024-12-07 16:27:57 -05:00
Tom Moor 92b1c578f6 feat: Improve inline rule matching (#8085)
* stash

* fix: Allow inline mark matching to work with preceding brackets
Refactor markInputRule, add markInputRuleForPattern

* docs
2024-12-07 12:46:25 -08:00
Tom Moor a738ea97b5 feat: Dropping a remote image will now upload (#8086)
* feat: Dropping a remote image will now upload

* refactor,DRY

* guard

* Parse correct file name from url where possible
2024-12-07 12:46:14 -08:00
Tom Moor 7fbe442863 Show editor tooltip shortcuts on separate line 2024-12-07 14:10:36 -05:00
Tom Moor e6626e1b1a Restore translations 2024-11-16 19:00:05 -05:00
Tom Moor d3964875ff wip 2024-11-16 18:56:28 -05:00
Tom Moor 5156b92d6a Refactor components for use in editor 2024-11-16 16:25:38 -05:00
Tom Moor 4648a405bb Merge public/main 2024-11-16 14:06:32 -05:00
Tom Moor 5f8a754cd9 Merge main 2024-10-21 19:04:38 -04:00
Tom Moor ad7d808704 stash 2024-10-04 23:29:29 -04:00
Tom Moor 59a8d801a4 Merge main 2024-10-03 21:56:29 -04:00
Tom Moor a815f0a12c Merge main 2024-10-03 20:58:18 -04:00
Tom Moor 5ed5c24498 wip 2024-06-10 20:33:06 -04:00
Tom Moor a77b24cf01 stash 2024-06-09 23:59:27 -04:00
Tom Moor 08efb34c29 poc 2024-06-09 22:39:36 -04:00
Tom Moor df3dde951a Merge branch 'main' into tom/document-mentions 2024-06-09 21:38:10 -04:00
Tom Moor d10149ccb3 Add type filter to parseMentions 2024-06-09 15:26:03 -04:00
292 changed files with 7475 additions and 6388 deletions
-37
View File
@@ -1,37 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots or videos to help explain your problem.
**Outline (please complete the following information):**
- Install: [getoutline.com or self hosted]
- Version: [commit sha if self hosted]
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Mobile (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
+63
View File
@@ -0,0 +1,63 @@
name: Bug report
description: File a bug to help us improve
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: checkboxes
attributes:
label: This is not related to configuring Outline
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
options:
- label: The issue is not related to self-hosting config
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
1. With this config...
1. Run '...'
1. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
examples:
- **Outline**: Outline 0.80.0
- **Browser**: Safari
value: |
- Outline:
- Browser:
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
+1 -2
View File
@@ -32,6 +32,7 @@ import {
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import {
ExportContentType,
TeamPreference,
@@ -46,7 +47,6 @@ import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import Icon from "~/components/Icon";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -732,7 +732,6 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
throw err;
}
};
+4
View File
@@ -13,6 +13,8 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
const activeDocument = stores.documents.active;
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
@@ -34,6 +36,8 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
+5 -1
View File
@@ -37,7 +37,11 @@ function Breadcrumb(
return (
<Flex justify="flex-start" align="center" ref={ref}>
{topLevelItems.map((item, index) => (
<React.Fragment key={String(item.to) || index}>
<React.Fragment
key={
(typeof item.to === "string" ? item.to : item.to.pathname) || index
}
>
{item.icon}
{item.to ? (
<Item
+1 -1
View File
@@ -3,6 +3,7 @@ import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
@@ -11,7 +12,6 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";
@@ -1,6 +1,6 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
@@ -1,6 +1,6 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import {
ActiveCollectionSection,
+6 -3
View File
@@ -8,8 +8,8 @@ import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
/** Callback when the dialog is submitted */
onSubmit: () => Promise<void> | void;
/** Callback when the dialog is submitted. Return false to prevent closing. */
onSubmit: () => Promise<void | boolean> | void;
/** Text to display on the submit button */
submitText?: string;
/** Text to display while the form is saving */
@@ -38,7 +38,10 @@ const ConfirmationDialog: React.FC<Props> = ({
ev.preventDefault();
setIsSaving(true);
try {
await onSubmit();
const res = await onSubmit();
if (res === false) {
return;
}
dialogs.closeAllModals();
} catch (err) {
toast.error(err.message);
+2
View File
@@ -109,6 +109,8 @@ const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
+1 -1
View File
@@ -3,10 +3,10 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
+1 -1
View File
@@ -7,6 +7,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
@@ -14,7 +15,6 @@ import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
+1 -1
View File
@@ -14,12 +14,12 @@ import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
+1 -1
View File
@@ -9,13 +9,13 @@ import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import Icon from "@shared/components/Icon";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
+6 -2
View File
@@ -185,9 +185,9 @@ const DocumentMeta: React.FC<Props> = ({
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
<strong>
<Strong>
<DocumentBreadcrumb document={document} onlyText />
</strong>
</Strong>
</span>
)}
{showParentDocuments && nestedDocumentsCount > 0 && (
@@ -210,6 +210,10 @@ const DocumentMeta: React.FC<Props> = ({
);
};
const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
+1 -74
View File
@@ -1,6 +1,4 @@
import deburr from "lodash/deburr";
import difference from "lodash/difference";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
@@ -9,10 +7,7 @@ import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { AttachmentPreset } from "@shared/types";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
@@ -22,12 +17,8 @@ import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
import Icon from "./Icon";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -50,76 +41,13 @@ export type Props = Optional<
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
props;
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const { comments, documents } = useStores();
const { comments } = useStores();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const previousCommentIds = React.useRef<string[]>();
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
if (!slug) {
return [];
}
try {
const document = await documents.fetch(slug);
const time = dateToRelative(Date.parse(document.updatedAt), {
addSuffix: true,
shorten: true,
locale,
});
return [
{
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
icon: document.icon ? (
<Icon
value={document.icon}
color={document.color ?? undefined}
/>
) : undefined,
},
];
} catch (error) {
// NotFoundError could not find document for slug
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
// default search for anything that doesn't look like a URL
const results = await documents.searchTitles({ query: term });
return sortBy(
results.map(({ document }) => ({
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
) : undefined,
})),
(document) =>
deburr(document.title)
.toLowerCase()
.startsWith(deburr(term).toLowerCase())
? -1
: 1
);
},
[locale, documents]
);
const handleUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
@@ -263,7 +191,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
+2 -2
View File
@@ -46,7 +46,7 @@ const FilterOptions = ({
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const menu = useMenuState({
modal: true,
modal: false,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
${Outline} {
border: none;
border-radius: 0;
border-bottom: 1px solid rgb(34 40 52);
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
}
-89
View File
@@ -1,89 +0,0 @@
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { s } from "@shared/styles";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupMembers from "~/scenes/GroupMembers";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import useBoolean from "~/hooks/useBoolean";
import { hover } from "~/styles";
import NudeButton from "./NudeButton";
type Props = {
group: Group;
membership?: GroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
};
function GroupListItem({ group, showFacepile, renderActions }: Props) {
const { t } = useTranslation();
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
useBoolean();
const memberCount = group.memberCount;
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
const overflow = memberCount - users.length;
return (
<>
<ListItem
image={
<Image>
<GroupIcon size={24} />
</Image>
}
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
subtitle={t("{{ count }} member", { count: memberCount })}
actions={
<Flex align="center" gap={8}>
{showFacepile && (
<NudeButton
width="auto"
height="auto"
onClick={setMembersModalOpen}
>
<Facepile users={users} overflow={overflow} />
</NudeButton>
)}
{renderActions({
openMembersModal: setMembersModalOpen,
})}
</Flex>
}
/>
<Modal
title={t("Group members")}
onRequestClose={setMembersModalClosed}
isOpen={membersModalOpen}
>
<GroupMembers group={group} />
</Modal>
</>
);
}
const Image = styled(Flex)`
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: ${s("backgroundSecondary")};
border-radius: 32px;
`;
const Title = styled.span`
&: ${hover} {
text-decoration: underline;
cursor: var(--pointer);
}
`;
export default observer(GroupListItem);
+1 -1
View File
@@ -10,12 +10,12 @@ import {
useTabState,
} from "reakit";
import styled, { css } from "styled-components";
import Icon from "@shared/components/Icon";
import { s } from "@shared/styles";
import theme from "@shared/styles/theme";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
+1 -1
View File
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
type Props = {
+1
View File
@@ -197,6 +197,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
return (
<TooltipProvider>
<Container
id="sidebar"
ref={ref}
style={style}
$hidden={hidden}
@@ -5,13 +5,13 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
@@ -93,15 +93,11 @@ const NavLink = ({
React.useLayoutEffect(() => {
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
// If the page has an anchor hash then this means we're linking to an
// anchor in the document smooth scrolling the sidebar may the scrolling
// to the anchor of the document so we must avoid it.
if (!window.location.hash) {
scrollIntoView(linkRef.current, {
scrollMode: "if-needed",
behavior: "auto",
});
}
scrollIntoView(linkRef.current, {
scrollMode: "if-needed",
behavior: "auto",
boundary: (parent) => parent.id !== "sidebar",
});
}
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
@@ -2,10 +2,10 @@ import includes from "lodash/includes";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { descendants } from "~/utils/tree";
@@ -6,6 +6,7 @@ import { getEmptyImage } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
@@ -13,7 +14,6 @@ import GroupMembership from "~/models/GroupMembership";
import Star from "~/models/Star";
import UserMembership from "~/models/UserMembership";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { DragObject } from "../components/SidebarLink";
@@ -1,6 +1,6 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import Icon from "@shared/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import useStores from "~/hooks/useStores";
+31
View File
@@ -0,0 +1,31 @@
import { ColumnSort } from "@tanstack/react-table";
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import useQuery from "~/hooks/useQuery";
import lazyWithRetry from "~/utils/lazyWithRetry";
import type { Props as TableProps } from "./Table";
const Table = lazyWithRetry(() => import("~/components/Table"));
export type Props<T> = Omit<TableProps<T>, "onChangeSort">;
export function SortableTable<T>(props: Props<T>) {
const location = useLocation();
const history = useHistory();
const params = useQuery();
const handleChangeSort = React.useCallback(
(sort: ColumnSort) => {
params.set("sort", sort.id);
params.set("direction", sort.desc ? "desc" : "asc");
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
return <Table onChangeSort={handleChangeSort} {...props} />;
}
+302 -240
View File
@@ -1,231 +1,283 @@
import isEqual from "lodash/isEqual";
import {
useReactTable,
getCoreRowModel,
SortingState,
flexRender,
ColumnSort,
functionalUpdate,
Row as TRow,
createColumnHelper,
AccessorFn,
CellContext,
} from "@tanstack/react-table";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTable, useSortBy, usePagination } from "react-table";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import { s } from "@shared/styles";
import Button from "~/components/Button";
import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import usePrevious from "~/hooks/usePrevious";
export type Props = {
data: any[];
offset?: number;
isLoading: boolean;
empty?: React.ReactNode;
currentPage?: number;
page: number;
pageSize?: number;
totalPages?: number;
defaultSort?: string;
topRef?: React.Ref<any>;
onChangePage: (index: number) => void;
onChangeSort: (
sort: string | null | undefined,
direction: "ASC" | "DESC"
) => void;
columns: any;
defaultSortDirection: "ASC" | "DESC";
const HEADER_HEIGHT = 40;
type DataColumn<TData> = {
type: "data";
header: string;
accessor: AccessorFn<TData>;
sortable?: boolean;
};
function Table({
data,
isLoading,
totalPages,
empty,
columns,
page,
pageSize = 50,
defaultSort = "name",
topRef,
onChangeSort,
onChangePage,
defaultSortDirection,
}: Props) {
const { t } = useTranslation();
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
canNextPage,
nextPage,
canPreviousPage,
previousPage,
state: { pageIndex, sortBy },
} = useTable(
{
columns,
data,
manualPagination: true,
manualSortBy: true,
autoResetSortBy: false,
autoResetPage: false,
pageCount: totalPages,
initialState: {
sortBy: [
{
id: defaultSort,
desc: defaultSortDirection === "DESC" ? true : false,
},
],
pageSize,
pageIndex: page,
},
stateReducer: (newState, action, prevState) => {
if (!isEqual(newState.sortBy, prevState.sortBy)) {
return { ...newState, pageIndex: 0 };
}
type ActionColumn = {
type: "action";
header?: string;
};
return newState;
},
},
useSortBy,
usePagination
export type Column<TData> = {
id: string;
component: (data: TData) => React.ReactNode;
width: string;
} & (DataColumn<TData> | ActionColumn);
export type Props<TData> = {
data: TData[];
columns: Column<TData>[];
sort: ColumnSort;
onChangeSort: (sort: ColumnSort) => void;
loading: boolean;
page: {
hasNext: boolean;
fetchNext?: () => void;
};
rowHeight: number;
stickyOffset?: number;
};
function Table<TData>({
data,
columns,
sort,
onChangeSort,
loading,
page,
rowHeight,
stickyOffset = 0,
}: Props<TData>) {
const { t } = useTranslation();
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
const [virtualContainerTop, setVirtualContainerTop] =
React.useState<number>();
const columnHelper = React.useMemo(() => createColumnHelper<TData>(), []);
const observedColumns = React.useMemo(
() =>
columns.map((column) => {
const cell = ({ row }: CellContext<TData, unknown>) => (
<ObservedCell data={row.original} render={column.component} />
);
return column.type === "data"
? columnHelper.accessor(column.accessor, {
id: column.id,
header: column.header,
enableSorting: column.sortable ?? true,
cell,
})
: columnHelper.display({
id: column.id,
header: column.header ?? "",
cell,
});
}),
[columns, columnHelper]
);
const prevSortBy = React.useRef(sortBy);
const gridColumns = React.useMemo(
() => columns.map((column) => column.width).join(" "),
[columns]
);
const handleChangeSort = React.useCallback(
(sortState: SortingState) => {
const newState = functionalUpdate(sortState, [sort]);
const newSort = newState[0];
onChangeSort(newSort);
},
[sort, onChangeSort]
);
const prevSort = usePrevious(sort);
const sortChanged = sort !== prevSort;
const isEmpty = !loading && data.length === 0;
const showPlaceholder = loading && data.length === 0;
const table = useReactTable({
data,
columns: observedColumns,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
enableMultiSort: false,
enableSortingRemoval: false,
state: {
sorting: [sort],
},
onSortingChange: handleChangeSort,
});
const { rows } = table.getRowModel();
const rowVirtualizer = useWindowVirtualizer({
count: rows.length,
estimateSize: () => rowHeight,
scrollMargin: virtualContainerTop,
overscan: 5,
});
React.useEffect(() => {
if (!isEqual(sortBy, prevSortBy.current)) {
prevSortBy.current = sortBy;
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
if (!sortChanged || !virtualContainerTop) {
return;
}
const scrollThreshold =
virtualContainerTop - (stickyOffset + HEADER_HEIGHT);
const reset = window.scrollY > scrollThreshold;
if (reset) {
rowVirtualizer.scrollToOffset(scrollThreshold, {
behavior: "smooth",
});
}
}, [rowVirtualizer, sortChanged, virtualContainerTop, stickyOffset]);
React.useLayoutEffect(() => {
if (virtualContainerRef.current) {
// determine the scrollable virtual container offsetTop on mount
setVirtualContainerTop(
virtualContainerRef.current.getBoundingClientRect().top
);
}
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
const handleNextPage = () => {
nextPage();
onChangePage(pageIndex + 1);
};
const handlePreviousPage = () => {
previousPage();
onChangePage(pageIndex - 1);
};
const isEmpty = !isLoading && data.length === 0;
const showPlaceholder = isLoading && data.length === 0;
}, []);
return (
<div style={{ overflowX: "auto" }}>
<Anchor ref={topRef} />
<InnerTable {...getTableProps()}>
<thead>
{headerGroups.map((headerGroup) => {
const groupProps = headerGroup.getHeaderGroupProps();
return (
<tr {...groupProps} key={groupProps.key}>
{headerGroup.headers.map((column) => (
<Head
{...column.getHeaderProps(column.getSortByToggleProps())}
key={column.id}
<>
<InnerTable role="table">
<THead role="rowgroup" $topPos={stickyOffset}>
{table.getHeaderGroups().map((headerGroup) => (
<TR role="row" key={headerGroup.id} $columns={gridColumns}>
{headerGroup.headers.map((header) => (
<TH role="columnheader" key={header.id}>
<SortWrapper
align="center"
gap={4}
onClick={header.column.getToggleSortingHandler()}
$sortable={header.column.getCanSort()}
>
<SortWrapper
align="center"
$sortable={!column.disableSortBy}
gap={4}
>
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
<DescSortIcon />
) : (
<AscSortIcon />
))}
</SortWrapper>
</Head>
))}
</tr>
);
})}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row);
return (
<Row {...row.getRowProps()} key={row.id}>
{row.cells.map((cell) => (
<Cell
{...cell.getCellProps([
{
// @ts-expect-error ts-migrate(2339) FIXME: Property 'className' does not exist on type 'Colum... Remove this comment to see the full error message
className: cell.column.className,
},
])}
key={cell.column.id}
>
{cell.render("Cell")}
</Cell>
))}
</Row>
);
})}
</tbody>
{showPlaceholder && <Placeholder columns={columns.length} />}
</InnerTable>
{isEmpty ? (
empty || <Empty>{t("No results")}</Empty>
) : (
<Pagination
justify={canPreviousPage ? "space-between" : "flex-end"}
gap={8}
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getIsSorted() === "asc" ? (
<AscSortIcon />
) : header.column.getIsSorted() === "desc" ? (
<DescSortIcon />
) : (
<div />
)}
</SortWrapper>
</TH>
))}
</TR>
))}
</THead>
<TBody
ref={virtualContainerRef}
role="rowgroup"
$height={rowVirtualizer.getTotalSize()}
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={handlePreviousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={handleNextPage} neutral>
{t("Next page")}
</Button>
)}
</Pagination>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] as TRow<TData>;
return (
<TR
role="row"
key={row.id}
data-index={virtualRow.index}
style={{
position: "absolute",
transform: `translateY(${
virtualRow.start - rowVirtualizer.options.scrollMargin
}px)`,
height: `${virtualRow.size}px`,
}}
$columns={gridColumns}
>
{row.getAllCells().map((cell) => (
<TD role="cell" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TD>
))}
</TR>
);
})}
</TBody>
{showPlaceholder && (
<Placeholder columns={columns.length} gridColumns={gridColumns} />
)}
</InnerTable>
{page.hasNext && (
<Waypoint
key={data?.length}
onEnter={page.fetchNext}
bottomOffset={-rowHeight * 5}
/>
)}
</div>
{isEmpty && <Empty>{t("No results")}</Empty>}
</>
);
}
export const Placeholder = ({
const ObservedCell = observer(function <TData>({
data,
render,
}: {
data: TData;
render: (data: TData) => React.ReactNode;
}) {
return <>{render(data)}</>;
});
function Placeholder({
columns,
rows = 3,
gridColumns,
}: {
columns: number;
rows?: number;
}) => (
<DelayedMount>
<tbody>
{new Array(rows).fill(1).map((_, row) => (
<Row key={row}>
{new Array(columns).fill(1).map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
</DelayedMount>
);
const Anchor = styled.div`
top: -32px;
position: relative;
`;
const Pagination = styled(Flex)`
margin: 0 0 32px;
`;
gridColumns: string;
}) {
return (
<DelayedMount>
<TBody $height={150}>
{new Array(rows).fill(1).map((_r, row) => (
<TR key={row} $columns={gridColumns}>
{new Array(columns).fill(1).map((_c, col) => (
<TD key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</TD>
))}
</TR>
))}
</TBody>
</DelayedMount>
);
}
const DescSortIcon = styled(CollapsedIcon)`
margin-left: -2px;
@@ -239,12 +291,6 @@ const AscSortIcon = styled(DescSortIcon)`
transform: rotate(180deg);
`;
const InnerTable = styled.table`
border-collapse: collapse;
margin: 16px 0;
min-width: 100%;
`;
const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
display: inline-flex;
height: 24px;
@@ -261,15 +307,66 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
}
`;
const Cell = styled.td`
padding: 10px 6px;
border-bottom: 1px solid ${s("divider")};
const InnerTable = styled.div`
width: 100%;
`;
const THead = styled.div<{ $topPos: number }>`
position: sticky;
top: ${({ $topPos }) => `${$topPos}px`};
height: ${HEADER_HEIGHT}px;
z-index: 1;
font-size: 14px;
text-wrap: nowrap;
color: ${s("textSecondary")};
font-weight: 500;
border-bottom: 1px solid ${s("divider")};
background: ${s("background")};
`;
const TBody = styled.div<{ $height: number }>`
position: relative;
height: ${({ $height }) => `${$height}px`};
`;
const TR = styled.div<{ $columns: string }>`
width: 100%;
display: grid;
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
&:last-child {
border-bottom: 0;
}
`;
const TH = styled.span`
padding: 6px 6px 2px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
`;
const TD = styled.span`
padding: 10px 6px;
font-size: 14px;
text-wrap: wrap;
word-break: break-word;
&:first-child {
font-size: 15px;
font-weight: 500;
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
&.actions,
@@ -292,39 +389,4 @@ const Cell = styled.td`
}
`;
const Row = styled.tr`
${Cell} {
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
&:last-child {
${Cell} {
border-bottom: 0;
}
}
`;
const Head = styled.th`
text-align: left;
padding: 6px 6px 2px;
border-bottom: 1px solid ${s("divider")};
background: ${s("background")};
font-size: 14px;
color: ${s("textSecondary")};
font-weight: 500;
z-index: 1;
:first-child {
padding-left: 0;
}
:last-child {
padding-right: 0;
}
`;
export default observer(Table);
-71
View File
@@ -1,71 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "scroll-into-view-if-needed";
import useQuery from "~/hooks/useQuery";
import lazyWithRetry from "~/utils/lazyWithRetry";
import type { Props } from "./Table";
const Table = lazyWithRetry(() => import("~/components/Table"));
const TableFromParams = (
props: Omit<Props, "onChangeSort" | "onChangePage" | "topRef">
) => {
const topRef = React.useRef();
const location = useLocation();
const history = useHistory();
const params = useQuery();
const handleChangeSort = React.useCallback(
(sort, direction) => {
if (sort) {
params.set("sort", sort);
} else {
params.delete("sort");
}
params.set("direction", direction.toLowerCase());
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangePage = React.useCallback(
(page) => {
if (page) {
params.set("page", page.toString());
} else {
params.delete("page");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
if (topRef.current) {
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "start",
});
}
},
[params, history, location.pathname]
);
return (
<Table
topRef={topRef}
onChangeSort={handleChangeSort}
onChangePage={handleChangePage}
{...props}
/>
);
};
export default observer(TableFromParams);
+13 -4
View File
@@ -12,6 +12,8 @@ export type Props = Omit<TippyProps, "content" | "theme"> & {
content?: React.ReactChild | React.ReactChild[];
/** A keyboard shortcut to display next to the content */
shortcut?: React.ReactNode;
/** Whether to show the shortcut on a new line */
shortcutOnNewline?: boolean;
};
/**
@@ -21,7 +23,13 @@ export type Props = Omit<TippyProps, "content" | "theme"> & {
* Wrap this component in a TooltipProvider to allow multiple tooltips to share the same
* singleton instance (delay, animation, etc).
*/
function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
function Tooltip({
shortcut,
shortcutOnNewline,
content: tooltip,
delay = 500,
...rest
}: Props) {
const isMobile = useMobile();
const singleton = useTooltipContext();
@@ -34,12 +42,13 @@ function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
if (shortcut) {
content = (
<>
{tooltip}{" "}
{tooltip}
{shortcutOnNewline ? <br /> : " "}
{typeof shortcut === "string" ? (
shortcut
.split("+")
.map((key) => (
<Shortcut key={key}>
.map((key, i) => (
<Shortcut key={`${key}${i}`}>
{key.length === 1 ? key.toUpperCase() : key}
</Shortcut>
))
+72 -2
View File
@@ -1,10 +1,14 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import User from "~/models/User";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input from "~/components/Input";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import Text from "./Text";
type Props = {
user: User;
@@ -85,7 +89,11 @@ export function UserSuspendDialog({ user, onSubmit }: Props) {
};
return (
<ConfirmationDialog onSubmit={handleSubmit} savingText={`${t("Saving")}`}>
<ConfirmationDialog
onSubmit={handleSubmit}
savingText={`${t("Saving")}`}
danger
>
{t(
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
{
@@ -123,6 +131,68 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
onChange={handleChange}
error={!name ? t("Name can't be empty") : undefined}
value={name}
autoSelect
required
flex
/>
</ConfirmationDialog>
);
}
export function UserChangeEmailDialog({ user, onSubmit }: Props) {
const { t } = useTranslation();
const actor = useCurrentUser();
const [email, setEmail] = React.useState<string>(user.email);
const [error, setError] = React.useState<string | undefined>();
const handleSubmit = async () => {
try {
await client.post(`/users.updateEmail`, { id: user.id, email });
onSubmit();
toast.info(
actor.id === user.id
? t("Check your email to verify the new address.")
: t("The email will be changed once verified.")
);
return true;
} catch (err) {
setError(err.message);
return false;
}
};
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
setEmail(ev.target.value);
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Save")}
savingText={`${t("Saving")}`}
disabled={!email || email === user.email}
>
<Text as="p">
{actor.id === user.id ? (
<Trans>
You will receive an email to verify your new address. It must be
unique in the workspace.
</Trans>
) : (
<Trans>
A confirmation email will be sent to the new address before it is
changed.
</Trans>
)}
</Text>
<Input
type="email"
name="email"
label={t("New email")}
onChange={handleChange}
error={!email ? t("Email can't be empty") : error}
value={email}
autoSelect
required
flex
/>
+9 -242
View File
@@ -1,43 +1,24 @@
import {
ArrowIcon,
DocumentIcon,
CloseIcon,
PlusIcon,
OpenIcon,
} from "outline-icons";
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { s, hideScrollbars } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
import Input from "./Input";
import LinkSearchResult from "./LinkSearchResult";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
export type SearchResult = {
title: string;
subtitle?: React.ReactNode;
icon?: React.ReactNode;
url: string;
};
type Props = {
mark?: Mark;
from: number;
to: number;
dictionary: Dictionary;
onRemoveLink?: () => void;
onCreateLink?: (title: string, nested?: boolean) => Promise<void>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onSelectLink: (options: {
href: string;
title?: string;
@@ -52,46 +33,25 @@ type Props = {
};
type State = {
results: {
[keyword: string]: SearchResult[];
};
value: string;
previousValue: string;
selectedIndex: number;
};
class LinkEditor extends React.Component<Props, State> {
discardInputValue = false;
initialValue = this.href;
initialSelectionLength = this.props.to - this.props.from;
resultsRef = React.createRef<HTMLDivElement>();
inputRef = React.createRef<HTMLInputElement>();
state: State = {
selectedIndex: -1,
value: this.href,
previousValue: "",
results: {},
};
get href(): string {
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
}
get selectedText(): string {
const { state } = this.props.view;
const selectionText = state.doc.cut(
state.selection.from,
state.selection.to
).textContent;
return selectionText.trim();
}
get suggestedLinkTitle(): string {
return this.state.value.trim() || this.selectedText;
}
componentDidMount(): void {
window.addEventListener("keydown", this.handleGlobalKeyDown);
}
@@ -139,25 +99,12 @@ class LinkEditor extends React.Component<Props, State> {
};
handleKeyDown = (event: React.KeyboardEvent): void => {
const results = this.results;
switch (event.key) {
case "Enter": {
event.preventDefault();
const { selectedIndex, value } = this.state;
const { onCreateLink } = this.props;
const { value } = this.state;
if (selectedIndex >= 0) {
const result = results[selectedIndex];
if (result) {
this.save(result.url, result.title);
} else if (onCreateLink && selectedIndex === results.length) {
void this.handleCreateLink(this.suggestedLinkTitle);
}
} else {
// saves the raw input as href
this.save(value, value);
}
this.save(value, value);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
@@ -176,45 +123,9 @@ class LinkEditor extends React.Component<Props, State> {
}
return;
}
case "ArrowUp": {
if (event.shiftKey) {
return;
}
event.preventDefault();
event.stopPropagation();
const prevIndex = this.state.selectedIndex - 1;
this.setState({
selectedIndex: Math.max(-1, prevIndex),
});
return;
}
case "ArrowDown":
case "Tab": {
if (event.shiftKey) {
return;
}
event.preventDefault();
event.stopPropagation();
const { selectedIndex } = this.state;
const total = results.length + 1;
const nextIndex = selectedIndex + 1;
this.setState({
selectedIndex: Math.min(nextIndex, total),
});
return;
}
}
};
handleFocusLink = (selectedIndex: number) => {
this.setState({ selectedIndex });
};
handleSearch = async (
event: React.ChangeEvent<HTMLInputElement>
): Promise<void> => {
@@ -222,21 +133,15 @@ class LinkEditor extends React.Component<Props, State> {
this.setState({
value,
selectedIndex: -1,
});
const trimmedValue = value.trim() || this.selectedText;
const trimmedValue = value.trim();
if (trimmedValue && this.props.onSearchLink) {
if (trimmedValue) {
try {
const results = await this.props.onSearchLink(trimmedValue);
this.setState((state) => ({
results: {
...state.results,
[trimmedValue]: results,
},
this.setState({
previousValue: trimmedValue,
}));
});
} catch (err) {
Logger.error("Error searching for link", err);
}
@@ -257,20 +162,6 @@ class LinkEditor extends React.Component<Props, State> {
}
};
handleCreateLink = async (title: string, nested?: boolean) => {
this.discardInputValue = true;
const { onCreateLink } = this.props;
title = title.trim();
if (title.length === 0) {
return;
}
if (onCreateLink) {
return onCreateLink(title, nested);
}
};
handleRemoveLink = (): void => {
this.discardInputValue = true;
@@ -285,16 +176,6 @@ class LinkEditor extends React.Component<Props, State> {
view.focus();
};
handleSelectLink =
(url: string, title: string) => (event: React.MouseEvent) => {
event.preventDefault();
this.save(url, title);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
};
moveSelectionToEnd = () => {
const { to, view } = this.props;
const { state, dispatch } = view;
@@ -305,42 +186,17 @@ class LinkEditor extends React.Component<Props, State> {
view.focus();
};
get results() {
const { value } = this.state;
return (
this.state.results[value.trim()] ||
this.state.results[this.state.previousValue] ||
[]
);
}
render() {
const { dictionary } = this.props;
const { value, selectedIndex } = this.state;
const results = this.results;
const looksLikeUrl = value.match(/^https?:\/\//i);
const suggestedLinkTitle = this.suggestedLinkTitle;
const { value } = this.state;
const isInternal = isInternalUrl(value);
const showCreateLink =
!!this.props.onCreateLink &&
!(suggestedLinkTitle === this.initialValue) &&
suggestedLinkTitle.length > 0 &&
!looksLikeUrl;
const hasResults =
!!suggestedLinkTitle && (showCreateLink || results.length > 0);
return (
<Wrapper>
<Input
ref={this.inputRef}
value={value}
placeholder={
showCreateLink
? dictionary.findOrCreateDoc
: dictionary.searchOrPasteLink
}
placeholder={dictionary.enterLink}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleSearch}
@@ -360,70 +216,6 @@ class LinkEditor extends React.Component<Props, State> {
<CloseIcon />
</ToolbarButton>
</Tooltip>
<SearchResults
ref={this.resultsRef}
$hasResults={hasResults}
role="menu"
>
<ResizingHeightContainer>
{hasResults && (
<>
{results.map((result, index) => (
<LinkSearchResult
key={result.url}
title={result.title}
subtitle={result.subtitle}
icon={result.icon ?? <DocumentIcon />}
onPointerMove={() => this.handleFocusLink(index)}
onClick={this.handleSelectLink(result.url, result.title)}
selected={index === selectedIndex}
containerRef={this.resultsRef}
/>
))}
{showCreateLink && (
<>
<LinkSearchResult
key="create"
containerRef={this.resultsRef}
title={suggestedLinkTitle}
subtitle={dictionary.createNewDoc}
icon={<PlusIcon />}
onPointerMove={() => this.handleFocusLink(results.length)}
onClick={async () => {
await this.handleCreateLink(suggestedLinkTitle);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
}}
selected={results.length === selectedIndex}
/>
<LinkSearchResult
key="create-nested"
containerRef={this.resultsRef}
title={suggestedLinkTitle}
subtitle={dictionary.createNewChildDoc}
icon={<PlusIcon />}
onPointerMove={() =>
this.handleFocusLink(results.length + 1)
}
onClick={async () => {
await this.handleCreateLink(suggestedLinkTitle, true);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
}}
selected={results.length + 1 === selectedIndex}
/>
</>
)}
</>
)}
</ResizingHeightContainer>
</SearchResults>
</Wrapper>
);
}
@@ -434,29 +226,4 @@ const Wrapper = styled(Flex)`
gap: 8px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
background: ${s("menuBackground")};
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
clip-path: inset(0px -100px -100px -100px);
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
margin-top: -6px;
border-radius: 0 0 4px 4px;
padding: ${(props) => (props.$hasResults ? "8px 0" : "0")};
max-height: 240px;
${hideScrollbars()}
@media (hover: none) and (pointer: coarse) {
position: fixed;
top: auto;
bottom: 40px;
border-radius: 0;
max-height: 50vh;
padding: 8px 8px 4px;
}
`;
export default LinkEditor;
-109
View File
@@ -1,109 +0,0 @@
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import { s, ellipsis } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLDivElement> & {
icon: React.ReactNode;
selected: boolean;
title: React.ReactNode;
subtitle?: React.ReactNode;
containerRef: React.RefObject<HTMLDivElement>;
};
function LinkSearchResult({
title,
subtitle,
containerRef,
selected,
icon,
...rest
}: Props) {
const ref = React.useCallback(
(node: HTMLElement | null) => {
if (selected && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
block: "center",
boundary: (parent) =>
// Prevents body and other parent elements from being scrolled
parent !== containerRef.current,
});
}
},
[containerRef, selected]
);
return (
<ListItem
ref={ref}
compact={!subtitle}
selected={selected}
role="menuitem"
{...rest}
>
<IconWrapper selected={selected}>{icon}</IconWrapper>
<Content>
<Title title={title}>{title}</Title>
{subtitle ? <Subtitle selected={selected}>{subtitle}</Subtitle> : null}
</Content>
</ListItem>
);
}
const Content = styled.div`
overflow: hidden;
`;
const IconWrapper = styled.span<{ selected: boolean }>`
flex-shrink: 0;
margin-right: 4px;
height: 24px;
opacity: 0.8;
${(props) =>
props.selected &&
css`
svg {
fill: ${s("accentText")};
color: ${s("accentText")};
}
`};
`;
const ListItem = styled.div<{
selected: boolean;
compact: boolean;
}>`
display: flex;
align-items: center;
padding: 8px;
border-radius: 4px;
margin: 0 6px;
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
background: ${(props) => (props.selected ? s("accent") : "transparent")};
font-family: ${s("fontFamily")};
text-decoration: none;
overflow: hidden;
white-space: nowrap;
cursor: var(--pointer);
user-select: none;
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
height: ${(props) => (props.compact ? "28px" : "auto")};
`;
const Title = styled.div`
${ellipsis()}
font-size: 14px;
font-weight: 500;
`;
const Subtitle = styled.div<{
selected: boolean;
}>`
${ellipsis()}
font-size: 13px;
opacity: ${(props) => (props.selected ? 0.75 : 0.5)};
`;
export default LinkSearchResult;
-146
View File
@@ -1,146 +0,0 @@
import { EditorView } from "prosemirror-view";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import { creatingUrlPrefix } from "@shared/utils/urls";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
import { useEditor } from "./EditorContext";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor, { SearchResult } from "./LinkEditor";
type Props = {
isActive: boolean;
onCreateLink?: (title: string) => Promise<string>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onClose: () => void;
};
function isActive(view: EditorView, active: boolean): boolean {
try {
const { selection } = view.state;
const paragraph = view.domAtPos(selection.from);
return active && !!paragraph.node;
} catch (err) {
return false;
}
}
export default function LinkToolbar({
onCreateLink,
onSearchLink,
onClickLink,
onClose,
...rest
}: Props) {
const dictionary = useDictionary();
const { view } = useEditor();
const menuRef = React.useRef<HTMLDivElement>(null);
useEventListener("mousedown", (event: Event) => {
if (
event.target instanceof HTMLElement &&
menuRef.current &&
menuRef.current.contains(event.target)
) {
return;
}
onClose();
});
const handleOnCreateLink = React.useCallback(
async (title: string, nested?: boolean) => {
onClose();
view.focus();
if (!onCreateLink) {
return;
}
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from !== to) {
// selection must be collapsed
return;
}
const href = `${creatingUrlPrefix}#${title}`;
// Insert a placeholder link
dispatch(
view.state.tr
.insertText(title, from, to)
.addMark(
from,
to + title.length,
state.schema.marks.link.create({ href })
)
);
return createAndInsertLink(view, title, href, {
nested,
onCreateLink,
dictionary,
});
},
[onCreateLink, onClose, view, dictionary]
);
const handleOnSelectLink = React.useCallback(
({
href,
title,
}: {
href: string;
title: string;
from: number;
to: number;
}) => {
onClose();
view.focus();
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from !== to) {
// selection must be collapsed
return;
}
dispatch(
view.state.tr
.insertText(title, from, to)
.addMark(
from,
to + title.length,
state.schema.marks.link.create({ href })
)
);
},
[onClose, view]
);
const { selection } = view.state;
const active = isActive(view, rest.isActive);
return (
<FloatingToolbar ref={menuRef} active={active} width={336}>
{active && (
<LinkEditor
key={`${selection.from}-${selection.to}`}
from={selection.from}
to={selection.to}
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
onSelectLink={handleOnSelectLink}
onRemoveLink={onClose}
onClickLink={onClickLink}
onSearchLink={onSearchLink}
dictionary={dictionary}
view={view}
/>
)}
</FloatingToolbar>
);
}
+102 -44
View File
@@ -1,27 +1,29 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { toast } from "sonner";
import { v4 } from "uuid";
import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { DocumentsSection, UserSection } from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import MentionMenuItem from "./MentionMenuItem";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
interface MentionItem extends MenuItem {
name: string;
user: User;
appendSpace: boolean;
attrs: {
id: string;
type: MentionType;
@@ -40,17 +42,22 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, users } = useStores();
const { auth, documents, users } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const { data, loading, request } = useRequest(
React.useCallback(
() =>
documentId
? users.fetchPage({ id: documentId, query: search })
: Promise.resolve([]),
[users, documentId, search]
)
const { data, loading, request } = useRequest<{
documents: Document[];
users: User[];
}>(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
return {
documents: res.data.documents.map(documents.add),
users: res.data.users.map(users.add),
};
}, [search, documents, users])
);
React.useEffect(() => {
@@ -60,28 +67,92 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
}, [request, isActive]);
React.useEffect(() => {
if (data && !loading) {
const items = data.map((user) => ({
name: "mention",
user,
title: user.name,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.User,
modelId: user.id,
actorId: auth.currentUserId ?? undefined,
label: user.name,
},
}));
if (data && actorId && !loading) {
const items = data.users
.map(
(user) =>
({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={user}
showBorder={false}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
),
title: user.name,
section: UserSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.User,
modelId: user.id,
actorId,
label: user.name,
},
} as MentionItem)
)
.concat(
data.documents.map(
(doc) =>
({
name: "mention",
icon: doc.icon ? (
<Icon value={doc.icon} color={doc.color ?? undefined} />
) : (
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collection?.name,
section: DocumentsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
label: doc.title,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
icon: <PlusIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a new doc"),
visible: !!search && !isEmail(search),
priority: -1,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Document,
modelId: v4(),
actorId,
label: search,
},
} as MentionItem,
]);
setItems(items);
setLoaded(true);
}
}, [auth.currentUserId, loading, data]);
}, [t, actorId, loading, search, data]);
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (item.attrs.type === MentionType.Document) {
return;
}
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
@@ -122,25 +193,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
search={search}
onSelect={handleSelect}
renderMenuItem={(item, _index, options) => (
<MentionMenuItem
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
subtitle={item.subtitle}
title={item.title}
label={item.attrs.label}
icon={
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={item.user}
showBorder={false}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
}
icon={item.icon}
/>
)}
items={items}
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react";
import SuggestionsMenuItem, {
Props as SuggestionsMenuItemProps,
} from "./SuggestionsMenuItem";
type MentionMenuItemProps = Omit<
SuggestionsMenuItemProps,
"shortcut" | "theme"
> & {
label: string;
};
export default function MentionMenuItem({
label,
...rest
}: MentionMenuItemProps) {
return <SuggestionsMenuItem {...rest} title={label} />;
}
@@ -25,4 +25,9 @@ export class NodeViewRenderer<T extends object> {
this.props = props;
}
}
@action
public setProp<K extends keyof T>(key: K, value: T[K]) {
this.props[key] = value;
}
}
+2 -43
View File
@@ -1,7 +1,6 @@
import some from "lodash/some";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
@@ -9,7 +8,6 @@ import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { creatingUrlPrefix } from "@shared/utils/urls";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
@@ -26,7 +24,7 @@ import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import { useEditor } from "./EditorContext";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor, { SearchResult } from "./LinkEditor";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
type Props = {
@@ -37,12 +35,10 @@ type Props = {
canUpdate?: boolean;
onOpen: () => void;
onClose: () => void;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
onCreateLink?: (title: string) => Promise<string>;
};
function useIsActive(state: EditorState) {
@@ -149,40 +145,6 @@ export default function SelectionToolbar(props: Props) {
};
}, [isActive, previousIsActive, readOnly, view]);
const handleOnCreateLink = async (
title: string,
nested?: boolean
): Promise<void> => {
const { onCreateLink } = props;
if (!onCreateLink) {
return;
}
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from === to) {
// Do not display a selection toolbar for collapsed selections
return;
}
const href = `${creatingUrlPrefix}${title}`;
const markType = state.schema.marks.link;
// Insert a placeholder link
dispatch(
view.state.tr
.removeMark(from, to, markType)
.addMark(from, to, markType.create({ href }))
);
return createAndInsertLink(view, title, href, {
nested,
onCreateLink,
dictionary,
});
};
const handleOnSelectLink = ({
href,
from,
@@ -203,8 +165,7 @@ export default function SelectionToolbar(props: Props) {
);
};
const { onCreateLink, isTemplate, rtl, canComment, canUpdate, ...rest } =
props;
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
@@ -283,8 +244,6 @@ export default function SelectionToolbar(props: Props) {
from={link.from}
to={link.to}
onClickLink={props.onClickLink}
onSearchLink={props.onSearchLink}
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
onSelectLink={handleOnSelectLink}
/>
) : (
+54 -19
View File
@@ -1,7 +1,8 @@
import commandScore from "command-score";
import capitalize from "lodash/capitalize";
import orderBy from "lodash/orderBy";
import * as React from "react";
import { Trans } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { toast } from "sonner";
import styled from "styled-components";
@@ -13,6 +14,7 @@ import { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Header from "~/components/ContextMenu/Header";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
@@ -78,8 +80,9 @@ export type Props<T extends MenuItem = MenuItem> = {
};
function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { view, commands } = useEditor();
const { view, commands, props: editorProps } = useEditor();
const dictionary = useDictionary();
const { t } = useTranslation();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
@@ -250,6 +253,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
props.onSelect?.(item);
switch (item.name) {
case "link":
insertNode({
...item,
name: "mention",
});
void editorProps.onCreateLink?.({
title: item.attrs.label,
id: item.attrs.modelId,
});
return;
case "image":
return triggerFilePick(
AttachmentValidation.imageContentTypes.join(", ")
@@ -264,7 +277,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
insertNode(item);
}
},
[insertNode]
[editorProps, props, insertNode]
);
const close = React.useCallback(() => {
@@ -414,6 +427,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return true;
}
if (item.visible === false) {
return false;
}
// Some extensions may be disabled, remove corresponding menu items
if (
item.name &&
@@ -445,16 +462,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
});
return filterExcessSeparators(
filtered
.map((item) => ({
orderBy(
filtered.map((item) => ({
item,
section:
"section" in item && item.section && "priority" in item.section
? (item.section.priority as number) ?? 0
: 0,
priority: "priority" in item ? item.priority : 0,
score:
searchInput && item.title
? commandScore(item.title, searchInput)
: 0,
}))
.sort((a, b) => b.score - a.score)
.map(({ item }) => item)
})),
["section", "priority", "score"],
["desc", "desc", "desc"]
).map(({ item }) => item)
);
}, [commands, props]);
@@ -555,6 +578,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { isActive, uploadFile } = props;
const items = filtered;
let previousHeading: string | undefined;
return (
<Portal>
@@ -614,18 +638,29 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
};
return (
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: () => handleClickItem(item),
})}
</ListItem>
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: () => handleClickItem(item),
})}
</ListItem>
</>
);
previousHeading = currentHeading;
return response;
})}
{items.length === 0 && (
<ListItem>
@@ -15,6 +15,8 @@ export type Props = {
icon?: React.ReactElement;
/** The title of the item */
title: React.ReactNode;
/** An optional subtitle for the item */
subtitle?: React.ReactNode;
/** A string representing the keyboard shortcut for the item */
shortcut?: string;
};
@@ -24,6 +26,7 @@ function SuggestionsMenuItem({
disabled,
onClick,
title,
subtitle,
shortcut,
icon,
}: Props) {
@@ -53,11 +56,17 @@ function SuggestionsMenuItem({
icon={icon}
>
{title}
{subtitle && <Subtitle $active={selected}>&middot; {subtitle}</Subtitle>}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuItem>
);
}
const Subtitle = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active ? props.theme.white50 : props.theme.textTertiary};
`;
const Shortcut = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active ? props.theme.white50 : props.theme.textTertiary};
+1
View File
@@ -12,6 +12,7 @@ const WrappedTooltip: React.FC<Props> = ({
delay={150}
content={content}
placement="top"
shortcutOnNewline
{...rest}
>
<TooltipContent>{children}</TooltipContent>
@@ -1,6 +1,7 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import textBetween from "@shared/editor/lib/textBetween";
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
/**
* A plugin that allows overriding the default behavior of the editor to allow
@@ -13,11 +14,7 @@ export default class ClipboardTextSerializer extends Extension {
}
get plugins() {
const textSerializers = Object.fromEntries(
Object.entries(this.editor.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
const textSerializers = getTextSerializers(this.editor.schema);
return [
new Plugin({
+4 -4
View File
@@ -1,6 +1,7 @@
import { action } from "mobx";
import * as React from "react";
import { WidgetProps } from "@shared/editor/lib/Extension";
import { isBrowser } from "@shared/utils/browser";
import Suggestion from "~/editor/extensions/Suggestion";
import EmojiMenu from "../components/EmojiMenu";
@@ -13,10 +14,9 @@ const languagesUsingColon = ["fr"];
export default class EmojiMenuExtension extends Suggestion {
get defaultOptions() {
const languageIsUsingColon =
typeof window === "undefined"
? false
: languagesUsingColon.includes(window.navigator.language.slice(0, 2));
const languageIsUsingColon = isBrowser
? languagesUsingColon.includes(window.navigator.language.slice(0, 2))
: false;
return {
openRegex: new RegExp(
+39 -16
View File
@@ -79,15 +79,17 @@ export default class FindAndReplaceExtension extends Extension {
}
private get decorations() {
return this.results.map((deco, index) =>
Decoration.inline(deco.from, deco.to, {
return this.results.map((deco, index) => {
const decorationType =
deco.type === "node" ? Decoration.node : Decoration.inline;
return decorationType(deco.from, deco.to, {
class:
this.options.resultClassName +
(this.currentResultIndex === index
? ` ${this.options.resultCurrentClassName}`
: ""),
})
);
});
});
}
public replace(replace: string): Command {
@@ -175,7 +177,7 @@ export default class FindAndReplaceExtension extends Extension {
private goToMatch(direction: number): Command {
return (state, dispatch) => {
if (direction > 0) {
if (this.currentResultIndex === this.results.length - 1) {
if (this.currentResultIndex >= this.results.length - 1) {
this.currentResultIndex = 0;
} else {
this.currentResultIndex += 1;
@@ -212,11 +214,12 @@ export default class FindAndReplaceExtension extends Extension {
const { from: currentFrom, to: currentTo } = this.results[index];
const offset = currentTo - currentFrom - replace.length + lastOffset;
const { from, to } = this.results[nextIndex];
const { from, to, type } = this.results[nextIndex];
this.results[nextIndex] = {
to: to - offset,
from: from - offset,
type,
};
return offset;
@@ -224,10 +227,19 @@ export default class FindAndReplaceExtension extends Extension {
private search(doc: Node) {
this.results = [];
const mergedTextNodes: {
text: string | undefined;
pos: number;
}[] = [];
const mergedTextNodes: (
| {
text: string | undefined;
pos: number;
type: "inline";
}
| {
text: string | undefined;
pos: number;
type: "node";
nodeSize: number;
}
)[] = [];
let index = 0;
if (!this.searchTerm) {
@@ -238,21 +250,32 @@ export default class FindAndReplaceExtension extends Extension {
if (node.isText) {
if (mergedTextNodes[index]) {
mergedTextNodes[index] = {
type: "inline",
text: mergedTextNodes[index].text + (node.text ?? ""),
pos: mergedTextNodes[index].pos,
};
} else {
mergedTextNodes[index] = {
type: "inline",
text: node.text,
pos,
};
}
} else if (node.type.name === "mention") {
mergedTextNodes[++index] = {
type: "node",
nodeSize: node.nodeSize,
text: node.attrs.label,
pos,
};
++index;
} else {
index += 1;
++index;
}
});
mergedTextNodes.forEach(({ text = "", pos }) => {
mergedTextNodes.forEach((node) => {
const { text = "", pos, type } = node;
try {
let m;
const search = this.findRegExp;
@@ -266,8 +289,8 @@ export default class FindAndReplaceExtension extends Extension {
// Reconstruct the correct match position
const i = m.index >= text.length ? m.index - text.length : m.index;
const from = pos + i;
const to = from + m[0].length;
const from = type === "inline" ? pos + i : pos;
const to = from + (type === "inline" ? m[0].length : node.nodeSize);
// Check if already exists in results, possible due to duplicated
// search string on L257
@@ -275,7 +298,7 @@ export default class FindAndReplaceExtension extends Extension {
continue;
}
this.results.push({ from, to });
this.results.push({ from, to, type });
}
} catch (e) {
// Invalid RegExp
@@ -349,7 +372,7 @@ export default class FindAndReplaceExtension extends Extension {
private open = false;
@observable
private results: { from: number; to: number }[] = [];
private results: { from: number; to: number; type: "inline" | "node" }[] = [];
@observable
private currentResultIndex = 0;
+25 -8
View File
@@ -1,13 +1,14 @@
import { toggleMark } from "prosemirror-commands";
import { Slice } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { v4 } from "uuid";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension from "@shared/editor/lib/Extension";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
import { IconType } from "@shared/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
@@ -185,15 +186,31 @@ export default class PasteHandler extends Extension {
return;
}
if (document) {
const { hash } = new URL(text);
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Document,
modelId: document.id,
label: document.titleWithDefault,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(document.icon) ===
IconType.Emoji;
const hasEmoji =
determineIconType(document.icon) === IconType.Emoji;
const title = `${
hasEmoji ? document.icon + " " : ""
}${document.titleWithDefault}`;
const title = `${
hasEmoji ? document.icon + " " : ""
}${document.titleWithDefault}`;
insertLink(`${document.path}${hash}`, title);
insertLink(`${document.path}${hash}`, title);
}
}
})
.catch(() => {
+23 -56
View File
@@ -1,5 +1,7 @@
/* global File Promise */
import { PluginSimple } from "markdown-it";
import { observable } from "mobx";
import { Observer } from "mobx-react";
import { darken, transparentize } from "polished";
import { baseKeymap } from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor";
@@ -33,22 +35,23 @@ 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";
import ReactNode from "@shared/editor/nodes/ReactNode";
import { ComponentProps, EventType } from "@shared/editor/types";
import { ComponentProps } from "@shared/editor/types";
import { ProsemirrorData, UserPreferences } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import EventEmitter from "@shared/utils/events";
import Document from "~/models/Document";
import Flex from "~/components/Flex";
import { PortalContext } from "~/components/Portal";
import { Dictionary } from "~/hooks/useDictionary";
import { Properties } from "~/types";
import Logger from "~/utils/Logger";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
@@ -115,13 +118,11 @@ export type Props = {
/** Callback when a file upload ends */
onFileUploadStop?: () => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (title: string) => Promise<string>;
/** Callback when user searches for documents from link insert interface */
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onCreateLink?: (params: Properties<Document>) => Promise<string>;
/** Callback when user clicks on any link in the document */
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
event?: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
/** Callback when user presses any key with document focused */
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
@@ -145,8 +146,6 @@ type State = {
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** If the insert link toolbar is visible */
linkToolbarOpen: boolean;
};
/**
@@ -176,7 +175,6 @@ export class Editor extends React.PureComponent<
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
linkToolbarOpen: false,
};
isInitialized = false;
@@ -197,7 +195,7 @@ export class Editor extends React.PureComponent<
};
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
renderers: Set<NodeViewRenderer<ComponentProps>> = new Set();
renderers: Set<NodeViewRenderer<ComponentProps>> = observable.set();
nodes: { [name: string]: NodeSpec };
marks: { [name: string]: MarkSpec };
commands: Record<string, CommandFactory>;
@@ -205,11 +203,6 @@ export class Editor extends React.PureComponent<
events = new EventEmitter();
mutationObserver?: MutationObserver;
public constructor(props: Props & ThemeProps<DefaultTheme>) {
super(props);
this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar);
}
/**
* We use componentDidMount instead of constructor as the init method requires
* that the dom is already mounted.
@@ -246,6 +239,12 @@ export class Editor extends React.PureComponent<
...this.view.props,
editable: () => !this.props.readOnly,
});
// NodeView will not automatically render when editable changes so we must trigger an update
// manually, see: https://discuss.prosemirror.net/t/re-render-custom-nodeview-when-view-editable-changes/6441
Array.from(this.renderers).forEach((view) =>
view.setProp("isEditable", !this.props.readOnly)
);
}
if (this.props.scrollTo && this.props.scrollTo !== prevProps.scrollTo) {
@@ -265,7 +264,6 @@ export class Editor extends React.PureComponent<
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
!this.state.linkToolbarOpen &&
!this.state.selectionToolbarOpen
) {
this.isBlurred = true;
@@ -274,9 +272,7 @@ export class Editor extends React.PureComponent<
if (
this.isBlurred &&
(this.state.isEditorFocused ||
this.state.linkToolbarOpen ||
this.state.selectionToolbarOpen)
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
) {
this.isBlurred = false;
this.props.onFocus?.();
@@ -713,11 +709,7 @@ export class Editor extends React.PureComponent<
*/
public getPlainText = () => {
const { doc } = this.view.state;
const textSerializers = Object.fromEntries(
Object.entries(this.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
const textSerializers = getTextSerializers(this.schema);
return textBetween(doc, 0, doc.content.size, textSerializers);
};
@@ -779,25 +771,8 @@ export class Editor extends React.PureComponent<
}));
};
private handleOpenLinkToolbar = () => {
if (this.state.selectionToolbarOpen) {
return;
}
this.setState((state) => ({
...state,
linkToolbarOpen: true,
}));
};
private handleCloseLinkToolbar = () => {
this.setState((state) => ({
...state,
linkToolbarOpen: false,
}));
};
public render() {
const { dir, readOnly, canUpdate, grow, style, className, onKeyDown } =
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
this.props;
const { isRTL } = this.state;
@@ -814,7 +789,6 @@ export class Editor extends React.PureComponent<
column
>
<EditorContainer
dir={dir}
rtl={isRTL}
grow={grow}
readOnly={readOnly}
@@ -834,25 +808,18 @@ export class Editor extends React.PureComponent<
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
/>
)}
{!readOnly && this.view && this.marks.link && (
<LinkToolbar
isActive={this.state.linkToolbarOpen}
onCreateLink={this.props.onCreateLink}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onClose={this.handleCloseLinkToolbar}
/>
)}
{this.widgets &&
Object.values(this.widgets).map((Widget, index) => (
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
))}
{Array.from(this.renderers).map((view) => view.content)}
<Observer>
{() => (
<>{Array.from(this.renderers).map((view) => view.content)}</>
)}
</Observer>
</Flex>
</EditorContext.Provider>
</PortalContext.Provider>
+6 -1
View File
@@ -241,7 +241,11 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+⌥+M`,
icon: <CommentIcon />,
label: isCodeBlock ? dictionary.comment : undefined,
active: isMarkActive(schema.marks.comment, { resolved: false }),
active: isMarkActive(
schema.marks.comment,
{ resolved: false },
{ exact: true }
),
visible: !isMobile || !isEmpty,
},
{
@@ -252,6 +256,7 @@ export default function formattingMenuItems(
name: "copyToClipboard",
icon: <CopyIcon />,
tooltip: dictionary.copy,
shortcut: `${metaDisplay}+C`,
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
},
];
+1 -1
View File
@@ -39,7 +39,7 @@ export default function useDictionary() {
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
file: t("File attachment"),
findOrCreateDoc: `${t("Paste a link, search, or create")}`,
enterLink: `${t("Enter a link")}`,
h1: t("Big heading"),
h2: t("Medium heading"),
h3: t("Small heading"),
+12 -3
View File
@@ -1,9 +1,10 @@
import * as React from "react";
import { useHistory } from "react-router-dom";
import { isModKey } from "@shared/utils/keyboard";
import { isInternalUrl } from "@shared/utils/urls";
import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import useStores from "./useStores";
type Params = {
/** The share ID of the document being viewed, if any */
@@ -12,8 +13,9 @@ type Params = {
export default function useEditorClickHandlers({ shareId }: Params) {
const history = useHistory();
const { documents } = useStores();
const handleClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
(href: string, event?: MouseEvent) => {
// on page hash
if (isHash(href)) {
window.location.href = href;
@@ -49,13 +51,20 @@ export default function useEditorClickHandlers({ shareId }: Params) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
}
if (isDocumentUrl(navigateTo)) {
const document = documents.getByUrl(navigateTo);
if (document) {
navigateTo = document.path;
}
}
// If we're navigating to a share link from a non-share link then open it in a new tab
if (!shareId && navigateTo.startsWith("/s/")) {
window.open(href, "_blank");
return;
}
if (!isModKey(event) && !event.shiftKey) {
if (!event || (!isModKey(event) && !event.shiftKey)) {
history.push(navigateTo, { sidebarContext: "collections" }); // optimistic preference of "collections"
} else {
window.open(navigateTo, "_blank");
+9 -5
View File
@@ -45,12 +45,16 @@ export default function useImportDocument(
}
for (const file of files) {
const doc = await documents.import(file, documentId, cId, {
publish: true,
});
try {
const doc = await documents.import(file, documentId, cId, {
publish: true,
});
if (redirect) {
history.push(documentPath(doc));
if (redirect) {
history.push(documentPath(doc));
}
} catch (err) {
toast.error(err.message);
}
}
} catch (err) {
+2 -1
View File
@@ -1,6 +1,7 @@
import * as React 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";
@@ -41,7 +42,7 @@ export default function usePersistedState<T extends Primitive | object>(
options?: Options
): [T, (value: T) => void] {
const [storedValue, setStoredValue] = React.useState(() => {
if (typeof window === "undefined") {
if (!isBrowser) {
return defaultValue;
}
return Storage.get(key) ?? defaultValue;
+9 -1
View File
@@ -1,5 +1,13 @@
import React from "react";
import { useLocation } from "react-router-dom";
export default function useQuery() {
return new URLSearchParams(useLocation().search);
const location = useLocation();
const query = React.useMemo(
() => new URLSearchParams(location.search),
[location.search]
);
return query;
}
+95
View File
@@ -0,0 +1,95 @@
import { ColumnSort } from "@tanstack/react-table";
import orderBy from "lodash/orderBy";
import React from "react";
import {
FetchPageParams,
PaginatedResponse,
PAGINATION_SYMBOL,
} from "~/stores/base/Store";
import useRequest from "./useRequest";
const INITIAL_OFFSET = 0;
const PAGE_SIZE = 25;
type Props<T> = {
data: T[];
sort: ColumnSort;
reqFn: (params: FetchPageParams) => Promise<PaginatedResponse<T>>;
reqParams: Omit<FetchPageParams, "offset" | "limit">;
};
type Response<T> = {
data: T[] | undefined;
error: unknown;
loading: boolean;
next: (() => void) | undefined;
};
export function useTableRequest<T extends { id: string }>({
data,
sort,
reqFn,
reqParams,
}: Props<T>): Response<T> {
const [total, setTotal] = React.useState<number>();
const [offset, setOffset] = React.useState({ value: INITIAL_OFFSET });
const prevParamsRef = React.useRef(reqParams);
const sortRef = React.useRef<ColumnSort>(sort);
const fetchPage = React.useCallback(
() => reqFn({ ...reqParams, offset: offset.value, limit: PAGE_SIZE }),
[reqFn, reqParams, offset]
);
const { request, loading, error } = useRequest(fetchPage);
const nextPage = React.useCallback(
() =>
setOffset((prev) => ({
value: prev.value + PAGE_SIZE,
})),
[]
);
const sortedData = data
? orderBy(data, sortRef.current.id, sortRef.current.desc ? "desc" : "asc")
: undefined;
const next =
!loading && total && sortedData && sortedData.length < total
? nextPage
: undefined;
React.useEffect(() => {
if (prevParamsRef.current !== reqParams) {
prevParamsRef.current = reqParams;
setOffset({ value: INITIAL_OFFSET });
return;
}
let ignore = false;
const handleRequest = async () => {
const response = await request();
if (!response || ignore) {
return;
}
sortRef.current = sort; // Change sort once we receive a response from server - avoids flicker with stale data.
setTotal(response[PAGINATION_SYMBOL]?.total);
};
void handleRequest();
return () => {
ignore = true;
};
}, [sort, reqParams, offset, request]);
return {
data: sortedData,
error,
loading,
next,
};
}
-1
View File
@@ -123,7 +123,6 @@ function CollectionMenu({
history.push(document.url);
} catch (err) {
toast.error(err.message);
throw err;
} finally {
ev.target.value = "";
}
+47 -24
View File
@@ -4,44 +4,57 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import Group from "~/models/Group";
import GroupDelete from "~/scenes/GroupDelete";
import GroupEdit from "~/scenes/GroupEdit";
import {
DeleteGroupDialog,
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import Modal from "~/components/Modal";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
type Props = {
group: Group;
onMembers: () => void;
};
function GroupMenu({ group, onMembers }: Props) {
function GroupMenu({ group }: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const menu = useMenuState({
modal: true,
});
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const can = usePolicy(group);
const handleViewMembers = React.useCallback(() => {
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={group} />,
fullscreen: true,
});
}, [t, group, dialogs]);
const handleEditGroup = React.useCallback(() => {
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, group, dialogs]);
const handleDeleteGroup = React.useCallback(() => {
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, group, dialogs]);
return (
<>
<Modal
title={t("Edit group")}
onRequestClose={() => setEditModalOpen(false)}
isOpen={editModalOpen}
>
<GroupEdit group={group} onSubmit={() => setEditModalOpen(false)} />
</Modal>
<Modal
title={t("Delete group")}
onRequestClose={() => setDeleteModalOpen(false)}
isOpen={deleteModalOpen}
>
<GroupDelete group={group} onSubmit={() => setDeleteModalOpen(false)} />
</Modal>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Group options")}>
<Template
@@ -51,7 +64,7 @@ function GroupMenu({ group, onMembers }: Props) {
type: "button",
title: `${t("Members")}`,
icon: <GroupIcon />,
onClick: onMembers,
onClick: handleViewMembers,
visible: !!(group && can.read),
},
{
@@ -61,7 +74,7 @@ function GroupMenu({ group, onMembers }: Props) {
type: "button",
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: () => setEditModalOpen(true),
onClick: handleEditGroup,
visible: !!(group && can.update),
},
{
@@ -69,9 +82,19 @@ function GroupMenu({ group, onMembers }: Props) {
title: `${t("Delete")}`,
icon: <TrashIcon />,
dangerous: true,
onClick: () => setDeleteModalOpen(true),
onClick: handleDeleteGroup,
visible: !!(group && can.delete),
},
{
type: "separator",
},
{
type: "link",
href: "",
title: group.externalId,
disabled: true,
visible: !!group.externalId,
},
]}
/>
</ContextMenu>
+1 -1
View File
@@ -3,12 +3,12 @@ import { DocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
+25 -1
View File
@@ -11,6 +11,7 @@ import Template from "~/components/ContextMenu/Template";
import {
UserSuspendDialog,
UserChangeNameDialog,
UserChangeEmailDialog,
} from "~/components/UserDialogs";
import { actionToMenuItem } from "~/actions";
import {
@@ -49,6 +50,22 @@ function UserMenu({ user }: Props) {
[dialogs, t, user]
);
const handleChangeEmail = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
dialogs.openModal({
title: t("Change email"),
content: (
<UserChangeEmailDialog
user={user}
onSubmit={dialogs.closeAllModals}
/>
),
});
},
[dialogs, t, user]
);
const handleSuspend = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
@@ -117,7 +134,13 @@ function UserMenu({ user }: Props) {
type: "button",
title: `${t("Change name")}`,
onClick: handleChangeName,
visible: can.update && user.role !== "admin",
visible: can.update,
},
{
type: "button",
title: `${t("Change email")}`,
onClick: handleChangeEmail,
visible: can.update,
},
{
type: "button",
@@ -144,6 +167,7 @@ function UserMenu({ user }: Props) {
{
type: "button",
title: `${t("Suspend user")}`,
dangerous: true,
onClick: handleSuspend,
visible: !user.isInvited && !user.isSuspended,
},
+3
View File
@@ -10,6 +10,9 @@ class Group extends Model {
@observable
name: string;
@observable
externalId: string | undefined;
@observable
memberCount: number;
+3
View File
@@ -59,6 +59,9 @@ class Share extends Model {
@observable
allowIndexing: boolean;
@observable
views: number;
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
+1 -1
View File
@@ -12,6 +12,7 @@ import {
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
@@ -22,7 +23,6 @@ import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import CollectionDescription from "~/components/CollectionDescription";
import Heading from "~/components/Heading";
import Icon, { IconTitleWrapper } from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSearchPage from "~/components/InputSearchPage";
import PlaceholderList from "~/components/List/Placeholder";
+11
View File
@@ -25,6 +25,7 @@ import useBuildTheme from "~/hooks/useBuildTheme";
import useCurrentUser from "~/hooks/useCurrentUser";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, OfflineError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import { changeLanguage, detectLanguage } from "~/utils/language";
@@ -109,6 +110,16 @@ function SharedDocumentScene(props: Props) {
: undefined;
const theme = useBuildTheme(response?.team?.customTheme, themeOverride);
React.useEffect(() => {
if (shareId) {
client.setShareId(shareId);
}
return () => {
client.setShareId(undefined);
};
}, [shareId]);
React.useEffect(() => {
if (!user) {
void changeLanguage(detectLanguage(), i18n);
@@ -19,19 +19,23 @@ const CommentSortMenu = () => {
const user = useCurrentUser();
const params = useQuery();
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved
? "resolved"
: user.getPreference(UserPreference.SortCommentsByOrderInDocument)
const preferredSortType = user.getPreference(
UserPreference.SortCommentsByOrderInDocument
)
? CommentSortType.OrderInDocument
: CommentSortType.MostRecent;
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved ? "resolved" : preferredSortType;
const handleSortTypeChange = (type: CommentSortType) => {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
if (type !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
}
};
const showResolved = () => {
@@ -8,6 +8,7 @@ import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { Avatar, AvatarSize } from "~/components/Avatar";
@@ -74,10 +75,10 @@ function CommentThread({
const canReply = can.comment && !thread.isResolved;
const highlightedCommentMarks = editor
?.getComments()
.filter((comment) => comment.id === thread.id);
const highlightedText = highlightedCommentMarks?.map((c) => c.text).join("");
const highlightedText = ProsemirrorHelper.getAnchorTextForComment(
editor?.getComments() ?? [],
thread.id
);
const commentsInThread = comments
.inThread(thread.id)
@@ -14,6 +14,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { Properties } from "~/types";
import Logger from "~/utils/Logger";
import {
NotFoundError,
@@ -46,7 +47,10 @@ type Children = (options: {
revision: Revision | undefined;
abilities: Record<string, boolean>;
readOnly: boolean;
onCreateLink: (title: string, nested?: boolean) => Promise<string>;
onCreateLink: (
params: Properties<Document>,
nested?: boolean
) => Promise<string>;
sharedTree: NavigationNode | undefined;
}) => React.ReactNode;
@@ -143,7 +147,7 @@ function DataLoader({ match, children }: Props) {
}, [document?.id, document?.isDeleted, revisionId, views]);
const onCreateLink = React.useCallback(
async (title: string, nested?: boolean) => {
async (params: Properties<Document>, nested?: boolean) => {
if (!document) {
throw new Error("Document not loaded yet");
}
@@ -152,8 +156,8 @@ function DataLoader({ match, children }: Props) {
{
collectionId: nested ? undefined : document.collectionId,
parentDocumentId: nested ? document.id : document.parentDocumentId,
title,
data: ProsemirrorHelper.getEmptyDocument(),
...params,
},
{
publish: document.isDraft ? undefined : true,
+5 -4
View File
@@ -45,7 +45,7 @@ import RegisterKeyDown from "~/components/RegisterKeyDown";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import withStores from "~/components/withStores";
import type { Editor as TEditor } from "~/editor";
import { SearchResult } from "~/editor/components/LinkEditor";
import { Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import { emojiToUrl } from "~/utils/emoji";
@@ -90,8 +90,10 @@ type Props = WithTranslation &
readOnly: boolean;
shareId?: string;
tocPosition?: TOCPosition;
onCreateLink?: (title: string, nested?: boolean) => Promise<string>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onCreateLink?: (
params: Properties<Document>,
nested?: boolean
) => Promise<string>;
};
@observer
@@ -571,7 +573,6 @@ class DocumentScene extends React.Component<Props> {
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.handleChangeTitle}
onChangeIcon={this.handleChangeIcon}
@@ -6,6 +6,7 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { extraArea, s } from "@shared/styles";
@@ -19,7 +20,6 @@ import { isModKey } from "@shared/utils/keyboard";
import { DocumentValidation } from "@shared/validations";
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import Icon, { IconTitleWrapper } from "~/components/Icon";
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
+3 -3
View File
@@ -221,6 +221,8 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[setEditorInitialized]
);
const direction = titleRef.current?.getComputedDirection();
return (
<Flex auto column>
<DocumentTitle
@@ -250,9 +252,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
: documentHistoryPath(document),
state: { sidebarContext },
}}
rtl={
titleRef.current?.getComputedDirection() === "rtl" ? true : false
}
rtl={direction === "rtl"}
/>
)}
<EditorComponent
+2 -1
View File
@@ -11,6 +11,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import { Theme } from "~/stores/UiStore";
@@ -24,7 +25,6 @@ import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import { useDocumentContext } from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import Header from "~/components/Header";
import Icon from "~/components/Icon";
import Star from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import { publishDocument } from "~/actions/definitions/documents";
@@ -199,6 +199,7 @@ function DocumentHeader({
if (shareId) {
return (
<StyledHeader
ref={ref}
$hidden={isEditingFocus}
title={
<Flex gap={4}>
@@ -1,5 +1,5 @@
import * as React from "react";
import useComponentSize from "@shared/editor/components/hooks/useComponentSize";
import useComponentSize from "@shared/hooks/useComponentSize";
export const MeasuredContainer = <T extends React.ElementType>({
as: As,
@@ -1,7 +1,7 @@
import * as React from "react";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import { MenuInternalLink } from "~/types";
import { sharedDocumentPath } from "~/utils/routeHelpers";
@@ -3,12 +3,12 @@ import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { s, ellipsis } from "@shared/styles";
import { IconType, NavigationNode } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import { hover } from "~/styles";
import { sharedDocumentPath } from "~/utils/routeHelpers";
-44
View File
@@ -1,44 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import Group from "~/models/Group";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { settingsPath } from "~/utils/routeHelpers";
type Props = {
group: Group;
onSubmit: () => void;
};
function GroupDelete({ group, onSubmit }: Props) {
const { t } = useTranslation();
const history = useHistory();
const handleSubmit = async () => {
await group.delete();
history.push(settingsPath("groups"));
onSubmit();
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
values={{
groupName: group.name,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(GroupDelete);
-73
View File
@@ -1,73 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Group from "~/models/Group";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
type Props = {
group: Group;
onSubmit: () => void;
};
function GroupEdit({ group, onSubmit }: Props) {
const { t } = useTranslation();
const [name, setName] = React.useState(group.name);
const [isSaving, setIsSaving] = React.useState(false);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await group.save({
name,
});
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[group, onSubmit, name]
);
const handleNameChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setName(ev.target.value);
},
[]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
<Trans>
You can edit the name of this group at any time, however doing so too
often might confuse your team mates.
</Trans>
</Text>
<Flex>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
);
}
export default observer(GroupEdit);
@@ -1,148 +0,0 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Group from "~/models/Group";
import User from "~/models/User";
import Invite from "~/scenes/Invite";
import { Avatar, AvatarSize } from "~/components/Avatar";
import ButtonLink from "~/components/ButtonLink";
import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import PlaceholderList from "~/components/List/Placeholder";
import Modal from "~/components/Modal";
import PaginatedList from "~/components/PaginatedList";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import GroupMemberListItem from "./components/GroupMemberListItem";
type Props = {
group: Group;
onSubmit: () => void;
};
function AddPeopleToGroup(props: Props) {
const { group } = props;
const { users, groupUsers } = useStores();
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(team);
const [query, setQuery] = React.useState("");
const [inviteModalOpen, handleInviteModalOpen, handleInviteModalClose] =
useBoolean(false);
const { fetchPage: fetchUsers } = users;
const debouncedFetch = React.useMemo(
() => debounce((query) => fetchUsers({ query }), 250),
[fetchUsers]
);
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
const updatedQuery = ev.target.value;
setQuery(updatedQuery);
void debouncedFetch(updatedQuery);
},
[debouncedFetch]
);
const handleAddUser = async (user: User) => {
try {
await groupUsers.create({
groupId: group.id,
userId: user.id,
});
toast.success(
t(`{{userName}} was added to the group`, {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} catch (err) {
toast.error(t("Could not add user"));
}
};
const { loading } = useRequest(
React.useCallback(
() => groupUsers.fetchAll({ id: group.id }),
[groupUsers, group]
),
true
);
return (
<Flex column>
<Text as="p" type="secondary">
{t(
"Add members below to give them access to the group. Need to add someone whos not yet a member?"
)}{" "}
{can.inviteUser ? (
<ButtonLink onClick={handleInviteModalOpen}>
{t("Invite them to {{teamName}}", {
teamName: team.name,
})}
</ButtonLink>
) : (
t("Ask an admin to invite them first")
)}
.
</Text>
<Input
type="search"
placeholder={`${t("Search by name")}`}
value={query}
onChange={handleFilter}
label={t("Search people")}
labelHidden
autoFocus
flex
/>
{loading ? (
<DelayedMount>
<PlaceholderList count={5} />
</DelayedMount>
) : (
<PaginatedList
empty={
query ? (
<Empty>{t("No people matching your search")}</Empty>
) : (
<Empty>{t("No people left to add")}</Empty>
)
}
items={users.notInGroup(group.id, query)}
fetch={query ? undefined : users.fetchPage}
renderItem={(item: User) => (
<GroupMemberListItem
key={item.id}
user={item}
onAdd={() => handleAddUser(item)}
/>
)}
/>
)}
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
</Flex>
);
}
export default observer(AddPeopleToGroup);
-126
View File
@@ -1,126 +0,0 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Group from "~/models/Group";
import User from "~/models/User";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import PaginatedList from "~/components/PaginatedList";
import Subheading from "~/components/Subheading";
import Text from "~/components/Text";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import AddPeopleToGroup from "./AddPeopleToGroup";
import GroupMemberListItem from "./components/GroupMemberListItem";
type Props = {
group: Group;
};
function GroupMembers({ group }: Props) {
const [addModalOpen, setAddModalOpen] = React.useState(false);
const { users, groupUsers } = useStores();
const { t } = useTranslation();
const can = usePolicy(group);
const handleAddModal = (state: boolean) => {
setAddModalOpen(state);
};
const handleRemoveUser = async (user: User) => {
try {
await groupUsers.delete({
groupId: group.id,
userId: user.id,
});
toast.success(
t(`{{userName}} was removed from the group`, {
userName: user.name,
})
);
} catch (err) {
toast.error(t("Could not remove user"));
}
};
return (
<Flex column>
{can.update ? (
<>
<Text as="p" type="secondary">
<Trans
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
values={{
groupName: group.name,
}}
components={{
em: <strong />,
}}
/>
</Text>
<span>
<Button
type="button"
onClick={() => handleAddModal(true)}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</span>
</>
) : (
<Text as="p" type="secondary">
<Trans
defaults="Listing members of the <em>{{groupName}}</em> group."
values={{
groupName: group.name,
}}
components={{
em: <strong />,
}}
/>
</Text>
)}
<Subheading>
<Trans>Members</Trans>
</Subheading>
<PaginatedList
items={users.inGroup(group.id)}
fetch={groupUsers.fetchPage}
options={{
id: group.id,
}}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(item: User) => (
<GroupMemberListItem
key={item.id}
user={item}
onRemove={can.update ? () => handleRemoveUser(item) : undefined}
/>
)}
/>
{can.update && (
<Modal
title={t(`Add people to {{groupName}}`, {
groupName: group.name,
})}
onRequestClose={() => handleAddModal(false)}
isOpen={addModalOpen}
>
<AddPeopleToGroup
group={group}
onSubmit={() => handleAddModal(false)}
/>
</Modal>
)}
</Flex>
);
}
export default observer(GroupMembers);
@@ -1,53 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import User from "~/models/User";
import { Avatar } from "~/components/Avatar";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Time from "~/components/Time";
import GroupMemberMenu from "~/menus/GroupMemberMenu";
type Props = {
user: User;
onAdd?: () => Promise<void>;
onRemove?: () => Promise<void>;
};
const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
const { t } = useTranslation();
return (
<ListItem
title={user.name}
subtitle={
<>
{user.lastActiveAt ? (
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
) : (
t("Never signed in")
)}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
image={<Avatar model={user} size={32} />}
actions={
<Flex align="center">
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
{onAdd && (
<Button onClick={onAdd} neutral>
{t("Add")}
</Button>
)}
</Flex>
}
/>
);
};
export default observer(GroupMemberListItem);
-3
View File
@@ -1,3 +0,0 @@
import GroupMembers from "./GroupMembers";
export default GroupMembers;
-90
View File
@@ -1,90 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Group from "~/models/Group";
import GroupMembers from "~/scenes/GroupMembers";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Modal from "~/components/Modal";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
onSubmit: () => void;
};
function GroupNew({ onSubmit }: Props) {
const { groups } = useStores();
const { t } = useTranslation();
const [name, setName] = React.useState<string | undefined>();
const [isSaving, setIsSaving] = React.useState(false);
const [group, setGroup] = React.useState<Group | undefined>();
const handleSubmit = async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
const group = new Group(
{
name,
},
groups
);
try {
await group.save();
setGroup(group);
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
};
const handleNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
setName(ev.target.value);
};
return (
<>
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
<Trans>
Groups are for organizing your team. They work best when centered
around a function or a responsibility Support or Engineering for
example.
</Trans>
</Text>
<Flex>
<Input
type="text"
label="Name"
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<Text as="p" type="secondary">
<Trans>Youll be able to add people to the group next.</Trans>
</Text>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Creating")}` : t("Continue")}
</Button>
</form>
<Modal
title={t("Group members")}
onRequestClose={onSubmit}
isOpen={!!group}
>
{group && <GroupMembers group={group} />}
</Modal>
</>
);
}
export default observer(GroupNew);
+116 -37
View File
@@ -1,31 +1,115 @@
import { ColumnSort } from "@tanstack/react-table";
import deburr from "lodash/deburr";
import { observer } from "mobx-react";
import { PlusIcon, GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import Group from "~/models/Group";
import GroupNew from "~/scenes/GroupNew";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import GroupListItem from "~/components/GroupListItem";
import Heading from "~/components/Heading";
import Modal from "~/components/Modal";
import PaginatedList from "~/components/PaginatedList";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import GroupMenu from "~/menus/GroupMenu";
import { useTableRequest } from "~/hooks/useTableRequest";
import { CreateGroupDialog } from "./components/GroupDialogs";
import { GroupsTable } from "./components/GroupsTable";
import { StickyFilters } from "./components/StickyFilters";
function getFilteredGroups(groups: Group[], query?: string) {
if (!query?.length) {
return groups;
}
const normalizedQuery = deburr(query.toLocaleLowerCase());
return groups.filter((group) =>
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
);
}
function Groups() {
const { t } = useTranslation();
const { groups } = useStores();
const { dialogs, groups } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const [newGroupModalOpen, handleNewGroupModalOpen, handleNewGroupModalClose] =
useBoolean();
const history = useHistory();
const location = useLocation();
const params = useQuery();
const [query, setQuery] = React.useState("");
const reqParams = React.useMemo(
() => ({
query: params.get("query") || undefined,
sort: params.get("sort") || "name",
direction: (params.get("direction") || "asc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
const sort: ColumnSort = React.useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const { data, error, loading, next } = useTableRequest({
data: getFilteredGroups(groups.orderedData, reqParams.query),
sort,
reqFn: groups.fetchPage,
reqParams,
});
const isEmpty = !loading && !groups.orderedData.length;
const updateQuery = React.useCallback(
(value: string) => {
if (value) {
params.set("query", value);
} else {
params.delete("query");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = React.useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
const handleNewGroup = React.useCallback(() => {
dialogs.openModal({
title: t("Create a group"),
content: <CreateGroupDialog />,
});
}, [t, dialogs]);
React.useEffect(() => {
if (error) {
toast.error(t("Could not load groups"));
}
}, [t, error]);
React.useEffect(() => {
const timeout = setTimeout(() => updateQuery(query), 250);
return () => clearTimeout(timeout);
}, [query, updateQuery]);
return (
<Scene
@@ -37,7 +121,7 @@ function Groups() {
<Action>
<Button
type="button"
onClick={handleNewGroupModalOpen}
onClick={handleNewGroup}
icon={<PlusIcon />}
>
{`${t("New group")}`}
@@ -46,6 +130,7 @@ function Groups() {
)}
</>
}
wide
>
<Heading>{t("Groups")}</Heading>
<Text as="p" type="secondary">
@@ -53,34 +138,28 @@ function Groups() {
Groups can be used to organize and manage the people on your team.
</Trans>
</Text>
<PaginatedList
items={groups.orderedData}
empty={<Empty>{t("No groups have been created yet")}</Empty>}
fetch={groups.fetchPage}
heading={
<h2>
<Trans>All</Trans>
</h2>
}
renderItem={(item: Group) => (
<GroupListItem
key={item.id}
group={item}
renderActions={({ openMembersModal }) => (
<GroupMenu group={item} onMembers={openMembersModal} />
)}
showFacepile
{isEmpty ? (
<Empty>{t("No groups have been created yet")}</Empty>
) : (
<>
<StickyFilters>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<GroupsTable
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
)}
/>
<Modal
title={t("Create a group")}
onRequestClose={handleNewGroupModalClose}
isOpen={newGroupModalOpen}
>
<GroupNew onSubmit={handleNewGroupModalClose} />
</Modal>
</>
)}
</Scene>
);
}
+119 -122
View File
@@ -1,15 +1,15 @@
import sortBy from "lodash/sortBy";
import { ColumnSort } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { PlusIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import styled from "styled-components";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import User from "~/models/User";
import UsersStore, { queriedUsers } from "~/stores/UsersStore";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Fade from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
@@ -21,11 +21,14 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import PeopleTable from "./components/PeopleTable";
import { useTableRequest } from "~/hooks/useTableRequest";
import { PeopleTable } from "./components/PeopleTable";
import { StickyFilters } from "./components/StickyFilters";
import UserRoleFilter from "./components/UserRoleFilter";
import UserStatusFilter from "./components/UserStatusFilter";
function Members() {
const appName = env.APP_NAME;
const location = useLocation();
const history = useHistory();
const team = useCurrentTeam();
@@ -33,83 +36,48 @@ function Members() {
const { users } = useStores();
const { t } = useTranslation();
const params = useQuery();
const [isLoading, setIsLoading] = React.useState(false);
const [data, setData] = React.useState<User[]>([]);
const [totalPages, setTotalPages] = React.useState(0);
const [userIds, setUserIds] = React.useState<string[]>([]);
const can = usePolicy(team);
const query = params.get("query") || undefined;
const filter = params.get("filter") || undefined;
const role = params.get("role") || undefined;
const sort = params.get("sort") || "name";
const direction = (params.get("direction") || "asc").toUpperCase() as
| "ASC"
| "DESC";
const page = parseInt(params.get("page") || "0", 10);
const limit = 25;
const [query, setQuery] = React.useState("");
React.useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
const reqParams = React.useMemo(
() => ({
query: params.get("query") || undefined,
filter: params.get("filter") || "active",
role: params.get("role") || undefined,
sort: params.get("sort") || "name",
direction: (params.get("direction") || "asc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
try {
const response = await users.fetchPage({
offset: page * limit,
limit,
sort,
direction,
query,
filter,
role,
});
if (response[PAGINATION_SYMBOL]) {
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
}
setUserIds(response.map((u: User) => u.id));
} finally {
setIsLoading(false);
}
};
const sort: ColumnSort = React.useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
void fetchData();
}, [query, sort, filter, role, page, direction, users]);
const { data, error, loading, next } = useTableRequest({
data: getFilteredUsers({
users,
query: reqParams.query,
filter: reqParams.filter,
role: reqParams.role,
}),
sort,
reqFn: users.fetchPage,
reqParams,
});
React.useEffect(() => {
let filtered = users.orderedData;
if (!filter) {
filtered = users.active.filter((u) => userIds.includes(u.id));
} else if (filter === "all") {
filtered = users.orderedData.filter((u) => userIds.includes(u.id));
} else if (filter === "suspended") {
filtered = users.suspended.filter((u) => userIds.includes(u.id));
} else if (filter === "invited") {
filtered = users.invited.filter((u) => userIds.includes(u.id));
}
if (role) {
filtered = filtered.filter((u) => u.role === role);
}
// sort the resulting data by the original order from the server
setData(sortBy(filtered, (item) => userIds.indexOf(item.id)));
}, [
filter,
role,
users.active,
users.orderedData,
users.suspended,
users.invited,
userIds,
]);
const handleStatusFilter = React.useCallback(
(f) => {
if (f) {
params.set("filter", f);
params.delete("page");
const updateParams = React.useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete("filter");
params.delete(name);
}
history.replace({
@@ -120,43 +88,31 @@ function Members() {
[params, history, location.pathname]
);
const handleStatusFilter = React.useCallback(
(status) => updateParams("filter", status),
[updateParams]
);
const handleRoleFilter = React.useCallback(
(r) => {
if (r) {
params.set("role", r);
params.delete("page");
} else {
params.delete("role");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
(role) => updateParams("role", role),
[updateParams]
);
const handleSearch = React.useCallback(
(event) => {
const { value } = event.target;
const handleSearch = React.useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
if (value) {
params.set("query", event.target.value);
params.delete("page");
} else {
params.delete("query");
}
React.useEffect(() => {
if (error) {
toast.error(t("Could not load members"));
}
}, [t, error]);
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const appName = env.APP_NAME;
React.useEffect(() => {
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateParams]);
return (
<Scene
@@ -191,35 +147,76 @@ function Members() {
{{ signinMethods: team.signinMethods }} but havent signed in yet.
</Trans>
</Text>
<Flex gap={8}>
<StickyFilters gap={8}>
<InputSearch
short
value={query ?? ""}
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={filter ?? ""}
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={role ?? ""}
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
</Flex>
<PeopleTable
data={data}
canManage={can.update}
isLoading={isLoading}
page={page}
pageSize={limit}
totalPages={totalPages}
defaultSortDirection="ASC"
/>
</StickyFilters>
<Fade>
<PeopleTable
data={data ?? []}
sort={sort}
canManage={can.update}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</Fade>
</Scene>
);
}
function getFilteredUsers({
users,
query,
filter,
role,
}: {
users: UsersStore;
query?: string;
filter?: string;
role?: string;
}) {
let filteredUsers;
switch (filter) {
case "all":
filteredUsers = users.orderedData;
break;
case "suspended":
filteredUsers = users.suspended;
break;
case "invited":
filteredUsers = users.invited;
break;
default:
filteredUsers = users.active;
}
if (role) {
filteredUsers = filteredUsers.filter((user) => user.role === role);
}
if (query) {
filteredUsers = queriedUsers(filteredUsers, query);
}
return filteredUsers;
}
const LargeUserStatusFilter = styled(UserStatusFilter)`
height: 32px;
`;
+5 -12
View File
@@ -19,19 +19,22 @@ import { toast } from "sonner";
import { NotificationEventType } from "@shared/types";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import isCloudHosted from "~/utils/isCloudHosted";
import SettingRow from "./components/SettingRow";
function Notifications() {
const user = useCurrentUser();
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(team.id);
const options = [
{
@@ -161,17 +164,7 @@ function Notifications() {
<Trans>Manage when and where you receive email notifications.</Trans>
</Text>
{env.EMAIL_ENABLED ? (
<SettingRow
label={t("Email address")}
name="email"
description={t(
"Your email address should be updated in your SSO provider."
)}
>
<Input type="email" value={user.email} readOnly />
</SettingRow>
) : (
{env.EMAIL_ENABLED && can.manage && (
<Notice>
<Trans>
The email integration is currently disabled. Please set the
+25 -1
View File
@@ -8,14 +8,18 @@ import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { UserChangeEmailDialog } from "~/components/UserDialogs";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import ImageInput from "./components/ImageInput";
import SettingRow from "./components/SettingRow";
const Profile = () => {
const user = useCurrentUser();
const { dialogs } = useStores();
const form = React.useRef<HTMLFormElement>(null);
const [name, setName] = React.useState<string>(user.name || "");
const [name, setName] = React.useState<string>(user.name);
const { t } = useTranslation();
const handleSubmit = async (ev: React.SyntheticEvent) => {
@@ -29,6 +33,15 @@ const Profile = () => {
}
};
const handleChangeEmail = () => {
dialogs.openModal({
title: t("Change email"),
content: (
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
};
const handleNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
setName(ev.target.value);
};
@@ -81,6 +94,17 @@ const Profile = () => {
/>
</SettingRow>
{env.EMAIL_ENABLED && (
<SettingRow label={t("Email address")} name="email">
<Input
type="email"
value={user.email}
readOnly
onClick={handleChangeEmail}
/>
</SettingRow>
)}
<Button type="submit" disabled={isSaving || !isValid}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
+41 -53
View File
@@ -1,11 +1,10 @@
import sortBy from "lodash/sortBy";
import { ColumnSort } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { GlobeIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import Share from "~/models/Share";
import { toast } from "sonner";
import Fade from "~/components/Fade";
import Heading from "~/components/Heading";
import Notice from "~/components/Notice";
@@ -15,7 +14,8 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import SharesTable from "./components/SharesTable";
import { useTableRequest } from "~/hooks/useTableRequest";
import { SharesTable } from "./components/SharesTable";
function Shares() {
const team = useCurrentTeam();
@@ -23,51 +23,38 @@ function Shares() {
const { shares, auth } = useStores();
const canShareDocuments = auth.team && auth.team.sharing;
const can = usePolicy(team);
const [isLoading, setIsLoading] = React.useState(false);
const [data, setData] = React.useState<Share[]>([]);
const [totalPages, setTotalPages] = React.useState(0);
const [shareIds, setShareIds] = React.useState<string[]>([]);
const params = useQuery();
const query = params.get("query") || "";
const sort = params.get("sort") || "createdAt";
const direction = (params.get("direction") || "desc").toUpperCase() as
| "ASC"
| "DESC";
const page = parseInt(params.get("page") || "0", 10);
const limit = 25;
const reqParams = React.useMemo(
() => ({
sort: params.get("sort") || "createdAt",
direction: (params.get("direction") || "desc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
const sort: ColumnSort = React.useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const { data, error, loading, next } = useTableRequest({
data: shares.orderedData,
sort,
reqFn: shares.fetchPage,
reqParams,
});
React.useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await shares.fetchPage({
offset: page * limit,
limit,
sort,
direction,
});
if (response[PAGINATION_SYMBOL]) {
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
}
setShareIds(response.map((u: Share) => u.id));
} finally {
setIsLoading(false);
}
};
void fetchData();
}, [query, sort, page, direction, shares]);
React.useEffect(() => {
// sort the resulting data by the original order from the server
setData(
sortBy(
shares.orderedData.filter((item) => shareIds.includes(item.id)),
(item) => shareIds.indexOf(item.id)
)
);
}, [shares.orderedData, shareIds]);
if (error) {
toast.error(t("Could not load shares"));
}
}, [t, error]);
return (
<Scene title={t("Shared Links")} icon={<GlobeIcon />} wide>
@@ -96,16 +83,17 @@ function Shares() {
</Trans>
</Text>
{data.length ? (
{data?.length ? (
<Fade>
<SharesTable
data={data}
data={data ?? []}
sort={sort}
canManage={can.update}
isLoading={isLoading}
page={page}
pageSize={limit}
totalPages={totalPages}
defaultSortDirection="ASC"
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</Fade>
) : null}
@@ -0,0 +1,450 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { v4 as uuidv4 } from "uuid";
import Group from "~/models/Group";
import User from "~/models/User";
import Invite from "~/scenes/Invite";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import ButtonLink from "~/components/ButtonLink";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import PlaceholderList from "~/components/List/Placeholder";
import PaginatedList from "~/components/PaginatedList";
import { ListItem } from "~/components/Sharing/components/ListItem";
import Subheading from "~/components/Subheading";
import Text from "~/components/Text";
import Time from "~/components/Time";
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";
type Props = {
group: Group;
onSubmit: () => void;
};
export function CreateGroupDialog() {
const { dialogs, groups } = useStores();
const { t } = useTranslation();
const [name, setName] = React.useState<string | undefined>();
const [isSaving, setIsSaving] = React.useState(false);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
const group = new Group(
{
name,
},
groups
);
try {
await group.save();
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={group} />,
fullscreen: true,
});
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[t, dialogs, groups, name]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
<Trans>
Groups are for organizing your team. They work best when centered
around a function or a responsibility Support or Engineering for
example.
</Trans>
</Text>
<Flex>
<Input
type="text"
label="Name"
onChange={(e) => setName(e.target.value)}
value={name}
required
autoFocus
flex
/>
</Flex>
<Text as="p" type="secondary">
<Trans>Youll be able to add people to the group next.</Trans>
</Text>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Creating")}` : t("Continue")}
</Button>
</form>
);
}
export function EditGroupDialog({ group, onSubmit }: Props) {
const { t } = useTranslation();
const [name, setName] = React.useState(group.name);
const [isSaving, setIsSaving] = React.useState(false);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await group.save({
name,
});
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[group, onSubmit, name]
);
const handleNameChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setName(ev.target.value);
},
[]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
<Trans>
You can edit the name of this group at any time, however doing so too
often might confuse your team mates.
</Trans>
</Text>
<Flex>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
);
}
export function DeleteGroupDialog({ group, onSubmit }: Props) {
const { t } = useTranslation();
const handleSubmit = async () => {
await group.delete();
onSubmit();
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
values={{
groupName: group.name,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export const ViewGroupMembersDialog = observer(function ({
group,
}: Pick<Props, "group">) {
const { dialogs, users, groupUsers } = useStores();
const { t } = useTranslation();
const can = usePolicy(group);
const handleAddPeople = React.useCallback(() => {
dialogs.openModal({
title: t(`Add people to {{groupName}}`, {
groupName: group.name,
}),
content: <AddPeopleToGroupDialog group={group} />,
fullscreen: true,
});
}, [t, group, dialogs]);
const handleRemoveUser = React.useCallback(
async (user: User) => {
try {
await groupUsers.delete({
groupId: group.id,
userId: user.id,
});
toast.success(
t(`{{userName}} was removed from the group`, {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} catch (err) {
toast.error(t("Could not remove user"));
}
},
[t, groupUsers, group.id]
);
return (
<Flex column>
{can.update ? (
<>
<Text as="p" type="secondary">
<Trans
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
values={{
groupName: group.name,
}}
components={{
em: <strong />,
}}
/>
</Text>
{can.update && (
<span>
<Button
type="button"
onClick={handleAddPeople}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</span>
)}
</>
) : (
<Text as="p" type="secondary">
<Trans
defaults="Listing members of the <em>{{groupName}}</em> group."
values={{
groupName: group.name,
}}
components={{
em: <strong />,
}}
/>
</Text>
)}
<Subheading>
<Trans>Members</Trans>
</Subheading>
<PaginatedList
items={users.inGroup(group.id)}
fetch={groupUsers.fetchPage}
options={{
id: group.id,
}}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(user: User) => (
<GroupMemberListItem
key={user.id}
user={user}
onRemove={can.update ? () => handleRemoveUser(user) : undefined}
/>
)}
/>
</Flex>
);
});
const AddPeopleToGroupDialog = observer(function ({
group,
}: Pick<Props, "group">) {
const { dialogs, users, groupUsers } = useStores();
const { t } = useTranslation();
const team = useCurrentTeam();
const can = usePolicy(team);
const [query, setQuery] = React.useState("");
const debouncedFetch = React.useMemo(
() => debounce((q) => users.fetchPage({ query: q }), 250),
[users]
);
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
const updatedQuery = ev.target.value;
setQuery(updatedQuery);
void debouncedFetch(updatedQuery);
},
[debouncedFetch]
);
const handleAddUser = React.useCallback(
async (user: User) => {
try {
await groupUsers.create({
groupId: group.id,
userId: user.id,
});
toast.success(
t(`{{userName}} was added to the group`, {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} catch (err) {
toast.error(t("Could not add user"));
}
},
[t, groupUsers, group.id]
);
const handleInvitePeople = React.useCallback(() => {
const id = uuidv4();
dialogs.openModal({
id,
title: t("Invite people"),
content: <Invite onSubmit={() => dialogs.closeModal(id)} />,
});
}, [t, dialogs]);
const { loading } = useRequest(
React.useCallback(
() => groupUsers.fetchAll({ id: group.id }),
[groupUsers, group]
),
true
);
return (
<Flex column>
<Text as="p" type="secondary">
{t(
"Add members below to give them access to the group. Need to add someone whos not yet a member?"
)}{" "}
{can.inviteUser ? (
<ButtonLink onClick={handleInvitePeople}>
{t("Invite them to {{teamName}}", {
teamName: team.name,
})}
</ButtonLink>
) : (
t("Ask an admin to invite them first")
)}
.
</Text>
<Input
type="search"
placeholder={`${t("Search by name")}`}
value={query}
onChange={handleFilter}
label={t("Search people")}
labelHidden
autoFocus
flex
/>
{loading ? (
<DelayedMount>
<PlaceholderList count={5} />
</DelayedMount>
) : (
<PaginatedList
empty={
query ? (
<Empty>{t("No people matching your search")}</Empty>
) : (
<Empty>{t("No people left to add")}</Empty>
)
}
items={users.notInGroup(group.id, query)}
fetch={query ? undefined : users.fetchPage}
renderItem={(item: User) => (
<GroupMemberListItem
key={item.id}
user={item}
onAdd={() => handleAddUser(item)}
/>
)}
/>
)}
</Flex>
);
});
type GroupMemberListItemProps = {
user: User;
onAdd?: () => Promise<void>;
onRemove?: () => Promise<void>;
};
const GroupMemberListItem = observer(function ({
user,
onRemove,
onAdd,
}: GroupMemberListItemProps) {
const { t } = useTranslation();
return (
<ListItem
title={user.name}
subtitle={
<>
{user.lastActiveAt ? (
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
) : (
t("Never signed in")
)}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
image={<Avatar model={user} size={32} />}
actions={
<Flex align="center">
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
{onAdd && (
<Button onClick={onAdd} neutral>
{t("Add")}
</Button>
)}
</Flex>
}
/>
);
});
@@ -0,0 +1,136 @@
import compact from "lodash/compact";
import { GroupIcon } from "outline-icons";
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { s } from "@shared/styles";
import Group from "~/models/Group";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import { HEADER_HEIGHT } from "~/components/Header";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import GroupMenu from "~/menus/GroupMenu";
import { hover } from "~/styles";
import { ViewGroupMembersDialog } from "./GroupDialogs";
import { FILTER_HEIGHT } from "./StickyFilters";
const ROW_HEIGHT = 60;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<Group>, "columns" | "rowHeight">;
export function GroupsTable(props: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const handleViewMembers = React.useCallback(
(group: Group) => {
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={group} />,
fullscreen: true,
});
},
[t, dialogs]
);
const columns = React.useMemo<TableColumn<Group>[]>(
() =>
compact<TableColumn<Group>>([
{
type: "data",
id: "name",
header: t("Name"),
accessor: (group) => group.name,
component: (group) => (
<Flex align="center" gap={8}>
<Image>
<GroupIcon size={24} />
</Image>
<Flex column>
<Title onClick={() => handleViewMembers(group)}>
{group.name}
</Title>
<Text type="tertiary" size="small">
<Trans
defaults="{{ count }} member"
values={{ count: group.memberCount }}
/>
</Text>
</Flex>
</Flex>
),
width: "2fr",
},
{
type: "data",
id: "members",
header: t("Members"),
accessor: (group) => `${group.memberCount} members`,
component: (group) => {
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
const overflow = group.memberCount - users.length;
return (
<Flex>
<Facepile users={users} overflow={overflow} />
</Flex>
);
},
width: "1fr",
sortable: false,
},
{
type: "data",
id: "createdAt",
header: t("Date created"),
accessor: (group) => group.createdAt,
component: (group) =>
group.createdAt ? (
<Time dateTime={group.createdAt} addSuffix />
) : null,
width: "1fr",
},
{
type: "action",
id: "action",
component: (group) => <GroupMenu group={group} />,
width: "50px",
},
]),
[t, handleViewMembers]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
{...props}
/>
);
}
const Image = styled(Flex)`
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: ${s("backgroundSecondary")};
border-radius: 32px;
`;
const Title = styled.span`
&: ${hover} {
text-decoration: underline;
cursor: var(--pointer);
}
`;
+68 -51
View File
@@ -1,4 +1,4 @@
import { observer } from "mobx-react";
import compact from "lodash/compact";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -6,94 +6,111 @@ import User from "~/models/User";
import { Avatar } from "~/components/Avatar";
import Badge from "~/components/Badge";
import Flex from "~/components/Flex";
import TableFromParams from "~/components/TableFromParams";
import { HEADER_HEIGHT } from "~/components/Header";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import UserMenu from "~/menus/UserMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
type Props = Omit<React.ComponentProps<typeof TableFromParams>, "columns"> & {
data: User[];
const ROW_HEIGHT = 60;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function PeopleTable({ canManage, ...rest }: Props) {
export function PeopleTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const columns = React.useMemo(
const columns = React.useMemo<TableColumn<User>[]>(
() =>
[
compact<TableColumn<User>>([
{
type: "data",
id: "name",
Header: t("Name"),
accessor: "name",
Cell: observer(
({ value, row }: { value: string; row: { original: User } }) => (
<Flex align="center" gap={8}>
<Avatar model={row.original} size={32} /> {value}{" "}
{currentUser.id === row.original.id && `(${t("You")})`}
</Flex>
)
header: t("Name"),
accessor: (user) => user.name,
component: (user) => (
<Flex align="center" gap={8}>
<Avatar model={user} size={32} /> {user.name}{" "}
{currentUser.id === user.id && `(${t("You")})`}
</Flex>
),
width: "4fr",
},
canManage
? {
type: "data",
id: "email",
Header: t("Email"),
accessor: "email",
Cell: observer(({ value }: { value: string }) => <>{value}</>),
header: t("Email"),
accessor: (user) => user.email,
component: (user) => <>{user.email}</>,
width: "4fr",
}
: undefined,
{
type: "data",
id: "lastActiveAt",
Header: t("Last active"),
accessor: "lastActiveAt",
Cell: observer(({ value }: { value: string }) =>
value ? <Time dateTime={value} addSuffix /> : null
),
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "role",
Header: t("Role"),
accessor: "rank",
Cell: observer(({ row }: { row: { original: User } }) => (
<Badges>
{!row.original.lastActiveAt && <Badge>{t("Invited")}</Badge>}
{row.original.isAdmin ? (
header: t("Role"),
accessor: (user) => user.role,
component: (user) => (
<Badges wrap>
{!user.lastActiveAt && <Badge>{t("Invited")}</Badge>}
{user.isAdmin ? (
<Badge primary>{t("Admin")}</Badge>
) : row.original.isViewer ? (
) : user.isViewer ? (
<Badge>{t("Viewer")}</Badge>
) : row.original.isGuest ? (
<Badge yellow>{t("Guest")}</Badge>
) : user.isGuest ? (
<Badge>{t("Guest")}</Badge>
) : (
<Badge>{t("Editor")}</Badge>
)}
{row.original.isSuspended && <Badge>{t("Suspended")}</Badge>}
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
)),
),
width: "2fr",
},
canManage
? {
Header: " ",
accessor: "id",
className: "actions",
disableSortBy: true,
Cell: observer(
({ row, value }: { value: string; row: { original: User } }) =>
currentUser.id !== value ? (
<UserMenu user={row.original} />
) : null
),
type: "action",
id: "action",
component: (user) =>
currentUser.id !== user.id ? <UserMenu user={user} /> : null,
width: "50px",
}
: undefined,
].filter((i) => i),
[t, canManage, currentUser]
]),
[t, currentUser, canManage]
);
return <TableFromParams columns={columns} {...rest} />;
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
{...rest}
/>
);
}
const Badges = styled.div`
const Badges = styled(Flex)`
margin-left: -10px;
row-gap: 4px;
`;
export default observer(PeopleTable);
+83 -59
View File
@@ -1,108 +1,132 @@
import { observer } from "mobx-react";
import compact from "lodash/compact";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Share from "~/models/Share";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import TableFromParams from "~/components/TableFromParams";
import { HEADER_HEIGHT } from "~/components/Header";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useUserLocale from "~/hooks/useUserLocale";
import ShareMenu from "~/menus/ShareMenu";
import { formatNumber } from "~/utils/language";
type Props = Omit<React.ComponentProps<typeof TableFromParams>, "columns"> & {
data: Share[];
const ROW_HEIGHT = 50;
type Props = Omit<TableProps<Share>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function SharesTable({ canManage, data, ...rest }: Props) {
export function SharesTable({ data, canManage, ...rest }: Props) {
const { t } = useTranslation();
const language = useUserLocale();
const hasDomain = data.some((share) => share.domain);
const columns = React.useMemo(
const columns = React.useMemo<TableColumn<Share>[]>(
() =>
[
compact<TableColumn<Share>>([
{
id: "documentTitle",
Header: t("Document"),
accessor: "documentTitle",
disableSortBy: true,
Cell: observer(({ value }: { value: string }) => <>{value}</>),
type: "data",
id: "title",
header: t("Document"),
accessor: (share) => share.documentTitle,
sortable: false,
component: (share) => <>{share.documentTitle}</>,
width: "4fr",
},
{
id: "who",
Header: t("Shared by"),
accessor: "createdById",
disableSortBy: true,
Cell: observer(
({ row }: { value: string; row: { original: Share } }) => (
<Flex align="center" gap={4}>
{row.original.createdBy && (
<Avatar model={row.original.createdBy} />
)}
{row.original.createdBy.name}
</Flex>
)
type: "data",
id: "createdBy",
header: t("Shared by"),
accessor: (share) => share.createdBy,
sortable: false,
component: (share) => (
<Flex align="center" gap={4}>
{share.createdBy && (
<>
<Avatar model={share.createdBy} />
{share.createdBy.name}
</>
)}
</Flex>
),
width: "2fr",
},
{
type: "data",
id: "createdAt",
Header: t("Date shared"),
accessor: "createdAt",
Cell: observer(({ value }: { value: string }) =>
value ? <Time dateTime={value} addSuffix /> : null
),
header: t("Date shared"),
accessor: (share) => share.createdAt,
component: (share) =>
share.createdAt ? (
<Time dateTime={share.createdAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "lastAccessedAt",
Header: t("Last accessed"),
accessor: "lastAccessedAt",
Cell: observer(({ value }: { value: string }) =>
value ? <Time dateTime={value} addSuffix /> : null
),
header: t("Last accessed"),
accessor: (share) => share.lastAccessedAt,
component: (share) =>
share.lastAccessedAt ? (
<Time dateTime={share.lastAccessedAt} addSuffix />
) : null,
width: "2fr",
},
hasDomain
? {
type: "data",
id: "domain",
Header: t("Domain"),
accessor: "domain",
disableSortBy: true,
header: t("Domain"),
accessor: (share) => share.domain,
sortable: false,
component: (share) => <>{share.domain}</>,
width: "1.5fr",
}
: undefined,
{
type: "data",
id: "views",
Header: t("Views"),
accessor: "views",
Cell: observer(({ value }: { value: number }) => (
header: t("Views"),
accessor: (share) => share.views,
component: (share) => (
<>
{language
? formatNumber(value, unicodeCLDRtoBCP47(language))
: value}
? formatNumber(share.views, unicodeCLDRtoBCP47(language))
: share.views}
</>
)),
),
width: "150px",
},
canManage
? {
Header: " ",
accessor: "id",
className: "actions",
disableSortBy: true,
Cell: observer(
({ row }: { value: string; row: { original: Share } }) => (
<Flex align="center">
<ShareMenu share={row.original} />
</Flex>
)
type: "action",
id: "action",
component: (share) => (
<Flex align="center">
<ShareMenu share={share} />
</Flex>
),
width: "50px",
}
: undefined,
].filter((i) => i),
[t, hasDomain, canManage]
]),
[t, language, hasDomain, canManage]
);
return <TableFromParams columns={columns} data={data} {...rest} />;
return (
<SortableTable
data={data}
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={HEADER_HEIGHT}
{...rest}
/>
);
}
export default SharesTable;
@@ -0,0 +1,14 @@
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import Flex from "~/components/Flex";
import { HEADER_HEIGHT } from "~/components/Header";
export const FILTER_HEIGHT = 40;
export const StickyFilters = styled(Flex)`
height: ${FILTER_HEIGHT}px;
position: sticky;
top: ${HEADER_HEIGHT}px;
z-index: ${depths.header};
background: ${s("background")};
`;
+3 -3
View File
@@ -44,12 +44,14 @@ export default class DialogsStore {
};
openModal = ({
id,
title,
content,
fullscreen,
replace,
style,
}: {
id?: string;
title: string;
fullscreen?: boolean;
content: React.ReactNode;
@@ -58,13 +60,11 @@ export default class DialogsStore {
}) => {
setTimeout(
action(() => {
const id = uuidv4();
if (replace) {
this.modalStack.clear();
}
this.modalStack.set(id, {
this.modalStack.set(id ?? uuidv4(), {
title,
content,
fullscreen,
+4 -1
View File
@@ -825,7 +825,10 @@ export default class DocumentsStore extends Store<Document> {
};
getByUrl = (url = ""): Document | undefined =>
find(this.orderedData, (doc) => url.endsWith(doc.urlId));
find(
this.orderedData,
(doc) => url.endsWith(doc.urlId) || url.endsWith(doc.id)
);
getCollectionForDocument(document: Document) {
return document.collectionId
+1 -1
View File
@@ -208,7 +208,7 @@ export default class UsersStore extends Store<User> {
};
}
function queriedUsers(users: User[], query?: string) {
export function queriedUsers(users: User[], query?: string) {
const normalizedQuery = deburr((query || "").toLocaleLowerCase());
return normalizedQuery
+35 -17
View File
@@ -54,6 +54,8 @@ export default abstract class Store<T extends Model> {
@observable
isLoaded = false;
requests: Map<string, Promise<any>> = new Map();
model: typeof Model;
modelName: string;
@@ -302,27 +304,43 @@ export default abstract class Store<T extends Model> {
if (item && !options.force) {
return item;
}
if (this.requests.has(id)) {
return this.requests.get(id);
}
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, {
id,
});
const promise = new Promise<T>((resolve, reject) => {
client
.post(`/${this.apiEndpoint}.info`, {
id,
})
.then((res) =>
runInAction(`info#${this.modelName}`, () => {
invariant(res?.data, "Data should be available");
this.addPolicies(res.policies);
resolve(this.add(accessor(res)));
})
)
.catch((err) => {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
this.remove(id);
}
return runInAction(`info#${this.modelName}`, () => {
invariant(res?.data, "Data should be available");
this.addPolicies(res.policies);
return this.add(accessor(res));
});
} catch (err) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
this.remove(id);
}
reject(err);
})
.finally(() => {
this.requests.delete(id);
this.isFetching = false;
});
});
throw err;
} finally {
this.isFetching = false;
}
this.requests.set(id, promise);
return promise;
}
@action
-55
View File
@@ -1,55 +0,0 @@
/* eslint-disable @typescript-eslint/ban-types */
import {
UsePaginationInstanceProps,
UsePaginationOptions,
UsePaginationState,
UseSortByColumnOptions,
UseSortByColumnProps,
UseSortByHooks,
UseSortByInstanceProps,
UseSortByOptions,
UseSortByState,
} from "react-table";
declare module "react-table" {
export interface TableOptions<D extends object>
extends UseExpandedOptions<D>,
UsePaginationOptions<D>,
UseSortByOptions<D>,
// note that having Record here allows you to add anything to the options, this matches the spirit of the
// underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
// feature set, this is a safe default.
Record<string, any> {}
export interface Hooks<D extends object = {}>
extends UseExpandedHooks<D>,
UseSortByHooks<D> {}
export interface TableInstance<D extends object = {}>
extends UsePaginationInstanceProps<D>,
UseSortByInstanceProps<D> {}
export interface TableState<D extends object = {}>
extends UseColumnOrderState<D>,
UseExpandedState<D>,
UsePaginationState<D>,
UseSortByState<D> {}
export interface ColumnInterface<D extends object = {}>
extends UseResizeColumnsColumnOptions<D>,
UseSortByColumnOptions<D> {}
export interface ColumnInstance<D extends object = {}>
extends UseResizeColumnsColumnProps<D>,
UseSortByColumnProps<D> {}
export interface Cell<D extends object = {}>
extends UseGroupByCellProps<D>,
UseRowStateCellProps<D> {}
export interface Row<D extends object = {}>
extends UseExpandedRowProps<D>,
UseGroupByRowProps<D>,
UseRowSelectRowProps<D>,
UseRowStateRowProps<D> {}
}
+14
View File
@@ -36,10 +36,16 @@ const fetchWithRetry = retry(fetch);
class ApiClient {
baseUrl: string;
shareId?: string;
constructor(options: Options = {}) {
this.baseUrl = options.baseUrl || "/api";
}
setShareId = (shareId: string | undefined) => {
this.shareId = shareId;
};
fetch = async <T = any>(
path: string,
method: string,
@@ -51,6 +57,14 @@ class ApiClient {
let urlToFetch;
let isJson;
if (this.shareId) {
// add to data
data = {
...(data || {}),
shareId: this.shareId,
};
}
if (method === "GET") {
if (data) {
modifiedPath = `${path}?${data && queryString.stringify(data)}`;
+1 -1
View File
@@ -4,7 +4,7 @@
*
* @param callback The callback to call inside the view transition.
*/
export const startViewTransition = (callback: UpdateCallback) => {
export const startViewTransition = (callback: ViewTransitionUpdateCallback) => {
if (self.document.startViewTransition) {
self.document.startViewTransition(callback);
} else {
+21 -21
View File
@@ -59,7 +59,7 @@
"@babel/plugin-transform-destructuring": "^7.24.8",
"@babel/plugin-transform-regenerator": "^7.25.9",
"@babel/preset-env": "^7.25.8",
"@babel/preset-react": "^7.25.9",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "^4.12.2",
@@ -83,8 +83,10 @@
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.119.0",
"@sentry/react": "^7.119.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tanstack/react-table": "^8.20.5",
"@tanstack/react-virtual": "^3.10.9",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.0",
"@types/mailparser": "^3.4.4",
@@ -124,8 +126,8 @@
"glob": "^8.1.0",
"http-errors": "2.0.0",
"i18next": "^22.5.1",
"i18next-fs-backend": "^2.3.2",
"i18next-http-backend": "^2.5.0",
"i18next-fs-backend": "^2.6.0",
"i18next-http-backend": "^2.7.1",
"invariant": "^2.2.4",
"ioredis": "^5.4.1",
"is-printable-key-event": "^1.0.0",
@@ -150,14 +152,14 @@
"markdown-it": "^13.0.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
"mermaid": "11.4.0",
"mermaid": "11.4.1",
"mime-types": "^2.1.35",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
"mobx-utils": "^4.0.1",
"natural-sort": "^1.0.0",
"node-fetch": "2.7.0",
"nodemailer": "^6.9.14",
"nodemailer": "^6.9.16",
"octokit": "^3.2.1",
"outline-icons": "^3.10.0",
"oy-vey": "^0.12.1",
@@ -179,12 +181,12 @@
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.23.0",
"prosemirror-model": "^1.24.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.4.0",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.36.0",
"prosemirror-view": "^1.37.1",
"query-string": "^7.1.3",
"randomstring": "1.3.0",
"rate-limiter-flexible": "^2.4.2",
@@ -203,7 +205,6 @@
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
"react-table": "^7.8.0",
"react-virtualized-auto-sizer": "^1.0.21",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.10",
@@ -226,7 +227,7 @@
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.0",
"socket.io-redis": "^6.1.1",
"sonner": "^1.0.3",
"sonner": "^1.7.1",
"stoppable": "^1.1.0",
"string-replace-to-array": "^2.1.1",
"styled-components": "^5.3.11",
@@ -237,7 +238,7 @@
"tmp": "^0.2.3",
"turndown": "^7.2.0",
"umzug": "^3.8.1",
"utility-types": "^3.10.0",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vite": "^5.4.11",
@@ -252,7 +253,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/cli": "^7.25.9",
"@babel/cli": "^7.26.4",
"@babel/preset-typescript": "^7.24.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.13",
@@ -290,7 +291,7 @@
"@types/natural-sort": "^0.0.24",
"@types/node": "20.14.2",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.15",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
"@types/pluralize": "^0.0.33",
"@types/png-chunks-extract": "^1.0.2",
@@ -303,13 +304,12 @@
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
"@types/react-table": "^7.7.18",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.18",
"@types/redis-info": "^3.0.3",
"@types/refractor": "^3.4.1",
"@types/resolve-path": "^1.4.2",
"@types/resolve-path": "^1.4.3",
"@types/semver": "^7.5.8",
"@types/sequelize": "^4.28.20",
"@types/slug": "^5.0.7",
@@ -333,14 +333,14 @@
"discord-api-types": "^0.37.102",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^8.0.3",
"i18next-parser": "^7.9.0",
@@ -348,14 +348,14 @@
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^13.3.0",
"nodemon": "^3.1.7",
"nodemon": "^3.1.9",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^0.4.1",
"terser": "^5.36.0",
"typescript": "^5.6.3",
"typescript": "^5.7.2",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
@@ -0,0 +1,239 @@
import {
buildAdmin,
buildUser,
buildWebhookSubscription,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#webhookSubscriptions.list", () => {
it("should fail with status 401 unauthorized when user token is missing", async () => {
const res = await server.post("/api/webhookSubscriptions.list", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body.message).toEqual("Authentication required");
});
it("should fail with status 403 forbidden for non-admin user", async () => {
const user = await buildUser();
const res = await server.post("/api/webhookSubscriptions.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body.message).toEqual("Admin role required");
});
it("should return the webhook subscriptions for the user's team", async () => {
const user = await buildAdmin();
const webhookSubscriptions = await Promise.all(
Array(20)
.fill(1)
.map(() =>
buildWebhookSubscription({
createdById: user.id,
teamId: user.teamId,
})
)
);
const res = await server.post("/api/webhookSubscriptions.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(webhookSubscriptions.length);
});
});
describe("#webhookSubscriptions.create", () => {
it("should fail with status 401 unauthorized when user token is missing", async () => {
const res = await server.post("/api/webhookSubscriptions.create", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body.message).toEqual("Authentication required");
});
it("should fail with status 403 forbidden for non-admin user", async () => {
const user = await buildUser();
const res = await server.post("/api/webhookSubscriptions.create", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body.message).toEqual("Admin role required");
});
it("should successfully create a webhook subscription", async () => {
const user = await buildAdmin();
const name = "Test webhook";
const url = "https://www.example.com";
const events = ["comments"];
const secret = "Test secret";
const res = await server.post("/api/webhookSubscriptions.create", {
body: {
token: user.getJwtToken(),
name,
url,
events,
secret,
},
});
const body = await res.json();
const webhook = body.data;
expect(res.status).toEqual(200);
expect(webhook.name).toEqual(name);
expect(webhook.url).toEqual(url);
expect(webhook.events).toEqual(events);
expect(webhook.secret).toEqual(secret);
expect(webhook.enabled).toEqual(true);
});
});
describe("#webhookSubscriptions.update", () => {
it("should fail with status 401 unauthorized when user token is missing", async () => {
const res = await server.post("/api/webhookSubscriptions.update", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body.message).toEqual("Authentication required");
});
it("should fail with status 403 forbidden for non-admin user", async () => {
const user = await buildUser();
const res = await server.post("/api/webhookSubscriptions.update", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body.message).toEqual("Admin role required");
});
it("should successfully update a webhook subscription", async () => {
const user = await buildAdmin();
const name = "Updated webhook name";
const url = "https://www.example.com/update";
const events = ["comments"];
const existingWebhook = await buildWebhookSubscription({
name: "Created webhook name",
url: "https://www.example.com/create",
events: ["*"],
createdById: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/webhookSubscriptions.update", {
body: {
token: user.getJwtToken(),
id: existingWebhook.id,
name,
url,
events,
},
});
const body = await res.json();
const webhook = body.data;
expect(res.status).toEqual(200);
expect(webhook.name).toEqual(name);
expect(webhook.url).toEqual(url);
expect(webhook.events).toEqual(events);
expect(webhook.enabled).toEqual(true);
});
it("should activate a disabled webhook subscription when it's updated", async () => {
const user = await buildAdmin();
const name = "Updated webhook name";
const url = "https://www.example.com/update";
const events = ["comments"];
const disabledWebhook = await buildWebhookSubscription({
name: "Created webhook name",
url: "https://www.example.com/create",
events: ["*"],
createdById: user.id,
teamId: user.teamId,
enabled: false,
});
const res = await server.post("/api/webhookSubscriptions.update", {
body: {
token: user.getJwtToken(),
id: disabledWebhook.id,
name,
url,
events,
},
});
const body = await res.json();
const webhook = body.data;
expect(res.status).toEqual(200);
expect(webhook.name).toEqual(name);
expect(webhook.url).toEqual(url);
expect(webhook.events).toEqual(events);
expect(webhook.enabled).toEqual(true);
});
});
describe("#webhookSubscriptions.delete", () => {
it("should fail with status 401 unauthorized when user token is missing", async () => {
const res = await server.post("/api/webhookSubscriptions.delete", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body.message).toEqual("Authentication required");
});
it("should fail with status 403 forbidden for non-admin user", async () => {
const user = await buildUser();
const res = await server.post("/api/webhookSubscriptions.delete", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body.message).toEqual("Admin role required");
});
it("should successfully delete a webhook subscription", async () => {
const user = await buildAdmin();
const createdWebhook = await buildWebhookSubscription({
name: "Test webhook",
url: "https://www.example.com",
events: ["*"],
createdById: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/webhookSubscriptions.delete", {
body: { token: user.getJwtToken(), id: createdWebhook.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
});
});
@@ -5,7 +5,7 @@ import { UserRole } from "@shared/types";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { WebhookSubscription, Event } from "@server/models";
import { WebhookSubscription } from "@server/models";
import { authorize } from "@server/policies";
import pagination from "@server/routes/api/middlewares/pagination";
import { APIContext } from "@server/types";
@@ -20,7 +20,9 @@ router.post(
pagination(),
async (ctx: APIContext) => {
const { user } = ctx.state.auth;
authorize(user, "listWebhookSubscription", user.team);
const webhooks = await WebhookSubscription.findAll({
where: {
teamId: user.teamId,
@@ -43,34 +45,19 @@ router.post(
validate(T.WebhookSubscriptionsCreateSchema),
transaction(),
async (ctx: APIContext<T.WebhookSubscriptionsCreateReq>) => {
const { transaction } = ctx.state;
const { name, url, secret, events } = ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "createWebhookSubscription", user.team);
const { name, url, secret } = ctx.input.body;
const events: string[] = compact(ctx.input.body.events);
const webhookSubscription = await WebhookSubscription.create(
{
name,
events,
createdById: user.id,
teamId: user.teamId,
url,
enabled: true,
secret: isEmpty(secret) ? undefined : secret,
},
{ transaction }
);
await Event.createFromContext(ctx, {
name: "webhookSubscriptions.create",
modelId: webhookSubscription.id,
data: {
name,
url,
events,
},
const webhookSubscription = await WebhookSubscription.createWithCtx(ctx, {
name,
url,
events: compact(events),
enabled: true,
secret: isEmpty(secret) ? undefined : secret,
createdById: user.id,
teamId: user.teamId,
});
ctx.body = {
@@ -88,6 +75,7 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const webhookSubscription = await WebhookSubscription.findByPk(id, {
rejectOnEmpty: true,
lock: transaction.LOCK.UPDATE,
@@ -96,17 +84,7 @@ router.post(
authorize(user, "delete", webhookSubscription);
await webhookSubscription.destroy({ transaction });
await Event.createFromContext(ctx, {
name: "webhookSubscriptions.delete",
modelId: webhookSubscription.id,
data: {
name: webhookSubscription.name,
url: webhookSubscription.url,
events: webhookSubscription.events,
},
});
await webhookSubscription.destroyWithCtx(ctx);
ctx.body = {
success: true,
@@ -120,10 +98,10 @@ router.post(
validate(T.WebhookSubscriptionsUpdateSchema),
transaction(),
async (ctx: APIContext<T.WebhookSubscriptionsUpdateReq>) => {
const { id, name, url, secret } = ctx.input.body;
const { id, name, url, secret, events } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const events: string[] = compact(ctx.input.body.events);
const webhookSubscription = await WebhookSubscription.findByPk(id, {
rejectOnEmpty: true,
lock: transaction.LOCK.UPDATE,
@@ -132,25 +110,12 @@ router.post(
authorize(user, "update", webhookSubscription);
await webhookSubscription.update(
{
name,
url,
events,
enabled: true,
secret: isEmpty(secret) ? undefined : secret,
},
{ transaction }
);
await Event.createFromContext(ctx, {
name: "webhookSubscriptions.update",
modelId: webhookSubscription.id,
data: {
name: webhookSubscription.name,
url: webhookSubscription.url,
events: webhookSubscription.events,
},
await webhookSubscription.updateWithCtx(ctx, {
name,
url,
events: compact(events),
enabled: true,
secret: isEmpty(secret) ? undefined : secret,
});
ctx.body = {
-44
View File
@@ -1,44 +0,0 @@
import { Event } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import commentCreator from "./commentCreator";
describe("commentCreator", () => {
const ip = "127.0.0.1";
it("should create comment", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await commentCreator({
documentId: document.id,
data: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
content: [],
type: "text",
text: "test",
},
],
},
],
},
user,
ip,
});
const event = await Event.findLatest({
teamId: user.teamId,
});
expect(comment.documentId).toEqual(document.id);
expect(comment.createdById).toEqual(user.id);
expect(event!.name).toEqual("comments.create");
expect(event!.modelId).toEqual(comment.id);
});
});
-63
View File
@@ -1,63 +0,0 @@
import { Transaction } from "sequelize";
import { ProsemirrorData } from "@shared/types";
import { Comment, User, Event } from "@server/models";
type Props = {
id?: string;
/** The user creating the comment */
user: User;
/** The comment as data in Prosemirror schema format */
data: ProsemirrorData;
/** The document to comment within */
documentId: string;
/** The parent comment we're replying to, if any */
parentCommentId?: string;
/** The IP address of the user creating the comment */
ip: string;
transaction?: Transaction;
};
/**
* This command creates a comment inside a document.
*
* @param Props The properties of the comment to create
* @returns Comment The comment that was created
*/
export default async function commentCreator({
id,
user,
data,
documentId,
parentCommentId,
ip,
transaction,
}: Props): Promise<Comment> {
// TODO: Parse data to validate
const comment = await Comment.create(
{
id,
createdById: user.id,
documentId,
parentCommentId,
data,
},
{ transaction }
);
comment.createdBy = user;
await Event.create(
{
name: "comments.create",
modelId: comment.id,
teamId: user.teamId,
actorId: user.id,
documentId,
ip,
},
{ transaction }
);
return comment;
}

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