From ac1120914a81d06873488c87aad4a718b3512529 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 24 Dec 2020 13:28:08 -0800 Subject: [PATCH 001/109] fix: Unable to delete archived and templated documents (#1749) closes #1746 --- server/models/Document.js | 13 +++++++++---- server/models/Document.test.js | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/server/models/Document.js b/server/models/Document.js index 6d5700cf1..71900e769 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -650,8 +650,10 @@ Document.prototype.delete = function (userId: string) { async (transaction: Transaction): Promise => { if (!this.archivedAt && !this.template) { // delete any children and remove from the document structure - const collection = await this.getCollection(); + const collection = await this.getCollection({ transaction }); if (collection) await collection.deleteDocument(this, { transaction }); + } else { + await this.destroy({ transaction }); } await Revision.destroy({ @@ -659,10 +661,13 @@ Document.prototype.delete = function (userId: string) { transaction, }); - this.lastModifiedById = userId; - this.deletedAt = new Date(); + await this.update( + { lastModifiedById: userId }, + { + transaction, + } + ); - await this.save({ transaction }); return this; } ); diff --git a/server/models/Document.test.js b/server/models/Document.test.js index 6ecdb7747..85527316b 100644 --- a/server/models/Document.test.js +++ b/server/models/Document.test.js @@ -279,4 +279,25 @@ describe("#delete", () => { expect(document.lastModifiedById).toBe(user.id); expect(document.deletedAt).toBeTruthy(); }); + + test("should soft delete templates", async () => { + let document = await buildDocument({ template: true }); + let user = await buildUser(); + + await document.delete(user.id); + + document = await Document.findByPk(document.id, { paranoid: false }); + expect(document.lastModifiedById).toBe(user.id); + expect(document.deletedAt).toBeTruthy(); + }); + test("should soft delete archived", async () => { + let document = await buildDocument({ archivedAt: new Date() }); + let user = await buildUser(); + + await document.delete(user.id); + + document = await Document.findByPk(document.id, { paranoid: false }); + expect(document.lastModifiedById).toBe(user.id); + expect(document.deletedAt).toBeTruthy(); + }); }); From b6ab816bb37db78b76a837de12464fc474b2b244 Mon Sep 17 00:00:00 2001 From: Malek Hijazi Date: Sat, 26 Dec 2020 01:23:55 +0200 Subject: [PATCH 002/109] feat: command to upgrade outline (#1727) * Add upgrade script to package.json * Update the docs to include docker and yarn guides --- README.md | 14 ++++++++++++++ package.json | 1 + 2 files changed, 15 insertions(+) diff --git a/README.md b/README.md index 40e04a04b..83a103760 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,20 @@ In development you can quickly get an environment running using Docker by follow 1. Ensure that the bot token scope contains at least `users:read` 1. Run `make up`. This will download dependencies, build and launch a development version of Outline +### Upgrade + +#### Docker + +If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like: +``` +docker run --rm outlinewiki/outline:latest yarn sequelize:migrate +``` +#### Yarn + +If you're running Outline by cloning this repository, run the following command to upgrade: +``` +yarn upgrade +``` ## Development diff --git a/package.json b/package.json index e2ed4fe9d..7d0c7513e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "heroku-postbuild": "yarn build && yarn sequelize:migrate", "sequelize:create-migration": "sequelize migration:create", "sequelize:migrate": "sequelize db:migrate", + "upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild", "test": "yarn test:app && yarn test:server", "test:app": "jest", "test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit", From 89903b4bbec987a57a8d1cd53bb5d1b24e82bdec Mon Sep 17 00:00:00 2001 From: Gustavo Maronato Date: Tue, 29 Dec 2020 02:08:10 -0300 Subject: [PATCH 003/109] feat: Compress avatar images before upload (#1751) * compress avatar images before upload * move compressImage to dedicated file * Update ImageUpload.js --- app/scenes/Settings/components/ImageUpload.js | 7 ++++++- app/utils/compressImage.js | 17 +++++++++++++++++ package.json | 1 + yarn.lock | 18 ++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 app/utils/compressImage.js diff --git a/app/scenes/Settings/components/ImageUpload.js b/app/scenes/Settings/components/ImageUpload.js index 5f824bbbf..c4d399719 100644 --- a/app/scenes/Settings/components/ImageUpload.js +++ b/app/scenes/Settings/components/ImageUpload.js @@ -10,6 +10,7 @@ import Button from "components/Button"; import Flex from "components/Flex"; import LoadingIndicator from "components/LoadingIndicator"; import Modal from "components/Modal"; +import { compressImage } from "utils/compressImage"; import { uploadFile, dataUrlToBlob } from "utils/uploadFile"; const EMPTY_OBJECT = {}; @@ -53,7 +54,11 @@ class ImageUpload extends React.Component { const canvas = this.avatarEditorRef.getImage(); const imageBlob = dataUrlToBlob(canvas.toDataURL()); try { - const attachment = await uploadFile(imageBlob, { + const compressed = await compressImage(imageBlob, { + maxHeight: 512, + maxWidth: 512, + }); + const attachment = await uploadFile(compressed, { name: this.file.name, public: true, }); diff --git a/app/utils/compressImage.js b/app/utils/compressImage.js new file mode 100644 index 000000000..cc363a46e --- /dev/null +++ b/app/utils/compressImage.js @@ -0,0 +1,17 @@ +// @flow +import Compressor from "compressorjs"; + +type Options = Omit; + +export const compressImage = async ( + file: File | Blob, + options?: Options +): Promise => { + return new Promise((resolve, reject) => { + new Compressor(file, { + ...options, + success: resolve, + error: reject, + }); + }); +}; diff --git a/package.json b/package.json index 7d0c7513e..450ee6e9e 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "boundless-popover": "^1.0.4", "bull": "^3.5.2", "cancan": "3.1.0", + "compressorjs": "^1.0.7", "copy-to-clipboard": "^3.0.6", "core-js": "2", "date-fns": "1.29.0", diff --git a/yarn.lock b/yarn.lock index 52397eef8..539012a30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2589,6 +2589,11 @@ bluebird@~3.4.0, bluebird@~3.4.1: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" integrity sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM= +blueimp-canvas-to-blob@^3.28.0: + version "3.28.0" + resolved "https://registry.yarnpkg.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.28.0.tgz#c8ab4dc6bb08774a7f273798cdf94b0776adf6c8" + integrity sha512-5q+YHzgGsuHQ01iouGgJaPJXod2AzTxJXmVv90PpGrRxU7G7IqgPqWXz+PBmt3520jKKi6irWbNV87DicEa7wg== + bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: version "4.11.9" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" @@ -3470,6 +3475,14 @@ compressible@^2.0.0: dependencies: mime-db ">= 1.43.0 < 2" +compressorjs@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/compressorjs/-/compressorjs-1.0.7.tgz#67cd0b3b9ac97540515b22b334dc32eb92b730b1" + integrity sha512-ca+H8CGrn0LG103//VQmXBbNdvzvHiW26LGdWncp4RmLNbNQjaaFWIUxMN9++hbhGobLtofkHoxzzXGisNyD3w== + dependencies: + blueimp-canvas-to-blob "^3.28.0" + is-blob "^2.1.0" + compute-scroll-into-view@^1.0.16: version "1.0.16" resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088" @@ -6275,6 +6288,11 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" +is-blob@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-blob/-/is-blob-2.1.0.tgz#e36cd82c90653f1e1b930f11baf9c64216a05385" + integrity sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw== + is-buffer@^1.1.5, is-buffer@~1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" From 37f2cc8d55eeea2df8714534529b7302ff5c5806 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Mon, 28 Dec 2020 21:35:13 -0800 Subject: [PATCH 004/109] closes #1752 --- app/menus/NewChildDocumentMenu.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/menus/NewChildDocumentMenu.js b/app/menus/NewChildDocumentMenu.js index 675dd173f..5b0f11c58 100644 --- a/app/menus/NewChildDocumentMenu.js +++ b/app/menus/NewChildDocumentMenu.js @@ -51,9 +51,11 @@ class NewChildDocumentMenu extends React.Component { items={[ { title: ( - - New document in {{ collectionName }} - + + + New document in {{ collectionName }} + + ), onClick: this.handleNewDocument, }, From d4bb04e921ee5239c9ff00ec89bb292d62f951d5 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 29 Dec 2020 10:32:09 -0800 Subject: [PATCH 005/109] fix: Handle linked documents destroyed when document is published closes #1739 --- server/services/backlinks.js | 4 ++- server/services/backlinks.test.js | 50 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/server/services/backlinks.js b/server/services/backlinks.js index 74c915b5d..4a3a0da59 100644 --- a/server/services/backlinks.js +++ b/server/services/backlinks.js @@ -17,7 +17,9 @@ export default class Backlinks { await Promise.all( linkIds.map(async (linkId) => { const linkedDocument = await Document.findByPk(linkId); - if (linkedDocument.id === event.documentId) return; + if (!linkedDocument || linkedDocument.id === event.documentId) { + return; + } await Backlink.findOrCreate({ where: { diff --git a/server/services/backlinks.test.js b/server/services/backlinks.test.js index 83c09f670..8487f81ea 100644 --- a/server/services/backlinks.test.js +++ b/server/services/backlinks.test.js @@ -9,6 +9,56 @@ const Backlinks = new BacklinksService(); beforeEach(() => flushdb()); beforeEach(jest.resetAllMocks); +describe("documents.publish", () => { + test("should create new backlink records", async () => { + const otherDocument = await buildDocument(); + const document = await buildDocument({ + text: `[this is a link](${otherDocument.url})`, + }); + + await Backlinks.on({ + name: "documents.publish", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: document.createdById, + }); + + const backlinks = await Backlink.findAll({ + where: { reverseDocumentId: document.id }, + }); + + expect(backlinks.length).toBe(1); + }); + + test("should not fail when linked document is destroyed", async () => { + const otherDocument = await buildDocument(); + await otherDocument.destroy(); + + const document = await buildDocument({ + version: null, + text: `[ ] checklist item`, + }); + + document.text = `[this is a link](${otherDocument.url})`; + await document.save(); + + await Backlinks.on({ + name: "documents.publish", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: document.createdById, + }); + + const backlinks = await Backlink.findAll({ + where: { reverseDocumentId: document.id }, + }); + + expect(backlinks.length).toBe(0); + }); +}); + describe("documents.update", () => { test("should not fail on a document with no previous revisions", async () => { const otherDocument = await buildDocument(); From 40bd9aed0a865fc1769324fb7b5cb1bcc2b17a1b Mon Sep 17 00:00:00 2001 From: Clifton Cunningham Date: Wed, 30 Dec 2020 18:35:18 +0100 Subject: [PATCH 006/109] fix: miro - use the incoming domain to ensure access to logged in boards works (#1756) --- app/embeds/Miro.js | 10 ++++++---- app/embeds/Miro.test.js | 6 ++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/embeds/Miro.js b/app/embeds/Miro.js index 51e7ccb2d..cdc8d91be 100644 --- a/app/embeds/Miro.js +++ b/app/embeds/Miro.js @@ -2,7 +2,7 @@ import * as React from "react"; import Frame from "./components/Frame"; -const URL_REGEX = /^https:\/\/(?:realtimeboard|miro).com\/app\/board\/(.*)$/; +const URL_REGEX = /^https:\/\/(realtimeboard|miro).com\/app\/board\/(.*)$/; type Props = {| attrs: {| @@ -16,13 +16,15 @@ export default class RealtimeBoard extends React.Component { render() { const { matches } = this.props.attrs; - const boardId = matches[1]; + const domain = matches[1]; + const boardId = matches[2]; + const titleName = domain === "realtimeboard" ? "RealtimeBoard" : "Miro"; return ( ); } diff --git a/app/embeds/Miro.test.js b/app/embeds/Miro.test.js index f87cb6c19..5e65d58d9 100644 --- a/app/embeds/Miro.test.js +++ b/app/embeds/Miro.test.js @@ -13,6 +13,12 @@ describe("Miro", () => { expect("https://miro.com/app/board/o9J_k0fwiss=".match(match)).toBeTruthy(); }); + test("to extract the domain as part of the match for later use", () => { + expect( + "https://realtimeboard.com/app/board/o9J_k0fwiss=".match(match)[1] + ).toBe("realtimeboard"); + }); + test("to not be enabled elsewhere", () => { expect("https://miro.com".match(match)).toBe(null); expect("https://realtimeboard.com".match(match)).toBe(null); From 8dba32b5e06e612e85fcc272bef2d8859354c786 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 30 Dec 2020 09:35:33 -0800 Subject: [PATCH 007/109] fix: Meta key shortcuts not bound correctly in Windows browsers (#1753) --- app/components/Editor.js | 3 ++- app/components/InputSearch.js | 3 ++- app/components/Layout.js | 5 ++-- app/scenes/Document/components/Document.js | 5 ++-- app/scenes/Document/components/Editor.js | 7 ++--- app/scenes/Document/components/Header.js | 8 +++--- app/scenes/KeyboardShortcuts.js | 30 ++++++++++++---------- app/scenes/Search/Search.js | 6 ++--- app/utils/keyboard.js | 9 ++++++- shared/i18n/locales/en_US/translation.json | 1 + 10 files changed, 47 insertions(+), 30 deletions(-) diff --git a/app/components/Editor.js b/app/components/Editor.js index a683c0dd3..23c3ca941 100644 --- a/app/components/Editor.js +++ b/app/components/Editor.js @@ -9,6 +9,7 @@ import ErrorBoundary from "components/ErrorBoundary"; import Tooltip from "components/Tooltip"; import embeds from "../embeds"; import isInternalUrl from "utils/isInternalUrl"; +import { isMetaKey } from "utils/keyboard"; import { uploadFile } from "utils/uploadFile"; const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor")); @@ -49,7 +50,7 @@ function Editor(props: PropsWithRef) { return; } - if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) { + if (isInternalUrl(href) && !isMetaKey(event) && !event.shiftKey) { // relative let navigateTo = href; diff --git a/app/components/InputSearch.js b/app/components/InputSearch.js index 8cb37273d..a6ede2a57 100644 --- a/app/components/InputSearch.js +++ b/app/components/InputSearch.js @@ -9,6 +9,7 @@ import { withRouter, type RouterHistory } from "react-router-dom"; import styled, { withTheme } from "styled-components"; import Input from "./Input"; import { type Theme } from "types"; +import { meta } from "utils/keyboard"; import { searchUrl } from "utils/routeHelpers"; type Props = { @@ -25,7 +26,7 @@ class InputSearch extends React.Component { input: ?Input; @observable focused: boolean = false; - @keydown("meta+f") + @keydown(`${meta}+f`) focus(ev: SyntheticEvent<>) { ev.preventDefault(); diff --git a/app/components/Layout.js b/app/components/Layout.js index 6e8dab13f..3a0210a7c 100644 --- a/app/components/Layout.js +++ b/app/components/Layout.js @@ -22,6 +22,7 @@ import Modal from "components/Modal"; import Sidebar from "components/Sidebar"; import SettingsSidebar from "components/Sidebar/Settings"; import { type Theme } from "types"; +import { meta } from "utils/keyboard"; import { homeUrl, searchUrl, @@ -65,7 +66,7 @@ class Layout extends React.Component { window.document.body.style.background = props.theme.background; } - @keydown("meta+.") + @keydown(`${meta}+.`) handleToggleSidebar() { this.props.ui.toggleCollapsedSidebar(); } @@ -80,7 +81,7 @@ class Layout extends React.Component { this.keyboardShortcutsOpen = false; }; - @keydown(["t", "/", "meta+k"]) + @keydown(["t", "/", `${meta}+k`]) goToSearch(ev: SyntheticEvent<>) { if (this.props.ui.editMode) return; ev.preventDefault(); diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js index abccfb43f..1d2376443 100644 --- a/app/scenes/Document/components/Document.js +++ b/app/scenes/Document/components/Document.js @@ -33,6 +33,7 @@ import References from "./References"; import { type LocationWithState, type Theme } from "types"; import { isCustomDomain } from "utils/domains"; import { emojiToUrl } from "utils/emoji"; +import { meta } from "utils/keyboard"; import { collectionUrl, documentMoveUrl, @@ -163,7 +164,7 @@ class DocumentScene extends React.Component { } } - @keydown("meta+shift+p") + @keydown(`${meta}+shift+p`) onPublish(ev) { ev.preventDefault(); const { document } = this.props; @@ -171,7 +172,7 @@ class DocumentScene extends React.Component { this.onSave({ publish: true, done: true }); } - @keydown("meta+ctrl+h") + @keydown(`${meta}+ctrl+h`) onToggleTableOfContents(ev) { if (!this.props.readOnly) return; diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index 62415b4ea..e06dc0918 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -12,6 +12,7 @@ import DocumentMetaWithViews from "components/DocumentMetaWithViews"; import Editor from "components/Editor"; import Flex from "components/Flex"; import HoverPreview from "components/HoverPreview"; +import { isMetaKey } from "utils/keyboard"; import { documentHistoryUrl } from "utils/routeHelpers"; type Props = { @@ -53,7 +54,7 @@ class DocumentEditor extends React.Component { handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => { if (event.key === "Enter") { event.preventDefault(); - if (event.metaKey) { + if (isMetaKey(event)) { this.props.onSave({ done: true }); return; } @@ -67,12 +68,12 @@ class DocumentEditor extends React.Component { this.focusAtStart(); return; } - if (event.key === "p" && event.metaKey && event.shiftKey) { + if (event.key === "p" && isMetaKey(event) && event.shiftKey) { event.preventDefault(); this.props.onSave({ publish: true, done: true }); return; } - if (event.key === "s" && event.metaKey) { + if (event.key === "s" && isMetaKey(event)) { event.preventDefault(); this.props.onSave({}); return; diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index f9c2c7a31..669ab3f26 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -34,7 +34,7 @@ import Tooltip from "components/Tooltip"; import DocumentMenu from "menus/DocumentMenu"; import NewChildDocumentMenu from "menus/NewChildDocumentMenu"; import TemplatesMenu from "menus/TemplatesMenu"; -import { meta } from "utils/keyboard"; +import { metaDisplay } from "utils/keyboard"; import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers"; type Props = { @@ -172,7 +172,7 @@ class Header extends React.Component { tooltip={ ui.tocVisible ? t("Hide contents") : t("Show contents") } - shortcut={`ctrl+${meta}+h`} + shortcut={`ctrl+${metaDisplay}+h`} delay={250} placement="bottom" > @@ -250,7 +250,7 @@ class Header extends React.Component { @@ -321,7 +321,7 @@ class Header extends React.Component { diff --git a/app/scenes/KeyboardShortcuts.js b/app/scenes/KeyboardShortcuts.js index cbcbe56f9..a4e56c31d 100644 --- a/app/scenes/KeyboardShortcuts.js +++ b/app/scenes/KeyboardShortcuts.js @@ -5,7 +5,7 @@ import styled from "styled-components"; import Flex from "components/Flex"; import HelpText from "components/HelpText"; import Key from "components/Key"; -import { meta } from "utils/keyboard"; +import { metaDisplay } from "utils/keyboard"; function KeyboardShortcuts() { const { t } = useTranslation(); @@ -41,9 +41,13 @@ function KeyboardShortcuts() { - {meta} + Ctrl + h + {metaDisplay} + Ctrl + h + + {metaDisplay} + . + + ? @@ -53,47 +57,47 @@ function KeyboardShortcuts() {

{t("Editor")}

- {meta} + Enter + {metaDisplay} + Enter - {meta} + Shift + p + {metaDisplay} + Shift + p - {meta} + s + {metaDisplay} + s - {meta} + Esc + {metaDisplay} + Esc - {meta} + b + {metaDisplay} + b - {meta} + i + {metaDisplay} + i - {meta} + u + {metaDisplay} + u - {meta} + d + {metaDisplay} + d - {meta} + k + {metaDisplay} + k - {meta} + z + {metaDisplay} + z - {meta} + Shift + z + {metaDisplay} + Shift + z diff --git a/app/scenes/Search/Search.js b/app/scenes/Search/Search.js index 831c34fc2..b87c16417 100644 --- a/app/scenes/Search/Search.js +++ b/app/scenes/Search/Search.js @@ -35,7 +35,7 @@ import StatusFilter from "./components/StatusFilter"; import UserFilter from "./components/UserFilter"; import NewDocumentMenu from "menus/NewDocumentMenu"; import { type LocationWithState } from "types"; -import { meta } from "utils/keyboard"; +import { metaDisplay } from "utils/keyboard"; import { newDocumentUrl, searchUrl } from "utils/routeHelpers"; type Props = { @@ -279,8 +279,8 @@ class Search extends React.Component { - Use the {{ meta }}+K shortcut to search from - anywhere in your knowledge base + Use the {{ meta: metaDisplay }}+K shortcut to + search from anywhere in your knowledge base diff --git a/app/utils/keyboard.js b/app/utils/keyboard.js index 833738a98..231e7e7ff 100644 --- a/app/utils/keyboard.js +++ b/app/utils/keyboard.js @@ -1,3 +1,10 @@ // @flow +const isMac = window.navigator.platform === "MacIntel"; -export const meta = window.navigator.platform === "MacIntel" ? "⌘" : "Ctrl"; +export const metaDisplay = isMac ? "⌘" : "Ctrl"; + +export const meta = isMac ? "cmd" : "ctrl"; + +export function isMetaKey(event: KeyboardEvent) { + return isMac ? event.metaKey : event.ctrlKey; +} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 654939983..f565d513e 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -253,6 +253,7 @@ "Jump to search": "Jump to search", "Jump to dashboard": "Jump to dashboard", "Table of contents": "Table of contents", + "Toggle sidebar": "Toggle sidebar", "Open this guide": "Open this guide", "Editor": "Editor", "Save and exit document edit mode": "Save and exit document edit mode", From ba61091c4c3f698ae55c1ad1dba44829964ac652 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 30 Dec 2020 09:40:23 -0800 Subject: [PATCH 008/109] fix: Allow soft deletion of teams (#1754) * fix: Allow soft deletion of teams * test: regression specs --- server/api/auth.test.js | 43 ++++++++++++++++++ server/auth/email.js | 4 ++ server/auth/google.js | 28 ++++++++---- server/auth/slack.js | 28 ++++++++---- server/middlewares/authentication.test.js | 54 +++++++++++++++++------ server/models/Team.js | 1 + server/utils/jwt.js | 12 ++++- 7 files changed, 137 insertions(+), 33 deletions(-) create mode 100644 server/api/auth.test.js diff --git a/server/api/auth.test.js b/server/api/auth.test.js new file mode 100644 index 000000000..a70bb71c5 --- /dev/null +++ b/server/api/auth.test.js @@ -0,0 +1,43 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import TestServer from "fetch-test-server"; +import app from "../app"; +import { buildUser, buildTeam } from "../test/factories"; +import { flushdb } from "../test/support"; + +const server = new TestServer(app.callback()); + +beforeEach(() => flushdb()); +afterAll(() => server.close()); + +describe("#auth.info", () => { + it("should return current authentication", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + + const res = await server.post("/api/auth.info", { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.user.name).toBe(user.name); + expect(body.data.team.name).toBe(team.name); + }); + + it("should require the team to not be deleted", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + + await team.destroy(); + + const res = await server.post("/api/auth.info", { + body: { token: user.getJwtToken() }, + }); + expect(res.status).toEqual(401); + }); + + it("should require authentication", async () => { + const res = await server.post("/api/auth.info"); + expect(res.status).toEqual(401); + }); +}); diff --git a/server/auth/email.js b/server/auth/email.js index defa94594..4159c7dc6 100644 --- a/server/auth/email.js +++ b/server/auth/email.js @@ -25,6 +25,10 @@ router.post("email", async (ctx) => { if (user) { const team = await Team.findByPk(user.teamId); + if (!team) { + ctx.redirect(`/?notice=auth-error`); + return; + } // If the user matches an email address associated with an SSO // signin then just forward them directly to that service's diff --git a/server/auth/google.js b/server/auth/google.js index d52a96394..74cd8f509 100644 --- a/server/auth/google.js +++ b/server/auth/google.js @@ -1,6 +1,7 @@ // @flow import crypto from "crypto"; import { OAuth2Client } from "google-auth-library"; +import invariant from "invariant"; import Router from "koa-router"; import { capitalize } from "lodash"; import Sequelize from "sequelize"; @@ -68,15 +69,24 @@ router.get("google.callback", auth({ required: false }), async (ctx) => { const cbResponse = await fetch(cbUrl); const avatarUrl = cbResponse.status === 200 ? cbUrl : tileyUrl; - const [team, isFirstUser] = await Team.findOrCreate({ - where: { - googleId, - }, - defaults: { - name: teamName, - avatarUrl, - }, - }); + let team, isFirstUser; + try { + [team, isFirstUser] = await Team.findOrCreate({ + where: { + googleId, + }, + defaults: { + name: teamName, + avatarUrl, + }, + }); + } catch (err) { + if (err instanceof Sequelize.UniqueConstraintError) { + ctx.redirect(`/?notice=auth-error`); + return; + } + } + invariant(team, "Team must exist"); try { const [user, isFirstSignin] = await User.findOrCreate({ diff --git a/server/auth/slack.js b/server/auth/slack.js index c1915e1ad..b8aaca5dd 100644 --- a/server/auth/slack.js +++ b/server/auth/slack.js @@ -1,5 +1,6 @@ // @flow import addHours from "date-fns/add_hours"; +import invariant from "invariant"; import Router from "koa-router"; import Sequelize from "sequelize"; import { slackAuth } from "../../shared/utils/routeHelpers"; @@ -40,15 +41,24 @@ router.get("slack.callback", auth({ required: false }), async (ctx) => { const data = await Slack.oauthAccess(code); - const [team, isFirstUser] = await Team.findOrCreate({ - where: { - slackId: data.team.id, - }, - defaults: { - name: data.team.name, - avatarUrl: data.team.image_88, - }, - }); + let team, isFirstUser; + try { + [team, isFirstUser] = await Team.findOrCreate({ + where: { + slackId: data.team.id, + }, + defaults: { + name: data.team.name, + avatarUrl: data.team.image_88, + }, + }); + } catch (err) { + if (err instanceof Sequelize.UniqueConstraintError) { + ctx.redirect(`/?notice=auth-error`); + return; + } + } + invariant(team, "Team must exist"); try { const [user, isFirstSignin] = await User.findOrCreate({ diff --git a/server/middlewares/authentication.test.js b/server/middlewares/authentication.test.js index 96279c752..d934793a3 100644 --- a/server/middlewares/authentication.test.js +++ b/server/middlewares/authentication.test.js @@ -1,8 +1,8 @@ /* eslint-disable flowtype/require-valid-file-annotation */ import randomstring from "randomstring"; import { ApiKey } from "../models"; -import { buildUser } from "../test/factories"; -import { flushdb, seed } from "../test/support"; +import { buildUser, buildTeam } from "../test/factories"; +import { flushdb } from "../test/support"; import auth from "./authentication"; beforeEach(() => flushdb()); @@ -11,7 +11,7 @@ describe("Authentication middleware", () => { describe("with JWT", () => { it("should authenticate with correct token", async () => { const state = {}; - const { user } = await seed(); + const user = await buildUser(); const authMiddleware = auth(); await authMiddleware( @@ -29,7 +29,7 @@ describe("Authentication middleware", () => { it("should return error with invalid token", async () => { const state = {}; - const { user } = await seed(); + const user = await buildUser(); const authMiddleware = auth(); try { @@ -52,7 +52,7 @@ describe("Authentication middleware", () => { describe("with API key", () => { it("should authenticate user with valid API key", async () => { const state = {}; - const { user } = await seed(); + const user = await buildUser(); const authMiddleware = auth(); const key = await ApiKey.create({ userId: user.id, @@ -116,7 +116,7 @@ describe("Authentication middleware", () => { it("should allow passing auth token as a GET param", async () => { const state = {}; - const { user } = await seed(); + const user = await buildUser(); const authMiddleware = auth(); await authMiddleware( @@ -138,7 +138,7 @@ describe("Authentication middleware", () => { it("should allow passing auth token in body params", async () => { const state = {}; - const { user } = await seed(); + const user = await buildUser(); const authMiddleware = auth(); await authMiddleware( @@ -159,13 +159,14 @@ describe("Authentication middleware", () => { it("should return an error for suspended users", async () => { const state = {}; - const admin = await buildUser({}); + const admin = await buildUser(); const user = await buildUser({ suspendedAt: new Date(), suspendedById: admin.id, }); const authMiddleware = auth(); + let error; try { await authMiddleware( { @@ -177,11 +178,38 @@ describe("Authentication middleware", () => { }, jest.fn() ); - } catch (e) { - expect(e.message).toEqual( - "Your access has been suspended by the team admin" - ); - expect(e.errorData.adminEmail).toEqual(admin.email); + } catch (err) { + error = err; } + expect(error.message).toEqual( + "Your access has been suspended by the team admin" + ); + expect(error.errorData.adminEmail).toEqual(admin.email); + }); + + it("should return an error for deleted team", async () => { + const state = {}; + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + + await team.destroy(); + + const authMiddleware = auth(); + let error; + try { + await authMiddleware( + { + request: { + get: jest.fn(() => `Bearer ${user.getJwtToken()}`), + }, + state, + cache: {}, + }, + jest.fn() + ); + } catch (err) { + error = err; + } + expect(error.message).toEqual("Invalid token"); }); }); diff --git a/server/models/Team.js b/server/models/Team.js index 9784019e6..19fb7d9a3 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -69,6 +69,7 @@ const Team = sequelize.define( slackData: DataTypes.JSONB, }, { + paranoid: true, getterMethods: { url() { if (this.domain) { diff --git a/server/utils/jwt.js b/server/utils/jwt.js index c41218324..a71ce4d23 100644 --- a/server/utils/jwt.js +++ b/server/utils/jwt.js @@ -2,7 +2,7 @@ import subMinutes from "date-fns/sub_minutes"; import JWT from "jsonwebtoken"; import { AuthenticationError } from "../errors"; -import { User } from "../models"; +import { Team, User } from "../models"; function getJWTPayload(token) { let payload; @@ -28,7 +28,15 @@ export async function getUserForJWT(token: string): Promise { } } - const user = await User.findByPk(payload.id); + const user = await User.findByPk(payload.id, { + include: [ + { + model: Team, + as: "team", + required: true, + }, + ], + }); if (payload.type === "transfer") { // If the user has made a single API request since the transfer token was From 2cc45187e6b9378c1696f2f0ede5847281a0995a Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Thu, 31 Dec 2020 12:51:12 -0800 Subject: [PATCH 009/109] feat: reordering documents in collection (#1722) * tweaking effect details * wrap work on this feature * adds correct color to drop cursor * simplify logic for early return * much better comment so Tom doesn't fire me * feat: Allow changing sort order of collections * refactor: Move validation to model feat: Make custom order the default (in prep for dnd) * feat: Add sort choice to edit collection modal fix: Improved styling of generic InputSelect * fix: Vertical space left after removing previous collection description * chore: Tweak language, menu contents, add auto-disclosure on sub menus * only show drop-to-reorder cursor when sort is set to manual Co-authored-by: Tom Moor --- app/components/DropToImport.js | 6 +- .../DropdownMenu/DropdownMenuItems.js | 18 ++- app/components/InputSelect.js | 3 +- .../Sidebar/components/CollectionLink.js | 27 +++- .../Sidebar/components/DocumentLink.js | 152 +++++++++++------- .../Sidebar/components/DropCursor.js | 42 +++++ .../Sidebar/components/SidebarLink.js | 10 +- app/menus/CollectionMenu.js | 47 +++++- app/menus/DocumentMenu.js | 2 +- app/models/Collection.js | 9 +- app/scenes/Collection.js | 7 +- app/scenes/CollectionEdit.js | 36 ++++- app/stores/DocumentsStore.js | 4 +- server/api/collections.js | 36 +++-- server/api/collections.test.js | 43 +++++ server/commands/documentMover.js | 28 +++- server/middlewares/validation.js | 2 +- .../20201230031607-collection-sort.js | 14 ++ server/models/Collection.js | 30 +++- server/models/Team.js | 1 + server/presenters/collection.js | 23 ++- shared/i18n/locales/en_US/translation.json | 4 +- 22 files changed, 435 insertions(+), 109 deletions(-) create mode 100644 app/components/Sidebar/components/DropCursor.js create mode 100644 server/migrations/20201230031607-collection-sort.js diff --git a/app/components/DropToImport.js b/app/components/DropToImport.js index f5dc68e6c..bdb33dbdc 100644 --- a/app/components/DropToImport.js +++ b/app/components/DropToImport.js @@ -87,7 +87,11 @@ class DropToImport extends React.Component { isDragAccept, isDragReject, }) => ( - + {this.isImporting && } {this.props.children} diff --git a/app/components/DropdownMenu/DropdownMenuItems.js b/app/components/DropdownMenu/DropdownMenuItems.js index b8f33cd21..3752c4ec1 100644 --- a/app/components/DropdownMenu/DropdownMenuItems.js +++ b/app/components/DropdownMenu/DropdownMenuItems.js @@ -1,6 +1,9 @@ // @flow +import { ExpandedIcon } from "outline-icons"; import * as React from "react"; import { Link } from "react-router-dom"; +import styled from "styled-components"; +import Flex from "components/Flex"; import DropdownMenu from "./DropdownMenu"; import DropdownMenuItem from "./DropdownMenuItem"; @@ -9,18 +12,21 @@ type MenuItem = title: React.Node, to: string, visible?: boolean, + selected?: boolean, disabled?: boolean, |} | {| title: React.Node, onClick: (event: SyntheticEvent<>) => void | Promise, visible?: boolean, + selected?: boolean, disabled?: boolean, |} | {| title: React.Node, href: string, visible?: boolean, + selected?: boolean, disabled?: boolean, |} | {| @@ -45,6 +51,10 @@ type Props = {| items: MenuItem[], |}; +const Disclosure = styled(ExpandedIcon)` + transform: rotate(270deg); +`; + export default function DropdownMenuItems({ items }: Props): React.Node { let filtered = items.filter((item) => item.visible !== false); @@ -71,6 +81,7 @@ export default function DropdownMenuItems({ items }: Props): React.Node { to={item.to} key={index} disabled={item.disabled} + selected={item.selected} > {item.title} @@ -83,6 +94,7 @@ export default function DropdownMenuItems({ items }: Props): React.Node { href={item.href} key={index} disabled={item.disabled} + selected={item.selected} target="_blank" > {item.title} @@ -95,6 +107,7 @@ export default function DropdownMenuItems({ items }: Props): React.Node { {item.title} @@ -108,7 +121,10 @@ export default function DropdownMenuItems({ items }: Props): React.Node { style={item.style} label={ - {item.title} + + {item.title} + + } hover={item.hover} diff --git a/app/components/InputSelect.js b/app/components/InputSelect.js index e9e6cdc5d..d53bbf5c1 100644 --- a/app/components/InputSelect.js +++ b/app/components/InputSelect.js @@ -9,7 +9,8 @@ import { Outline, LabelText } from "./Input"; const Select = styled.select` border: 0; flex: 1; - padding: 8px 12px; + padding: 8px 0; + margin: 0 12px; outline: none; background: none; color: ${(props) => props.theme.text}; diff --git a/app/components/Sidebar/components/CollectionLink.js b/app/components/Sidebar/components/CollectionLink.js index 9be2a5898..6a2759cab 100644 --- a/app/components/Sidebar/components/CollectionLink.js +++ b/app/components/Sidebar/components/CollectionLink.js @@ -8,6 +8,7 @@ import Document from "models/Document"; import CollectionIcon from "components/CollectionIcon"; import DropToImport from "components/DropToImport"; import DocumentLink from "./DocumentLink"; +import DropCursor from "./DropCursor"; import EditableTitle from "./EditableTitle"; import SidebarLink from "./SidebarLink"; import useStores from "hooks/useStores"; @@ -39,11 +40,13 @@ function CollectionLink({ const { documents, policies } = useStores(); const expanded = collection.id === ui.activeCollectionId; + const manualSort = collection.sort.field === "index"; - // Droppable + // Drop to re-parent const [{ isOver, canDrop }, drop] = useDrop({ accept: "document", drop: (item, monitor) => { + if (monitor.didDrop()) return; if (!collection) return; documents.move(item.id, collection.id); }, @@ -51,14 +54,26 @@ function CollectionLink({ return policies.abilities(collection.id).update; }, collect: (monitor) => ({ - isOver: !!monitor.isOver(), + isOver: !!monitor.isOver({ shallow: true }), canDrop: monitor.canDrop(), }), }); + // Drop to reorder + const [{ isOverReorder }, dropToReorder] = useDrop({ + accept: "document", + drop: async (item, monitor) => { + if (!collection) return; + documents.move(item.id, collection.id, undefined, 0); + }, + collect: (monitor) => ({ + isOverReorder: !!monitor.isOver(), + }), + }); + return ( <> -
+
+ {expanded && manualSort && ( + + )}
{expanded && - collection.documents.map((node) => ( + collection.documents.map((node, index) => ( ))} diff --git a/app/components/Sidebar/components/DocumentLink.js b/app/components/Sidebar/components/DocumentLink.js index bc6e80e70..0f97d90f2 100644 --- a/app/components/Sidebar/components/DocumentLink.js +++ b/app/components/Sidebar/components/DocumentLink.js @@ -9,6 +9,7 @@ import Collection from "models/Collection"; import Document from "models/Document"; import DropToImport from "components/DropToImport"; import Fade from "components/Fade"; +import DropCursor from "./DropCursor"; import EditableTitle from "./EditableTitle"; import SidebarLink from "./SidebarLink"; import useStores from "hooks/useStores"; @@ -23,16 +24,20 @@ type Props = {| activeDocumentRef?: (?HTMLElement) => void, prefetchDocument: (documentId: string) => Promise, depth: number, + index: number, + parentId?: string, |}; function DocumentLink({ node, + canUpdate, collection, activeDocument, activeDocumentRef, prefetchDocument, depth, - canUpdate, + index, + parentId, }: Props) { const { documents, policies } = useStores(); const { t } = useTranslation(); @@ -76,6 +81,14 @@ function DocumentLink({ } }, [showChildren]); + // when the last child document is removed, + // also close the local folder state to closed + React.useEffect(() => { + if (expanded && !hasChildDocuments) { + setExpanded(false); + } + }, [expanded, hasChildDocuments]); + const handleDisclosureClick = React.useCallback( (ev: SyntheticEvent<>) => { ev.preventDefault(); @@ -108,6 +121,7 @@ function DocumentLink({ const [menuOpen, setMenuOpen] = React.useState(false); const isMoving = documents.movingDocumentId === node.id; + const manualSort = collection?.sort.field === "index"; // Draggable const [{ isDragging }, drag] = useDrag({ @@ -120,77 +134,101 @@ function DocumentLink({ }, }); - // Droppable - const [{ isOver, canDrop }, drop] = useDrop({ + // Drop to re-parent + const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({ accept: "document", drop: async (item, monitor) => { + if (monitor.didDrop()) return; if (!collection) return; documents.move(item.id, collection.id, node.id); }, canDrop: (item, monitor) => pathToNode && !pathToNode.includes(monitor.getItem().id), collect: (monitor) => ({ - isOver: !!monitor.isOver(), - canDrop: monitor.canDrop(), + isOverReparent: !!monitor.isOver({ shallow: true }), + canDropToReparent: monitor.canDrop(), + }), + }); + + // Drop to reorder + const [{ isOverReorder }, dropToReorder] = useDrop({ + accept: "document", + drop: async (item, monitor) => { + if (!collection) return; + if (item.id === node.id) return; + + if (expanded) { + documents.move(item.id, collection.id, node.id, 0); + return; + } + + documents.move(item.id, collection.id, parentId, index + 1); + }, + collect: (monitor) => ({ + isOverReorder: !!monitor.isOver(), }), }); return ( <> - -
- - - {hasChildDocuments && ( - + +
+ + + {hasChildDocuments && ( + + )} + - )} - - - } - isActiveDrop={isOver && canDrop} - depth={depth} - exact={false} - menuOpen={menuOpen} - menu={ - document && !isMoving ? ( - - setMenuOpen(true)} - onClose={() => setMenuOpen(false)} - /> - - ) : undefined - } - /> - -
-
- + + } + isActiveDrop={isOverReparent && canDropToReparent} + depth={depth} + exact={false} + menuOpen={menuOpen} + menu={ + document && !isMoving ? ( + + setMenuOpen(true)} + onClose={() => setMenuOpen(false)} + /> + + ) : undefined + } + /> +
+
+
+ {manualSort && ( + + )} +
{expanded && !isDragging && ( <> - {node.children.map((childNode) => ( + {node.children.map((childNode, index) => ( ))} diff --git a/app/components/Sidebar/components/DropCursor.js b/app/components/Sidebar/components/DropCursor.js new file mode 100644 index 000000000..2eb584cf8 --- /dev/null +++ b/app/components/Sidebar/components/DropCursor.js @@ -0,0 +1,42 @@ +// @flow +import * as React from "react"; +import styled, { withTheme } from "styled-components"; +import { type Theme } from "types"; + +function DropCursor({ + isActiveDrop, + innerRef, + theme, +}: { + isActiveDrop: boolean, + innerRef: React.Ref, + theme: Theme, +}) { + return ; +} + +// transparent hover zone with a thin visible band vertically centered +const Cursor = styled("div")` + opacity: ${(props) => (props.isOver ? 1 : 0)}; + transition: opacity 150ms; + + position: absolute; + z-index: 1; + + width: 100%; + height: 14px; + bottom: -7px; + background: transparent; + + ::after { + background: ${(props) => props.theme.slateDark}; + position: absolute; + top: 6px; + content: ""; + height: 2px; + border-radius: 2px; + width: 100%; + } +`; + +export default withTheme(DropCursor); diff --git a/app/components/Sidebar/components/SidebarLink.js b/app/components/Sidebar/components/SidebarLink.js index f311b8fae..b31c68d0a 100644 --- a/app/components/Sidebar/components/SidebarLink.js +++ b/app/components/Sidebar/components/SidebarLink.js @@ -48,16 +48,20 @@ function SidebarLink({ }, [depth]); const activeStyle = { - color: theme.text, fontWeight: 600, + color: theme.text, background: theme.sidebarItemBackground, ...style, }; + const activeFontWeightOnly = { + fontWeight: 600, + }; + return ( props.$isActiveDrop ? props.theme.slateDark : "inherit"}; color: ${(props) => @@ -115,6 +120,7 @@ const StyledNavLink = styled(NavLink)` svg { ${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")} + transition: fill 50ms } &:hover { diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js index d00fb4c52..4d29bb70b 100644 --- a/app/menus/CollectionMenu.js +++ b/app/menus/CollectionMenu.js @@ -26,6 +26,7 @@ type Props = { documents: DocumentsStore, collection: Collection, history: RouterHistory, + showSort?: boolean, onOpen?: () => void, onClose?: () => void, t: TFunction, @@ -70,6 +71,15 @@ class CollectionMenu extends React.Component { } }; + handleChangeSort = (field: string) => { + return this.props.collection.save({ + sort: { + field, + direction: "asc", + }, + }); + }; + handleEditCollectionOpen = (ev: SyntheticEvent<>) => { ev.preventDefault(); this.showCollectionEdit = true; @@ -112,6 +122,7 @@ class CollectionMenu extends React.Component { documents, collection, position, + showSort, onOpen, onClose, t, @@ -147,12 +158,12 @@ class CollectionMenu extends React.Component { items={[ { title: t("New document"), - visible: !!(collection && can.update), + visible: can.update, onClick: this.onNewDocument, }, { title: t("Import document"), - visible: !!(collection && can.update), + visible: can.update, onClick: this.onImportDocument, }, { @@ -160,12 +171,12 @@ class CollectionMenu extends React.Component { }, { title: `${t("Edit")}…`, - visible: !!(collection && can.update), + visible: can.update, onClick: this.handleEditCollectionOpen, }, { title: `${t("Permissions")}…`, - visible: !!(collection && can.update), + visible: can.update, onClick: this.handleMembersModalOpen, }, { @@ -173,6 +184,34 @@ class CollectionMenu extends React.Component { visible: !!(collection && can.export), onClick: this.handleExportCollectionOpen, }, + { + type: "separator", + }, + { + title: t("Sort in sidebar"), + visible: can.update && showSort, + hover: true, + style: { + left: 170, + position: "relative", + top: -40, + }, + items: [ + { + title: t("Alphabetical"), + onClick: () => this.handleChangeSort("title"), + selected: collection.sort.field === "title", + }, + { + title: t("Manual sort"), + onClick: () => this.handleChangeSort("index"), + selected: collection.sort.field === "index", + }, + ], + }, + { + type: "separator", + }, { title: `${t("Delete")}…`, visible: !!(collection && can.delete), diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index a28df456f..31289f0b6 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -200,7 +200,7 @@ class DocumentMenu extends React.Component { onClick: this.handleRestore, }, { - title: `${t("Restore")}…`, + title: t("Restore"), visible: !collection && !!can.restore, style: { left: -170, diff --git a/app/models/Collection.js b/app/models/Collection.js index 0a6c0a2ac..30876264c 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -1,5 +1,5 @@ // @flow -import { pick } from "lodash"; +import { pick, trim } from "lodash"; import { action, computed, observable } from "mobx"; import BaseModel from "models/BaseModel"; import Document from "models/Document"; @@ -20,6 +20,7 @@ export default class Collection extends BaseModel { createdAt: ?string; updatedAt: ?string; deletedAt: ?string; + sort: { field: string, direction: "asc" | "desc" }; url: string; @computed @@ -45,6 +46,11 @@ export default class Collection extends BaseModel { return results; } + @computed + get hasDescription(): string { + return !!trim(this.description, "\\").trim(); + } + @action updateDocument(document: Document) { const travelDocuments = (documentList, path) => @@ -108,6 +114,7 @@ export default class Collection extends BaseModel { "description", "icon", "private", + "sort", ]); }; diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js index 27f086a10..c9468f987 100644 --- a/app/scenes/Collection.js +++ b/app/scenes/Collection.js @@ -164,7 +164,7 @@ class CollectionScene extends React.Component { )} - + ); @@ -179,9 +179,10 @@ class CollectionScene extends React.Component { const pinnedDocuments = this.collection ? documents.pinnedInCollection(this.collection.id) : []; - const hasPinnedDocuments = !!pinnedDocuments.length; const collection = this.collection; const collectionName = collection ? collection.name : ""; + const hasPinnedDocuments = !!pinnedDocuments.length; + const hasDescription = collection ? collection.hasDescription : false; return ( @@ -240,7 +241,7 @@ class CollectionScene extends React.Component { {collection.name} - {collection.description && ( + {hasDescription && ( Loading…

}> { @observable icon: string = this.props.collection.icon; @observable color: string = this.props.collection.color || "#4E5C6E"; @observable private: boolean = this.props.collection.private; + @observable sort: { field: string, direction: "asc" | "desc" } = this.props + .collection.sort; @observable isSaving: boolean; handleSubmit = async (ev: SyntheticEvent<*>) => { @@ -41,6 +44,7 @@ class CollectionEdit extends React.Component { icon: this.icon, color: this.color, private: this.private, + sort: this.sort, }); this.props.onSubmit(); this.props.ui.showToast(t("The collection was updated")); @@ -51,6 +55,14 @@ class CollectionEdit extends React.Component { } }; + handleSortChange = (ev: SyntheticInputEvent) => { + const [field, direction] = ev.target.value.split("."); + + if (direction === "asc" || direction === "desc") { + this.sort = { field, direction }; + } + }; + handleDescriptionChange = (getValue: () => string) => { this.description = getValue(); }; @@ -75,9 +87,10 @@ class CollectionEdit extends React.Component {
- {t( - "You can edit the name and other details at any time, however doing so often might confuse your team mates." - )} + + You can edit the name and other details at any time, however doing + so often might confuse your team mates. + { minHeight={68} maxHeight={200} /> + { checked={this.private} /> - {t( - "A private collection will only be visible to invited team members." - )} + + A private collection will only be visible to invited team members. + @@ -244,7 +242,6 @@ class Header extends React.Component { disabled={savingIsDisabled} isSaving={isSaving} neutral={isDraft} - small > {isDraft ? t("Save Draft") : t("Done Editing")} @@ -265,7 +262,6 @@ class Header extends React.Component { icon={} to={editDocumentUrl(this.props.document)} neutral - small > {t("Edit")} @@ -300,7 +296,6 @@ class Header extends React.Component { templateId: document.id, })} primary - small > {t("New from template")} @@ -318,7 +313,6 @@ class Header extends React.Component { onClick={this.handlePublish} title={t("Publish document")} disabled={publishingIsDisabled} - small > {isPublishing ? `${t("Publishing")}…` : t("Publish")} @@ -339,7 +333,6 @@ class Header extends React.Component { {...props} borderOnHover neutral - small /> )} showToggleEmbeds={canToggleEmbeds} diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js index b5e65d0ef..d5c702e26 100644 --- a/app/stores/UiStore.js +++ b/app/stores/UiStore.js @@ -2,6 +2,7 @@ import { orderBy } from "lodash"; import { observable, action, autorun, computed } from "mobx"; import { v4 } from "uuid"; +import { light as defaultTheme } from "shared/styles/theme"; import Collection from "models/Collection"; import Document from "models/Document"; import type { Toast } from "types"; @@ -23,6 +24,7 @@ class UiStore { @observable editMode: boolean = false; @observable tocVisible: boolean = false; @observable mobileSidebarVisible: boolean = false; + @observable sidebarWidth: number; @observable sidebarCollapsed: boolean = false; @observable toasts: Map = new Map(); lastToastId: string; @@ -54,6 +56,7 @@ class UiStore { // persisted keys this.languagePromptDismissed = data.languagePromptDismissed; this.sidebarCollapsed = data.sidebarCollapsed; + this.sidebarWidth = data.sidebarWidth || defaultTheme.sidebarWidth; this.tocVisible = data.tocVisible; this.theme = data.theme || "system"; @@ -110,6 +113,11 @@ class UiStore { this.activeCollectionId = undefined; }; + @action + setSidebarWidth = (sidebarWidth: number): void => { + this.sidebarWidth = sidebarWidth; + }; + @action collapseSidebar = () => { this.sidebarCollapsed = true; @@ -219,6 +227,7 @@ class UiStore { return JSON.stringify({ tocVisible: this.tocVisible, sidebarCollapsed: this.sidebarCollapsed, + sidebarWidth: this.sidebarWidth, languagePromptDismissed: this.languagePromptDismissed, theme: this.theme, }); diff --git a/shared/styles/theme.js b/shared/styles/theme.js index 8d6c3a34b..16ff7252b 100644 --- a/shared/styles/theme.js +++ b/shared/styles/theme.js @@ -47,10 +47,10 @@ const spacing = { padding: "1.5vw 1.875vw", vpadding: "1.5vw", hpadding: "1.875vw", - sidebarWidth: "280px", - sidebarCollapsedWidth: "16px", - sidebarMinWidth: "250px", - sidebarMaxWidth: "350px", + sidebarWidth: 280, + sidebarCollapsedWidth: 16, + sidebarMinWidth: 200, + sidebarMaxWidth: 400, }; export const base = { From 40491fafe9da883da13e580d69fd0be42e67256f Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 20 Jan 2021 23:07:39 -0800 Subject: [PATCH 057/109] fix: Document star/unstar from list item navigates to document (regression) --- app/menus/DocumentMenu.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index 5602cf045..d27e7bd07 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -106,6 +106,7 @@ function DocumentMenu({ const handleStar = React.useCallback( (ev: SyntheticEvent<>) => { + ev.preventDefault(); ev.stopPropagation(); document.star(); }, @@ -114,6 +115,7 @@ function DocumentMenu({ const handleUnstar = React.useCallback( (ev: SyntheticEvent<>) => { + ev.preventDefault(); ev.stopPropagation(); document.unstar(); }, From 24ecaa8ce40ceac961a4f52e7fe0dab54bad8e3d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 20 Jan 2021 23:07:48 -0800 Subject: [PATCH 058/109] chore: Reduce default menu width --- shared/styles/theme.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/styles/theme.js b/shared/styles/theme.js index 16ff7252b..0d59bbeca 100644 --- a/shared/styles/theme.js +++ b/shared/styles/theme.js @@ -47,7 +47,7 @@ const spacing = { padding: "1.5vw 1.875vw", vpadding: "1.5vw", hpadding: "1.875vw", - sidebarWidth: 280, + sidebarWidth: 260, sidebarCollapsedWidth: 16, sidebarMinWidth: 200, sidebarMaxWidth: 400, From 836b2e310aed4158467c68b92ce4a5d394d76c1c Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 20 Jan 2021 23:13:51 -0800 Subject: [PATCH 059/109] chore: Missing translation hooks in settings sidebar --- app/components/Sidebar/Settings.js | 236 ++++++++++++++--------------- app/hooks/useCurrentTeam.js | 9 ++ 2 files changed, 121 insertions(+), 124 deletions(-) create mode 100644 app/hooks/useCurrentTeam.js diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index dc93d5fb6..0d0e74782 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -1,5 +1,5 @@ // @flow -import { observer, inject } from "mobx-react"; +import { observer } from "mobx-react"; import { DocumentIcon, EmailIcon, @@ -13,11 +13,9 @@ import { ExpandedIcon, } from "outline-icons"; import * as React from "react"; -import { withTranslation, type TFunction } from "react-i18next"; -import type { RouterHistory } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; import styled from "styled-components"; -import AuthStore from "stores/AuthStore"; -import PoliciesStore from "stores/PoliciesStore"; import Flex from "components/Flex"; import Scrollable from "components/Scrollable"; @@ -30,131 +28,123 @@ import Version from "./components/Version"; import SlackIcon from "./icons/Slack"; import ZapierIcon from "./icons/Zapier"; import env from "env"; +import useCurrentTeam from "hooks/useCurrentTeam"; +import useStores from "hooks/useStores"; const isHosted = env.DEPLOYMENT === "hosted"; -type Props = { - history: RouterHistory, - policies: PoliciesStore, - auth: AuthStore, - t: TFunction, -}; +function SettingsSidebar() { + const { t } = useTranslation(); + const history = useHistory(); + const team = useCurrentTeam(); + const { policies } = useStores(); + const can = policies.abilities(team.id); -@observer -class SettingsSidebar extends React.Component { - returnToDashboard = () => { - this.props.history.push("/home"); - }; + const returnToDashboard = React.useCallback(() => { + history.push("/home"); + }, [history]); - render() { - const { policies, t, auth } = this.props; - const { team } = auth; - if (!team) return null; + return ( + + + {t("Return to App")} + + } + teamName={team.name} + logoUrl={team.avatarUrl} + onClick={returnToDashboard} + /> - const can = policies.abilities(team.id); - - return ( - - - {t("Return to App")} - - } - teamName={team.name} - logoUrl={team.avatarUrl} - onClick={this.returnToDashboard} - /> - - - -
-
Account
- } - label={t("Profile")} - /> - } - label={t("Notifications")} - /> - } - label={t("API Tokens")} - /> -
-
-
Team
- {can.update && ( - } - label={t("Details")} - /> - )} - {can.update && ( - } - label={t("Security")} - /> - )} - } - exact={false} - label={t("People")} - /> - } - exact={false} - label={t("Groups")} - /> - } - label={t("Share Links")} - /> - {can.export && ( - } - label={t("Export Data")} - /> - )} -
+ + +
+
{t("Account")}
+ } + label={t("Profile")} + /> + } + label={t("Notifications")} + /> + } + label={t("API Tokens")} + /> +
+
+
{t("Team")}
{can.update && ( -
-
{t("Integrations")}
+ } + label={t("Details")} + /> + )} + {can.update && ( + } + label={t("Security")} + /> + )} + } + exact={false} + label={t("People")} + /> + } + exact={false} + label={t("Groups")} + /> + } + label={t("Share Links")} + /> + {can.export && ( + } + label={t("Export Data")} + /> + )} +
+ {can.update && ( +
+
{t("Integrations")}
+ } + label="Slack" + /> + {isHosted && ( } - label="Slack" + to="/settings/integrations/zapier" + icon={} + label="Zapier" /> - {isHosted && ( - } - label="Zapier" - /> - )} -
- )} - {can.update && !isHosted && ( -
-
{t("Installation")}
- -
- )} - - - - ); - } + )} +
+ )} + {can.update && !isHosted && ( +
+
{t("Installation")}
+ +
+ )} +
+
+
+ ); } const BackIcon = styled(ExpandedIcon)` @@ -166,6 +156,4 @@ const ReturnToApp = styled(Flex)` height: 16px; `; -export default withTranslation()( - inject("auth", "policies")(SettingsSidebar) -); +export default observer(SettingsSidebar); diff --git a/app/hooks/useCurrentTeam.js b/app/hooks/useCurrentTeam.js new file mode 100644 index 000000000..e03b0ade9 --- /dev/null +++ b/app/hooks/useCurrentTeam.js @@ -0,0 +1,9 @@ +// @flow +import invariant from "invariant"; +import useStores from "./useStores"; + +export default function useCurrentTeam() { + const { auth } = useStores(); + invariant(auth.team, "team required"); + return auth.team; +} From 6fa9e700c873d53363256feca03c0c4f3cf7de05 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 20 Jan 2021 23:20:06 -0800 Subject: [PATCH 060/109] chore: Flip chinese label in language select --- shared/i18n/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/i18n/index.js b/shared/i18n/index.js index f3fa744db..03fad40e9 100644 --- a/shared/i18n/index.js +++ b/shared/i18n/index.js @@ -36,7 +36,7 @@ export const initI18n = () => { export const languageOptions = [ { label: "English (US)", value: "en_US" }, - { label: "Chinese, Simplified (简体中文)", value: "zh_CN" }, + { label: "简体中文 (Chinese, Simplified)", value: "zh_CN" }, { label: "Deutsch (Deutschland)", value: "de_DE" }, { label: "Español (España)", value: "es_ES" }, { label: "Français (France)", value: "fr_FR" }, From 993aad004e8c3c81d559a540a45496034be46cd1 Mon Sep 17 00:00:00 2001 From: Translate-O-Tron <75237327+outline-translations@users.noreply.github.com> Date: Thu, 21 Jan 2021 07:21:23 -0800 Subject: [PATCH 061/109] fix: New Korean translations from Crowdin [ci skip] (#1833) --- shared/i18n/locales/ko_KR/translation.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shared/i18n/locales/ko_KR/translation.json b/shared/i18n/locales/ko_KR/translation.json index d6d2109ac..760082d7a 100644 --- a/shared/i18n/locales/ko_KR/translation.json +++ b/shared/i18n/locales/ko_KR/translation.json @@ -2,14 +2,14 @@ "currently editing": "현재 편집 중", "currently viewing": "현재 조회 중", "viewed {{ timeAgo }} ago": "{{ timeAgo }} 전 조회", - "You": "귀하", + "You": "본인", "Trash": "휴지통", "Archive": "보관", "Drafts": "임시 보관함", - "Templates": "탬플릿", + "Templates": "템플릿", "Deleted Collection": "삭제 된 콜렉션", "Submenu": "Submenu", - "New": "새", + "New": "신규", "Only visible to you": "나에게만 보임", "Draft": "임시보관", "Template": "템플릿", From 70626ffff07c5bcd54cd320c9fdfe34deae941e8 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 21 Jan 2021 07:22:20 -0800 Subject: [PATCH 062/109] feat: Organize sidebar (#1834) * chore: Flip chinese label in language select * feat: Add settings to sidebar, organize secondary items to bottom --- app/components/Scrollable.js | 73 +++-- app/components/Sidebar/Main.js | 324 ++++++++++--------- app/components/Sidebar/Settings.js | 2 +- app/components/Sidebar/components/Section.js | 1 + app/hooks/useWindowSize.js | 31 ++ shared/i18n/locales/en_US/translation.json | 3 +- 6 files changed, 253 insertions(+), 181 deletions(-) create mode 100644 app/hooks/useWindowSize.js diff --git a/app/components/Scrollable.js b/app/components/Scrollable.js index 179bee869..762301a6b 100644 --- a/app/components/Scrollable.js +++ b/app/components/Scrollable.js @@ -1,28 +1,52 @@ // @flow -import { observable } from "mobx"; import { observer } from "mobx-react"; import * as React from "react"; import styled from "styled-components"; +import useWindowSize from "hooks/useWindowSize"; -type Props = { +type Props = {| shadow?: boolean, -}; + topShadow?: boolean, + bottomShadow?: boolean, +|}; -@observer -class Scrollable extends React.Component { - @observable shadow: boolean = false; +function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) { + const ref = React.useRef(); + const [topShadowVisible, setTopShadow] = React.useState(false); + const [bottomShadowVisible, setBottomShadow] = React.useState(false); + const { height } = useWindowSize(); - handleScroll = (ev: SyntheticMouseEvent) => { - this.shadow = !!(this.props.shadow && ev.currentTarget.scrollTop > 0); - }; + const updateShadows = React.useCallback(() => { + const c = ref.current; + if (!c) return; - render() { - const { shadow, ...rest } = this.props; + const scrollTop = c.scrollTop; + const tsv = !!((shadow || topShadow) && scrollTop > 0); + if (tsv !== topShadowVisible) { + setTopShadow(tsv); + } - return ( - - ); - } + const wrapperHeight = c.scrollHeight - c.clientHeight; + const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0); + + if (bsv !== bottomShadowVisible) { + setBottomShadow(bsv); + } + }, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]); + + React.useEffect(() => { + updateShadows(); + }, [height, updateShadows]); + + return ( + + ); } const Wrapper = styled.div` @@ -31,9 +55,20 @@ const Wrapper = styled.div` overflow-x: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch; - box-shadow: ${(props) => - props.shadow ? "0 1px inset rgba(0,0,0,.1)" : "none"}; - transition: all 250ms ease-in-out; + box-shadow: ${(props) => { + if (props.$topShadowVisible && props.$bottomShadowVisible) { + return "0 1px inset rgba(0,0,0,.1), 0 -1px inset rgba(0,0,0,.1)"; + } + if (props.$topShadowVisible) { + return "0 1px inset rgba(0,0,0,.1)"; + } + if (props.$bottomShadowVisible) { + return "0 -1px inset rgba(0,0,0,.1)"; + } + + return "none"; + }}; + transition: all 100ms ease-in-out; `; -export default Scrollable; +export default observer(Scrollable); diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index 44b3794ce..61d06cdd7 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -1,6 +1,5 @@ // @flow -import { observable } from "mobx"; -import { observer, inject } from "mobx-react"; +import { observer } from "mobx-react"; import { ArchiveIcon, HomeIcon, @@ -10,14 +9,11 @@ import { ShapesIcon, TrashIcon, PlusIcon, + SettingsIcon, } from "outline-icons"; import * as React from "react"; -import { withTranslation, type TFunction } from "react-i18next"; +import { useTranslation } from "react-i18next"; import styled from "styled-components"; - -import AuthStore from "stores/AuthStore"; -import DocumentsStore from "stores/DocumentsStore"; -import PoliciesStore from "stores/PoliciesStore"; import CollectionNew from "scenes/CollectionNew"; import Invite from "scenes/Invite"; import Flex from "components/Flex"; @@ -29,176 +25,184 @@ import Collections from "./components/Collections"; import HeaderBlock from "./components/HeaderBlock"; import Section from "./components/Section"; import SidebarLink from "./components/SidebarLink"; +import useStores from "hooks/useStores"; import AccountMenu from "menus/AccountMenu"; -type Props = { - auth: AuthStore, - documents: DocumentsStore, - policies: PoliciesStore, - t: TFunction, -}; +function MainSidebar() { + const { t } = useTranslation(); + const { policies, auth, documents } = useStores(); + const [inviteModalOpen, setInviteModalOpen] = React.useState(false); + const [ + createCollectionModalOpen, + setCreateCollectionModalOpen, + ] = React.useState(false); -@observer -class MainSidebar extends React.Component { - @observable inviteModalOpen = false; - @observable createCollectionModalOpen = false; + React.useEffect(() => { + documents.fetchDrafts(); + documents.fetchTemplates(); + }, [documents]); - componentDidMount() { - this.props.documents.fetchDrafts(); - this.props.documents.fetchTemplates(); - } + const handleCreateCollectionModalOpen = React.useCallback( + (ev: SyntheticEvent<>) => { + ev.preventDefault(); + setCreateCollectionModalOpen(true); + }, + [] + ); - handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => { + const handleCreateCollectionModalClose = React.useCallback( + (ev: SyntheticEvent<>) => { + ev.preventDefault(); + setCreateCollectionModalOpen(false); + }, + [] + ); + + const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => { ev.preventDefault(); - this.createCollectionModalOpen = true; - }; + setInviteModalOpen(true); + }, []); - handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => { - this.createCollectionModalOpen = false; - }; - - handleInviteModalOpen = (ev: SyntheticEvent<>) => { + const handleInviteModalClose = React.useCallback((ev: SyntheticEvent<>) => { ev.preventDefault(); - this.inviteModalOpen = true; - }; + setInviteModalOpen(false); + }, []); - handleInviteModalClose = () => { - this.inviteModalOpen = false; - }; + const { user, team } = auth; + if (!user || !team) return null; - render() { - const { auth, documents, policies, t } = this.props; - const { user, team } = auth; - if (!user || !team) return null; + const can = policies.abilities(team.id); - const can = policies.abilities(team.id); - - return ( - - - {(props) => ( - + + {(props) => ( + + )} + + + +
+ } + exact={false} + label={t("Home")} /> - )} - - - -
+ } + label={t("Search")} + exact={false} + /> + } + exact={false} + label={t("Starred")} + /> + } + exact={false} + label={t("Templates")} + active={documents.active ? documents.active.template : undefined} + /> + } + label={ + + {t("Drafts")} + {documents.totalDrafts > 0 && ( + + )} + + } + active={ + documents.active + ? !documents.active.publishedAt && + !documents.active.isDeleted && + !documents.active.isTemplate + : undefined + } + /> +
+
+ +
+
+ +
+ } + exact={false} + label={t("Archive")} + active={ + documents.active + ? documents.active.isArchived && !documents.active.isDeleted + : undefined + } + /> + } + exact={false} + label={t("Trash")} + active={documents.active ? documents.active.isDeleted : undefined} + /> + } + exact={false} + label={t("Settings")} + /> + {can.invite && ( } - exact={false} - label={t("Home")} + to="/settings/people" + onClick={handleInviteModalOpen} + icon={} + label={t("Invite people…")} /> - } - label={t("Search")} - exact={false} - /> - } - exact={false} - label={t("Starred")} - /> - } - exact={false} - label={t("Templates")} - active={ - documents.active ? documents.active.template : undefined - } - /> - } - label={ - - {t("Drafts")} - {documents.totalDrafts > 0 && ( - - )} - - } - active={ - documents.active - ? !documents.active.publishedAt && - !documents.active.isDeleted && - !documents.active.isTemplate - : undefined - } - /> -
-
- -
-
- } - exact={false} - label={t("Archive")} - active={ - documents.active - ? documents.active.isArchived && !documents.active.isDeleted - : undefined - } - /> - } - exact={false} - label={t("Trash")} - active={ - documents.active ? documents.active.isDeleted : undefined - } - /> - {can.invite && ( - } - label={t("Invite people…")} - /> - )} -
- -
- - - - - - - - ); - } + )} +
+ +
+ + + + + + +
+ ); } +const Secondary = styled.div` + overflow-x: hidden; + flex-shrink: 0; +`; + const Drafts = styled(Flex)` height: 24px; `; -export default withTranslation()( - inject("documents", "policies", "auth")(MainSidebar) -); +export default observer(MainSidebar); diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index 0d0e74782..94f732e4c 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -58,7 +58,7 @@ function SettingsSidebar() { /> - +
{t("Account")}
props.theme.sidebarMinWidth}px; + flex-shrink: 0; `; export default Section; diff --git a/app/hooks/useWindowSize.js b/app/hooks/useWindowSize.js new file mode 100644 index 000000000..8c2111b24 --- /dev/null +++ b/app/hooks/useWindowSize.js @@ -0,0 +1,31 @@ +// @flow +import { debounce } from "lodash"; +import * as React from "react"; + +export default function useWindowSize() { + const [windowSize, setWindowSize] = React.useState({ + width: undefined, + height: undefined, + }); + + React.useEffect(() => { + // Handler to call on window resize + const handleResize = debounce(() => { + // Set window width/height to state + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }, 100); + + // Add event listener + window.addEventListener("resize", handleResize); + + // Call handler right away so state gets updated with initial window size + handleResize(); + + return () => window.removeEventListener("resize", handleResize); + }, []); + + return windowSize; +} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index b0c41d233..3d4e8cbf6 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -94,9 +94,11 @@ "Invite people": "Invite people", "Create a collection": "Create a collection", "Return to App": "Return to App", + "Account": "Account", "Profile": "Profile", "Notifications": "Notifications", "API Tokens": "API Tokens", + "Team": "Team", "Details": "Details", "Security": "Security", "People": "People", @@ -109,7 +111,6 @@ "System": "System", "Light": "Light", "Dark": "Dark", - "Account": "Account", "Settings": "Settings", "API documentation": "API documentation", "Changelog": "Changelog", From 6e9c456147a85f803128dd72d622cafa23124bc3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 21 Jan 2021 07:28:10 -0800 Subject: [PATCH 063/109] isMetaKey -> isModKey --- app/components/Editor.js | 4 ++-- app/scenes/Document/components/Editor.js | 8 ++++---- app/utils/keyboard.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/components/Editor.js b/app/components/Editor.js index 378d41bd3..82e2dc820 100644 --- a/app/components/Editor.js +++ b/app/components/Editor.js @@ -8,7 +8,7 @@ import UiStore from "stores/UiStore"; import ErrorBoundary from "components/ErrorBoundary"; import Tooltip from "components/Tooltip"; import embeds from "../embeds"; -import { isMetaKey } from "utils/keyboard"; +import { isModKey } from "utils/keyboard"; import { uploadFile } from "utils/uploadFile"; import { isInternalUrl } from "utils/urls"; @@ -50,7 +50,7 @@ function Editor(props: PropsWithRef) { return; } - if (isInternalUrl(href) && !isMetaKey(event) && !event.shiftKey) { + if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) { // relative let navigateTo = href; diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index b80810557..35403ee9a 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -13,7 +13,7 @@ import Editor from "components/Editor"; import Flex from "components/Flex"; import HoverPreview from "components/HoverPreview"; import Star, { AnimatedStar } from "components/Star"; -import { isMetaKey } from "utils/keyboard"; +import { isModKey } from "utils/keyboard"; import { documentHistoryUrl } from "utils/routeHelpers"; type Props = { @@ -55,7 +55,7 @@ class DocumentEditor extends React.Component { handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => { if (event.key === "Enter") { event.preventDefault(); - if (isMetaKey(event)) { + if (isModKey(event)) { this.props.onSave({ done: true }); return; } @@ -69,12 +69,12 @@ class DocumentEditor extends React.Component { this.focusAtStart(); return; } - if (event.key === "p" && isMetaKey(event) && event.shiftKey) { + if (event.key === "p" && isModKey(event) && event.shiftKey) { event.preventDefault(); this.props.onSave({ publish: true, done: true }); return; } - if (event.key === "s" && isMetaKey(event)) { + if (event.key === "s" && isModKey(event)) { event.preventDefault(); this.props.onSave({}); return; diff --git a/app/utils/keyboard.js b/app/utils/keyboard.js index ff4b3b98e..09e8f1c0c 100644 --- a/app/utils/keyboard.js +++ b/app/utils/keyboard.js @@ -5,7 +5,7 @@ export const metaDisplay = isMac ? "⌘" : "Ctrl"; export const meta = isMac ? "cmd" : "ctrl"; -export function isMetaKey( +export function isModKey( event: KeyboardEvent | MouseEvent | SyntheticKeyboardEvent<> ) { return isMac ? event.metaKey : event.ctrlKey; From c69b393776e65d19fcde0c8d05ef992963a32c73 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 22 Jan 2021 08:57:52 -0800 Subject: [PATCH 064/109] fix: JS error when submitting invites from sidebar-triggered modal --- app/components/Sidebar/Main.js | 3 +-- shared/i18n/locales/en_US/translation.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index 61d06cdd7..ae385e95c 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -63,8 +63,7 @@ function MainSidebar() { setInviteModalOpen(true); }, []); - const handleInviteModalClose = React.useCallback((ev: SyntheticEvent<>) => { - ev.preventDefault(); + const handleInviteModalClose = React.useCallback(() => { setInviteModalOpen(false); }, []); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 3d4e8cbf6..b722ca282 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -90,6 +90,7 @@ "Untitled": "Untitled", "Home": "Home", "Starred": "Starred", + "Settings": "Settings", "Invite people…": "Invite people…", "Invite people": "Invite people", "Create a collection": "Create a collection", @@ -111,7 +112,6 @@ "System": "System", "Light": "Light", "Dark": "Dark", - "Settings": "Settings", "API documentation": "API documentation", "Changelog": "Changelog", "Send us feedback": "Send us feedback", From 6a206de6cd3b47686a9eb3ae7b9bd02dcbc39a24 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 22 Jan 2021 19:12:39 -0800 Subject: [PATCH 065/109] chore: Add meta description --- server/static/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/server/static/index.html b/server/static/index.html index 4b6c67ecd..285907fd2 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -4,6 +4,7 @@ Outline + //inject-prefetch// Date: Fri, 22 Jan 2021 19:31:30 -0800 Subject: [PATCH 066/109] chore: Add missing labels to buttons without text and search inputs --- app/components/IconPicker.js | 4 ++-- app/components/Sidebar/Sidebar.js | 4 +++- app/components/Sidebar/components/CollapseToggle.js | 2 +- app/menus/BreadcrumbMenu.js | 2 +- app/menus/CollectionGroupMemberMenu.js | 2 +- app/menus/CollectionMenu.js | 2 +- app/menus/CollectionSortMenu.js | 2 +- app/menus/DocumentMenu.js | 6 +++++- app/menus/GroupMemberMenu.js | 2 +- app/menus/GroupMenu.js | 2 +- app/menus/MemberMenu.js | 2 +- app/menus/RevisionMenu.js | 1 + app/menus/ShareMenu.js | 2 +- app/menus/UserMenu.js | 2 +- app/scenes/Collection.js | 2 ++ app/scenes/Dashboard.js | 6 +++++- app/scenes/Drafts.js | 6 +++++- app/scenes/Starred.js | 6 +++++- shared/i18n/locales/en_US/translation.json | 5 +++++ 19 files changed, 43 insertions(+), 17 deletions(-) diff --git a/app/components/IconPicker.js b/app/components/IconPicker.js index 1c297b400..2b6b09407 100644 --- a/app/components/IconPicker.js +++ b/app/components/IconPicker.js @@ -145,8 +145,8 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) { {(props) => ( - )} diff --git a/app/components/Sidebar/Sidebar.js b/app/components/Sidebar/Sidebar.js index afc6332b7..1e2682ca5 100644 --- a/app/components/Sidebar/Sidebar.js +++ b/app/components/Sidebar/Sidebar.js @@ -1,6 +1,7 @@ // @flow import { observer } from "mobx-react"; import * as React from "react"; +import { useTranslation } from "react-i18next"; import { Portal } from "react-portal"; import { withRouter } from "react-router-dom"; import type { Location } from "react-router-dom"; @@ -85,6 +86,7 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => { function Sidebar({ location, children }: Props) { const theme = useTheme(); + const { t } = useTranslation(); const { ui } = useStores(); const previousLocation = usePrevious(location); @@ -157,7 +159,7 @@ function Sidebar({ location, children }: Props) { onDoubleClick={handleReset} $isResizing={isResizing} > - + )} diff --git a/app/components/Sidebar/components/CollapseToggle.js b/app/components/Sidebar/components/CollapseToggle.js index 60989a44a..d7dd88a8b 100644 --- a/app/components/Sidebar/components/CollapseToggle.js +++ b/app/components/Sidebar/components/CollapseToggle.js @@ -21,7 +21,7 @@ function CollapseToggle({ collapsed, ...rest }: Props) { delay={500} placement="bottom" > -