Compare commits

...

75 Commits

Author SHA1 Message Date
Tom Moor 66ae221aae cleanup type 2025-01-02 07:44:01 -05:00
Tom Moor 2b8f6752c0 Move Group to model event writing 2025-01-01 15:19:26 -05: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 2db7690e27 feat: Triple clicking in code mark should select entire mark, closes #8072 2024-12-07 12:58:15 -05:00
Tom Moor 06b89635be Improved tooltip context – separate for header,sidebar,editor. 2024-12-07 12:45:29 -05:00
Tom Moor 1ff23756ac fix: Make FindAndReplace popover dynamic, fixes button overflow.
closes #8079
2024-12-06 19:55:45 -05:00
Tom Moor a00b677076 fix: Use sidebarContext in header breadcrumbs (#8077) 2024-12-06 08:00:50 -05:00
Tom Moor 6c1e4a5b40 Add shortcuts to formatting menu tooltips (#8080)
* Add shortcuts to formatting menu tooltips

* Tooltip styling

* tsc
2024-12-05 20:50:16 -08:00
Tom Moor 59078704c8 fix: Embed toggle is unresponsive (#8078)
* fix: Embed toggle is unresponsive

* fix: View recorded when toggling embeds
2024-12-05 20:01:16 -08:00
Hemachandar f1a20b27fd fix: auto-scroll sidebar to show active document (#7956) 2024-12-05 17:23:13 -08:00
Tom Moor 313b046e4e fix: Use singleton for tooltips, ensures that only one is visible at a time. (#8069)
* fix: Use singleton for tooltips, ensures that only one is visible at a time and animations are shared

* fix: give toolbar menu its own context

* Remove duplicate props
2024-12-05 16:10:12 -08:00
Tom Moor 1154432924 Adds count of occurences and index to find and replace (#8070)
* Adds count of occurences and index to find and replace

* Disable replace buttons also
2024-12-05 15:58:24 -08:00
infinite-persistence e8bddbe104 Notification for resolved comment (#8045)
* fix: probably copy-pasted function description

* fix: userIdsMentioned was always empty

* add: NotificationEventType.ResolveComment

* move: split handler for "mentioned" vs. "resolved"

The recipients for "resolved" will include more people (creator, repliers, mentioned), so it's easier to just split the handler than trying to augment it.

* implement: handleResolvedComment

* clone: CommentMentionedEmail as CommentResolvedEmail

Changes coming up in next commit...

* implement: CommentResolvedEmail

* Fix "New Comment↓" incorrectly showing in Resolved

## Repro 1 (with production code)
1. In a list of long resolved comments, scroll up and select the first one.
2. From another account, resolve another comment. The hint appears.

## Repro 2 (with production code)
1. Select Most-Recent, then Resolved.
2. F5. It's scrolled all the way to the bottom.

## Repro 3 (after this PR)
1. Click on the notification when someone resolved a comment. The screen jumps to "Resolved" + showing hint unnecessarily.

## Fix
The scrolling and hint was meant for Most Recent only, but missed out this case since "Resolve" is not part of the enum.

* Better sentences

* Refactor "mentions + author" calculation

* Remove unnecessary check

The resolver is already added to `userIdsNotified` from the start, so no point checking it again here.
2024-12-04 15:10:03 -08:00
Tom Moor dddb12027c fix: Crash in header ref, regressed in 7a6f75c34f closes #8068 2024-12-04 08:35:55 -05:00
dependabot[bot] 5cb3da82bc chore(deps): bump socket.io from 4.7.5 to 4.8.1 (#8056)
Bumps [socket.io](https://github.com/socketio/socket.io) from 4.7.5 to 4.8.1.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io@4.7.5...socket.io@4.8.1)

---
updated-dependencies:
- dependency-name: socket.io
  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-03 19:15:07 -08:00
Tom Moor 7a6f75c34f fix: Improved responsiveness of document header elements (#8066)
* fix: Made the document header components more responsive to the available space

* doc
2024-12-03 19:11:36 -08:00
225 changed files with 3891 additions and 1773 deletions
+10 -2
View File
@@ -53,9 +53,13 @@ export const resolveCommentFactory = ({
perform: async ({ t }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: null,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onResolve();
@@ -81,9 +85,13 @@ export const unresolveCommentFactory = ({
perform: async () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: null,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onUnresolve();
-1
View File
@@ -732,7 +732,6 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
throw err;
}
};
+15 -12
View File
@@ -8,18 +8,16 @@ import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { MenuInternalLink } from "~/types";
type Props = {
type Props = React.PropsWithChildren<{
items: MenuInternalLink[];
max?: number;
highlightFirstItem?: boolean;
};
}>;
function Breadcrumb({
items,
highlightFirstItem,
children,
max = 2,
}: React.PropsWithChildren<Props>) {
function Breadcrumb(
{ items, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems;
@@ -37,9 +35,13 @@ function Breadcrumb({
}
return (
<Flex justify="flex-start" align="center">
<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
@@ -67,6 +69,8 @@ const Slash = styled(GoToIcon)`
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
${ellipsis()}
${undraggableOnDesktop()}
display: flex;
flex-shrink: 1;
min-width: 0;
@@ -76,7 +80,6 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
height: 24px;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
${undraggableOnDesktop()}
svg {
flex-shrink: 0;
@@ -87,4 +90,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
}
`;
export default Breadcrumb;
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
+4 -2
View File
@@ -18,6 +18,8 @@ import useStores from "~/hooks/useStores";
type Props = {
/** The document to display live collaborators for */
document: Document;
/** The maximum number of collaborators to display, defaults to 6 */
limit?: number;
};
/**
@@ -25,6 +27,7 @@ type Props = {
* and presence status.
*/
function Collaborators(props: Props) {
const { limit = 6 } = props;
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
@@ -75,8 +78,6 @@ function Collaborators(props: Props) {
placement: "bottom-end",
});
const limit = 8;
return (
<>
<PopoverDisclosure {...popover}>
@@ -88,6 +89,7 @@ function Collaborators(props: Props) {
>
<Facepile
limit={limit}
overflow={collaborators.length - limit}
users={collaborators}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
+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 = {
+19 -17
View File
@@ -8,15 +8,11 @@ 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";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
import {
archivePath,
collectionPath,
settingsPath,
trashPath,
} from "~/utils/routeHelpers";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
type Props = {
children?: React.ReactNode;
@@ -57,14 +53,14 @@ function useCategory(document: Document): MenuInternalLink | null {
return null;
}
const DocumentBreadcrumb: React.FC<Props> = ({
document,
children,
onlyText,
}: Props) => {
function DocumentBreadcrumb(
{ document, children, onlyText }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
const sidebarContext = useLocationSidebarContext();
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -81,7 +77,10 @@ const DocumentBreadcrumb: React.FC<Props> = ({
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
to: {
pathname: collection.path,
state: { sidebarContext },
},
};
} else if (document.isCollectionDeleted) {
collectionNode = {
@@ -115,11 +114,14 @@ const DocumentBreadcrumb: React.FC<Props> = ({
) : (
node.title
),
to: node.url,
to: {
pathname: node.url,
state: { sidebarContext },
},
});
});
return output;
}, [path, category, collectionNode]);
}, [path, category, sidebarContext, collectionNode]);
if (!collections.isLoaded) {
return null;
@@ -140,11 +142,11 @@ const DocumentBreadcrumb: React.FC<Props> = ({
}
return (
<Breadcrumb items={items} highlightFirstItem>
<Breadcrumb items={items} ref={ref} highlightFirstItem>
{children}
</Breadcrumb>
);
};
}
const StyledIcon = styled(Icon)`
margin-right: 2px;
@@ -160,4 +162,4 @@ const SmallSlash = styled(GoToIcon)`
opacity: 0.5;
`;
export default observer(DocumentBreadcrumb);
export default observer(React.forwardRef(DocumentBreadcrumb));
+1 -6
View File
@@ -144,12 +144,7 @@ function DocumentCard(props: Props) {
</Heading>
<DocumentMeta size="xsmall">
<Clock size={18} />
<Time
dateTime={document.updatedAt}
tooltipDelay={500}
addSuffix
shorten
/>
<Time dateTime={document.updatedAt} addSuffix shorten />
</DocumentMeta>
</div>
</Content>
+1 -1
View File
@@ -15,6 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
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";
@@ -25,7 +26,6 @@ import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
import { ancestors, descendants } from "~/utils/tree";
type Props = {
+11 -5
View File
@@ -21,9 +21,11 @@ import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
type Props = {
document: Document;
@@ -50,6 +52,7 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
let itemRef: React.Ref<HTMLAnchorElement> =
@@ -78,6 +81,12 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const sidebarContext = determineSidebarContext({
document,
user,
currentContext: locationSidebarContext,
});
return (
<DocumentLink
ref={itemRef}
@@ -89,6 +98,7 @@ function DocumentListItem(
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
@@ -111,11 +121,7 @@ function DocumentListItem(
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip
content={t("Only visible to you")}
delay={500}
placement="top"
>
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</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")};
+6 -2
View File
@@ -19,6 +19,7 @@ import Event from "~/models/Event";
import { Avatar } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import { hover } from "~/styles";
@@ -35,6 +36,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const opts = {
userName: event.actor.name,
};
@@ -66,7 +68,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
);
to = {
pathname: documentHistoryPath(document, event.modelId || "latest"),
state: { retainScrollPosition: true },
state: {
sidebarContext,
retainScrollPosition: true,
},
};
break;
@@ -140,7 +145,6 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
title={
<Time
dateTime={event.createdAt}
tooltipDelay={500}
format={{
en_US: "MMM do, h:mm a",
fr_FR: "'Le 'd MMMM 'à' H:mm",
+56 -36
View File
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
import { MenuIcon } from "outline-icons";
import { transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
@@ -10,27 +11,35 @@ import { supportsPassiveListener } from "@shared/utils/browser";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import Desktop from "~/utils/Desktop";
import { TooltipProvider } from "./TooltipContext";
export const HEADER_HEIGHT = 64;
type Props = {
left?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
actions?:
| ((props: { isCompact: boolean }) => React.ReactNode)
| React.ReactNode;
hasSidebar?: boolean;
className?: string;
};
function Header({ left, title, actions, hasSidebar, className }: Props) {
function Header(
{ left, title, actions, hasSidebar, className }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const internalRef = React.useRef<HTMLDivElement | null>(null);
const breadcrumbsRef = React.useRef<HTMLDivElement | null>(null);
const passThrough = !actions && !left && !title;
const [isScrolled, setScrolled] = React.useState(false);
@@ -53,38 +62,50 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
});
}, []);
return (
<Wrapper
align="center"
shrink={false}
className={className}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs>
{hasMobileSidebar && (
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
/>
)}
{left}
</Breadcrumbs>
) : null}
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement | null) => {
breadcrumbsRef.current = node?.firstElementChild as HTMLDivElement;
}, []);
{isScrolled ? (
<Title onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (
<div />
)}
<Actions align="center" justify="flex-end">
{actions}
</Actions>
</Wrapper>
const size = useComponentSize(internalRef);
const breadcrumbsSize = useComponentSize(breadcrumbsRef);
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
return (
<TooltipProvider>
<Wrapper
ref={mergeRefs([ref, internalRef])}
align="center"
shrink={false}
className={className}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
/>
)}
{left}
</Breadcrumbs>
) : null}
{isScrolled && !isCompact ? (
<Title onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (
<div />
)}
<Actions align="center" justify="flex-end">
{typeof actions === "function" ? actions({ isCompact }) : actions}
</Actions>
</Wrapper>
</TooltipProvider>
);
}
@@ -151,7 +172,6 @@ const Wrapper = styled(Flex)<WrapperProps>`
${breakpoint("tablet")`
padding: 16px;
justify-content: center;
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
`};
`;
@@ -190,4 +210,4 @@ const MobileMenuButton = styled(Button)`
}
`;
export default observer(Header);
export default observer(React.forwardRef(Header));
+1 -1
View File
@@ -4,9 +4,9 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { isModKey } from "@shared/utils/keyboard";
import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard";
import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input";
+1 -1
View File
@@ -4,6 +4,7 @@ import { Helmet } from "react-helmet-async";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { isModKey } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import SkipNavContent from "~/components/SkipNavContent";
@@ -13,7 +14,6 @@ import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
type Props = {
children?: React.ReactNode;
+1 -3
View File
@@ -23,7 +23,6 @@ function eachMinute(fn: () => void) {
export type Props = {
children?: React.ReactNode;
dateTime: string;
tooltipDelay?: number;
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
@@ -37,7 +36,6 @@ const LocaleTime: React.FC<Props> = ({
shorten,
format,
relative,
tooltipDelay,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
@@ -82,7 +80,7 @@ const LocaleTime: React.FC<Props> = ({
});
return (
<Tooltip content={tooltipContent} delay={tooltipDelay} placement="bottom">
<Tooltip content={tooltipContent} placement="bottom">
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
@@ -52,11 +52,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
<Text weight="bold">{notification.subject}</Text>
</Text>
<Text type="tertiary" size="xsmall">
<Time
dateTime={notification.createdAt}
tooltipDelay={1000}
addSuffix
/>{" "}
<Time dateTime={notification.createdAt} addSuffix />{" "}
{collection && <>&middot; {collection.name}</>}
</Text>
{notification.comment && (
@@ -60,7 +60,7 @@ function Notifications(
</Text>
<Flex gap={8}>
{notifications.approximateUnreadCount > 0 && (
<Tooltip delay={500} content={t("Mark all as read")}>
<Tooltip content={t("Mark all as read")}>
<Button action={markNotificationsAsRead} context={context}>
<MarkAsReadIcon />
</Button>
+15 -1
View File
@@ -10,13 +10,23 @@ import { fadeAndScaleIn } from "~/styles/animations";
type Props = PopoverProps & {
children: React.ReactNode;
/** The width of the popover, defaults to 380px. */
width?: number;
/** The minimum width of the popover, use instead of width if contents adjusts size. */
minWidth?: number;
/** Shrink the padding of the popover */
shrink?: boolean;
/** Make the popover flex */
flex?: boolean;
/** The tab index of the popover */
tabIndex?: number;
/** Whether the popover should be scrollable, defaults to true. */
scrollable?: boolean;
/** The position of the popover on mobile, defaults to "top". */
mobilePosition?: "top" | "bottom";
/** Function to show the popover */
show: () => void;
/** Function to hide the popover */
hide: () => void;
};
@@ -25,6 +35,7 @@ const Popover = (
children,
shrink,
width = 380,
minWidth,
scrollable = true,
flex,
mobilePosition,
@@ -71,6 +82,7 @@ const Popover = (
ref={ref}
$shrink={shrink}
$width={width}
$minWidth={minWidth}
$scrollable={scrollable}
$flex={flex}
>
@@ -83,6 +95,7 @@ const Popover = (
type ContentsProps = {
$shrink?: boolean;
$width?: number;
$minWidth?: number;
$flex?: boolean;
$scrollable: boolean;
$mobilePosition?: "top" | "bottom";
@@ -101,7 +114,8 @@ const Contents = styled.div<ContentsProps>`
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
max-height: 75vh;
box-shadow: ${s("menuShadow")};
width: ${(props) => props.$width}px;
${(props) => props.$width && `width: ${props.$width}px`};
${(props) => props.$minWidth && `min-width: ${props.$minWidth}px`};
${(props) =>
props.$scrollable
+1 -1
View File
@@ -128,7 +128,7 @@ const Reaction: React.FC<Props> = ({
);
return tooltipContent ? (
<Tooltip content={tooltipContent} delay={250} placement="bottom">
<Tooltip content={tooltipContent} placement="bottom">
{DisplayedEmoji}
</Tooltip>
) : (
+1 -6
View File
@@ -98,12 +98,7 @@ const ReactionPicker: React.FC<Props> = ({
<>
<PopoverDisclosure {...popover}>
{(props) => (
<Tooltip
content={t("Add reaction")}
placement="top"
delay={500}
hideOnClick
>
<Tooltip content={t("Add reaction")} placement="top" hideOnClick>
<NudeButton
{...props}
aria-label={t("Reaction picker")}
@@ -119,7 +119,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
: share?.url ?? "";
const copyButton = (
<Tooltip content={t("Copy public link")} delay={500} placement="top">
<Tooltip content={t("Copy public link")} placement="top">
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
<NudeButton type="button" disabled={!share} style={{ marginRight: 3 }}>
<CopyIcon color={theme.placeholder} size={18} />
@@ -31,7 +31,7 @@ export function CopyLinkButton({
}, [onCopy, t]);
return (
<Tooltip content={t("Copy link")} delay={500} placement="top">
<Tooltip content={t("Copy link")} placement="top">
<CopyToClipboard text={url} onCopy={handleCopied}>
<NudeButton type="button">
<LinkIcon size={20} />
+1 -2
View File
@@ -5,6 +5,7 @@ import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
@@ -14,7 +15,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import { metaDisplay } from "~/utils/keyboard";
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
@@ -80,7 +80,6 @@ function AppSidebar() {
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
delay={500}
>
<ToggleButton
position="bottom"
+1 -1
View File
@@ -128,7 +128,7 @@ const Sidebar = styled(m.div)<{
max-width: 80%;
border-left: 1px solid ${s("divider")};
transition: border-left 100ms ease-in-out;
z-index: 1;
z-index: ${depths.sidebar};
${breakpoint("mobile", "tablet")`
display: flex;
+2 -6
View File
@@ -5,12 +5,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted";
import { metaDisplay } from "~/utils/keyboard";
import { settingsPath } from "~/utils/routeHelpers";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
@@ -42,11 +42,7 @@ function SettingsSidebar() {
image={<StyledBackIcon />}
onClick={returnToApp}
>
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
delay={500}
>
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
+2 -6
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { NavigationNode } from "@shared/types";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
@@ -12,7 +13,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import history from "~/utils/history";
import { metaDisplay } from "~/utils/keyboard";
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
import { useTeamContext } from "../TeamContext";
import TeamLogo from "../TeamLogo";
@@ -81,11 +81,7 @@ const ToggleSidebar = () => {
const { ui } = useStores();
return (
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
delay={500}
>
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
+4 -2
View File
@@ -17,6 +17,7 @@ import { fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import NotificationIcon from "../Notifications/NotificationIcon";
import NotificationsPopover from "../Notifications/NotificationsPopover";
import { TooltipProvider } from "../TooltipContext";
import ResizeBorder from "./components/ResizeBorder";
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
@@ -194,8 +195,9 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
);
return (
<>
<TooltipProvider>
<Container
id="sidebar"
ref={ref}
style={style}
$hidden={hidden}
@@ -242,7 +244,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
/>
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</>
</TooltipProvider>
);
});
@@ -16,6 +16,7 @@ import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction";
import SidebarContext from "./SidebarContext";
import { DragObject } from "./SidebarLink";
function Collections() {
@@ -49,38 +50,40 @@ function Collections() {
});
return (
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.allActive}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
<SidebarContext.Provider value="collections">
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.allActive}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
)}
/>
<SidebarAction action={createCollection} depth={0} />
</Relative>
</Header>
</Flex>
)}
/>
<SidebarAction action={createCollection} depth={0} />
</Relative>
</Header>
</Flex>
</SidebarContext.Provider>
);
}
@@ -278,7 +278,7 @@ function InnerDocumentLink(
!isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<Tooltip content={t("New doc")} delay={500}>
<Tooltip content={t("New doc")}>
<NudeButton
type={undefined}
aria-label={t("New nested document")}
@@ -7,8 +7,8 @@ import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { useLocationState } from "../hooks/useLocationState";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DropCursor from "./DropCursor";
@@ -29,7 +29,7 @@ function DraggableCollectionLink({
prefetchDocument,
belowCollection,
}: Props) {
const locationSidebarContext = useLocationState();
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = useSidebarContext();
const { ui, policies, collections } = useStores();
const [expanded, setExpanded] = React.useState(
@@ -2,10 +2,11 @@ import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import Group from "~/models/Group";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import Folder from "./Folder";
import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext from "./SidebarContext";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -14,13 +15,23 @@ type Props = {
};
const GroupLink: React.FC<Props> = ({ group }) => {
const [expanded, setExpanded] = React.useState(false);
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = groupSidebarContext(group.id);
const [expanded, setExpanded] = React.useState(
locationSidebarContext === sidebarContext
);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
React.useEffect(() => {
if (locationSidebarContext === sidebarContext) {
setExpanded(true);
}
}, [sidebarContext, locationSidebarContext, setExpanded]);
return (
<Relative>
<SidebarLink
@@ -30,7 +41,7 @@ const GroupLink: React.FC<Props> = ({ group }) => {
onClick={handleDisclosureClick}
depth={0}
/>
<SidebarContext.Provider value={group.id}>
<SidebarContext.Provider value={sidebarContext}>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
@@ -43,12 +43,12 @@ function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
return (
<Navigation gap={4} {...props}>
<Tooltip content={t("Go back")} delay={500}>
<Tooltip content={t("Go back")}>
<NudeButton onClick={() => Desktop.bridge?.goBack()}>
<Back $active={back} />
</NudeButton>
</Tooltip>
<Tooltip content={t("Go forward")} delay={500}>
<Tooltip content={t("Go forward")}>
<NudeButton onClick={() => Desktop.bridge?.goForward()}>
<Forward $active={forward} />
</NudeButton>
@@ -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]);
@@ -9,6 +9,7 @@ import GroupMembership from "~/models/GroupMembership";
import UserMembership from "~/models/UserMembership";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import {
@@ -16,7 +17,6 @@ import {
useDropToReorderUserMembership,
useDropToReparentDocument,
} from "../hooks/useDragAndDrop";
import { useLocationState } from "../hooks/useLocationState";
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
@@ -36,7 +36,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId } = membership;
const isActiveDocument = documentId === ui.activeDocumentId;
const locationSidebarContext = useLocationState();
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = useSidebarContext();
const document = documentId ? documents.get(documentId) : undefined;
@@ -1,9 +1,57 @@
import * as React from "react";
import Document from "~/models/Document";
import User from "~/models/User";
export type SidebarContextType = "collections" | "starred" | string | undefined;
export type SidebarContextType =
| "collections"
| "shared"
| `group-${string}`
| `starred-${string}`
| undefined;
const SidebarContext = React.createContext<SidebarContextType>(undefined);
export const useSidebarContext = () => React.useContext(SidebarContext);
export const groupSidebarContext = (groupId: string): SidebarContextType =>
`group-${groupId}`;
export const starredSidebarContext = (modelId: string): SidebarContextType =>
`starred-${modelId}`;
export const determineSidebarContext = ({
document,
user,
currentContext,
}: {
document: Document;
user: User;
currentContext?: SidebarContextType;
}): SidebarContextType => {
const isStarred = document.isStarred || !!document.collection?.isStarred;
const preferStarred = !currentContext || currentContext.startsWith("starred");
if (isStarred && preferStarred) {
const currentlyInStarredCollection =
currentContext === starredSidebarContext(document.collectionId ?? "");
return document.isStarred && !currentlyInStarredCollection
? starredSidebarContext(document.id)
: starredSidebarContext(document.collectionId!);
}
if (document.collection) {
return "collections";
} else if (
user.documentMemberships.find((m) => m.documentId === document.id)
) {
return "shared";
} else {
const group = user.groupsWithDocumentMemberships.find(
(g) => !!g.documentMemberships.find((m) => m.documentId === document.id)
);
return groupSidebarContext(group?.id ?? "");
}
};
export default SidebarContext;
+40 -43
View File
@@ -15,7 +15,6 @@ import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import StarredLink from "./StarredLink";
@@ -42,48 +41,46 @@ function Starred() {
}
return (
<SidebarContext.Provider value="starred">
<Flex column>
<Header id="starred" title={t("Starred")}>
<Relative>
{reorderStarProps.isDragging && (
<DropCursor
isActiveDrop={reorderStarProps.isOverCursor}
innerRef={dropToReorder}
position="top"
/>
)}
{createStarProps.isDragging && (
<DropCursor
isActiveDrop={createStarProps.isOverCursor}
innerRef={dropToStarRef}
position="top"
/>
)}
{stars.orderedData
.slice(0, page * STARRED_PAGINATION_LIMIT)
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={stars.isFetching}
depth={0}
/>
)}
{loading && (
<Flex column>
<DelayedMount>
<PlaceholderCollections />
</DelayedMount>
</Flex>
)}
</Relative>
</Header>
</Flex>
</SidebarContext.Provider>
<Flex column>
<Header id="starred" title={t("Starred")}>
<Relative>
{reorderStarProps.isDragging && (
<DropCursor
isActiveDrop={reorderStarProps.isOverCursor}
innerRef={dropToReorder}
position="top"
/>
)}
{createStarProps.isDragging && (
<DropCursor
isActiveDrop={createStarProps.isOverCursor}
innerRef={dropToStarRef}
position="top"
/>
)}
{stars.orderedData
.slice(0, page * STARRED_PAGINATION_LIMIT)
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={stars.isFetching}
depth={0}
/>
)}
{loading && (
<Flex column>
<DelayedMount>
<PlaceholderCollections />
</DelayedMount>
</Flex>
)}
</Relative>
</Header>
</Flex>
);
}
@@ -8,6 +8,7 @@ import styled, { useTheme } from "styled-components";
import Star from "~/models/Star";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import {
@@ -15,7 +16,6 @@ import {
useDropToCreateStar,
useDropToReorderStar,
} from "../hooks/useDragAndDrop";
import { useLocationState } from "../hooks/useLocationState";
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
@@ -25,7 +25,7 @@ import Folder from "./Folder";
import Relative from "./Relative";
import SidebarContext, {
SidebarContextType,
useSidebarContext,
starredSidebarContext,
} from "./SidebarContext";
import SidebarLink from "./SidebarLink";
@@ -39,10 +39,14 @@ function StarredLink({ star }: Props) {
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId, collectionId } = star;
const collection = collections.get(collectionId);
const locationSidebarContext = useLocationState();
const sidebarContext = useSidebarContext();
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = starredSidebarContext(
star.documentId ?? star.collectionId
);
const [expanded, setExpanded] = useState(
star.collectionId === ui.activeCollectionId &&
(star.documentId
? star.documentId === ui.activeDocumentId
: star.collectionId === ui.activeCollectionId) &&
sidebarContext === locationSidebarContext
);
@@ -159,7 +163,7 @@ function StarredLink({ star }: Props) {
}
/>
</Draggable>
<SidebarContext.Provider value={document.id}>
<SidebarContext.Provider value={sidebarContext}>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
@@ -183,7 +187,7 @@ function StarredLink({ star }: Props) {
if (collection) {
return (
<>
<SidebarContext.Provider value={sidebarContext}>
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
<CollectionLink
collection={collection}
@@ -193,16 +197,14 @@ function StarredLink({ star }: Props) {
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<SidebarContext.Provider value={collection.id}>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
</>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
);
}
+1 -1
View File
@@ -22,7 +22,7 @@ function Time({ onClick, ...props }: Props) {
<time dateTime={props.dateTime}>{props.children || content}</time>
}
>
<LocaleTime tooltipDelay={250} {...props} />
<LocaleTime {...props} />
</React.Suspense>
</span>
);
+41 -9
View File
@@ -1,19 +1,37 @@
import Tippy, { TippyProps } from "@tippyjs/react";
import { transparentize } from "polished";
import * as React from "react";
import styled, { createGlobalStyle } from "styled-components";
import { roundArrow } from "tippy.js";
import { s } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
export type Props = Omit<TippyProps, "content" | "theme"> & {
/** The content to display in the tooltip. */
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;
};
function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
/**
* A tooltip component that wraps Tippy and provides a consistent look and feel. Optionally
* displays a keyboard shortcut next to the content.
*
* Wrap this component in a TooltipProvider to allow multiple tooltips to share the same
* singleton instance (delay, animation, etc).
*/
function Tooltip({
shortcut,
shortcutOnNewline,
content: tooltip,
delay = 500,
...rest
}: Props) {
const isMobile = useMobile();
const singleton = useTooltipContext();
let content = <>{tooltip}</>;
@@ -24,7 +42,19 @@ function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
if (shortcut) {
content = (
<>
{tooltip} &middot; <Shortcut>{shortcut}</Shortcut>
{tooltip}
{shortcutOnNewline ? <br /> : " "}
{typeof shortcut === "string" ? (
shortcut
.split("+")
.map((key, i) => (
<Shortcut key={`${key}${i}`}>
{key.length === 1 ? key.toUpperCase() : key}
</Shortcut>
))
) : (
<Shortcut>{shortcut}</Shortcut>
)}
</>
);
}
@@ -32,9 +62,10 @@ function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
return (
<Tippy
arrow={roundArrow}
animation="shift-away"
content={content}
delay={delay}
animation="shift-away"
singleton={singleton}
duration={[200, 150]}
inertia
{...rest}
@@ -44,16 +75,17 @@ function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
const Shortcut = styled.kbd`
position: relative;
top: -2px;
top: -1px;
margin-left: 2px;
display: inline-block;
padding: 2px 4px;
font: 10px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
monospace;
font-size: 12px;
font-family: ${s("fontFamilyMono")};
line-height: 10px;
color: ${s("tooltipBackground")};
color: ${s("tooltipText")};
border: 1px solid ${(props) => transparentize(0.75, props.theme.tooltipText)};
vertical-align: middle;
background-color: ${s("tooltipText")};
border-radius: 3px;
`;
@@ -132,7 +164,7 @@ export const TooltipStyles = createGlobalStyle`
padding:5px 9px;
z-index:1
}
/* Arrow Styles */
.tippy-box[data-placement^=top]>.tippy-svg-arrow{
bottom:0
+40
View File
@@ -0,0 +1,40 @@
import Tippy, { useSingleton, TippyProps } from "@tippyjs/react";
import * as React from "react";
import { roundArrow } from "tippy.js";
export const TooltipContext =
React.createContext<TippyProps["singleton"]>(undefined);
export function useTooltipContext() {
return React.useContext(TooltipContext);
}
type Props = {
children: React.ReactNode;
/** Props to pass to the Tippy component */
tippyProps?: TippyProps;
};
/**
* Wrap a collection of tooltips in a provider to allow them to share the same singleton instance.
*/
export function TooltipProvider({ children, tippyProps }: Props) {
const [source, target] = useSingleton();
return (
<>
<Tippy
delay={500}
arrow={roundArrow}
animation="shift-away"
singleton={source}
duration={[200, 150]}
inertia
{...tippyProps}
/>
<TooltipContext.Provider value={target}>
{children}
</TooltipContext.Provider>
</>
);
}
+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
/>
+48 -21
View File
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
import { usePopoverState } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import { altDisplay, isModKey, metaDisplay } from "@shared/utils/keyboard";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
@@ -21,14 +22,21 @@ import Tooltip from "~/components/Tooltip";
import useKeyDown from "~/hooks/useKeyDown";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import Desktop from "~/utils/Desktop";
import { altDisplay, isModKey, metaDisplay } from "~/utils/keyboard";
import { useEditor } from "./EditorContext";
type Props = {
/** Whether the find and replace popover is open */
open: boolean;
/** Callback when the find and replace popover is opened */
onOpen: () => void;
/** Callback when the find and replace popover is closed */
onClose: () => void;
/** Whether the editor is in read-only mode */
readOnly?: boolean;
/** The current highlighted index in the search results */
currentIndex: number;
/** The total number of search results */
totalResults: number;
};
export default function FindAndReplace({
@@ -36,6 +44,8 @@ export default function FindAndReplace({
open,
onOpen,
onClose,
currentIndex,
totalResults,
}: Props) {
const editor = useEditor();
const finalFocusRef = React.useRef<HTMLElement>(
@@ -270,25 +280,26 @@ export default function FindAndReplace({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [popover.visible]);
const disabled = totalResults === 0;
const navigation = (
<>
<Tooltip
content={t("Previous match")}
shortcut="shift+enter"
delay={500}
placement="bottom"
>
<ButtonLarge onClick={() => editor.commands.prevSearchMatch()}>
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.prevSearchMatch()}
>
<CaretUpIcon />
</ButtonLarge>
</Tooltip>
<Tooltip
content={t("Next match")}
shortcut="enter"
delay={500}
placement="bottom"
>
<ButtonLarge onClick={() => editor.commands.nextSearchMatch()}>
<Tooltip content={t("Next match")} shortcut="enter" placement="bottom">
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.nextSearchMatch()}
>
<CaretDownIcon />
</ButtonLarge>
</Tooltip>
@@ -303,10 +314,11 @@ export default function FindAndReplace({
style={style}
aria-label={t("Find and replace")}
scrollable={false}
width={420}
minWidth={420}
width={0}
>
<Content column>
<Flex gap={8}>
<Flex gap={4}>
<StyledInput
ref={inputRef}
maxLength={255}
@@ -319,7 +331,6 @@ export default function FindAndReplace({
<Tooltip
content={t("Match case")}
shortcut={`${altDisplay}+${metaDisplay}+c`}
delay={500}
placement="bottom"
>
<ButtonSmall onClick={handleCaseSensitive}>
@@ -331,7 +342,6 @@ export default function FindAndReplace({
<Tooltip
content={t("Enable regex")}
shortcut={`${altDisplay}+${metaDisplay}+r`}
delay={500}
placement="bottom"
>
<ButtonSmall onClick={handleRegex}>
@@ -344,16 +354,15 @@ export default function FindAndReplace({
</StyledInput>
{navigation}
{!readOnly && (
<Tooltip
content={t("Replace options")}
delay={500}
placement="bottom"
>
<Tooltip content={t("Replace options")} placement="bottom">
<ButtonLarge onClick={handleMore}>
<ReplaceIcon color={theme.textSecondary} />
</ButtonLarge>
</Tooltip>
)}
<Results>
{totalResults > 0 ? currentIndex + 1 : 0} / {totalResults}
</Results>
</Flex>
<ResizingHeightContainer>
{showReplace && !readOnly && (
@@ -367,10 +376,10 @@ export default function FindAndReplace({
onRequestSubmit={handleReplaceAll}
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
/>
<Button onClick={handleReplace} neutral>
<Button onClick={handleReplace} disabled={disabled} neutral>
{t("Replace")}
</Button>
<Button onClick={handleReplaceAll} neutral>
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
{t("Replace all")}
</Button>
</Flex>
@@ -396,6 +405,12 @@ const ButtonSmall = styled(NudeButton)`
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
&:disabled {
color: ${s("textTertiary")};
background: none;
cursor: default;
}
`;
const ButtonLarge = styled(ButtonSmall)`
@@ -408,3 +423,15 @@ const Content = styled(Flex)`
margin-bottom: -16px;
position: static;
`;
const Results = styled.span`
color: ${s("textSecondary")};
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 32px;
min-width: 32px;
letter-spacing: -0.5px;
text-align: right;
user-select: none;
`;
+2 -3
View File
@@ -13,10 +13,10 @@ import Flex from "~/components/Flex";
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;
@@ -122,11 +122,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
search={search}
onSelect={handleSelect}
renderMenuItem={(item, _index, options) => (
<MentionMenuItem
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.title}
label={item.attrs.label}
icon={
<Flex
align="center"
-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} />;
}
@@ -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}>{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};
+36 -29
View File
@@ -1,3 +1,4 @@
import { TippyProps } from "@tippyjs/react";
import * as React from "react";
import { useMenuState } from "reakit";
import { MenuButton } from "reakit/Menu";
@@ -7,6 +8,7 @@ import { MenuItem } from "@shared/editor/types";
import { s } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { TooltipProvider } from "~/components/TooltipContext";
import { MenuItem as TMenuItem } from "~/types";
import { useEditor } from "./EditorContext";
import ToolbarButton from "./ToolbarButton";
@@ -75,6 +77,8 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
);
}
const tippyProps = { placement: "top" } as TippyProps;
function ToolbarMenu(props: Props) {
const { commands, view } = useEditor();
const { items } = props;
@@ -91,36 +95,39 @@ function ToolbarMenu(props: Props) {
};
return (
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || !item.icon) {
return null;
}
const isActive = item.active ? item.active(state) : false;
<TooltipProvider tippyProps={tippyProps}>
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || !item.icon) {
return null;
}
const isActive = item.active ? item.active(state) : false;
return (
<Tooltip
content={item.label === item.tooltip ? undefined : item.tooltip}
key={index}
>
{item.children ? (
<ToolbarDropdown active={isActive && !item.label} item={item} />
) : (
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.children ? (
<ToolbarDropdown active={isActive && !item.label} item={item} />
) : (
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
</TooltipProvider>
);
}
+14 -9
View File
@@ -1,15 +1,20 @@
import * as React from "react";
import styled from "styled-components";
import Tooltip from "~/components/Tooltip";
import Tooltip, { Props } from "~/components/Tooltip";
type Props = {
/** The content to display in the tooltip. */
content?: string;
children?: React.ReactNode;
};
const WrappedTooltip: React.FC<Props> = ({ children, content }: Props) => (
<Tooltip offset={[0, 16]} delay={150} content={content} placement="top">
const WrappedTooltip: React.FC<Props> = ({
children,
content,
...rest
}: Props) => (
<Tooltip
offset={[0, 16]}
delay={150}
content={content}
placement="top"
shortcutOnNewline
{...rest}
>
<TooltipContent>{children}</TooltipContent>
</Tooltip>
);
+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(
+6
View File
@@ -332,6 +332,8 @@ export default class FindAndReplaceExtension extends Extension {
public widget = ({ readOnly }: WidgetProps) => (
<FindAndReplace
currentIndex={this.currentResultIndex}
totalResults={this.results.length}
readOnly={readOnly}
open={this.open}
onOpen={() => {
@@ -346,7 +348,11 @@ export default class FindAndReplaceExtension extends Extension {
@observable
private open = false;
@observable
private results: { from: number; to: number }[] = [];
@observable
private currentResultIndex = 0;
private searchTerm = "";
}
+1 -1
View File
@@ -26,8 +26,8 @@ import * as React from "react";
import styled from "styled-components";
import Image from "@shared/editor/components/Img";
import { MenuItem } from "@shared/editor/types";
import { metaDisplay } from "@shared/utils/keyboard";
import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "~/utils/keyboard";
const Img = styled(Image)`
border-radius: 2px;
+25 -1
View File
@@ -28,6 +28,7 @@ import { isInList } from "@shared/editor/queries/isInList";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { metaDisplay } from "@shared/utils/keyboard";
import CircleIcon from "~/components/Icons/CircleIcon";
import { Dictionary } from "~/hooks/useDictionary";
@@ -63,6 +64,7 @@ export default function formattingMenuItems(
{
name: "strong",
tooltip: dictionary.strong,
shortcut: `${metaDisplay}+B`,
icon: <BoldIcon />,
active: isMarkActive(schema.marks.strong),
visible: !isCode && (!isMobile || !isEmpty),
@@ -70,6 +72,7 @@ export default function formattingMenuItems(
{
name: "em",
tooltip: dictionary.em,
shortcut: `${metaDisplay}+I`,
icon: <ItalicIcon />,
active: isMarkActive(schema.marks.em),
visible: !isCode && (!isMobile || !isEmpty),
@@ -77,12 +80,14 @@ export default function formattingMenuItems(
{
name: "strikethrough",
tooltip: dictionary.strikethrough,
shortcut: `${metaDisplay}+D`,
icon: <StrikethroughIcon />,
active: isMarkActive(schema.marks.strikethrough),
visible: !isCode && (!isMobile || !isEmpty),
},
{
tooltip: dictionary.mark,
shortcut: `${metaDisplay}+Ctrl+H`,
icon: highlight ? (
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
) : (
@@ -114,6 +119,7 @@ export default function formattingMenuItems(
{
name: "code_inline",
tooltip: dictionary.codeInline,
shortcut: `${metaDisplay}+E`,
icon: <CodeIcon />,
active: isMarkActive(schema.marks.code_inline),
visible: !isCodeBlock && (!isMobile || !isEmpty),
@@ -125,6 +131,7 @@ export default function formattingMenuItems(
{
name: "heading",
tooltip: dictionary.heading,
shortcut: `⇧+Ctrl+1`,
icon: <Heading1Icon />,
active: isNodeActive(schema.nodes.heading, { level: 1 }),
attrs: { level: 1 },
@@ -133,6 +140,7 @@ export default function formattingMenuItems(
{
name: "heading",
tooltip: dictionary.subheading,
shortcut: `⇧+Ctrl+2`,
icon: <Heading2Icon />,
active: isNodeActive(schema.nodes.heading, { level: 2 }),
attrs: { level: 2 },
@@ -141,6 +149,7 @@ export default function formattingMenuItems(
{
name: "heading",
tooltip: dictionary.subheading,
shortcut: `⇧+Ctrl+3`,
icon: <Heading3Icon />,
active: isNodeActive(schema.nodes.heading, { level: 3 }),
attrs: { level: 3 },
@@ -149,6 +158,7 @@ export default function formattingMenuItems(
{
name: "blockquote",
tooltip: dictionary.quote,
shortcut: `${metaDisplay}+]`,
icon: <BlockQuoteIcon />,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
@@ -161,6 +171,7 @@ export default function formattingMenuItems(
{
name: "checkbox_list",
tooltip: dictionary.checkboxList,
shortcut: `⇧+Ctrl+7`,
icon: <TodoListIcon />,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
@@ -169,6 +180,7 @@ export default function formattingMenuItems(
{
name: "bullet_list",
tooltip: dictionary.bulletList,
shortcut: `⇧+Ctrl+8`,
icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
@@ -176,6 +188,7 @@ export default function formattingMenuItems(
{
name: "ordered_list",
tooltip: dictionary.orderedList,
shortcut: `⇧+Ctrl+9`,
icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
@@ -183,6 +196,7 @@ export default function formattingMenuItems(
{
name: "outdentList",
tooltip: dictionary.outdent,
shortcut: `⇧+Tab`,
icon: <OutdentIcon />,
visible:
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
@@ -190,6 +204,7 @@ export default function formattingMenuItems(
{
name: "indentList",
tooltip: dictionary.indent,
shortcut: `Tab`,
icon: <IndentIcon />,
visible:
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
@@ -197,12 +212,14 @@ export default function formattingMenuItems(
{
name: "outdentCheckboxList",
tooltip: dictionary.outdent,
shortcut: `⇧+Tab`,
icon: <OutdentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
},
{
name: "indentCheckboxList",
tooltip: dictionary.indent,
shortcut: `Tab`,
icon: <IndentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
},
@@ -213,6 +230,7 @@ export default function formattingMenuItems(
{
name: "link",
tooltip: dictionary.createLink,
shortcut: `${metaDisplay}+K`,
icon: <LinkIcon />,
attrs: { href: "" },
visible: !isCodeBlock && (!isMobile || !isEmpty),
@@ -220,9 +238,14 @@ export default function formattingMenuItems(
{
name: "comment",
tooltip: dictionary.comment,
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,
},
{
@@ -233,6 +256,7 @@ export default function formattingMenuItems(
name: "copyToClipboard",
icon: <CopyIcon />,
tooltip: dictionary.copy,
shortcut: `${metaDisplay}+C`,
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
},
];
+2 -2
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { useHistory } from "react-router-dom";
import { isModKey } from "@shared/utils/keyboard";
import { isInternalUrl } from "@shared/utils/urls";
import { isModKey } from "~/utils/keyboard";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
@@ -56,7 +56,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
}
if (!isModKey(event) && !event.shiftKey) {
history.push(navigateTo);
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) {
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { isModKey } from "@shared/utils/keyboard";
import isTextInput from "~/utils/isTextInput";
import { isModKey } from "~/utils/keyboard";
type Callback = (event: KeyboardEvent) => void;
@@ -1,10 +1,10 @@
import { useLocation } from "react-router-dom";
import { SidebarContextType } from "../components/SidebarContext";
import { SidebarContextType } from "../components/Sidebar/components/SidebarContext";
/**
* Hook to retrieve the sidebar context from the current location state.
*/
export function useLocationState() {
export function useLocationSidebarContext() {
const location = useLocation<{
sidebarContext?: SidebarContextType;
}>();
+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;
-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 = "";
}
+3 -3
View File
@@ -136,14 +136,14 @@ type MenuContentProps = {
showToggleEmbeds?: boolean;
};
const MenuContent: React.FC<MenuContentProps> = ({
const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
onOpen,
onClose,
onFindAndReplace,
onRename,
showDisplayOptions,
showToggleEmbeds,
}) => {
}) {
const user = useCurrentUser();
const { model: document, menuState } = useMenuContext<Document>();
const can = usePolicy(document);
@@ -348,7 +348,7 @@ const MenuContent: React.FC<MenuContentProps> = ({
)}
</ContextMenu>
) : null;
};
});
function DocumentMenu({
document,
+1 -6
View File
@@ -19,12 +19,7 @@ function NewDocumentMenu() {
}
return (
<Tooltip
content={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Tooltip content={t("New document")} shortcut="n" placement="bottom">
<Button as={Link} to={newDocumentPath()} icon={<PlusIcon />}>
{t("New doc")}
</Button>
+13 -4
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import { DocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
@@ -14,11 +14,15 @@ import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
type Props = {
/** The document to which the templates will be applied */
document: Document;
/** Whether to render the button as a compact icon */
isCompact?: boolean;
/** Callback to handle when a template is selected */
onSelectTemplate: (template: Document) => void;
};
function TemplatesMenu({ onSelectTemplate, document }: Props) {
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
const menu = useMenuState({
modal: true,
});
@@ -79,8 +83,13 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
<>
<MenuButton {...menu}>
{(props) => (
<Button {...props} disclosure neutral>
{t("Templates")}
<Button
{...props}
icon={isCompact ? <ShapesIcon /> : undefined}
disclosure={!isCompact}
neutral
>
{isCompact ? undefined : t("Templates")}
</Button>
)}
</MenuButton>
+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
@@ -119,6 +119,8 @@ class Notification extends Model {
return t("mentioned you in");
case NotificationEventType.CreateComment:
return t("left a comment on");
case NotificationEventType.ResolveComment:
return t("resolved a comment on");
case NotificationEventType.AddUserToDocument:
return t("shared");
case NotificationEventType.AddUserToCollection:
@@ -170,6 +172,7 @@ class Notification extends Model {
return this.document?.path;
}
case NotificationEventType.MentionedInComment:
case NotificationEventType.ResolveComment:
case NotificationEventType.CreateComment: {
return this.document && this.comment
? commentPath(this.document, this.comment)
@@ -27,7 +27,6 @@ function Actions({ collection }: Props) {
<Tooltip
content={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
@@ -316,7 +316,7 @@ function CommentForm({
{t("Cancel")}
</ButtonSmall>
</Flex>
<Tooltip delay={500} content={t("Upload image")} placement="top">
<Tooltip content={t("Upload image")} placement="top">
<NudeButton onClick={handleImageUpload}>
<ImageIcon color={theme.textTertiary} />
</NudeButton>
@@ -7,12 +7,14 @@ import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import InputSelect from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useQuery from "~/hooks/useQuery";
import { CommentSortType } from "~/types";
const CommentSortMenu = () => {
const { t } = useTranslation();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const history = useHistory();
const user = useCurrentUser();
const params = useQuery();
@@ -39,6 +41,7 @@ const CommentSortMenu = () => {
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
};
@@ -49,6 +52,7 @@ const CommentSortMenu = () => {
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
};
@@ -15,6 +15,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -61,6 +62,7 @@ function CommentThread({
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
const can = usePolicy(document);
@@ -101,7 +103,10 @@ function CommentThread({
history.replace({
search: location.search,
pathname: location.pathname,
state: { commentId: undefined },
state: {
commentId: undefined,
sidebarContext,
},
});
}
});
@@ -115,7 +120,10 @@ function CommentThread({
// Clear any commentId from the URL when explicitly focusing a thread
search: thread.isResolved ? "resolved=" : "",
pathname: location.pathname.replace(/\/history$/, ""),
state: { commentId: thread.id },
state: {
commentId: thread.id,
sidebarContext,
},
});
};
@@ -197,21 +197,12 @@ function CommentThreadItem({
{showAuthor && <em>{comment.createdBy.name}</em>}
{showAuthor && showTime && <> &middot; </>}
{showTime && (
<Time
dateTime={comment.createdAt}
tooltipDelay={500}
addSuffix
shorten
/>
<Time dateTime={comment.createdAt} addSuffix shorten />
)}
{showEdited && (
<>
{" "}
(
<Time dateTime={comment.updatedAt} tooltipDelay={500}>
{t("edited")}
</Time>
)
(<Time dateTime={comment.updatedAt}>{t("edited")}</Time>)
</>
)}
</Meta>
@@ -304,12 +295,7 @@ const ResolveButton = ({
const { t } = useTranslation();
return (
<Tooltip
content={t("Mark as resolved")}
placement="top"
delay={500}
hideOnClick
>
<Tooltip content={t("Mark as resolved")} placement="top" hideOnClick>
<Action
as={NudeButton}
context={context}
+8 -4
View File
@@ -94,14 +94,18 @@ function Comments() {
React.useEffect(() => {
// Handles: 1. on refresh 2. when switching sort setting
const readyToDisplay = Boolean(document && isEditorInitialized);
if (readyToDisplay && sortOption.type === CommentSortType.MostRecent) {
if (
readyToDisplay &&
sortOption.type === CommentSortType.MostRecent &&
!viewingResolved
) {
scrollToBottom();
}
}, [sortOption.type, document, isEditorInitialized]);
}, [sortOption.type, document, isEditorInitialized, viewingResolved]);
React.useEffect(() => {
setShowJumpToRecentBtn(false);
if (sortOption.type === CommentSortType.MostRecent) {
if (sortOption.type === CommentSortType.MostRecent && !viewingResolved) {
const commentsAdded = threads.length > prevThreadCount.current;
if (commentsAdded) {
if (isAtBottom.current) {
@@ -112,7 +116,7 @@ function Comments() {
}
}
prevThreadCount.current = threads.length;
}, [sortOption.type, threads.length]);
}, [sortOption.type, threads.length, viewingResolved]);
if (!document || !isEditorInitialized) {
return null;
+14 -10
View File
@@ -23,6 +23,7 @@ import {
import history from "~/utils/history";
import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers";
import Loading from "./Loading";
import MarkAsViewed from "./MarkAsViewed";
type Params = {
/** The document urlId + slugified title */
@@ -222,16 +223,19 @@ function DataLoader({ match, children }: Props) {
const readOnly = !isEditing || !canEdit;
return (
<React.Fragment key={canEdit ? "edit" : "read"}>
{children({
document,
revision,
abilities: can,
readOnly,
onCreateLink,
sharedTree,
})}
</React.Fragment>
<>
{!shareId && !revision && <MarkAsViewed document={document} />}
<React.Fragment key={canEdit ? "edit" : "read"}>
{children({
document,
revision,
abilities: can,
readOnly,
onCreateLink,
sharedTree,
})}
</React.Fragment>
</>
);
}
+33 -18
View File
@@ -29,6 +29,7 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import { parseDomain } from "@shared/utils/domains";
import { determineIconType } from "@shared/utils/icon";
import { isModKey } from "@shared/utils/keyboard";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
@@ -41,12 +42,12 @@ import LoadingIndicator from "~/components/LoadingIndicator";
import PageTitle from "~/components/PageTitle";
import PlaceholderDocument from "~/components/PlaceholderDocument";
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 { client } from "~/utils/ApiClient";
import { emojiToUrl } from "~/utils/emoji";
import { isModKey } from "~/utils/keyboard";
import {
documentHistoryPath,
@@ -58,7 +59,6 @@ import Contents from "./Contents";
import Editor from "./Editor";
import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import MarkAsViewed from "./MarkAsViewed";
import { MeasuredContainer } from "./MeasuredContainer";
import Notices from "./Notices";
import PublicReferences from "./PublicReferences";
@@ -77,6 +77,7 @@ type LocationState = {
title?: string;
restore?: boolean;
revisionId?: string;
sidebarContext?: SidebarContextType;
};
type Props = WithTranslation &
@@ -252,7 +253,10 @@ class DocumentScene extends React.Component<Props> {
const { document, abilities } = this.props;
if (abilities.update) {
this.props.history.push(documentEditPath(document));
this.props.history.push({
pathname: documentEditPath(document),
state: { sidebarContext: this.props.location.state?.sidebarContext },
});
}
} else if (this.editor.current?.isBlurred) {
ev.preventDefault();
@@ -271,9 +275,15 @@ class DocumentScene extends React.Component<Props> {
const { document, location } = this.props;
if (location.pathname.endsWith("history")) {
this.props.history.push(document.url);
this.props.history.push({
pathname: document.url,
state: { sidebarContext: this.props.location.state?.sidebarContext },
});
} else {
this.props.history.push(documentHistoryPath(document));
this.props.history.push({
pathname: documentHistoryPath(document),
state: { sidebarContext: this.props.location.state?.sidebarContext },
});
}
};
@@ -339,10 +349,16 @@ class DocumentScene extends React.Component<Props> {
this.isEditorDirty = false;
if (options.done) {
this.props.history.push(savedDocument.url);
this.props.history.push({
pathname: savedDocument.url,
state: { sidebarContext: this.props.location.state?.sidebarContext },
});
this.props.ui.setActiveDocument(savedDocument);
} else if (document.isNew) {
this.props.history.push(documentEditPath(savedDocument));
this.props.history.push({
pathname: documentEditPath(savedDocument),
state: { sidebarContext: this.props.location.state?.sidebarContext },
});
this.props.ui.setActiveDocument(savedDocument);
}
} catch (err) {
@@ -396,7 +412,10 @@ class DocumentScene extends React.Component<Props> {
goBack = () => {
if (!this.props.readOnly) {
this.props.history.push(this.props.document.url);
this.props.history.push({
pathname: this.props.document.url,
state: { sidebarContext: this.props.location.state?.sidebarContext },
});
}
};
@@ -563,7 +582,7 @@ class DocumentScene extends React.Component<Props> {
canUpdate={abilities.update}
canComment={abilities.comment}
>
{shareId && (
{shareId ? (
<ReferencesWrapper>
<PublicReferences
shareId={shareId}
@@ -571,15 +590,11 @@ class DocumentScene extends React.Component<Props> {
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
)}
{!isShare && !revision && (
<>
<MarkAsViewed document={document} />
<ReferencesWrapper>
<References document={document} />
</ReferencesWrapper>
</>
)}
) : !revision ? (
<ReferencesWrapper>
<References document={document} />
</ReferencesWrapper>
) : null}
</Editor>
</MeasuredContainer>
</>
@@ -11,6 +11,7 @@ import Revision from "~/models/Revision";
import DocumentMeta from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
@@ -27,6 +28,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const { views, comments, ui } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const sidebarContext = useLocationSidebarContext();
const team = useCurrentTeam();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
@@ -45,7 +47,10 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
<>
&nbsp;&nbsp;
<CommentLink
to={documentPath(document)}
to={{
pathname: documentPath(document),
state: { sidebarContext },
}}
onClick={() => ui.toggleComments()}
>
<CommentIcon size={18} />
@@ -62,9 +67,13 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
<Wrapper>
&nbsp;&nbsp;
<Link
to={
match.url === insightsPath ? documentPath(document) : insightsPath
}
to={{
pathname:
match.url === insightsPath
? documentPath(document)
: insightsPath,
state: { sidebarContext },
}}
>
{t("Viewed by")}{" "}
{onlyYou
@@ -15,6 +15,7 @@ import {
getCurrentDateTimeAsString,
getCurrentTimeAsString,
} from "@shared/utils/date";
import { isModKey } from "@shared/utils/keyboard";
import { DocumentValidation } from "@shared/validations";
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
@@ -22,7 +23,6 @@ import Icon, { IconTitleWrapper } from "~/components/Icon";
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { isModKey } from "~/utils/keyboard";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
+18 -11
View File
@@ -26,6 +26,7 @@ import SmartText from "~/editor/extensions/SmartText";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
@@ -82,6 +83,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const user = useCurrentUser({ rejectOnEmpty: false });
const team = useCurrentTeam({ rejectOnEmpty: false });
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const params = useQuery();
const {
document,
@@ -113,12 +115,15 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
history.replace({
search: focusedComment.isResolved ? "resolved=" : "",
pathname: location.pathname,
state: { commentId: focusedComment.id },
state: {
commentId: focusedComment.id,
sidebarContext,
},
});
}
ui.set({ commentsExpanded: true });
}
}, [focusedComment, ui, document.id, history, params]);
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
// Save document when blurring title, but delay so that if clicking on a
// button this is allowed to execute first.
@@ -143,10 +148,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
(commentId: string) => {
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId },
state: { commentId, sidebarContext },
});
},
[history]
[history, sidebarContext]
);
// Create a Comment model in local store when a comment mark is created, this
@@ -171,10 +176,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId },
state: { commentId, sidebarContext },
});
},
[comments, user?.id, props.id, history]
[comments, user?.id, props.id, history, sidebarContext]
);
// Soft delete the Comment model when associated mark is totally removed.
@@ -238,11 +243,13 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
{!shareId && (
<DocumentMeta
document={document}
to={
match.path === matchDocumentHistory
? documentPath(document)
: documentHistoryPath(document)
}
to={{
pathname:
match.path === matchDocumentHistory
? documentPath(document)
: documentHistoryPath(document),
state: { sidebarContext },
}}
rtl={
titleRef.current?.getComputedDirection() === "rtl" ? true : false
}
+25 -19
View File
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { NavigationNode } from "@shared/types";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import { Theme } from "~/stores/UiStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
@@ -30,10 +31,12 @@ import { publishDocument } from "~/actions/definitions/documents";
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
import { restoreRevision } from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import useComponentSize from "~/hooks/useComponentSize";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useEditingFocus from "~/hooks/useEditingFocus";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -41,7 +44,6 @@ import DocumentMenu from "~/menus/DocumentMenu";
import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
import TemplatesMenu from "~/menus/TemplatesMenu";
import { altDisplay, metaDisplay } from "~/utils/keyboard";
import { documentEditPath } from "~/utils/routeHelpers";
import ObservingBanner from "./ObservingBanner";
import PublicBreadcrumb from "./PublicBreadcrumb";
@@ -86,11 +88,14 @@ function DocumentHeader({
const team = useCurrentTeam({ rejectOnEmpty: false });
const user = useCurrentUser({ rejectOnEmpty: false });
const { resolvedTheme } = ui;
const isMobile = useMobile();
const isMobileMedia = useMobile();
const isRevision = !!revision;
const isEditingFocus = useEditingFocus();
const { editor } = useDocumentContext();
const { hasHeadings } = useDocumentContext();
const { hasHeadings, editor } = useDocumentContext();
const sidebarContext = useLocationSidebarContext();
const ref = React.useRef<HTMLDivElement | null>(null);
const size = useComponentSize(ref);
const isMobile = isMobileMedia || size.width < 700;
// We cache this value for as long as the component is mounted so that if you
// apply a template there is still the option to replace it until the user
@@ -129,8 +134,7 @@ function DocumentHeader({
? t("Show contents")
: `${t("Show contents")} (${t("available when headings are added")})`
}
shortcut={`ctrl+${altDisplay}+h`}
delay={250}
shortcut={`Ctrl+${altDisplay}+h`}
placement="bottom"
>
<Button
@@ -148,13 +152,15 @@ function DocumentHeader({
noun: document.noun,
})}
shortcut="e"
delay={500}
placement="bottom"
>
<Button
as={Link}
icon={<EditIcon />}
to={documentEditPath(document)}
to={{
pathname: documentEditPath(document),
state: { sidebarContext },
}}
neutral
>
{isMobile ? null : t("Edit")}
@@ -168,7 +174,6 @@ function DocumentHeader({
content={
resolvedTheme === "light" ? t("Switch to dark") : t("Switch to light")
}
delay={500}
placement="bottom"
>
<Button
@@ -194,6 +199,7 @@ function DocumentHeader({
if (shareId) {
return (
<StyledHeader
ref={ref}
$hidden={isEditingFocus}
title={
<Flex gap={4}>
@@ -230,6 +236,7 @@ function DocumentHeader({
return (
<>
<StyledHeader
ref={ref}
$hidden={isEditingFocus}
hasSidebar
left={
@@ -254,7 +261,7 @@ function DocumentHeader({
{document.isArchived && <Badge>{t("Archived")}</Badge>}
</Flex>
}
actions={
actions={({ isCompact }) => (
<>
<ObservingBanner />
@@ -262,11 +269,15 @@ function DocumentHeader({
<Status>{t("Saving")}</Status>
)}
{!isDeleted && !isRevision && can.listViews && (
<Collaborators document={document} />
<Collaborators
document={document}
limit={isCompact ? 3 : undefined}
/>
)}
{(isEditing || !user?.separateEditMode) && !isTemplate && isNew && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document}
onSelectTemplate={onSelectTemplate}
/>
@@ -282,7 +293,6 @@ function DocumentHeader({
<Tooltip
content={t("Save")}
shortcut={`${metaDisplay}+enter`}
delay={500}
placement="bottom"
>
<Button
@@ -306,6 +316,7 @@ function DocumentHeader({
{can.update &&
can.createChildDocument &&
!isRevision &&
!isCompact &&
!isMobile && (
<Action>
<NewChildDocumentMenu
@@ -314,7 +325,6 @@ function DocumentHeader({
<Tooltip
content={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button icon={<PlusIcon />} {...props} neutral>
@@ -327,11 +337,7 @@ function DocumentHeader({
)}
{revision && revision.createdAt !== document.updatedAt && (
<Action>
<Tooltip
content={t("Restore version")}
delay={500}
placement="bottom"
>
<Tooltip content={t("Restore version")} placement="bottom">
<Button
action={restoreRevision}
context={context}
@@ -377,7 +383,7 @@ function DocumentHeader({
/>
</Action>
</>
}
)}
/>
</>
);
+6 -1
View File
@@ -9,6 +9,7 @@ import Event from "~/models/Event";
import Empty from "~/components/Empty";
import PaginatedEventList from "~/components/PaginatedEventList";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
@@ -20,6 +21,7 @@ function History() {
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
@@ -28,7 +30,10 @@ function History() {
const onCloseHistory = () => {
if (document) {
history.push(documentPath(document));
history.push({
pathname: documentPath(document),
state: { sidebarContext },
});
} else {
history.goBack();
}
+6 -1
View File
@@ -16,6 +16,7 @@ import PaginatedList from "~/components/PaginatedList";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useTextSelection from "~/hooks/useTextSelection";
@@ -28,6 +29,7 @@ function Insights() {
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const selectedText = useTextSelection();
const document = documents.getByUrl(match.params.documentSlug);
const { editor } = useDocumentContext();
@@ -38,7 +40,10 @@ function Insights() {
const onCloseInsights = () => {
if (document) {
history.push(documentPath(document));
history.push({
pathname: documentPath(document),
state: { sidebarContext },
});
}
};
@@ -23,7 +23,7 @@ function KeyboardShortcutsButton() {
};
return (
<Tooltip content={t("Keyboard shortcuts")} shortcut="?" delay={500}>
<Tooltip content={t("Keyboard shortcuts")} shortcut="?">
<Button onClick={handleOpenKeyboardShortcuts} $hidden={isEditingFocus}>
<KeyboardIcon />
</Button>
@@ -9,6 +9,7 @@ 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";
@@ -17,6 +18,7 @@ type Props = {
document: Document | NavigationNode;
anchor?: string;
showCollection?: boolean;
sidebarContext?: SidebarContextType;
};
const DocumentLink = styled(Link)`
@@ -57,6 +59,7 @@ function ReferenceListItem({
showCollection,
anchor,
shareId,
sidebarContext,
...rest
}: Props) {
const { icon, color } = document;
@@ -73,6 +76,7 @@ function ReferenceListItem({
hash: anchor ? `d-${anchor}` : undefined,
state: {
title: document.title,
sidebarContext,
},
}}
{...rest}
+25 -2
View File
@@ -5,8 +5,11 @@ import { useLocation } from "react-router-dom";
import styled from "styled-components";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import { determineSidebarContext } from "~/components/Sidebar/components/SidebarContext";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import ReferenceListItem from "./ReferenceListItem";
@@ -16,7 +19,9 @@ type Props = {
function References({ document }: Props) {
const { collections, documents } = useStores();
const user = useCurrentUser();
const location = useLocation();
const locationSidebarContext = useLocationSidebarContext();
React.useEffect(() => {
void documents.fetchBacklinks(document.id);
@@ -40,12 +45,24 @@ function References({ document }: Props) {
<Component>
<Tabs>
{showChildDocuments && (
<Tab to="#children" isActive={() => !isBacklinksTab}>
<Tab
to={{
hash: "#children",
state: { sidebarContext: locationSidebarContext },
}}
isActive={() => !isBacklinksTab}
>
<Trans>Documents</Trans>
</Tab>
)}
{showBacklinks && (
<Tab to="#backlinks" isActive={() => isBacklinksTab}>
<Tab
to={{
hash: "#backlinks",
state: { sidebarContext: locationSidebarContext },
}}
isActive={() => isBacklinksTab}
>
<Trans>Backlinks</Trans>
</Tab>
)}
@@ -61,6 +78,11 @@ function References({ document }: Props) {
showCollection={
backlinkedDocument.collectionId !== document.collectionId
}
sidebarContext={determineSidebarContext({
document: backlinkedDocument,
user,
currentContext: locationSidebarContext,
})}
/>
))}
</List>
@@ -76,6 +98,7 @@ function References({ document }: Props) {
key={node.id}
document={document || node}
showCollection={false}
sidebarContext={locationSidebarContext}
/>
);
})}
@@ -32,7 +32,7 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
<>
<Header>
<Title>{title}</Title>
<Tooltip content={t("Close")} shortcut="Esc" delay={500}>
<Tooltip content={t("Close")} shortcut="Esc">
<Button
icon={<ForwardIcon />}
onClick={onClose}
+14 -1
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import { StaticContext } from "react-router";
import { StaticContext, useHistory } from "react-router";
import { RouteComponentProps } from "react-router-dom";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import useStores from "~/hooks/useStores";
import DataLoader from "./components/DataLoader";
@@ -16,12 +17,14 @@ type LocationState = {
title?: string;
restore?: boolean;
revisionId?: string;
sidebarContext?: SidebarContextType;
};
type Props = RouteComponentProps<Params, StaticContext, LocationState>;
export default function DocumentScene(props: Props) {
const { ui } = useStores();
const history = useHistory();
const { documentSlug, revisionId } = props.match.params;
const currentPath = props.location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
@@ -32,6 +35,16 @@ export default function DocumentScene(props: Props) {
React.useEffect(() => () => ui.clearActiveDocument(), [ui]);
React.useEffect(() => {
// When opening a document directly on app load, sidebarContext will not be set.
if (!props.location.state?.sidebarContext) {
history.replace({
...props.location,
state: { ...props.location.state, sidebarContext: "collections" }, // optimistic preference of "collections"
});
}
}, [props.location, history]);
// the urlId portion of the url does not include the slugified title
// we only want to force a re-mount of the document component when the
// document changes, not when the title does so only this portion is used
+1 -1
View File
@@ -3,10 +3,10 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { isMac } from "@shared/utils/browser";
import { metaDisplay, altDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import Key from "~/components/Key";
import { metaDisplay, altDisplay } from "~/utils/keyboard";
function KeyboardShortcuts() {
const { t } = useTranslation();
@@ -17,7 +17,7 @@ export function DocumentFilter(props: Props) {
return (
<div>
<Tooltip content={t("Remove document filter")} delay={350}>
<Tooltip content={t("Remove document filter")}>
<StyledButton onClick={props.onClick} icon={<CloseIcon />} neutral>
{props.document.title}
</StyledButton>
@@ -33,7 +33,7 @@ function RecentSearchListItem({ searchQuery }: Props) {
{...rovingTabIndex}
>
{searchQuery.query}
<Tooltip content={t("Remove search")} delay={150}>
<Tooltip content={t("Remove search")}>
<RemoveButton
aria-label={t("Remove search")}
onClick={async (ev) => {
+14 -12
View File
@@ -6,6 +6,7 @@ import {
CollectionIcon,
CommentIcon,
DocumentIcon,
DoneIcon,
EditIcon,
EmailIcon,
PublishIcon,
@@ -18,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 = [
{
@@ -65,6 +69,14 @@ function Notifications() {
"Receive a notification when someone mentions you in a document or comment"
),
},
{
event: NotificationEventType.ResolveComment,
icon: <DoneIcon />,
title: t("Resolved"),
description: t(
"Receive a notification when a comment thread you were involved in is resolved"
),
},
{
event: NotificationEventType.CreateCollection,
icon: <CollectionIcon />,
@@ -152,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>
+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 {
+13 -13
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",
@@ -124,8 +124,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,7 +150,7 @@
"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",
@@ -179,12 +179,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",
@@ -223,7 +223,7 @@
"sequelize-typescript": "^2.1.6",
"slug": "^5.3.0",
"slugify": "^1.6.6",
"socket.io": "^4.7.5",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.0",
"socket.io-redis": "^6.1.1",
"sonner": "^1.0.3",
@@ -237,7 +237,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",
@@ -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 = {
+4 -4
View File
@@ -16,7 +16,7 @@ describe("documentDuplicator", () => {
document: original,
collection: original.collection,
user,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
@@ -42,7 +42,7 @@ describe("documentDuplicator", () => {
collection: original.collection,
title: "New title",
user,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
@@ -75,7 +75,7 @@ describe("documentDuplicator", () => {
collection: original.collection,
user,
recursive: true,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
@@ -95,7 +95,7 @@ describe("documentDuplicator", () => {
collection: original.collection,
publish: false,
user,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
+13 -13
View File
@@ -23,7 +23,7 @@ describe("documentImporter", () => {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
const attachments = await Attachment.count({
@@ -49,7 +49,7 @@ describe("documentImporter", () => {
mimeType: "application/octet-stream",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
const attachments = await Attachment.count({
@@ -78,7 +78,7 @@ describe("documentImporter", () => {
mimeType: "application/octet-stream",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
} catch (err) {
@@ -100,7 +100,7 @@ describe("documentImporter", () => {
mimeType: "application/octet-stream",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
const attachments = await Attachment.count({
@@ -127,7 +127,7 @@ describe("documentImporter", () => {
mimeType: "text/html",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
expect(response.text).toContain("Text paragraph");
@@ -146,7 +146,7 @@ describe("documentImporter", () => {
mimeType: "application/msword",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
@@ -167,7 +167,7 @@ describe("documentImporter", () => {
mimeType: "text/plain",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
expect(response.text).toContain("This is a test paragraph");
@@ -184,7 +184,7 @@ describe("documentImporter", () => {
mimeType: "text/plain",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
@@ -205,7 +205,7 @@ describe("documentImporter", () => {
mimeType: "application/lol",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
expect(response.text).toContain("This is a test paragraph");
@@ -227,7 +227,7 @@ describe("documentImporter", () => {
mimeType: "executable/zip",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
} catch (err) {
@@ -257,7 +257,7 @@ describe("documentImporter", () => {
mimeType: "text/html",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
expect(response.text).toEqual("\\$100");
@@ -283,7 +283,7 @@ describe("documentImporter", () => {
mimeType: "text/html",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
expect(response.text).toEqual("`echo $foo`");
@@ -309,7 +309,7 @@ describe("documentImporter", () => {
mimeType: "text/html",
fileName,
content,
ctx: createContext(user, transaction),
ctx: createContext({ user, transaction }),
})
);
expect(response.text).toEqual("```\necho $foo\n```");
+1 -5
View File
@@ -1,5 +1,4 @@
import emojiRegex from "emoji-regex";
import escapeRegExp from "lodash/escapeRegExp";
import truncate from "lodash/truncate";
import parseTitle from "@shared/utils/parseTitle";
import { DocumentValidation } from "@shared/validations";
@@ -51,10 +50,7 @@ async function documentImporter({
if (text.trim().startsWith("# ")) {
const result = parseTitle(text);
title = result.title;
text = text
.trim()
.replace(new RegExp(`#\\s+${escapeRegExp(title)}`), "")
.trimStart();
text = text.replace(/^.+(\n|$)/, "");
}
// Replace any <br> generated by the turndown plugin with escaped newlines
+12 -8
View File
@@ -171,15 +171,19 @@ export default async function loadDocument({
throw AuthorizationError();
}
const childDocumentIds =
(await share.document?.findAllChildDocumentIds({
archivedAt: {
[Op.is]: null,
},
})) ?? [];
// If the document is not a direct child of the shared document then we
// need to check if it is nested within the shared document somewhere.
if (document.parentDocumentId !== share.documentId) {
const childDocumentIds =
(await share.document?.findAllChildDocumentIds({
archivedAt: {
[Op.is]: null,
},
})) ?? [];
if (!childDocumentIds.includes(document.id)) {
throw AuthorizationError();
if (!childDocumentIds.includes(document.id)) {
throw AuthorizationError();
}
}
}
-39
View File
@@ -1,39 +0,0 @@
import type { Transaction } from "sequelize";
import { Event, Group, type User } from "@server/models";
type Props = {
name: string;
actor: User;
ip: string;
transaction?: Transaction;
};
export default async function groupCreator({
name,
actor,
ip,
transaction,
}: Props): Promise<Group> {
const group = await Group.create(
{
name,
teamId: actor.teamId,
createdById: actor.id,
},
{ transaction }
);
await Event.create(
{
name: "groups.create",
modelId: group.id,
teamId: actor.teamId,
actorId: actor.id,
data: {
name: group.name,
},
ip,
},
{ transaction }
);
return group;
}
-31
View File
@@ -1,31 +0,0 @@
import type { Transaction } from "sequelize";
import { Event, type Group, type User } from "@server/models";
type Props = {
group: Group;
actor: User;
ip: string;
transaction?: Transaction;
};
export default async function groupDestroyer({
group,
actor,
ip,
transaction,
}: Props): Promise<void> {
await group.destroy({ transaction });
await Event.create(
{
name: "groups.delete",
modelId: group.id,
teamId: actor.teamId,
actorId: actor.id,
data: {
name: group.name,
},
ip,
},
{ transaction }
);
}
-39
View File
@@ -1,39 +0,0 @@
import type { Transaction } from "sequelize";
import { Event, type Group, type User } from "@server/models";
type Props = {
group: Group;
name: string;
actor: User;
ip: string;
transaction?: Transaction;
};
export default async function groupUpdater({
group,
name,
actor,
ip,
transaction,
}: Props): Promise<Group> {
group.name = name;
if (group.changed()) {
await group.save({ transaction });
await Event.create(
{
name: "groups.update",
modelId: group.id,
teamId: actor.teamId,
actorId: actor.id,
data: {
name: group.name,
},
ip,
},
{ transaction }
);
}
return group;
}
+12 -40
View File
@@ -1,8 +1,8 @@
import { createContext } from "@server/context";
import { Subscription, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
import { buildDocument, buildUser } from "@server/test/factories";
import subscriptionCreator from "./subscriptionCreator";
import subscriptionDestroyer from "./subscriptionDestroyer";
describe("subscriptionCreator", () => {
const ip = "127.0.0.1";
@@ -18,11 +18,9 @@ describe("subscriptionCreator", () => {
const subscription = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
user,
ctx: createContext({ user, transaction, ip }),
documentId: document.id,
event: subscribedEvent,
ip,
transaction,
})
);
@@ -57,11 +55,9 @@ describe("subscriptionCreator", () => {
const subscription1 = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
user,
ctx: createContext({ user, transaction, ip }),
documentId: document.id,
event: subscribedEvent,
ip,
transaction,
})
);
@@ -92,21 +88,14 @@ describe("subscriptionCreator", () => {
const subscription0 = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
user,
ctx: createContext({ user, transaction, ip }),
documentId: document.id,
event: subscribedEvent,
ip,
transaction,
})
);
await sequelize.transaction(async (transaction) =>
subscriptionDestroyer({
user,
subscription: subscription0,
ip,
transaction,
})
subscription0.destroyWithCtx(createContext({ user, transaction, ip }))
);
expect(subscription0.id).toBeDefined();
@@ -116,11 +105,9 @@ describe("subscriptionCreator", () => {
const subscription1 = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
user,
ctx: createContext({ user, transaction, ip }),
documentId: document.id,
event: subscribedEvent,
ip,
transaction,
})
);
@@ -153,21 +140,17 @@ describe("subscriptionCreator", () => {
const subscription0 = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
user,
ctx: createContext({ user, transaction, ip }),
documentId: document.id,
event: subscribedEvent,
ip,
transaction,
})
);
const subscription1 = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
user,
ctx: createContext({ user, transaction, ip }),
documentId: document.id,
event: subscribedEvent,
ip,
transaction,
})
);
@@ -198,21 +181,14 @@ describe("subscriptionCreator", () => {
const subscription0 = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
user,
ctx: createContext({ user, transaction, ip }),
documentId: document.id,
event: subscribedEvent,
ip,
transaction,
})
);
await sequelize.transaction(async (transaction) =>
subscriptionDestroyer({
user,
subscription: subscription0,
ip,
transaction,
})
subscription0.destroyWithCtx(createContext({ user, transaction, ip }))
);
expect(subscription0.id).toBeDefined();
@@ -222,11 +198,9 @@ describe("subscriptionCreator", () => {
const subscription1 = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
user,
ctx: createContext({ user, transaction, ip }),
documentId: document.id,
event: subscribedEvent,
ip,
transaction,
})
);
@@ -265,11 +239,9 @@ describe("subscriptionCreator", () => {
const subscription0 = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
user,
ctx: createContext({ user, transaction, ip }),
documentId: document.id,
event: subscribedEvent,
ip,
transaction,
})
);

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