chore: Move to Typescript (#2783)

This PR moves the entire project to Typescript. Due to the ~1000 ignores this will lead to a messy codebase for a while, but the churn is worth it – all of those ignore comments are places that were never type-safe previously.

closes #1282
This commit is contained in:
Tom Moor
2021-11-29 06:40:55 -08:00
committed by GitHub
parent 25ccfb5d04
commit 15b1069bcc
1017 changed files with 17410 additions and 54942 deletions

View File

@@ -1,7 +1,7 @@
{
"presets": [
"@babel/preset-react",
"@babel/preset-flow",
"@babel/preset-typescript",
[
"@babel/preset-env",
{

View File

@@ -41,8 +41,8 @@ jobs:
name: lint
command: yarn lint
- run:
name: flow
command: yarn flow check --max-workers 4
name: typescript
command: yarn tsc
- run:
name: test
command: yarn test

View File

@@ -6,14 +6,13 @@ __mocks__
.DS_Store
.env*
.eslint*
.flowconfig
.log
Makefile
Procfile
app.json
crowdin.yml
build
docker-compose.yml
fakes3
flow-typed
node_modules
setupJest.js
tsconfig.json

View File

@@ -1,20 +1,34 @@
{
"parser": "babel-eslint",
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"extraFileExtensions": [".json"],
"ecmaFeatures": {
"jsx": true
}
},
"extends": [
"react-app",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:flowtype/recommended",
"plugin:react-hooks/recommended"
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended"
],
"plugins": [
"prettier",
"flowtype"
"@typescript-eslint",
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"eslint-plugin-react-hooks",
"import"
],
"rules": {
"eqeqeq": 2,
"no-unused-vars": 2,
"no-mixed-operators": "off",
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"import/newline-after-import": 2,
"import/order": [
"error",
{
@@ -23,53 +37,48 @@
},
"pathGroups": [
{
"pattern": "shared/**",
"pattern": "@shared/**",
"group": "external",
"position": "after"
},
{
"pattern": "stores",
"pattern": "@server/**",
"group": "external",
"position": "after"
},
{
"pattern": "stores/**",
"pattern": "~/stores",
"group": "external",
"position": "after"
},
{
"pattern": "models/**",
"pattern": "~/stores/**",
"group": "external",
"position": "after"
},
{
"pattern": "scenes/**",
"pattern": "~/models/**",
"group": "external",
"position": "after"
},
{
"pattern": "components/**",
"pattern": "~/scenes/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/components/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/**",
"group": "external",
"position": "after"
}
]
}
],
"flowtype/require-valid-file-annotation": [
2,
"always",
{
"annotationStyle": "line"
}
],
"flowtype/space-after-type-colon": [
2,
"always"
],
"flowtype/space-before-type-colon": [
2,
"never"
],
"prettier/prettier": [
"error",
{
@@ -84,21 +93,13 @@
"pragma": "React",
"version": "detect"
},
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"]
},
"import/resolver": {
"node": {
"paths": [
"app",
"."
]
"typescript": {}
}
},
"flowtype": {
"onlyFilesWithFlowAnnotation": false
}
},
"env": {
"jest": true
},
"globals": {
"EDITOR_VERSION": true
}

View File

@@ -1,44 +0,0 @@
[include]
.*/app/.*
.*/server/.*
.*/shared/.*
[ignore]
.*/node_modules/tiny-cookie/flow/.*
.*/node_modules/styled-components/.*
.*/node_modules/polished/.*
.*/node_modules/mobx/.*.flow
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/y-indexeddb/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
[libs]
[options]
emoji=true
sharedmemory.heap_size=3221225472
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=app
module.name_mapper='^\(.*\)\.md$' -> 'empty/object'
module.name_mapper='^shared\/\(.*\)$' -> '<PROJECT_ROOT>/shared/\1'
module.file_ext=.js
module.file_ext=.md
module.file_ext=.json
esproposal.decorators=ignore
esproposal.class_static_fields=enable
esproposal.class_instance_fields=enable
esproposal.optional_chaining=enable
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue

View File

@@ -1,7 +1,7 @@
{
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.validate.enable": false,
"typescript.format.enable": false,
"javascript.validate.enable": true,
"javascript.format.enable": true,
"typescript.validate.enable": true,
"typescript.format.enable": true,
"editor.formatOnSave": true,
}

View File

@@ -8,9 +8,10 @@
</p>
<p align="center">
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&amp;circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg"></a>
<a href="https://translate.getoutline.com/project/outline"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).

View File

@@ -1,4 +1,3 @@
/* eslint-disable flowtype/require-valid-file-annotation */
export default class Queue {
name;

9
app/.eslintrc Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": [
"../.eslintrc"
],
"env": {
"jest": true,
"browser": true
}
}

View File

@@ -7,14 +7,10 @@
"<rootDir>/shared"
],
"moduleNameMapper": {
"^shared/(.*)$": "<rootDir>/shared/$1",
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleFileExtensions": [
"js",
"jsx",
"json"
],
"moduleDirectories": [
"node_modules"
],
@@ -25,6 +21,6 @@
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.js"
"./app/test/setup.ts"
]
}

View File

@@ -1,13 +1,12 @@
// @flow
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionNew from "scenes/CollectionNew";
import DynamicCollectionIcon from "components/CollectionIcon";
import { createAction } from "actions";
import { CollectionSection } from "actions/sections";
import history from "utils/history";
import stores from "~/stores";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import DynamicCollectionIcon from "~/components/CollectionIcon";
import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
import history from "~/utils/history";
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
@@ -16,7 +15,6 @@ export const openCollection = createAction({
icon: <CollectionIcon />,
children: ({ stores }) => {
const collections = stores.collections.orderedData;
return collections.map((collection) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
@@ -39,7 +37,6 @@ export const createCollection = createAction({
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create a collection"),
content: <CollectionNew onSubmit={stores.dialogs.closeAllModals} />,
@@ -55,6 +52,8 @@ export const editCollection = createAction({
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) return;
stores.dialogs.openModal({
title: t("Edit collection"),
content: (

View File

@@ -1,11 +1,10 @@
// @flow
import { ToolsIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import { createAction } from "actions";
import { DebugSection } from "actions/sections";
import env from "env";
import { deleteAllDatabases } from "utils/developer";
import stores from "~/stores";
import { createAction } from "~/actions";
import { DebugSection } from "~/actions/sections";
import env from "~/env";
import { deleteAllDatabases } from "~/utils/developer";
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),

View File

@@ -1,4 +1,3 @@
// @flow
import invariant from "invariant";
import {
DownloadIcon,
@@ -12,12 +11,12 @@ import {
ImportIcon,
} from "outline-icons";
import * as React from "react";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import { createAction } from "actions";
import { DocumentSection } from "actions/sections";
import getDataTransferFiles from "utils/getDataTransferFiles";
import history from "utils/history";
import { newDocumentPath } from "utils/routeHelpers";
import DocumentTemplatize from "~/scenes/DocumentTemplatize";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import getDataTransferFiles from "~/utils/getDataTransferFiles";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -36,9 +35,7 @@ export const openDocument = createAction({
id: path.url,
name: path.title,
icon: () =>
stores.documents.get(path.id)?.isStarred ? (
<StarredIcon />
) : undefined,
stores.documents.get(path.id)?.isStarred ? <StarredIcon /> : null,
section: DocumentSection,
perform: () => history.push(path.url),
}));
@@ -65,13 +62,12 @@ export const starDocument = createAction({
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!document?.isStarred && stores.policies.abilities(activeDocumentId).star
);
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
if (!activeDocumentId) return;
const document = stores.documents.get(activeDocumentId);
document?.star();
@@ -86,14 +82,13 @@ export const unstarDocument = createAction({
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isStarred &&
stores.policies.abilities(activeDocumentId).unstar
);
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
if (!activeDocumentId) return;
const document = stores.documents.get(activeDocumentId);
document?.unstar();
@@ -109,7 +104,7 @@ export const downloadDocument = createAction({
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
if (!activeDocumentId) return;
const document = stores.documents.get(activeDocumentId);
document?.download();
@@ -125,16 +120,16 @@ export const duplicateDocument = createAction({
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ activeDocumentId, t, stores }) => {
if (!activeDocumentId) return false;
if (!activeDocumentId) return;
const document = stores.documents.get(activeDocumentId);
invariant(document, "Document must exist");
const duped = await document.duplicate();
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
stores.toasts.showToast(t("Document duplicated"), { type: "success" });
stores.toasts.showToast(t("Document duplicated"), {
type: "success",
});
},
});
@@ -150,7 +145,7 @@ export const printDocument = createAction({
});
export const importDocument = createAction({
name: ({ t, activeDocumentId }) => t("Import document"),
name: ({ t }) => t("Import document"),
section: DocumentSection,
icon: <ImportIcon />,
keywords: "upload",
@@ -158,18 +153,20 @@ export const importDocument = createAction({
if (activeDocumentId) {
return !!stores.policies.abilities(activeDocumentId).createChildDocument;
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).update;
}
return false;
},
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
const { documents, toasts } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev: SyntheticEvent<>) => {
input.onchange = async (ev: Event) => {
const files = getDataTransferFiles(ev);
try {
@@ -187,10 +184,10 @@ export const importDocument = createAction({
toasts.showToast(err.message, {
type: "error",
});
throw err;
}
};
input.click();
},
});
@@ -202,9 +199,7 @@ export const createTemplate = createAction({
keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
@@ -212,6 +207,8 @@ export const createTemplate = createAction({
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
if (!activeDocumentId) return;
event?.preventDefault();
event?.stopPropagation();

View File

@@ -1,4 +1,3 @@
// @flow
import {
HomeIcon,
SearchIcon,
@@ -17,12 +16,12 @@ import {
changelogUrl,
mailToUrl,
githubIssuesUrl,
} from "shared/utils/routeHelpers";
import stores from "stores";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import { createAction } from "actions";
import { NavigationSection } from "actions/sections";
import history from "utils/history";
} from "@shared/utils/routeHelpers";
import stores from "~/stores";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import history from "~/utils/history";
import {
settingsPath,
homePath,
@@ -31,7 +30,7 @@ import {
templatesPath,
archivePath,
trashPath,
} from "utils/routeHelpers";
} from "~/utils/routeHelpers";
export const navigateToHome = createAction({
name: ({ t }) => t("Home"),

View File

@@ -1,9 +1,9 @@
// @flow
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import { createAction } from "actions";
import { SettingsSection } from "actions/sections";
import stores from "~/stores";
import { Theme } from "~/stores/UiStore";
import { createAction } from "~/actions";
import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"),
@@ -12,7 +12,7 @@ export const changeToDarkTheme = createAction({
keywords: "theme dark night",
section: SettingsSection,
selected: () => stores.ui.theme === "dark",
perform: () => stores.ui.setTheme("dark"),
perform: () => stores.ui.setTheme(Theme.Dark),
});
export const changeToLightTheme = createAction({
@@ -22,7 +22,7 @@ export const changeToLightTheme = createAction({
keywords: "theme light day",
section: SettingsSection,
selected: () => stores.ui.theme === "light",
perform: () => stores.ui.setTheme("light"),
perform: () => stores.ui.setTheme(Theme.Light),
});
export const changeToSystemTheme = createAction({
@@ -32,7 +32,7 @@ export const changeToSystemTheme = createAction({
keywords: "theme system default",
section: SettingsSection,
selected: () => stores.ui.theme === "system",
perform: () => stores.ui.setTheme("system"),
perform: () => stores.ui.setTheme(Theme.System),
});
export const changeTheme = createAction({

View File

@@ -1,10 +1,9 @@
// @flow
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import Invite from "scenes/Invite";
import { createAction } from "actions";
import { UserSection } from "actions/sections";
import stores from "~/stores";
import Invite from "~/scenes/Invite";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,

View File

@@ -1,17 +1,22 @@
// @flow
import { flattenDeep } from "lodash";
import * as React from "react";
import { $Diff } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import type {
import {
Action,
ActionContext,
CommandBarAction,
MenuItemClickable,
MenuItemButton,
MenuItemWithChildren,
} from "types";
} from "~/types";
export function createAction(
definition: $Diff<Action, { id?: string }>
definition: $Diff<
Action,
{
id?: string;
}
>
): Action {
return {
id: uuidv4(),
@@ -22,7 +27,7 @@ export function createAction(
export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemClickable | MenuItemWithChildren {
): MenuItemButton | MenuItemWithChildren {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
@@ -31,18 +36,20 @@ export function actionToMenuItem(
return value;
}
const resolvedIcon = resolve<React.Element<any>>(action.icon);
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? React.cloneElement(resolvedIcon, { color: "currentColor" })
? React.cloneElement(resolvedIcon, {
color: "currentColor",
})
: undefined;
if (resolvedChildren) {
return {
type: "submenu",
title,
icon,
items: resolvedChildren
@@ -53,6 +60,7 @@ export function actionToMenuItem(
}
return {
type: "button",
title,
icon,
visible,
@@ -77,12 +85,11 @@ export function actionToKBar(
return [];
}
const resolvedIcon = resolve<React.Element<any>>(action.icon);
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const resolvedSection = resolve<string>(action.section);
const resolvedName = resolve<string>(action.name);
const resolvedPlaceholder = resolve<string>(action.placeholder);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
@@ -99,19 +106,17 @@ export function actionToKBar(
.filter((c) => !!c.keywords)
.map((c) => c.keywords)
.join(" ")}`,
shortcut: action.shortcut,
shortcut: action.shortcut || [],
icon: resolvedIcon
? React.cloneElement(resolvedIcon, { color: "currentColor" })
? React.cloneElement(resolvedIcon, {
color: "currentColor",
})
: undefined,
perform: action.perform
? () => action.perform && action.perform(context)
: undefined,
children: children.length ? children.map((a) => a.id) : undefined,
},
].concat(
children.map((child) => ({
...child,
parent: action.id,
}))
);
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
].concat(children.map((child) => ({ ...child, parent: action.id })));
}

View File

@@ -1,4 +1,3 @@
// @flow
import { rootCollectionActions } from "./definitions/collections";
import { rootDebugActions } from "./definitions/debug";
import { rootDocumentActions } from "./definitions/documents";

View File

@@ -1,5 +1,4 @@
// @flow
import { type ActionContext } from "types";
import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");

View File

@@ -1,7 +1,6 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
import Flex from "~/components/Flex";
export const Action = styled(Flex)`
justify-content: center;

View File

@@ -1,32 +1,30 @@
// @flow
/* global ga */
import * as React from "react";
import env from "env";
import env from "~/env";
type Props = {
children?: React.Node,
children?: React.ReactNode;
};
export default class Analytics extends React.Component<Props> {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return null;
return;
}
// standard Google Analytics script
window.ga =
window.ga ||
function () {
// $FlowIssue
(ga.q = ga.q || []).push(arguments);
function (...args) {
(ga.q = ga.q || []).push(args);
};
// $FlowIssue
ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("set", { dimension1: "true" });
ga("set", {
dimension1: "true",
});
ga("send", "pageview");
const script = document.createElement("script");
script.src = "https://www.google-analytics.com/analytics.js";
script.async = true;

View File

@@ -1,4 +1,3 @@
// @flow
import * as React from "react";
export default function Arrow() {

View File

@@ -1,10 +1,9 @@
// @flow
import * as React from "react";
type Props = {
size?: number,
fill?: string,
className?: string,
size?: number;
fill?: string;
className?: string;
};
function GoogleLogo({ size = 34, fill = "#FFF", className }: Props) {

View File

@@ -1,10 +1,9 @@
// @flow
import * as React from "react";
type Props = {
size?: number,
fill?: string,
className?: string,
size?: number;
fill?: string;
className?: string;
};
function MicrosoftLogo({ size = 34, fill = "#FFF", className }: Props) {

View File

@@ -1,10 +1,9 @@
// @flow
import * as React from "react";
type Props = {
size?: number,
fill?: string,
className?: string,
size?: number;
fill?: string;
className?: string;
};
function SlackLogo({ size = 34, fill = "#FFF", className }: Props) {

View File

@@ -1,14 +1,13 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import GoogleLogo from "./GoogleLogo";
import MicrosoftLogo from "./MicrosoftLogo";
import SlackLogo from "./SlackLogo";
type Props = {|
providerName: string,
size?: number,
|};
type Props = {
providerName: string;
size?: number;
};
function AuthLogo({ providerName, size = 16 }: Props) {
switch (providerName) {
@@ -18,18 +17,21 @@ function AuthLogo({ providerName, size = 16 }: Props) {
<SlackLogo size={size} />
</Logo>
);
case "google":
return (
<Logo>
<GoogleLogo size={size} />
</Logo>
);
case "azure":
return (
<Logo>
<MicrosoftLogo size={size} />
</Logo>
);
default:
return null;
}

View File

@@ -1,16 +1,15 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "shared/utils/domains";
import LoadingIndicator from "components/LoadingIndicator";
import useStores from "../hooks/useStores";
import { changeLanguage } from "../utils/language";
import env from "env";
import { isCustomSubdomain } from "@shared/utils/domains";
import LoadingIndicator from "~/components/LoadingIndicator";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
type Props = {
children: React.Node,
children: JSX.Element;
};
const Authenticated = ({ children }: Props) => {

View File

@@ -1,24 +1,24 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import User from "models/User";
import User from "~/models/User";
import placeholder from "./placeholder.png";
type Props = {|
src: string,
size: number,
icon?: React.Node,
user?: User,
alt?: string,
onClick?: () => void,
className?: string,
|};
type Props = {
src: string;
size: number;
icon?: React.ReactNode;
user?: User;
alt?: string;
onClick?: () => void;
className?: string;
};
@observer
class Avatar extends React.Component<Props> {
@observable error: boolean;
@observable
error: boolean;
static defaultProps = {
size: 24,
@@ -30,7 +30,6 @@ class Avatar extends React.Component<Props> {
render() {
const { src, icon, ...rest } = this.props;
return (
<AvatarWrapper>
<CircleImg
@@ -60,7 +59,7 @@ const IconWrapper = styled.div`
height: 20px;
`;
const CircleImg = styled.img`
const CircleImg = styled.img<{ size: number }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;

View File

@@ -1,26 +1,25 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { WithTranslation, withTranslation } from "react-i18next";
import styled from "styled-components";
import User from "models/User";
import UserProfile from "scenes/UserProfile";
import Avatar from "components/Avatar";
import Tooltip from "components/Tooltip";
import User from "~/models/User";
import UserProfile from "~/scenes/UserProfile";
import Avatar from "~/components/Avatar";
import Tooltip from "~/components/Tooltip";
type Props = {
user: User,
isPresent: boolean,
isEditing: boolean,
isCurrentUser: boolean,
profileOnClick: boolean,
t: TFunction,
type Props = WithTranslation & {
user: User;
isPresent: boolean;
isEditing: boolean;
isCurrentUser: boolean;
profileOnClick: boolean;
};
@observer
class AvatarWithPresence extends React.Component<Props> {
@observable isOpen: boolean = false;
@observable
isOpen = false;
handleOpenProfile = () => {
this.isOpen = true;
@@ -32,13 +31,11 @@ class AvatarWithPresence extends React.Component<Props> {
render() {
const { user, isPresent, isEditing, isCurrentUser, t } = this.props;
const action = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
return (
<>
<Tooltip
@@ -81,9 +78,9 @@ const Centered = styled.div`
text-align: center;
`;
const AvatarWrapper = styled.div`
const AvatarWrapper = styled.div<{ isPresent: boolean }>`
opacity: ${(props) => (props.isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
`;
export default withTranslation()<AvatarWithPresence>(AvatarWithPresence);
export default withTranslation()(AvatarWithPresence);

View File

@@ -1,6 +1,6 @@
// @flow
import Avatar from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
export { AvatarWithPresence };
export default Avatar;

View File

@@ -1,7 +1,6 @@
// @flow
import styled from "styled-components";
const Badge = styled.span`
const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
margin-left: 10px;
padding: 1px 5px 2px;
background-color: ${({ yellow, primary, theme }) =>

View File

@@ -1,11 +1,10 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import env from "~/env";
import OutlineLogo from "./OutlineLogo";
import env from "env";
type Props = {
href?: string,
href?: string;
};
function Branding({ href = env.URL }: Props) {

View File

@@ -1,35 +1,36 @@
// @flow
import { GoToIcon } 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 BreadcrumbMenu from "menus/BreadcrumbMenu";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { MenuInternalLink } from "~/types";
type MenuItem = {|
icon?: React.Node,
title: React.Node,
to?: string,
|};
export type Crumb = {
title: React.ReactNode;
icon?: React.ReactNode;
to?: string;
};
type Props = {|
items: MenuItem[],
max?: number,
children?: React.Node,
highlightFirstItem?: boolean,
|};
type Props = {
items: Crumb[];
max?: number;
children?: React.ReactNode;
highlightFirstItem?: boolean;
};
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
const totalItems = items.length;
let topLevelItems: MenuItem[] = [...items];
const topLevelItems: Crumb[] = [...items];
let overflowItems;
// chop middle breadcrumbs and present a "..." menu instead
if (totalItems > max) {
const halfMax = Math.floor(max / 2);
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
topLevelItems.splice(halfMax, 0, {
title: <BreadcrumbMenu items={overflowItems} />,
title: <BreadcrumbMenu items={overflowItems as MenuInternalLink[]} />,
});
}
@@ -42,7 +43,7 @@ function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={highlightFirstItem && index === 0}
$highlight={!!highlightFirstItem && index === 0}
>
{item.title}
</Item>
@@ -62,7 +63,7 @@ const Slash = styled(GoToIcon)`
fill: ${(props) => props.theme.divider};
`;
const Item = styled(Link)`
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
display: flex;
flex-shrink: 1;
min-width: 0;

View File

@@ -1,11 +1,10 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "styles/animations";
import { bounceIn } from "~/styles/animations";
type Props = {|
count: number,
|};
type Props = {
count: number;
};
const Bubble = ({ count }: Props) => {
if (!count) {

View File

@@ -1,10 +1,15 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import styled from "styled-components";
const RealButton = styled.button`
const RealButton = styled.button<{
fullwidth?: boolean;
borderOnHover?: boolean;
neutral?: boolean;
danger?: boolean;
iconColor?: string;
}>`
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
margin: 0;
@@ -50,7 +55,7 @@ const RealButton = styled.button`
}
${(props) =>
props.$neutral &&
props.neutral &&
`
background: ${props.theme.buttonNeutralBackground};
color: ${props.theme.buttonNeutralText};
@@ -87,7 +92,9 @@ const RealButton = styled.button`
fill: ${props.theme.textTertiary};
}
}
`} ${(props) =>
`}
${(props) =>
props.danger &&
`
background: ${props.theme.danger};
@@ -99,7 +106,7 @@ const RealButton = styled.button`
`};
`;
const Label = styled.span`
const Label = styled.span<{ hasIcon?: boolean }>`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@@ -107,7 +114,11 @@ const Label = styled.span`
${(props) => props.hasIcon && "padding-left: 4px;"};
`;
export const Inner = styled.span`
export const Inner = styled.span<{
disclosure?: boolean;
hasIcon?: boolean;
hasText?: boolean;
}>`
display: flex;
padding: 0 8px;
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
@@ -120,50 +131,34 @@ export const Inner = styled.span`
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props = {|
type?: "button" | "submit",
value?: string,
icon?: React.Node,
iconColor?: string,
className?: string,
children?: React.Node,
innerRef?: React.ElementRef<any>,
disclosure?: boolean,
neutral?: boolean,
danger?: boolean,
primary?: boolean,
disabled?: boolean,
fullwidth?: boolean,
autoFocus?: boolean,
style?: Object,
as?: React.ComponentType<any> | string,
to?: string,
onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
href?: string,
"data-on"?: string,
"data-event-category"?: string,
"data-event-action"?: string,
|};
export type Props<T> = {
icon?: React.ReactNode;
iconColor?: string;
children?: React.ReactNode;
disclosure?: boolean;
neutral?: boolean;
danger?: boolean;
primary?: boolean;
fullwidth?: boolean;
as?: T;
to?: string;
borderOnHover?: boolean;
href?: string;
"data-on"?: string;
"data-event-category"?: string;
"data-event-action"?: string;
};
const Button = React.forwardRef<Props, HTMLButtonElement>(
(
{
type = "text",
icon,
children,
value,
disclosure,
neutral,
...rest
}: Props,
innerRef
const Button = <T extends React.ElementType = "button">(
props: Props<T> & React.ComponentPropsWithoutRef<T>,
ref: React.Ref<HTMLButtonElement>
) => {
const { type, icon, children, value, disclosure, neutral, ...rest } = props;
const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined;
return (
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
<RealButton type={type || "button"} ref={ref} neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
@@ -171,7 +166,6 @@ const Button = React.forwardRef<Props, HTMLButtonElement>(
</Inner>
</RealButton>
);
}
);
};
export default Button;
export default React.forwardRef(Button);

View File

@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
import Button, { Inner } from "./Button";

View File

@@ -1,10 +1,9 @@
// @flow
import * as React from "react";
import styled from "styled-components";
type Props = {
onClick: (ev: SyntheticEvent<>) => void,
children: React.Node,
onClick: React.MouseEventHandler<HTMLButtonElement>;
children: React.ReactNode;
};
export default function ButtonLink(props: Props) {

View File

@@ -1,20 +1,19 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {|
children?: React.Node,
withStickyHeader?: boolean,
|};
type Props = {
children?: React.ReactNode;
withStickyHeader?: boolean;
};
const Container = styled.div`
const Container = styled.div<{ withStickyHeader?: boolean }>`
width: 100%;
max-width: 100vw;
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")`
padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")};
padding: ${(props: any) => (props.withStickyHeader ? "4px 60px" : "60px")};
`};
`;

View File

@@ -1,29 +1,27 @@
// @flow
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import HelpText from "components/HelpText";
import HelpText from "~/components/HelpText";
export type Props = {|
checked?: boolean,
label?: React.Node,
labelHidden?: boolean,
className?: string,
name?: string,
disabled?: boolean,
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
note?: React.Node,
short?: boolean,
small?: boolean,
|};
export type Props = {
checked?: boolean;
label?: React.ReactNode;
labelHidden?: boolean;
className?: string;
name?: string;
disabled?: boolean;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
note?: React.ReactNode;
small?: boolean;
};
const LabelText = styled.span`
const LabelText = styled.span<{ small?: boolean }>`
font-weight: 500;
margin-left: ${(props) => (props.small ? "6px" : "10px")};
${(props) => (props.small ? `color: ${props.theme.textSecondary}` : "")};
`;
const Wrapper = styled.div`
const Wrapper = styled.div<{ small?: boolean }>`
padding-bottom: 8px;
${(props) => (props.small ? "font-size: 14px" : "")};
width: 100%;
@@ -41,14 +39,13 @@ export default function Checkbox({
note,
className,
small,
short,
...rest
}: Props) {
const wrappedLabel = <LabelText small={small}>{label}</LabelText>;
return (
<>
<Wrapper small={small}>
<Wrapper small={small} className={className}>
<Label>
<input type="checkbox" {...rest} />
{label &&

View File

@@ -1,8 +1,7 @@
// @flow
import React from "react";
import styled, { useTheme } from "styled-components";
const cleanPercentage = (percentage) => {
const cleanPercentage = (percentage: number) => {
const tooLow = !Number.isFinite(+percentage) || percentage < 0;
const tooHigh = percentage > 100;
return tooLow ? 0 : tooHigh ? 100 : +percentage;
@@ -13,13 +12,14 @@ const Circle = ({
percentage,
offset,
}: {
color: string,
percentage?: number,
offset: number,
color: string;
percentage?: number;
offset: number;
}) => {
const radius = offset * 0.7;
const circumference = 2 * Math.PI * radius;
let strokePercentage;
if (percentage) {
// because the circle is so small, anything greater than 85% appears like 100%
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
@@ -39,7 +39,9 @@ const Circle = ({
strokeDasharray={circumference}
strokeDashoffset={percentage ? strokePercentage : 0}
strokeLinecap="round"
style={{ transition: "stroke-dashoffset 0.6s ease 0s" }}
style={{
transition: "stroke-dashoffset 0.6s ease 0s",
}}
></circle>
);
};
@@ -48,8 +50,8 @@ const CircularProgressBar = ({
percentage,
size = 16,
}: {
percentage: number,
size?: number,
percentage: number;
size?: number;
}) => {
const theme = useTheme();
percentage = cleanPercentage(percentage);

View File

@@ -1,7 +1,6 @@
// @flow
import styled from "styled-components";
const ClickablePadding = styled.div`
const ClickablePadding = styled.div<{ grow?: boolean }>`
min-height: 10em;
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
${({ grow }) => grow && `flex-grow: 100;`};

View File

@@ -1,4 +1,3 @@
// @flow
import { sortBy, filter, uniq, isEqual } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
@@ -6,18 +5,18 @@ import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import { AvatarWithPresence } from "components/Avatar";
import DocumentViews from "components/DocumentViews";
import Facepile from "components/Facepile";
import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import Document from "~/models/Document";
import { AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
type Props = {|
document: Document,
|};
type Props = {
document: Document;
};
function Collaborators(props: Props) {
const { t } = useTranslation();
@@ -26,14 +25,13 @@ function Collaborators(props: Props) {
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
const { users, presence } = useStores();
const { document } = props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
const documentPresence = presence.get(document.id);
const documentPresenceArray = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
const presentIds = documentPresenceArray.map((p) => p.userId);
const editingIds = documentPresenceArray
.filter((p) => p.isEditing)
.map((p) => p.userId);
@@ -83,7 +81,6 @@ function Collaborators(props: Props) {
renderAvatar={(user) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
return (
<AvatarWithPresence
key={user.id}

View File

@@ -1,22 +1,21 @@
// @flow
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "models/Collection";
import Arrow from "components/Arrow";
import ButtonLink from "components/ButtonLink";
import Editor from "components/Editor";
import LoadingIndicator from "components/LoadingIndicator";
import NudeButton from "components/NudeButton";
import useDebouncedCallback from "hooks/useDebouncedCallback";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {|
collection: Collection,
|};
type Props = {
collection: Collection;
};
function CollectionDescription({ collection }: Props) {
const { collections, policies } = useStores();
@@ -40,6 +39,7 @@ function CollectionDescription({ collection }: Props) {
event.preventDefault();
if (isExpanded && document.activeElement) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
document.activeElement.blur();
}
@@ -75,20 +75,16 @@ function CollectionDescription({ collection }: Props) {
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input
$isEditable={can.update}
data-editing={isEditing}
data-expanded={isExpanded}
>
<Input data-editing={isEditing} data-expanded={isExpanded}>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
<React.Suspense fallback={<Placeholder>Loading</Placeholder>}>
<Editor
key={key}
@@ -105,7 +101,15 @@ function CollectionDescription({ collection }: Props) {
/>
</React.Suspense>
) : (
can.update && <Placeholder>{placeholder}</Placeholder>
can.update && (
<Placeholder
onClick={() => {
//
}}
>
{placeholder}
</Placeholder>
)
)}
</span>
</Input>

View File

@@ -1,16 +1,15 @@
// @flow
import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import Collection from "models/Collection";
import { icons } from "components/IconPicker";
import useStores from "hooks/useStores";
import Collection from "~/models/Collection";
import { icons } from "~/components/IconPicker";
import useStores from "~/hooks/useStores";
type Props = {
collection: Collection,
expanded?: boolean,
size?: number,
collection: Collection;
expanded?: boolean;
size?: number;
};
function ResolvedCollectionIcon({ collection, expanded, size }: Props) {

View File

@@ -1,4 +1,3 @@
// @flow
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import * as React from "react";
@@ -6,9 +5,10 @@ import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import CommandBarResults from "components/CommandBarResults";
import rootActions from "actions/root";
import useCommandBarActions from "hooks/useCommandBarActions";
import CommandBarResults from "~/components/CommandBarResults";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import { CommandBarAction } from "~/types";
export const CommandBarOptions = {
animations: {
@@ -19,11 +19,12 @@ export const CommandBarOptions = {
function CommandBar() {
const { t } = useTranslation();
useCommandBarActions(rootActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.actions[state.currentRootActionId],
rootAction: state.currentRootActionId
? (state.actions[state.currentRootActionId] as CommandBarAction)
: undefined,
}));
return (
@@ -44,7 +45,7 @@ function CommandBar() {
);
}
function KBarPortal({ children }: { children: React.Node }) {
function KBarPortal({ children }: { children: React.ReactNode }) {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));

View File

@@ -1,23 +1,27 @@
// @flow
import { BackIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
import Key from "components/Key";
import type { CommandBarAction } from "types";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import { CommandBarAction } from "~/types";
type Props = {|
action: CommandBarAction,
active: Boolean,
|};
type Props = {
action: CommandBarAction;
active: boolean;
};
function CommandBarItem({ action, active }: Props, ref) {
function CommandBarItem(
{ action, active }: Props,
ref: React.RefObject<HTMLDivElement>
) {
return (
<Item active={active} ref={ref}>
<Text align="center" gap={8}>
<Icon>
{action.icon ? (
React.cloneElement(action.icon, { size: 22 })
React.cloneElement(action.icon, {
size: 22,
})
) : (
<ForwardIcon color="currentColor" size={22} />
)}
@@ -26,8 +30,14 @@ function CommandBarItem({ action, active }: Props, ref) {
{action.children?.length ? "…" : ""}
</Text>
{action.shortcut?.length ? (
<div style={{ display: "grid", gridAutoFlow: "column", gap: "4px" }}>
{action.shortcut.map((sc) => (
<div
style={{
display: "grid",
gridAutoFlow: "column",
gap: "4px",
}}
>
{action.shortcut.map((sc: string) => (
<Key key={sc}>{sc}</Key>
))}
</div>
@@ -48,7 +58,7 @@ const Text = styled(Flex)`
flex-shrink: 1;
`;
const Item = styled.div`
const Item = styled.div<{ active?: boolean }>`
font-size: 15px;
padding: 12px 16px;
background: ${(props) =>
@@ -68,4 +78,4 @@ const ForwardIcon = styled(BackIcon)`
transform: rotate(180deg);
`;
export default React.forwardRef<Props, HTMLDivElement>(CommandBarItem);
export default React.forwardRef<HTMLDivElement, Props>(CommandBarItem);

View File

@@ -1,8 +1,8 @@
// @flow
import { useMatches, KBarResults, NO_GROUP } from "kbar";
import { useMatches, KBarResults, Action } from "kbar";
import * as React from "react";
import styled from "styled-components";
import CommandBarItem from "components/CommandBarItem";
import CommandBarItem from "~/components/CommandBarItem";
import { CommandBarAction } from "~/types";
export default function CommandBarResults() {
const matches = useMatches();
@@ -14,8 +14,8 @@ export default function CommandBarResults() {
acc.push(name);
acc.push(...actions);
return acc;
}, [])
.filter((i) => i !== NO_GROUP),
}, [] as (Action | string)[])
.filter((i) => i !== "none"),
[matches]
);
@@ -27,7 +27,7 @@ export default function CommandBarResults() {
typeof item === "string" ? (
<Header>{item}</Header>
) : (
<CommandBarItem action={item} active={active} />
<CommandBarItem action={item as CommandBarAction} active={active} />
)
}
/>

View File

@@ -1,14 +1,13 @@
// @flow
import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useStores from "hooks/useStores";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useStores from "~/hooks/useStores";
function ConnectionStatus() {
const { ui } = useStores();

View File

@@ -1,22 +1,20 @@
// @flow
import isPrintableKeyEvent from "is-printable-key-event";
import * as React from "react";
import styled from "styled-components";
type Props = {|
disabled?: boolean,
readOnly?: boolean,
onChange?: (text: string) => void,
onBlur?: (event: SyntheticInputEvent<>) => void,
onInput?: (event: SyntheticInputEvent<>) => void,
onKeyDown?: (event: SyntheticInputEvent<>) => void,
placeholder?: string,
maxLength?: number,
autoFocus?: boolean,
className?: string,
children?: React.Node,
value: string,
|};
type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
disabled?: boolean;
readOnly?: boolean;
onChange?: (text: string) => void;
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
placeholder?: string;
maxLength?: number;
autoFocus?: boolean;
children?: React.ReactNode;
value: string;
};
/**
* Defines a content editable component with the same interface as a native
@@ -37,18 +35,22 @@ function ContentEditable({
readOnly,
...rest
}: Props) {
const ref = React.useRef<?HTMLSpanElement>();
const ref = React.useRef<HTMLSpanElement>(null);
const [innerHTML, setInnerHTML] = React.useState<string>(value);
const lastValue = React.useRef("");
const wrappedEvent = (callback) => (
event: SyntheticInputEvent<HTMLInputElement>
) => {
const wrappedEvent = (
callback:
| React.FocusEventHandler<HTMLSpanElement>
| React.FormEventHandler<HTMLSpanElement>
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
) => (event: any) => {
const text = ref.current?.innerText || "";
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event.preventDefault();
return false;
event?.preventDefault();
return;
}
if (text !== lastValue.current) {
@@ -56,7 +58,7 @@ function ContentEditable({
onChange && onChange(text);
}
callback && callback(event);
callback?.(event);
};
React.useLayoutEffect(() => {
@@ -74,14 +76,16 @@ function ContentEditable({
return (
<div className={className}>
<Content
ref={ref}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
ref={ref}
data-placeholder={placeholder}
role="textbox"
dangerouslySetInnerHTML={{ __html: innerHTML }}
dangerouslySetInnerHTML={{
__html: innerHTML,
}}
{...rest}
/>
{children}

View File

@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const Header = styled.h3`

View File

@@ -1,4 +1,3 @@
// @flow
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
@@ -6,19 +5,19 @@ import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {|
onClick?: (SyntheticEvent<>) => void | Promise<void>,
children?: React.Node,
selected?: boolean,
disabled?: boolean,
to?: string,
href?: string,
target?: "_blank",
as?: string | React.ComponentType<*>,
hide?: () => void,
level?: number,
icon?: React.Node,
|};
type Props = {
onClick?: (arg0: React.SyntheticEvent) => void | Promise<void>;
children?: React.ReactNode;
selected?: boolean;
disabled?: boolean;
to?: string;
href?: string;
target?: "_blank";
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
};
const MenuItem = ({
onClick,
@@ -88,7 +87,7 @@ const Spacer = styled.svg`
flex-shrink: 0;
`;
export const MenuAnchorCSS = css`
export const MenuAnchorCSS = css<{ level?: number; disabled?: boolean }>`
display: flex;
margin: 0;
border: 0;
@@ -138,6 +137,7 @@ export const MenuAnchorCSS = css`
font-size: 14px;
`};
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;

View File

@@ -1,14 +1,18 @@
// @flow
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
import NudeButton from "components/NudeButton";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof MenuButton> & {
className?: string;
iconColor?: string;
};
export default function OverflowMenuButton({
iconColor,
className,
...rest
}: any) {
}: Props) {
return (
<MenuButton {...rest}>
{(props) => (

View File

@@ -1,9 +1,8 @@
// @flow
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: {}) {
export default function Separator(rest: any) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}

View File

@@ -1,197 +0,0 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import Flex from "components/Flex";
import MenuIconWrapper from "components/MenuIconWrapper";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
import { actionToMenuItem } from "actions";
import useStores from "hooks/useStores";
import type {
MenuItem as TMenuItem,
Action,
ActionContext,
MenuSeparator,
MenuHeading,
} from "types";
type Props = {|
items: TMenuItem[],
actions: (Action | MenuSeparator | MenuHeading)[],
context?: $Shape<ActionContext>,
|};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
`;
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor {...props}>
{title} <Disclosure color="currentColor" />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unnecessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) return acc;
if (item.type === "separator" && index === filtered.length - 1) return acc;
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator")
return acc;
// otherwise, continue
return [...acc, item];
}, []);
return filtered;
}
function Template({ items, actions, context, ...menu }: Props): React.Node {
const { t } = useTranslation();
const location = useLocation();
const stores = useStores();
const { ui } = stores;
const ctx = {
t,
isCommandBar: false,
isContextMenu: true,
activeCollectionId: ui.activeCollectionId,
activeDocumentId: ui.activeDocumentId,
location,
stores,
...context,
};
const filteredTemplates = filterTemplateItems(
actions
? actions.map((action) =>
action.type ? action : actionToMenuItem(action, ctx)
)
: items
);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) => !item.type && !!item.icon
);
return filteredTemplates.map((item, index) => {
if (iconIsPresentInAnyMenuItem && !item.type) {
item.icon = item.icon || <MenuIconWrapper />;
}
if (item.to) {
return (
<MenuItem
as={Link}
to={item.to}
key={index}
disabled={item.disabled}
selected={item.selected}
icon={item.icon}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.href) {
return (
<MenuItem
href={item.href}
key={index}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={item.icon}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.onClick) {
return (
<MenuItem
as="button"
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
key={index}
icon={item.icon}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.items) {
return (
<BaseMenuItem
key={index}
as={Submenu}
templateItems={item.items}
title={<Title title={item.title} icon={item.icon} />}
{...menu}
/>
);
}
if (item.type === "separator") {
return <Separator key={index} />;
}
if (item.type === "heading") {
return <Header>{item.title}</Header>;
}
console.warn("Unrecognized menu item", item);
return null;
});
}
function Title({ title, icon }) {
return (
<Flex align="center">
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);

View File

@@ -0,0 +1,225 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import { $Shape } from "utility-types";
import Flex from "~/components/Flex";
import MenuIconWrapper from "~/components/MenuIconWrapper";
import { actionToMenuItem } from "~/actions";
import useStores from "~/hooks/useStores";
import {
Action,
ActionContext,
MenuSeparator,
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
type Props = {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: $Shape<ActionContext>;
items?: TMenuItem[];
};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
`;
const Submenu = React.forwardRef(
(
{
templateItems,
title,
...rest
}: { templateItems: TMenuItem[]; title: React.ReactNode },
ref: React.LegacyRef<HTMLButtonElement>
) => {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor {...props}>
{title} <Disclosure color="currentColor" />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
}
);
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unnecessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) return acc;
if (item.type === "separator" && index === filtered.length - 1) return acc;
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator")
return acc;
// otherwise, continue
return [...acc, item];
}, []);
return filtered;
}
function Template({ items, actions, context, ...menu }: Props) {
const { t } = useTranslation();
const location = useLocation();
const stores = useStores();
const { ui } = stores;
const ctx = {
t,
isCommandBar: false,
isContextMenu: true,
activeCollectionId: ui.activeCollectionId,
activeDocumentId: ui.activeDocumentId,
location,
stores,
...context,
};
const templateItems = actions
? actions.map((item) =>
item.type === "separator" || item.type === "heading"
? item
: actionToMenuItem(item, ctx)
)
: items || [];
const filteredTemplates = filterTemplateItems(templateItems);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) =>
item.type !== "separator" && item.type !== "heading" && !!item.icon
);
return (
<>
{filteredTemplates.map((item, index) => {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading"
) {
item.icon = item.icon || <MenuIconWrapper />;
}
if (item.type === "route") {
return (
<MenuItem
as={Link}
to={item.to}
key={index}
disabled={item.disabled}
selected={item.selected}
icon={item.icon}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "link") {
return (
<MenuItem
href={item.href}
key={index}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={item.icon}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "button") {
return (
<MenuItem
as="button"
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
key={index}
icon={item.icon}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "submenu") {
return (
<BaseMenuItem
key={index}
as={Submenu}
templateItems={item.items}
title={<Title title={item.title} icon={item.icon} />}
{...menu}
/>
);
}
if (item.type === "separator") {
return <Separator key={index} />;
}
if (item.type === "heading") {
return <Header>{item.title}</Header>;
}
const _exhaustiveCheck: never = item;
return _exhaustiveCheck;
})}
</>
);
}
function Title({
title,
icon,
}: {
title: React.ReactNode;
icon?: React.ReactNode;
}) {
return (
<Flex align="center">
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);

View File

@@ -1,31 +1,45 @@
// @flow
import * as React from "react";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import useMenuHeight from "hooks/useMenuHeight";
import usePrevious from "hooks/usePrevious";
import useMenuHeight from "~/hooks/useMenuHeight";
import usePrevious from "~/hooks/usePrevious";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "styles/animations";
} from "~/styles/animations";
type Props = {|
"aria-label": string,
visible?: boolean,
placement?: string,
animating?: boolean,
children: React.Node,
unstable_disclosureRef?: {
current: null | React.ElementRef<"button">,
},
onOpen?: () => void,
onClose?: () => void,
hide?: () => void,
|};
export type Placement =
| "auto-start"
| "auto"
| "auto-end"
| "top-start"
| "top"
| "top-end"
| "right-start"
| "right"
| "right-end"
| "bottom-end"
| "bottom"
| "bottom-start"
| "left-end"
| "left"
| "left-start";
type Props = {
"aria-label": string;
visible?: boolean;
placement?: Placement;
animating?: boolean;
children: React.ReactNode;
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
onOpen?: () => void;
onClose?: () => void;
hide?: () => void;
};
export default function ContextMenu({
children,
@@ -43,6 +57,7 @@ export default function ContextMenu({
onOpen();
}
}
if (!rest.visible && previousVisible) {
if (onClose) {
onClose();
@@ -50,6 +65,11 @@ export default function ContextMenu({
}
}, [onOpen, onClose, previousVisible, rest.visible]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
}
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
@@ -59,7 +79,9 @@ export default function ContextMenu({
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
// @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'.
const topAnchor = props.style.top === "0";
// @ts-expect-error ts-migrate(2339) FIXME: Property 'placement' does not exist on type 'Extra... Remove this comment to see the full error message
const rightAnchor = props.placement === "bottom-end";
return (
@@ -68,8 +90,15 @@ export default function ContextMenu({
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
ref={backgroundRef}
style={maxHeight && topAnchor ? { maxHeight } : undefined}
style={
maxHeight && topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
@@ -115,7 +144,10 @@ export const Position = styled.div`
`};
`;
export const Background = styled.div`
export const Background = styled.div<{
topAnchor?: boolean;
rightAnchor?: boolean;
}>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
@@ -135,11 +167,11 @@ export const Background = styled.div`
}
${breakpoint("tablet")`
animation: ${(props) =>
animation: ${(props: any) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props) => (props.rightAnchor ? "75%" : "25%")} 0;
transform-origin: ${(props: any) => (props.rightAnchor ? "75%" : "25%")} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
background: ${(props: any) => props.theme.menuBackground};
box-shadow: ${(props: any) => props.theme.menuShadow};
`};
`;

View File

@@ -1,24 +1,25 @@
// @flow
import copy from "copy-to-clipboard";
import * as React from "react";
type Props = {
text: string,
children?: React.Node,
onClick?: () => void,
onCopy: () => void,
text: string;
children?: React.ReactElement;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
onCopy: () => void;
};
class CopyToClipboard extends React.PureComponent<Props> {
onClick = (ev: SyntheticEvent<>) => {
onClick = (ev: React.SyntheticEvent) => {
const { text, onCopy, children } = this.props;
const elem = React.Children.only(children);
copy(text, {
debug: process.env.NODE_ENV !== "production",
format: "text/plain",
});
if (onCopy) onCopy();
if (onCopy) {
onCopy();
}
if (elem && elem.props && typeof elem.props.onClick === "function") {
elem.props.onClick(ev);
@@ -28,6 +29,10 @@ class CopyToClipboard extends React.PureComponent<Props> {
render() {
const { text: _text, onCopy: _onCopy, children, ...rest } = this.props;
const elem = React.Children.only(children);
if (!elem) {
return null;
}
return React.cloneElement(elem, { ...rest, onClick: this.onClick });
}
}

View File

@@ -1,9 +1,8 @@
// @flow
import * as React from "react";
type Props = {
delay?: number,
children: React.Node,
delay?: number;
children: JSX.Element;
};
export default function DelayedMount({ delay = 250, children }: Props) {

View File

@@ -1,14 +1,12 @@
// @flow
import { observer } from "mobx-react-lite";
import * as React from "react";
import Guide from "components/Guide";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import Guide from "~/components/Guide";
import Modal from "~/components/Modal";
import useStores from "~/hooks/useStores";
function Dialogs() {
const { dialogs } = useStores();
const { guide, modalStack } = dialogs;
return (
<>
{guide ? (

View File

@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const Divider = styled.hr`

View File

@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
@@ -10,19 +9,20 @@ import {
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import CollectionIcon from "components/CollectionIcon";
import useStores from "hooks/useStores";
import { collectionUrl } from "utils/routeHelpers";
import Document from "~/models/Document";
import Breadcrumb, { Crumb } from "~/components/Breadcrumb";
import CollectionIcon from "~/components/CollectionIcon";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import { collectionUrl } from "~/utils/routeHelpers";
type Props = {|
document: Document,
children?: React.Node,
onlyText: boolean,
|};
type Props = {
document: Document;
children?: React.ReactNode;
onlyText?: boolean;
};
function useCategory(document) {
function useCategory(document: Document) {
const { t } = useTranslation();
if (document.isDeleted) {
@@ -32,6 +32,7 @@ function useCategory(document) {
to: "/trash",
};
}
if (document.isArchived) {
return {
icon: <ArchiveIcon color="currentColor" />,
@@ -39,6 +40,7 @@ function useCategory(document) {
to: "/archive",
};
}
if (document.isDraft) {
return {
icon: <EditIcon color="currentColor" />,
@@ -46,6 +48,7 @@ function useCategory(document) {
to: "/drafts",
};
}
if (document.isTemplate) {
return {
icon: <ShapesIcon color="currentColor" />,
@@ -53,6 +56,7 @@ function useCategory(document) {
to: "/templates",
};
}
return null;
}
@@ -60,49 +64,46 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
const collection = collections.get(document.collectionId);
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
url: "deleted-collection",
let collectionNode: Crumb;
if (collection) {
collectionNode = {
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionUrl(collection.url),
};
} else {
collectionNode = {
title: t("Deleted Collection"),
icon: undefined,
to: collectionUrl("deleted-collection"),
};
}
const path = React.useMemo(
() =>
collection && collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [],
() => collection?.pathToDocument?.(document.id).slice(0, -1) || [],
[collection, document.id]
);
const items = React.useMemo(() => {
let output = [];
const output: Crumb[] = [];
if (category) {
output.push(category);
}
if (collection) {
output.push({
icon: <CollectionIcon collection={collection} expanded />,
title: collection.name,
to: collectionUrl(collection.url),
});
}
output.push(collectionNode);
path.forEach((p) => {
path.forEach((p: NavigationNode) => {
output.push({
title: p.title,
to: p.url,
});
});
return output;
}, [path, category, collection]);
}, [path, category, collectionNode]);
if (!collections.isLoaded) {
return null;
@@ -111,8 +112,8 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
if (onlyText === true) {
return (
<>
{collection.name}
{path.map((n) => (
{collection?.name}
{path.map((n: any) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}

View File

@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
@@ -6,30 +5,34 @@ import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "models/Event";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import PaginatedEventList from "components/PaginatedEventList";
import Scrollable from "components/Scrollable";
import useStores from "hooks/useStores";
import { documentUrl } from "utils/routeHelpers";
import Event from "~/models/Event";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import PaginatedEventList from "~/components/PaginatedEventList";
import Scrollable from "~/components/Scrollable";
import useStores from "~/hooks/useStores";
import { documentUrl } from "~/utils/routeHelpers";
const EMPTY_ARRAY = [];
const EMPTY_ARRAY: Event[] = [];
function DocumentHistory() {
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
? events.inDocument(document.id)
: EMPTY_ARRAY;
const onCloseHistory = () => {
if (document) {
history.push(documentUrl(document));
} else {
history.goBack();
}
};
const items = React.useMemo(() => {
@@ -39,17 +42,20 @@ function DocumentHistory() {
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event({
new Event(
{
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
})
},
events
)
);
}
return eventsInDocument;
}, [eventsInDocument, document]);
}, [eventsInDocument, events, document]);
return (
<Sidebar>
@@ -68,7 +74,9 @@ function DocumentHistory() {
<PaginatedEventList
fetch={events.fetchPage}
events={items}
options={{ documentId: document.id }}
options={{
documentId: document.id,
}}
document={document}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>

View File

@@ -1,22 +1,20 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import * as React from "react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
type Props = {|
documents: Document[],
limit?: number,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
type Props = {
documents: Document[];
limit?: number;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
export default function DocumentList({ limit, documents, ...rest }: Props) {
const items = limit ? documents.splice(0, limit) : documents;
return (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}

View File

@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
@@ -6,34 +5,33 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import Badge from "components/Badge";
import Button from "components/Button";
import DocumentMeta from "components/DocumentMeta";
import EventBoundary from "components/EventBoundary";
import Flex from "components/Flex";
import Highlight from "components/Highlight";
import StarButton, { AnimatedStar } from "components/Star";
import Tooltip from "components/Tooltip";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentPath } from "utils/routeHelpers";
type Props = {|
document: Document,
highlight?: ?string,
context?: ?string,
showNestedDocuments?: boolean,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { newDocumentPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
highlight?: string | undefined;
context?: string | undefined;
showNestedDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
@@ -42,12 +40,16 @@ function replaceResultMarks(tag: string) {
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(props: Props, ref) {
function DocumentListItem(
props: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const {
document,
showNestedDocuments,
@@ -59,7 +61,6 @@ function DocumentListItem(props: Props, ref) {
highlight,
context,
} = props;
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
@@ -76,7 +77,9 @@ function DocumentListItem(props: Props, ref) {
$menuOpen={menuOpen}
to={{
pathname: document.url,
state: { title: document.titleWithDefault },
state: {
title: document.titleWithDefault,
},
}}
>
<Content>
@@ -173,7 +176,10 @@ const Actions = styled(EventBoundary)`
`};
`;
const DocumentLink = styled(Link)`
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
align-items: center;
margin: 10px -8px;
@@ -228,7 +234,7 @@ const DocumentLink = styled(Link)`
`}
`;
const Heading = styled.h3`
const Heading = styled.h3<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;

View File

@@ -1,18 +1,17 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import DocumentTasks from "components/DocumentTasks";
import Flex from "components/Flex";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import Document from "~/models/Document";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import DocumentTasks from "~/components/DocumentTasks";
import Flex from "~/components/Flex";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
const Container = styled(Flex)`
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
@@ -26,20 +25,20 @@ const Viewed = styled.span`
overflow: hidden;
`;
const Modified = styled.span`
const Modified = styled.span<{ highlight?: boolean }>`
color: ${(props) => props.theme.textTertiary};
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
type Props = {|
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
showNestedDocuments?: boolean,
document: Document,
children: React.Node,
to?: string,
|};
type Props = {
showCollection?: boolean;
showPublished?: boolean;
showLastViewed?: boolean;
showNestedDocuments?: boolean;
document: Document;
children?: React.ReactNode;
to?: string;
};
function DocumentMeta({
showPublished,
@@ -54,7 +53,6 @@ function DocumentMeta({
const { t } = useTranslation();
const { collections } = useStores();
const user = useCurrentUser();
const {
modifiedSinceViewed,
updatedAt,
@@ -126,6 +124,7 @@ function DocumentMeta({
if (isDraft || !showLastViewed) {
return null;
}
if (!lastViewedAt) {
return (
<Viewed>
@@ -156,7 +155,9 @@ function DocumentMeta({
{showNestedDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp; {nestedDocumentsCount}{" "}
{t("nested document", { count: nestedDocumentsCount })}
{t("nested document", {
count: nestedDocumentsCount,
})}
</span>
)}
&nbsp;{timeSinceNow()}

View File

@@ -1,21 +1,20 @@
// @flow
import { useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import DocumentViews from "components/DocumentViews";
import Popover from "components/Popover";
import useStores from "../hooks/useStores";
import Document from "~/models/Document";
import DocumentMeta from "~/components/DocumentMeta";
import DocumentViews from "~/components/DocumentViews";
import Popover from "~/components/Popover";
import useStores from "~/hooks/useStores";
type Props = {|
document: Document,
isDraft: boolean,
to?: string,
rtl?: boolean,
|};
type Props = {
document: Document;
isDraft: boolean;
to?: string;
rtl?: boolean;
};
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const { views } = useStores();
@@ -26,7 +25,9 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
React.useEffect(() => {
if (!document.isDeleted) {
views.fetchPage({ documentId: document.id });
views.fetchPage({
documentId: document.id,
});
}
}, [views, document.id, document.isDeleted]);
@@ -62,7 +63,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
);
}
const Meta = styled(DocumentMeta)`
const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
margin: -12px 0 2em 0;
font-size: 14px;

View File

@@ -1,22 +1,27 @@
// @flow
import { DoneIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTranslation, TFunction } from "react-i18next";
import styled, { useTheme } from "styled-components";
import CircularProgressBar from "components/CircularProgressBar";
import usePrevious from "../hooks/usePrevious";
import Document from "../models/Document";
import { bounceIn } from "styles/animations";
import Document from "~/models/Document";
import CircularProgressBar from "~/components/CircularProgressBar";
import usePrevious from "~/hooks/usePrevious";
import { bounceIn } from "~/styles/animations";
type Props = {|
document: Document,
|};
type Props = {
document: Document;
};
function getMessage(t, total, completed) {
function getMessage(t: TFunction, total: number, completed: number): string {
if (completed === 0) {
return t(`{{ total }} task`, { total, count: total });
return t(`{{ total }} task`, {
total,
count: total,
});
} else if (completed === total) {
return t(`{{ completed }} task done`, { completed, count: completed });
return t(`{{ completed }} task done`, {
completed,
count: completed,
});
} else {
return t(`{{ completed }} of {{ total }} tasks`, {
total,
@@ -33,7 +38,6 @@ function DocumentTasks({ document }: Props) {
const done = completed === total;
const previousDone = usePrevious(done);
const message = getMessage(t, total, completed);
return (
<>
{completed === total ? (

View File

@@ -1,31 +1,28 @@
// @flow
import { formatDistanceToNow } from "date-fns";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "models/Document";
import Avatar from "components/Avatar";
import ListItem from "components/List/Item";
import PaginatedList from "components/PaginatedList";
import useStores from "hooks/useStores";
import Document from "~/models/Document";
import Avatar from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import useStores from "~/hooks/useStores";
type Props = {|
document: Document,
isOpen?: boolean,
|};
type Props = {
document: Document;
isOpen?: boolean;
};
function DocumentViews({ document, isOpen }: Props) {
const { t } = useTranslation();
const { views, presence } = useStores();
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
const documentPresence = presence.get(document.id);
const documentPresenceArray = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
const presentIds = documentPresenceArray.map((p) => p.userId);
const editingIds = documentPresenceArray
.filter((p) => p.isEditing)
.map((p) => p.userId);
@@ -35,7 +32,6 @@ function DocumentViews({ document, isOpen }: Props) {
documentViews,
(view) => !presentIds.includes(view.user.id)
);
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
sortedViews,
]);
@@ -49,7 +45,6 @@ function DocumentViews({ document, isOpen }: Props) {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);
const subtitle = isPresent
? isEditing
? t("Currently editing")
@@ -59,7 +54,6 @@ function DocumentViews({ document, isOpen }: Props) {
view ? Date.parse(view.lastViewedAt) : new Date()
),
});
return (
<ListItem
key={item.id}

View File

@@ -1,76 +1,84 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import { Extension } from "rich-markdown-editor";
import styled, { withTheme } from "styled-components";
import embeds from "shared/embeds";
import { light } from "shared/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import useMediaQuery from "hooks/useMediaQuery";
import useToasts from "hooks/useToasts";
import { type Theme } from "types";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl, isHash } from "utils/urls";
import styled, { DefaultTheme, withTheme } from "styled-components";
import embeds from "@shared/embeds";
import { light } from "@shared/theme";
import UiStore from "~/stores/UiStore";
import ErrorBoundary from "~/components/ErrorBoundary";
import Tooltip from "~/components/Tooltip";
import useMediaQuery from "~/hooks/useMediaQuery";
import useToasts from "~/hooks/useToasts";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { uploadFile } from "~/utils/uploadFile";
import { isInternalUrl, isHash } from "~/utils/urls";
const RichMarkdownEditor = React.lazy(() =>
import(/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor")
const RichMarkdownEditor = React.lazy(
() =>
import(
/* webpackChunkName: "rich-markdown-editor" */
"rich-markdown-editor"
)
);
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'EMPTY_ARRAY' implicitly has type 'any[]'... Remove this comment to see the full error message
const EMPTY_ARRAY = [];
export type Props = {|
id?: string,
value?: string,
defaultValue?: string,
readOnly?: boolean,
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
style?: Object,
extensions?: Extension[],
shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
maxLength?: number,
scrollTo?: string,
theme?: Theme,
className?: string,
handleDOMEvents?: Object,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
onFocus?: (event: SyntheticEvent<>) => any,
onPublish?: (event: SyntheticEvent<>) => any,
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
onCancel?: () => any,
onDoubleClick?: () => any,
onChange?: (getValue: () => string) => any,
onSearchLink?: (title: string) => any,
onHoverLink?: (event: MouseEvent) => any,
onCreateLink?: (title: string) => Promise<string>,
onImageUploadStart?: () => any,
onImageUploadStop?: () => any,
|};
export type Props = {
id?: string;
value?: string;
defaultValue?: string;
readOnly?: boolean;
grow?: boolean;
disableEmbeds?: boolean;
ui?: UiStore;
style?: React.CSSProperties;
extensions?: Extension[];
shareId?: string | null | undefined;
autoFocus?: boolean;
template?: boolean;
placeholder?: string;
maxLength?: number;
scrollTo?: string;
theme?: DefaultTheme;
className?: string;
readOnlyWriteCheckboxes?: boolean;
onBlur?: () => void;
onFocus?: () => void;
onPublish?: (event: React.SyntheticEvent) => any;
onSave?: (arg0: {
done?: boolean;
autosave?: boolean;
publish?: boolean;
}) => any;
onSynced?: () => Promise<void>;
onCancel?: () => any;
onDoubleClick?: () => any;
onChange?: (getValue: () => string) => any;
onSearchLink?: (title: string) => any;
onHoverLink?: (event: MouseEvent) => any;
onCreateLink?: (title: string) => Promise<string>;
onImageUploadStart?: () => any;
onImageUploadStop?: () => any;
};
type PropsWithRef = Props & {
forwardedRef: React.Ref<any>,
history: RouterHistory,
forwardedRef: React.Ref<any>;
};
function Editor(props: PropsWithRef) {
const { id, shareId, history } = props;
const { id, shareId } = props;
const { t } = useTranslation();
const { showToast } = useToasts();
const isPrinting = useMediaQuery("print");
const onUploadImage = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, { documentId: id });
const result = await uploadFile(file, {
documentId: id,
});
return result.url;
},
[id]
@@ -166,7 +174,9 @@ function Editor(props: PropsWithRef) {
pageBreak: t("Page break"),
pasteLink: `${t("Paste a link")}`,
pasteLinkWithTitle: (service: string) =>
t("Paste a {{service}} link…", { service }),
t("Paste a {{service}} link…", {
service,
}),
placeholder: t("Placeholder"),
quote: t("Quote"),
removeLink: t("Remove link"),
@@ -189,17 +199,20 @@ function Editor(props: PropsWithRef) {
uploadImage={onUploadImage}
onClickLink={onClickLink}
onShowToast={onShowToast}
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'EMPTY_ARRAY' implicitly has an 'any[]' t... Remove this comment to see the full error message
embeds={props.disableEmbeds ? EMPTY_ARRAY : embeds}
tooltip={EditorTooltip}
dictionary={dictionary}
{...props}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
theme={isPrinting ? light : props.theme}
/>
</ErrorBoundary>
);
}
const StyledEditor = styled(RichMarkdownEditor)`
const StyledEditor = styled(RichMarkdownEditor)<{ grow?: boolean }>`
flex-grow: ${(props) => (props.grow ? 1 : 0)};
justify-content: start;
@@ -307,7 +320,9 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
`;
// @ts-expect-error ts-migrate(7031) FIXME: Binding element 'children' implicitly has an 'any'... Remove this comment to see the full error message
const EditorTooltip = ({ children, ...props }) => (
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
<Tooltip offset="0, 16" delay={150} {...props}>
<Span>{children}</Span>
</Tooltip>
@@ -317,8 +332,8 @@ const Span = styled.span`
outline: none;
`;
const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
const EditorWithTheme = withTheme(Editor);
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
export default React.forwardRef<typeof Editor, Props>((props, ref) => (
<EditorWithTheme {...props} forwardedRef={ref} />
));

View File

@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const Empty = styled.p`

View File

@@ -1,29 +1,30 @@
// @flow
import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import { withTranslation, Trans, WithTranslation } from "react-i18next";
import styled from "styled-components";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
import HelpText from "components/HelpText";
import PageTitle from "components/PageTitle";
import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
import env from "env";
import { githubIssuesUrl } from "@shared/utils/routeHelpers";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import HelpText from "~/components/HelpText";
import PageTitle from "~/components/PageTitle";
import env from "~/env";
type Props = {|
children: React.Node,
reloadOnChunkMissing?: boolean,
t: TFunction,
|};
type Props = WithTranslation & {
children: React.ReactNode;
reloadOnChunkMissing?: boolean;
};
@observer
class ErrorBoundary extends React.Component<Props> {
@observable error: ?Error;
@observable showDetails: boolean = false;
@observable
error: Error | null | undefined;
componentDidCatch(error: Error, info: Object) {
@observable
showDetails = false;
componentDidCatch(error: Error) {
this.error = error;
console.error(error);
@@ -35,7 +36,7 @@ class ErrorBoundary extends React.Component<Props> {
// If the editor bundle fails to load then reload the entire window. This
// can happen if a deploy happens between the user loading the initial JS
// bundle and the async-loaded editor JS bundle as the hash will change.
window.location.reload(true);
window.location.reload();
return;
}
@@ -45,7 +46,7 @@ class ErrorBoundary extends React.Component<Props> {
}
handleReload = () => {
window.location.reload(true);
window.location.reload();
};
handleShowDetails = () => {
@@ -79,9 +80,7 @@ class ErrorBoundary extends React.Component<Props> {
</Trans>
</HelpText>
<p>
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>
<Button onClick={this.handleReload}>{t("Reload")}</Button>
</p>
</CenteredContent>
);
@@ -105,9 +104,7 @@ class ErrorBoundary extends React.Component<Props> {
</HelpText>
{this.showDetails && <Pre>{error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>{" "}
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
{this.showDetails ? (
<Button onClick={this.handleReportBug} neutral>
<Trans>Report a Bug</Trans>
@@ -121,6 +118,7 @@ class ErrorBoundary extends React.Component<Props> {
</CenteredContent>
);
}
return this.props.children;
}
}
@@ -133,4 +131,4 @@ const Pre = styled.pre`
white-space: pre-wrap;
`;
export default withTranslation()<ErrorBoundary>(ErrorBoundary);
export default withTranslation()(ErrorBoundary);

View File

@@ -1,14 +1,12 @@
// @flow
import * as React from "react";
type Props = {
children: React.Node,
className?: string,
children: React.ReactNode;
className?: string;
};
export default function EventBoundary({ children, className }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);

View File

@@ -1,4 +1,3 @@
// @flow
import {
TrashIcon,
ArchiveIcon,
@@ -10,23 +9,25 @@ import {
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import Avatar from "components/Avatar";
import Item, { Actions } from "components/List/Item";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { documentHistoryUrl } from "utils/routeHelpers";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Avatar from "~/components/Avatar";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import RevisionMenu from "~/menus/RevisionMenu";
import { documentHistoryUrl } from "~/utils/routeHelpers";
type Props = {|
document: Document,
event: Event,
latest?: boolean,
|};
type Props = {
document: Document;
event: Event;
latest?: boolean;
};
const EventListItem = ({ event, latest, document }: Props) => {
const { t } = useTranslation();
const opts = { userName: event.actor.name };
const opts = {
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
let meta, icon, to;
@@ -45,28 +46,35 @@ const EventListItem = ({ event, latest, document }: Props) => {
break;
}
}
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon color="currentColor" size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.restore":
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
break;
default:
console.warn("Unhandled event: ", event.name);
}
@@ -78,7 +86,6 @@ const EventListItem = ({ event, latest, document }: Props) => {
return (
<ListItem
small
exact
to={to}
title={
<Time
@@ -97,7 +104,7 @@ const EventListItem = ({ event, latest, document }: Props) => {
</Subtitle>
}
actions={
isRevision ? (
isRevision && event.modelId ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}

View File

@@ -1,22 +1,21 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import User from "models/User";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Flex from "~/components/Flex";
type Props = {|
users: User[],
size?: number,
overflow: number,
onClick?: (event: SyntheticEvent<>) => mixed,
renderAvatar?: (user: User) => React.Node,
|};
type Props = {
users: User[];
size?: number;
overflow?: number;
onClick?: React.MouseEventHandler<HTMLDivElement>;
renderAvatar?: (user: User) => React.ReactNode;
};
function Facepile({
users,
overflow,
overflow = 0,
size = 32,
renderAvatar = DefaultAvatar,
...rest
@@ -47,7 +46,7 @@ const AvatarWrapper = styled.div`
}
`;
const More = styled.div`
const More = styled.div<{ size: number }>`
display: flex;
flex-direction: column;
align-items: center;

View File

@@ -1,8 +1,7 @@
// @flow
import styled from "styled-components";
import { fadeIn } from "styles/animations";
import { fadeIn } from "~/styles/animations";
const Fade = styled.span`
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;

View File

@@ -1,51 +1,51 @@
// @flow
import { find } from "lodash";
import * as React from "react";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import Button, { Inner } from "components/Button";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import HelpText from "components/HelpText";
import Button, { Inner } from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import HelpText from "~/components/HelpText";
type TFilterOption = {|
key: string,
label: string,
note?: string,
|};
type TFilterOption = {
key: string;
label: string;
note?: string;
};
type Props = {|
options: TFilterOption[],
activeKey: ?string,
defaultLabel?: string,
selectedPrefix?: string,
className?: string,
onSelect: (key: ?string) => void,
|};
type Props = {
options: TFilterOption[];
activeKey: string | null | undefined;
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
};
const FilterOptions = ({
options,
activeKey = "",
defaultLabel,
defaultLabel = "Filter options",
selectedPrefix = "",
className,
onSelect,
}: Props) => {
const menu = useMenuState({ modal: true });
const selected = find(options, { key: activeKey }) || options[0];
const menu = useMenuState({
modal: true,
});
const selected =
find(options, {
key: activeKey,
}) || options[0];
// @ts-expect-error ts-migrate(2339) FIXME: Property 'label' does not exist on type 'number | ... Remove this comment to see the full error message
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
return (
<Wrapper>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
neutral
disclosure
small
>
<StyledButton {...props} className={className} neutral disclosure>
{activeKey ? selectedLabel : defaultLabel}
</StyledButton>
)}

View File

@@ -1,5 +1,3 @@
// @flow
import * as React from "react";
import styled from "styled-components";
type JustifyValues =
@@ -16,29 +14,14 @@ type AlignValues =
| "flex-start"
| "flex-end";
type Props = {|
column?: ?boolean,
shrink?: ?boolean,
align?: AlignValues,
justify?: JustifyValues,
auto?: ?boolean,
className?: string,
children?: React.Node,
role?: string,
gap?: number,
|};
const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => {
const { children, ...restProps } = props;
return (
<Container ref={ref} {...restProps}>
{children}
</Container>
);
});
const Container = styled.div`
const Flex = styled.div<{
auto?: boolean;
column?: boolean;
align?: AlignValues;
justify?: JustifyValues;
shrink?: boolean;
gap?: number;
}>`
display: flex;
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
flex-direction: ${({ column }) => (column ? "column" : "row")};

View File

@@ -1,9 +1,8 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Empty from "components/Empty";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Empty from "~/components/Empty";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
export default function FullscreenLoading() {
return (

View File

@@ -1,10 +1,9 @@
// @flow
import * as React from "react";
type Props = {
size?: number,
fill?: string,
className?: string,
size?: number;
fill?: string;
className?: string;
};
function GithubLogo({ size = 34, fill = "#FFF", className }: Props) {

View File

@@ -1,31 +1,31 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
import CollectionGroupMembership from "models/CollectionGroupMembership";
import Group from "models/Group";
import GroupMembers from "scenes/GroupMembers";
import Facepile from "components/Facepile";
import Flex from "components/Flex";
import ListItem from "components/List/Item";
import Modal from "components/Modal";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import RootStore from "~/stores/RootStore";
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
import Group from "~/models/Group";
import GroupMembers from "~/scenes/GroupMembers";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
type Props = {
group: Group,
groupMemberships: GroupMembershipsStore,
membership?: CollectionGroupMembership,
showFacepile?: boolean,
showAvatar?: boolean,
renderActions: ({ openMembersModal: () => void }) => React.Node,
type Props = RootStore & {
group: Group;
membership?: CollectionGroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (arg0: { openMembersModal: () => void }) => React.ReactNode;
};
@observer
class GroupListItem extends React.Component<Props> {
@observable membersModalOpen: boolean = false;
@observable
membersModalOpen = false;
handleMembersModalOpen = () => {
this.membersModalOpen = true;
@@ -37,14 +37,12 @@ class GroupListItem extends React.Component<Props> {
render() {
const { group, groupMemberships, showFacepile, renderActions } = this.props;
const memberCount = group.memberCount;
const membershipsInGroup = groupMemberships.inGroup(group.id);
const users = membershipsInGroup
.slice(0, MAX_AVATAR_DISPLAY)
// @ts-expect-error ts-migrate(2339) FIXME: Property 'user' does not exist on type 'GroupMembe... Remove this comment to see the full error message
.map((gm) => gm.user);
const overflow = memberCount - users.length;
return (
@@ -84,7 +82,7 @@ class GroupListItem extends React.Component<Props> {
onRequestClose={this.handleMembersModalClose}
isOpen={this.membersModalOpen}
>
<GroupMembers group={group} onSubmit={this.handleMembersModalClose} />
<GroupMembers group={group} />
</Modal>
</>
);
@@ -107,4 +105,4 @@ const Title = styled.span`
}
`;
export default inject("groupMemberships")(GroupListItem);
export default withStores(GroupListItem);

View File

@@ -1,17 +1,16 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import Scrollable from "components/Scrollable";
import usePrevious from "hooks/usePrevious";
import Scrollable from "~/components/Scrollable";
import usePrevious from "~/hooks/usePrevious";
type Props = {|
children?: React.Node,
isOpen: boolean,
title?: string,
onRequestClose: () => void,
|};
type Props = {
children?: React.ReactNode;
isOpen: boolean;
title?: string;
onRequestClose: () => void;
};
const Guide = ({
children,
@@ -20,13 +19,16 @@ const Guide = ({
onRequestClose,
...rest
}: Props) => {
const dialog = useDialogState({ animated: 250 });
const dialog = useDialogState({
animated: 250,
});
const wasOpen = usePrevious(isOpen);
React.useEffect(() => {
if (!wasOpen && isOpen) {
dialog.show();
}
if (wasOpen && !isOpen) {
dialog.hide();
}

View File

@@ -1,22 +1,20 @@
// @flow
import { throttle } from "lodash";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
type Props = {|
breadcrumb?: React.Node,
title: React.Node,
actions?: React.Node,
|};
type Props = {
breadcrumb?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
};
function Header({ breadcrumb, title, actions }: Props) {
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useCallback(
throttle(() => setScrolled(window.scrollY > 75), 50),
[]
@@ -24,7 +22,6 @@ function Header({ breadcrumb, title, actions }: Props) {
React.useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
@@ -39,7 +36,7 @@ function Header({ breadcrumb, title, actions }: Props) {
<Wrapper align="center" shrink={false}>
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
{isScrolled ? (
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
<Title onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (

View File

@@ -1,7 +1,6 @@
// @flow
import styled from "styled-components";
const Heading = styled.h1`
const Heading = styled.h1<{ centered?: boolean }>`
display: flex;
align-items: center;
${(props) => (props.centered ? "text-align: center;" : "")}

View File

@@ -1,7 +1,6 @@
// @flow
import styled from "styled-components";
const HelpText = styled.p`
const HelpText = styled.p<{ small?: boolean }>`
margin-top: 0;
color: ${(props) => props.theme.textSecondary};
font-size: ${(props) => (props.small ? "13px" : "inherit")};

View File

@@ -1,24 +1,24 @@
// @flow
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
type Props = {
highlight: ?string | RegExp,
processResult?: (tag: string) => string,
text: string,
caseSensitive?: boolean,
type Props = React.HTMLAttributes<HTMLSpanElement> & {
highlight: (string | null | undefined) | RegExp;
processResult?: (tag: string) => string;
text: string | undefined;
caseSensitive?: boolean;
};
function Highlight({
highlight,
processResult,
caseSensitive,
text,
text = "",
...rest
}: Props) {
let regex;
let index = 0;
if (highlight instanceof RegExp) {
regex = highlight;
} else {
@@ -27,10 +27,11 @@ function Highlight({
caseSensitive ? "g" : "gi"
);
}
return (
<span {...rest}>
{highlight
? replace(text, regex, (tag) => (
? replace(text, regex, (tag: string) => (
<Mark key={index++}>
{processResult ? processResult(tag) : tag}
</Mark>

View File

@@ -1,32 +1,29 @@
// @flow
import { inject } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import { fadeAndSlideDown } from "styles/animations";
import { isInternalUrl } from "utils/urls";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
import useStores from "~/hooks/useStores";
import { fadeAndSlideDown } from "~/styles/animations";
import { isInternalUrl } from "~/utils/urls";
const DELAY_OPEN = 300;
const DELAY_CLOSE = 300;
type Props = {
node: HTMLAnchorElement,
event: MouseEvent,
documents: DocumentsStore,
onClose: () => void,
node: HTMLAnchorElement;
event: MouseEvent;
onClose: () => void;
};
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
function HoverPreviewInternal({ node, onClose }: Props) {
const { documents } = useStores();
const slug = parseDocumentSlug(node.href);
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef();
const timerOpen = React.useRef();
const cardRef = React.useRef<?HTMLDivElement>();
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement>(null);
const startCloseTimer = () => {
stopOpenTimer();
@@ -54,9 +51,7 @@ function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
React.useEffect(() => {
if (slug) {
documents.prefetchDocument(slug, {
prefetch: true,
});
documents.prefetchDocument(slug);
}
startOpenTimer();
@@ -64,6 +59,7 @@ function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
if (cardRef.current) {
cardRef.current.addEventListener("mouseenter", stopCloseTimer);
}
if (cardRef.current) {
cardRef.current.addEventListener("mouseleave", startCloseTimer);
}
@@ -71,7 +67,6 @@ function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
node.addEventListener("mouseout", startCloseTimer);
node.addEventListener("mouseover", stopCloseTimer);
node.addEventListener("mouseover", startOpenTimer);
return () => {
node.removeEventListener("mouseout", startCloseTimer);
node.removeEventListener("mouseover", stopCloseTimer);
@@ -80,6 +75,7 @@ function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
if (cardRef.current) {
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
}
if (cardRef.current) {
cardRef.current.removeEventListener("mouseleave", startCloseTimer);
}
@@ -88,12 +84,10 @@ function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
clearTimeout(timerClose.current);
}
};
}, [node]);
}, [node, slug]);
const anchorBounds = node.getBoundingClientRect();
const cardBounds = cardRef.current
? cardRef.current.getBoundingClientRect()
: undefined;
const cardBounds = cardRef.current?.getBoundingClientRect();
const left = cardBounds
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
: anchorBounds.left;
@@ -108,7 +102,7 @@ function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
>
<div ref={cardRef}>
<HoverPreviewDocument url={node.href}>
{(content) =>
{(content: React.ReactNode) =>
isVisible ? (
<Animate>
<Card>
@@ -196,7 +190,7 @@ const Card = styled.div`
}
`;
const Position = styled.div`
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
z-index: ${(props) => props.theme.depths.hoverPreview};
@@ -207,7 +201,7 @@ const Position = styled.div`
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
`;
const Pointer = styled.div`
const Pointer = styled.div<{ offset: number }>`
top: -22px;
left: ${(props) => props.offset}px;
width: 22px;
@@ -238,4 +232,4 @@ const Pointer = styled.div`
}
`;
export default inject("documents")(HoverPreview);
export default HoverPreview;

View File

@@ -1,25 +1,24 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
import Editor from "components/Editor";
import useStores from "hooks/useStores";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
import Editor from "~/components/Editor";
import useStores from "~/hooks/useStores";
type Props = {
url: string,
children: (React.Node) => React.Node,
url: string;
children: (arg0: React.ReactNode) => React.ReactNode;
};
function HoverPreviewDocument({ url, children }: Props) {
const { documents } = useStores();
const slug = parseDocumentSlug(url);
documents.prefetchDocument(slug, {
prefetch: true,
});
if (slug) {
documents.prefetchDocument(slug);
}
const document = slug ? documents.getByUrl(slug) : undefined;
if (!document) return null;
@@ -50,4 +49,5 @@ const Heading = styled.h2`
color: ${(props) => props.theme.text};
`;
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type '({ url, children }: Props) => Re... Remove this comment to see the full error message
export default observer(HoverPreviewDocument);

View File

@@ -1,4 +1,3 @@
// @flow
import {
BookmarkedIcon,
CollectionIcon,
@@ -36,15 +35,18 @@ import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import ContextMenu from "components/ContextMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import { LabelText } from "components/Input";
import NudeButton from "components/NudeButton";
import ContextMenu from "~/components/ContextMenu";
import Flex from "~/components/Flex";
import HelpText from "~/components/HelpText";
import { LabelText } from "~/components/Input";
import NudeButton from "~/components/NudeButton";
const style = { width: 30, height: 30 };
const TwitterPicker = React.lazy(() =>
const style = {
width: 30,
height: 30,
};
const TwitterPicker = React.lazy(
() =>
import(
/* webpackChunkName: "twitter-picker" */
"react-color/lib/components/twitter/Twitter"
@@ -173,7 +175,6 @@ export const icons = {
keywords: "warning alert error",
},
};
const colors = [
"#4E5C6E",
"#0366d6",
@@ -186,14 +187,13 @@ const colors = [
"#FF4DFA",
"#2F362F",
];
type Props = {|
onOpen?: () => void,
onClose?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
|};
type Props = {
onOpen?: () => void;
onClose?: () => void;
onChange: (color: string, icon: string) => void;
icon: string;
color: string;
};
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
const { t } = useTranslation();

View File

@@ -1,13 +1,12 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
import Flex from "~/components/Flex";
const RealTextarea = styled.textarea`
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
border: 0;
flex: 1;
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
@@ -21,7 +20,7 @@ const RealTextarea = styled.textarea`
}
`;
const RealInput = styled.input`
const RealInput = styled.input<{ hasIcon?: boolean }>`
border: 0;
flex: 1;
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
@@ -48,7 +47,12 @@ const RealInput = styled.input`
`};
`;
const Wrapper = styled.div`
const Wrapper = styled.div<{
flex?: boolean;
short?: boolean;
minHeight?: number;
maxHeight?: number;
}>`
flex: ${(props) => (props.flex ? "1" : "0")};
width: ${(props) => (props.short ? "49%" : "auto")};
max-width: ${(props) => (props.short ? "350px" : "100%")};
@@ -63,7 +67,11 @@ const IconWrapper = styled.span`
height: 24px;
`;
export const Outline = styled(Flex)`
export const Outline = styled(Flex)<{
margin?: string | number;
hasError?: boolean;
focused?: boolean;
}>`
flex: 1;
margin: ${(props) =>
props.margin !== undefined ? props.margin : "0 0 16px"};
@@ -88,56 +96,58 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = {|
type?: "text" | "email" | "checkbox" | "search" | "textarea",
value?: string,
label?: string,
className?: string,
labelHidden?: boolean,
flex?: boolean,
short?: boolean,
margin?: string | number,
icon?: React.Node,
name?: string,
minLength?: number,
maxLength?: number,
autoFocus?: boolean,
autoComplete?: boolean | string,
readOnly?: boolean,
required?: boolean,
disabled?: boolean,
placeholder?: string,
export type Props = {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
value?: string;
label?: string;
className?: string;
labelHidden?: boolean;
flex?: boolean;
short?: boolean;
margin?: string | number;
icon?: React.ReactNode;
name?: string;
minLength?: number;
maxLength?: number;
autoFocus?: boolean;
autoComplete?: boolean | string;
readOnly?: boolean;
required?: boolean;
disabled?: boolean;
placeholder?: string;
onChange?: (
ev: SyntheticInputEvent<HTMLInputElement | HTMLTextAreaElement>
) => mixed,
onKeyDown?: (ev: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
onFocus?: (ev: SyntheticEvent<>) => mixed,
onBlur?: (ev: SyntheticEvent<>) => mixed,
|};
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
};
@observer
class Input extends React.Component<Props> {
input: ?HTMLInputElement;
@observable focused: boolean = false;
input = React.createRef<HTMLInputElement | HTMLTextAreaElement>();
handleBlur = (ev: SyntheticEvent<>) => {
@observable
focused = false;
handleBlur = (ev: React.SyntheticEvent) => {
this.focused = false;
if (this.props.onBlur) {
this.props.onBlur(ev);
}
};
handleFocus = (ev: SyntheticEvent<>) => {
handleFocus = (ev: React.SyntheticEvent) => {
this.focused = true;
if (this.props.onFocus) {
this.props.onFocus(ev);
}
};
focus() {
if (this.input) {
this.input.focus();
}
this.input.current?.focus();
}
render() {
@@ -155,7 +165,8 @@ class Input extends React.Component<Props> {
...rest
} = this.props;
const InputComponent = type === "textarea" ? RealTextarea : RealInput;
const InputComponent: React.ComponentType =
type === "textarea" ? RealTextarea : RealInput;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
@@ -170,11 +181,12 @@ class Input extends React.Component<Props> {
<Outline focused={this.focused} margin={margin}>
{icon && <IconWrapper>{icon}</IconWrapper>}
<InputComponent
ref={(ref) => (this.input = ref)}
// @ts-expect-error no idea why this is not working
ref={this.input}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
type={type === "textarea" ? undefined : type}
hasIcon={!!icon}
type={type === "textarea" ? undefined : type}
{...rest}
/>
</Outline>

View File

@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
import Input from "./Input";

View File

@@ -1,28 +1,25 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { Trans } from "react-i18next";
import styled, { withTheme } from "styled-components";
import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
import useStores from "hooks/useStores";
import styled from "styled-components";
import Editor from "~/components/Editor";
import HelpText from "~/components/HelpText";
import { LabelText, Outline } from "~/components/Input";
import useStores from "~/hooks/useStores";
type Props = {|
label: string,
minHeight?: number,
maxHeight?: number,
readOnly?: boolean,
|};
type Props = {
label: string;
minHeight?: number;
maxHeight?: number;
readOnly?: boolean;
};
function InputRich({ label, minHeight, maxHeight, ...rest }: Props) {
const [focused, setFocused] = React.useState<boolean>(false);
const { ui } = useStores();
const handleBlur = React.useCallback(() => {
setFocused(false);
}, []);
const handleFocus = React.useCallback(() => {
setFocused(true);
}, []);
@@ -55,7 +52,11 @@ function InputRich({ label, minHeight, maxHeight, ...rest }: Props) {
);
}
const StyledOutline = styled(Outline)`
const StyledOutline = styled(Outline)<{
minHeight?: number;
maxHeight?: number;
focused?: boolean;
}>`
display: block;
padding: 8px 12px;
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
@@ -67,4 +68,4 @@ const StyledOutline = styled(Outline)`
}
`;
export default observer(withTheme(InputRich));
export default observer(InputRich);

View File

@@ -1,23 +1,20 @@
// @flow
import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Input, { type Props as InputProps } from "./Input";
import Input, { Props as InputProps } from "./Input";
type Props = {|
...InputProps,
placeholder?: string,
value?: string,
onChange: (event: SyntheticInputEvent<>) => mixed,
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|};
type Props = InputProps & {
placeholder?: string;
value?: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
};
export default function InputSearch(props: Props) {
const { t } = useTranslation();
const theme = useTheme();
const [isFocused, setIsFocused] = React.useState(false);
const handleFocus = React.useCallback(() => {
setIsFocused(true);
}, []);

View File

@@ -1,26 +1,25 @@
// @flow
import { observer } from "mobx-react";
import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard";
import { searchUrl } from "~/utils/routeHelpers";
import Input from "./Input";
import useBoolean from "hooks/useBoolean";
import useKeyDown from "hooks/useKeyDown";
import { isModKey } from "utils/keyboard";
import { searchUrl } from "utils/routeHelpers";
type Props = {|
source: string,
placeholder?: string,
label?: string,
labelHidden?: boolean,
collectionId?: string,
value: string,
onChange: (event: SyntheticInputEvent<>) => mixed,
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|};
type Props = {
source: string;
placeholder?: string;
label?: string;
labelHidden?: boolean;
collectionId?: string;
value?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
};
function InputSearchPage({
onKeyDown,
@@ -31,12 +30,11 @@ function InputSearchPage({
collectionId,
source,
}: Props) {
const inputRef = React.useRef();
const inputRef = React.useRef<Input>(null);
const theme = useTheme();
const history = useHistory();
const { t } = useTranslation();
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
const focus = React.useCallback(() => {
inputRef.current?.focus();
}, []);
@@ -49,7 +47,7 @@ function InputSearchPage({
});
const handleKeyDown = React.useCallback(
(ev: SyntheticKeyboardEvent<HTMLInputElement>) => {
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
ev.preventDefault();
history.push(

View File

@@ -1,4 +1,3 @@
// @flow
import {
Select,
SelectOption,
@@ -11,32 +10,38 @@ import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import Button, { Inner } from "components/Button";
import HelpText from "components/HelpText";
import { Position, Background, Backdrop } from "./ContextMenu";
import Button, { Inner } from "~/components/Button";
import HelpText from "~/components/HelpText";
import useMenuHeight from "~/hooks/useMenuHeight";
import { Position, Background, Backdrop, Placement } from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input";
import useMenuHeight from "hooks/useMenuHeight";
export type Option = { label: string, value: string };
export type Props = {
value?: string,
label?: string,
nude?: boolean,
ariaLabel: string,
short?: boolean,
disabled?: boolean,
className?: string,
labelHidden?: boolean,
icon?: React.Node,
options: Option[],
note?: React.Node,
onChange: (string) => Promise<void> | void,
export type Option = {
label: string;
value: string;
};
const getOptionFromValue = (options: Option[], value) => {
return options.find((option) => option.value === value) || {};
export type Props = {
value?: string;
label?: string;
nude?: boolean;
ariaLabel: string;
short?: boolean;
disabled?: boolean;
className?: string;
labelHidden?: boolean;
icon?: React.ReactNode;
options: Option[];
note?: React.ReactNode;
onChange: (value: string | null) => void;
};
const getOptionFromValue = (
options: Option[],
value: string | undefined | null
) => {
return options.find((option) => option.value === value);
};
const InputSelect = (props: Props) => {
@@ -50,7 +55,6 @@ const InputSelect = (props: Props) => {
ariaLabel,
onChange,
disabled,
nude,
note,
icon,
} = props;
@@ -69,13 +73,12 @@ const InputSelect = (props: Props) => {
disabled,
});
const previousValue = React.useRef(value);
const contentRef = React.useRef();
const selectedRef = React.useRef();
const buttonRef = React.useRef();
const previousValue = React.useRef<string | undefined | null>(value);
const contentRef = React.useRef<HTMLDivElement>(null);
const selectedRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const [offset, setOffset] = React.useState(0);
const minWidth = buttonRef.current?.offsetWidth || 0;
const maxHeight = useMenuHeight(
select.visible,
select.unstable_disclosureRef
@@ -83,16 +86,15 @@ const InputSelect = (props: Props) => {
React.useEffect(() => {
if (previousValue.current === select.selectedValue) return;
previousValue.current = select.selectedValue;
async function load() {
await onChange(select.selectedValue);
}
load();
}, [onChange, select.selectedValue]);
const wrappedLabel = <LabelText>{label}</LabelText>;
const selectedValueIndex = options.findIndex(
(option) => option.value === select.selectedValue
);
@@ -102,7 +104,7 @@ const InputSelect = (props: Props) => {
if (!select.animating && selectedRef.current) {
scrollIntoView(selectedRef.current, {
scrollMode: "if-needed",
behavior: "instant",
behavior: "auto",
block: "start",
});
}
@@ -134,18 +136,24 @@ const InputSelect = (props: Props) => {
neutral
disclosure
className={className}
nude={nude}
icon={icon}
{...props}
>
{getOptionFromValue(options, select.selectedValue).label || (
{getOptionFromValue(options, select.selectedValue)?.label || (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popOver} aria-label={ariaLabel}>
{(props) => {
{(
props: React.HTMLAttributes<HTMLDivElement> & {
placement: Placement;
}
) => {
if (!props.style) {
props.style = {};
}
const topAnchor = props.style.top === "0";
const rightAnchor = props.placement === "bottom-end";
@@ -163,8 +171,13 @@ const InputSelect = (props: Props) => {
rightAnchor={rightAnchor}
style={
maxHeight && topAnchor
? { maxHeight, minWidth }
: { minWidth }
? {
maxHeight,
minWidth,
}
: {
minWidth,
}
}
>
{select.visible || select.animating
@@ -173,7 +186,7 @@ const InputSelect = (props: Props) => {
{...select}
value={option.value}
key={option.value}
animating={select.animating}
$animating={select.animating}
ref={
select.selectedValue === option.value
? selectedRef
@@ -201,7 +214,6 @@ const InputSelect = (props: Props) => {
</SelectPopover>
</Wrapper>
{note && <HelpText small>{note}</HelpText>}
{(select.visible || select.animating) && <Backdrop />}
</>
);
@@ -217,7 +229,7 @@ const Spacer = styled.div`
flex-shrink: 0;
`;
const StyledButton = styled(Button)`
const StyledButton = styled(Button)<{ nude?: boolean }>`
font-weight: normal;
text-transform: none;
margin-bottom: 16px;
@@ -243,17 +255,17 @@ const StyledButton = styled(Button)`
}
`;
export const StyledSelectOption = styled(SelectOption)`
export const StyledSelectOption = styled(SelectOption)<{ $animating: boolean }>`
${MenuAnchorCSS}
${(props) =>
props.animating &&
props.$animating &&
css`
pointer-events: none;
`}
`;
const Wrapper = styled.label`
const Wrapper = styled.label<{ short?: boolean }>`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;

View File

@@ -1,19 +1,25 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "./InputSelect";
import { $Diff } from "utility-types";
import InputSelect, { Props, Option } from "./InputSelect";
export default function InputSelectPermission(
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
props: $Diff<
Props,
{
options: Array<Option>;
ariaLabel: string;
}
>
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
const handleChange = React.useCallback(
(value) => {
if (value === "no_access") {
value = "";
}
onChange(value);
},
[onChange]
@@ -23,9 +29,18 @@ export default function InputSelectPermission(
<InputSelect
label={t("Default access")}
options={[
{ label: t("View and edit"), value: "read_write" },
{ label: t("View only"), value: "read" },
{ label: t("No access"), value: "no_access" },
{
label: t("View and edit"),
value: "read_write",
},
{
label: t("View only"),
value: "read",
},
{
label: t("No access"),
value: "no_access",
},
]}
ariaLabel={t("Default access")}
value={value || "no_access"}

View File

@@ -1,25 +0,0 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "components/InputSelect";
const InputSelectRole = (
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
) => {
const { t } = useTranslation();
return (
<InputSelect
label={t("Role")}
options={[
{ label: t("Member"), value: "member" },
{ label: t("Viewer"), value: "viewer" },
{ label: t("Admin"), value: "admin" },
]}
ariaLabel={t("Role")}
{...props}
/>
);
};
export default InputSelectRole;

View File

@@ -0,0 +1,39 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { $Diff } from "utility-types";
import InputSelect, { Props, Option } from "~/components/InputSelect";
const InputSelectRole = (
props: $Diff<
Props,
{
options: Array<Option>;
ariaLabel: string;
}
>
) => {
const { t } = useTranslation();
return (
<InputSelect
label={t("Role")}
options={[
{
label: t("Member"),
value: "member",
},
{
label: t("Viewer"),
value: "viewer",
},
{
label: t("Admin"),
value: "admin",
},
]}
ariaLabel={t("Role")}
{...props}
/>
);
};
export default InputSelectRole;

View File

@@ -1,4 +1,3 @@
// @flow
import styled from "styled-components";
const Key = styled.kbd`

View File

@@ -1,13 +1,12 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
import Flex from "~/components/Flex";
type Props = {|
label: React.Node | string,
children: React.Node,
|};
type Props = {
label: React.ReactNode | string;
children?: React.ReactNode;
};
const Labeled = ({ label, children, ...props }: Props) => (
<Flex column {...props}>

View File

@@ -1,17 +1,16 @@
// @flow
import { find } from "lodash";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { languages, languageOptions } from "shared/i18n";
import ButtonLink from "components/ButtonLink";
import Flex from "components/Flex";
import NoticeTip from "components/NoticeTip";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import { detectLanguage } from "utils/language";
import { languages, languageOptions } from "@shared/i18n";
import ButtonLink from "~/components/ButtonLink";
import Flex from "~/components/Flex";
import NoticeTip from "~/components/NoticeTip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { detectLanguage } from "~/utils/language";
function Icon(props) {
function Icon(props: any) {
return (
<svg
width="32"
@@ -65,8 +64,11 @@ export default function LanguagePrompt() {
<LanguageIcon />
<span>
<Trans>
Outline is available in your language {{ optionLabel }}, would you
like to change?
Outline is available in your language{" "}
{{
optionLabel,
}}
, would you like to change?
</Trans>
<br />
<Link

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