Compare commits

..

33 Commits

Author SHA1 Message Date
Tom Moor 12f2a21bda Add hover state, tweak descriptions 2025-05-10 09:49:54 -04:00
Tom Moor 20769ea041 Merge public/main 2025-05-10 09:23:29 -04:00
Tom Moor fd984774d0 Add smart preloading of settings screens to reduce flicker (#9165) 2025-05-10 09:17:43 -04:00
Tom Moor e216c68f6d fix: CMD+F with in-app find interface open should open native find interface (#9153) 2025-05-08 21:40:01 -04:00
Tom Moor 14102de673 refactor 2025-05-08 21:35:18 -04:00
Tom Moor d876bf99fb Add filtering 2025-05-08 21:20:06 -04:00
Tom Moor d29bae9a14 Add breadcrumbs 2025-05-08 21:05:05 -04:00
Tom Moor d0c5ec2f39 fix: Google analytics never shows as installed
fix: Styling tweaks
Move webhooks out of integrations
2025-05-08 19:06:48 -04:00
Tom Moor 2e2a8bcc94 fix: Allow searching for current user in collection permissions (#9154) 2025-05-08 22:15:16 +00:00
Tom Moor 245d14f905 fix: Upgrade KaTeX (#9151) 2025-05-08 00:40:50 +00:00
Tom Moor 8717d160ce fix: Backlinks are limited at 25 (#9150) 2025-05-07 20:36:56 -04:00
Tom Moor 587ba85cc9 fix: LaTeX blocks show vertical scrollbar (#9149) 2025-05-08 00:17:47 +00:00
Tom Moor 80bb1ce977 fix: ExportDocumentTreeTask needs documentStructure (#9148) 2025-05-07 23:42:59 +00:00
codegen-sh[bot] c598c61afe Add PromQL as a code highlighting option in the editor (#9146)
* Add PromQL as a code highlighting option in the editor

* Update code.ts

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-07 19:11:47 -04:00
Tom Moor 68b07eb466 fix: withoutState scope should include state as fallback (#9145) 2025-05-07 18:49:33 -04:00
Tom Moor 06a149407a fix: withoutState scope should include state as fallback (#9144) 2025-05-07 09:00:42 -04:00
Tom Moor b9387734c7 perf: Remove documentStructure from default query select (#9141)
* perf: Remove documentStructure from default query select

* test
2025-05-07 07:47:57 -04:00
dependabot[bot] 810b7908e4 chore(deps): bump the aws group with 5 updates (#9136)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.797.0` | `3.802.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.797.0` | `3.802.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.797.0` | `3.802.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.797.0` | `3.802.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.796.0` | `3.800.0` |


Updates `@aws-sdk/client-s3` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.796.0 to 3.800.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.800.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 08:02:14 -04:00
dependabot[bot] 6b76a898fa chore(deps): bump the babel group with 9 updates (#9139)
Bumps the babel group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.26.10` | `7.27.1` |
| [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-class-properties](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-class-properties) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator) | `7.27.0` | `7.27.1` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.26.9` | `7.27.1` |
| [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) | `7.26.3` | `7.27.1` |
| [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) | `7.27.0` | `7.27.1` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.27.0` | `7.27.1` |


Updates `@babel/core` from 7.26.10 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-core)

Updates `@babel/plugin-proposal-decorators` from 7.25.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-proposal-decorators)

Updates `@babel/plugin-transform-class-properties` from 7.25.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-transform-class-properties)

Updates `@babel/plugin-transform-destructuring` from 7.25.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-transform-destructuring)

Updates `@babel/plugin-transform-regenerator` from 7.27.0 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-transform-regenerator)

Updates `@babel/preset-env` from 7.26.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-preset-env)

Updates `@babel/preset-react` from 7.26.3 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-preset-react)

Updates `@babel/cli` from 7.27.0 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-cli)

Updates `@babel/preset-typescript` from 7.27.0 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-class-properties"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-react"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-version: 7.27.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-version: 7.27.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 21:05:41 -04:00
dependabot[bot] 8ba83e2173 chore(deps): bump react-medium-image-zoom from 5.2.13 to 5.2.14 (#9137)
Bumps [react-medium-image-zoom](https://github.com/rpearce/react-medium-image-zoom) from 5.2.13 to 5.2.14.
- [Release notes](https://github.com/rpearce/react-medium-image-zoom/releases)
- [Changelog](https://github.com/rpearce/react-medium-image-zoom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rpearce/react-medium-image-zoom/compare/v5.2.13...v5.2.14)

---
updated-dependencies:
- dependency-name: react-medium-image-zoom
  dependency-version: 5.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 21:05:27 -04:00
dependabot[bot] 5a4b8c5faa chore(deps): bump validator and @types/validator (#9138)
Bumps [validator](https://github.com/validatorjs/validator.js) and [@types/validator](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/validator). These dependencies needed to be updated together.

Updates `validator` from 13.12.0 to 13.15.0
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.12.0...13.15.0)

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

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: "@types/validator"
  dependency-version: 13.15.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 21:05:14 -04:00
Tom Moor 3f8bdf7ac2 Refactor withMembershipScope (#9134) 2025-05-04 18:37:01 -04:00
Tess99854 cffe5c0ec6 update card status 2025-05-01 20:48:37 +01:00
Mahmoud Mohammed Ali 4d294c8cbe update IntegrationCard to use the Text component 2025-04-26 22:42:47 +01:00
Mahmoud Mohammed Ali f52634139c Update all integrations menu item 2025-04-26 22:33:47 +01:00
Mahmoud Ali 0790358fb3 Update integration icon size
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-04-27 00:11:13 +03:00
Mahmoud Ali 760b1041bc update integration card
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-04-27 00:10:46 +03:00
Tess99854 2f0f966665 clean up 2025-04-24 23:53:28 +01:00
Mahmoud Mohammed Ali 62a894ca66 Integration page style update 2025-04-24 23:39:30 +01:00
Tess99854 29ec5f5a46 update design 2025-04-24 22:49:57 +01:00
Tess99854 11f2b8a53f add descriptions 2025-04-24 20:10:04 +01:00
Mahmoud Mohammed Ali a5f593d68d Integration page skeleton 2025-04-24 19:58:28 +01:00
Tess99854 b48498d07f update useSettings 2025-04-23 18:52:44 +01:00
45 changed files with 1424 additions and 1058 deletions
+8 -2
View File
@@ -15,6 +15,7 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
@@ -22,7 +23,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
@@ -88,6 +89,11 @@ export const CollectionForm = observer(function CollectionForm_({
const values = watch();
// Preload the IconPicker component on mount
React.useEffect(() => {
void IconPicker.preload();
}, []);
React.useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes.
@@ -202,7 +208,7 @@ export const CollectionForm = observer(function CollectionForm_({
);
});
const StyledIconPicker = styled(IconPicker)`
const StyledIconPicker = styled(IconPicker.Component)`
margin-left: 4px;
margin-right: 4px;
`;
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<any>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
interface LazyLoadOptions {
retries?: number;
interval?: number;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
*
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
*
* @example
* ```typescript
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval } = options;
return {
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
};
}
+4 -2
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import { createLazyComponent } from "~/components/LazyLoad";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import Popover from "~/components/Popover";
@@ -12,7 +13,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
const EmojiPanel = React.lazy(
const EmojiPanel = createLazyComponent(
() => import("~/components/IconPicker/components/EmojiPanel")
);
@@ -104,6 +105,7 @@ const ReactionPicker: React.FC<Props> = ({
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
onMouseEnter={() => EmojiPanel.preload()}
size={size}
>
<ReactionIcon size={22} />
@@ -123,7 +125,7 @@ const ReactionPicker: React.FC<Props> = ({
{popover.visible && (
<React.Suspense fallback={<Placeholder />}>
<EventBoundary>
<EmojiPanel
<EmojiPanel.Component
height={300}
panelWidth={panelWidth}
query={query}
@@ -93,11 +93,13 @@ export const Suggestions = observer(
const suggestions = React.useMemo(() => {
const filtered: Suggestion[] = (
document
? users.notInDocument(document.id, query)
? users
.notInDocument(document.id, query)
.filter((u) => u.id !== user.id)
: collection
? users.notInCollection(collection.id, query)
: users.activeOrInvited
).filter((u) => !u.isSuspended && u.id !== user.id);
).filter((u) => !u.isSuspended);
if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query));
+8 -2
View File
@@ -27,7 +27,12 @@ function SettingsSidebar() {
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const configs = useSettingsConfig();
let configs = useSettingsConfig();
configs = configs.filter((item) =>
"isActive" in item ? item.isActive : true
);
const groupedConfig = groupBy(configs, "group");
const returnToApp = React.useCallback(() => {
@@ -63,8 +68,9 @@ function SettingsSidebar() {
<SidebarLink
key={item.path}
to={item.path}
onClickIntent={item.preload}
active={
item.path !== settingsPath()
item.path.startsWith(settingsPath("templates"))
? location.pathname.startsWith(item.path)
: undefined
}
+1
View File
@@ -41,6 +41,7 @@ function useKeyboardShortcuts({
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
+59 -18
View File
@@ -13,20 +13,23 @@ import {
ImportIcon,
ShapesIcon,
Icon,
PlusIcon,
InternetIcon,
} from "outline-icons";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import { Integrations } from "~/scenes/Settings/Integrations";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import isCloudHosted from "~/utils/isCloudHosted";
import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import { useComputed } from "./useComputed";
import useCurrentTeam from "./useCurrentTeam";
import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
import useStores from "./useStores";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
@@ -50,23 +53,32 @@ export type ConfigItem = {
path: string;
icon: React.FC<ComponentProps<typeof Icon>>;
component: React.ComponentType;
description?: string;
preload?: () => void;
enabled: boolean;
group: string;
isActive?: boolean;
};
const useSettingsConfig = () => {
const { integrations } = useStores();
const user = useCurrentUser();
const team = useCurrentTeam();
const can = usePolicy(team);
const { t } = useTranslation();
React.useEffect(() => {
void integrations.fetchAll();
}, [integrations]);
const config = useComputed(() => {
const items: ConfigItem[] = [
// Account
{
name: t("Profile"),
path: settingsPath(),
component: Profile,
component: Profile.Component,
preload: Profile.preload,
enabled: true,
group: t("Account"),
icon: ProfileIcon,
@@ -74,7 +86,8 @@ const useSettingsConfig = () => {
{
name: t("Preferences"),
path: settingsPath("preferences"),
component: Preferences,
component: Preferences.Component,
preload: Preferences.preload,
enabled: true,
group: t("Account"),
icon: SettingsIcon,
@@ -82,7 +95,8 @@ const useSettingsConfig = () => {
{
name: t("Notifications"),
path: settingsPath("notifications"),
component: Notifications,
component: Notifications.Component,
preload: Notifications.preload,
enabled: true,
group: t("Account"),
icon: EmailIcon,
@@ -90,7 +104,8 @@ const useSettingsConfig = () => {
{
name: t("API & Apps"),
path: settingsPath("api-and-apps"),
component: APIAndApps,
component: APIAndApps.Component,
preload: APIAndApps.preload,
enabled: true,
group: t("Account"),
icon: PadlockIcon,
@@ -99,7 +114,8 @@ const useSettingsConfig = () => {
{
name: t("Details"),
path: settingsPath("details"),
component: Details,
component: Details.Component,
preload: Details.preload,
enabled: can.update,
group: t("Workspace"),
icon: TeamIcon,
@@ -107,7 +123,8 @@ const useSettingsConfig = () => {
{
name: t("Security"),
path: settingsPath("security"),
component: Security,
component: Security.Component,
preload: Security.preload,
enabled: can.update,
group: t("Workspace"),
icon: PadlockIcon,
@@ -115,7 +132,8 @@ const useSettingsConfig = () => {
{
name: t("Features"),
path: settingsPath("features"),
component: Features,
component: Features.Component,
preload: Features.preload,
enabled: can.update,
group: t("Workspace"),
icon: BeakerIcon,
@@ -123,7 +141,8 @@ const useSettingsConfig = () => {
{
name: t("Members"),
path: settingsPath("members"),
component: Members,
component: Members.Component,
preload: Members.preload,
enabled: can.listUsers,
group: t("Workspace"),
icon: UserIcon,
@@ -131,7 +150,8 @@ const useSettingsConfig = () => {
{
name: t("Groups"),
path: settingsPath("groups"),
component: Groups,
component: Groups.Component,
preload: Groups.preload,
enabled: can.listGroups,
group: t("Workspace"),
icon: GroupIcon,
@@ -139,7 +159,8 @@ const useSettingsConfig = () => {
{
name: t("Templates"),
path: settingsPath("templates"),
component: Templates,
component: Templates.Component,
preload: Templates.preload,
enabled: can.readTemplate,
group: t("Workspace"),
icon: ShapesIcon,
@@ -147,7 +168,8 @@ const useSettingsConfig = () => {
{
name: t("API Keys"),
path: settingsPath("api-keys"),
component: ApiKeys,
component: ApiKeys.Component,
preload: ApiKeys.preload,
enabled: can.listApiKeys,
group: t("Workspace"),
icon: CodeIcon,
@@ -155,7 +177,8 @@ const useSettingsConfig = () => {
{
name: t("Applications"),
path: settingsPath("applications"),
component: Applications,
component: Applications.Component,
preload: Applications.preload,
enabled: can.listOAuthClients,
group: t("Workspace"),
icon: InternetIcon,
@@ -163,7 +186,8 @@ const useSettingsConfig = () => {
{
name: t("Shared Links"),
path: settingsPath("shares"),
component: Shares,
component: Shares.Component,
preload: Shares.preload,
enabled: can.listShares,
group: t("Workspace"),
icon: GlobeIcon,
@@ -171,7 +195,8 @@ const useSettingsConfig = () => {
{
name: t("Import"),
path: settingsPath("import"),
component: Import,
component: Import.Component,
preload: Import.preload,
enabled: can.createImport,
group: t("Workspace"),
icon: ImportIcon,
@@ -179,7 +204,8 @@ const useSettingsConfig = () => {
{
name: t("Export"),
path: settingsPath("export"),
component: Export,
component: Export.Component,
preload: Export.preload,
enabled: can.createExport,
group: t("Workspace"),
icon: ExportIcon,
@@ -188,11 +214,20 @@ const useSettingsConfig = () => {
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
component: Zapier,
component: Zapier.Component,
preload: Zapier.preload,
enabled: can.update && isCloudHosted,
group: t("Integrations"),
icon: ZapierIcon,
},
{
name: `${t("Install")}`,
path: settingsPath("integrations"),
component: Integrations,
enabled: true,
group: t("Integrations"),
icon: PlusIcon,
},
];
// Plugins
@@ -208,11 +243,17 @@ const useSettingsConfig = () => {
? integrationSettingsPath(plugin.id)
: settingsPath(plugin.id),
group: t(group),
component: plugin.value.component,
description: plugin.value.description,
component: plugin.value.component.Component,
preload: plugin.value.component.preload,
enabled: plugin.value.enabled
? plugin.value.enabled(team, user)
: can.update,
icon: plugin.value.icon,
isActive: integrations.orderedData.some(
(integration) =>
integration.service.toLowerCase() === plugin.id.toLowerCase()
),
} as ConfigItem);
});
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import { settingsPath } from "~/utils/routeHelpers";
import IntegrationCard from "./components/IntegrationCard";
import { StickyFilters } from "./components/StickyFilters";
export function Integrations() {
const { t } = useTranslation();
let items = useSettingsConfig();
const [query, setQuery] = React.useState("");
const handleQuery = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
};
items = items
.filter(
(item) =>
item.group === "Integrations" &&
item.enabled &&
item.path !== settingsPath("integrations") &&
item.name.toLowerCase().includes(query.toLowerCase())
)
.sort((item) => (item.isActive ? -1 : 1));
return (
<Scene title={t("Integrations")}>
<Heading>{t("Integrations")}</Heading>
<Text as="p" type="secondary">
<Trans>
Configure a variety of integrations with third-party services.
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleQuery}
/>
</StickyFilters>
<CardsFlex gap={30} wrap>
{items.map((item) => (
<IntegrationCard key={item.path} integration={item} />
))}
</CardsFlex>
</Scene>
);
}
const CardsFlex = styled(Flex)`
margin-top: 20px;
width: "100%";
`;
@@ -0,0 +1,91 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { ConfigItem } from "~/hooks/useSettingsConfig";
import Button from "../../../components/Button";
import Flex from "../../../components/Flex";
import Text from "../../../components/Text";
type Props = {
integration: ConfigItem;
};
function IntegrationCard({ integration }: Props) {
const { t } = useTranslation();
return (
<Card as={Link} to={integration.path}>
<Flex align="center" gap={8}>
<integration.icon size={48} />
<Flex auto column>
<Name>{integration.name}</Name>
{integration.isActive && <Status>{t("Connected")}</Status>}
</Flex>
<Button as={Link} to={integration.path} neutral>
{integration.isActive ? t("Configure") : t("Connect")}
</Button>
</Flex>
<Description>{integration.description}</Description>
</Card>
);
}
export default IntegrationCard;
const Card = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 20px;
width: 300px;
background: ${s("background")};
border: 1px solid ${s("inputBorder")};
color: ${s("text")};
border-radius: 8px;
transition: box-shadow 200ms ease;
cursor: var(--pointer);
&:hover {
box-shadow: rgba(0, 0, 0, 0.08) 0px 2px 4px, rgba(0, 0, 0, 0.06) 0px 4px 8px;
}
`;
const Name = styled(Text)`
margin: 0;
font-size: 16px;
font-weight: 600;
color: ${s("text")};
${ellipsis()}
`;
const Description = styled(Text)`
margin: 8px 0 0;
font-size: 15px;
max-width: 100%;
color: ${s("textTertiary")};
`;
const Status = styled(Text).attrs({
type: "secondary",
size: "small",
as: "span",
})`
display: inline-flex;
align-items: center;
&::after {
content: "";
display: inline-block;
width: 17px;
height: 17px;
background: radial-gradient(
circle at center,
${s("accent")} 0 33%,
transparent 33%
);
border-radius: 50%;
}
`;
@@ -0,0 +1,33 @@
import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Breadcrumb from "~/components/Breadcrumb";
import Scene from "~/components/Scene";
import { settingsPath } from "~/utils/routeHelpers";
export function IntegrationScene({
children,
...rest
}: React.ComponentProps<typeof Scene>) {
const { t } = useTranslation();
return (
<Scene
left={
<Breadcrumb
items={[
{
type: "route",
title: t("Integrations"),
icon: <SettingsIcon />,
to: settingsPath("integrations"),
},
]}
/>
}
{...rest}
>
{children}
</Scene>
);
}
+2 -7
View File
@@ -279,19 +279,14 @@ export default class DocumentsStore extends Store<Document> {
@action
fetchBacklinks = async (documentId: string): Promise<void> => {
const res = await client.post(`/documents.list`, {
const documents = await this.fetchAll({
backlinkDocumentId: documentId,
});
invariant(res?.data, "Document list not available");
const { data } = res;
runInAction("DocumentsStore#fetchBacklinks", () => {
data.forEach(this.add);
this.addPolicies(res.policies);
this.backlinks.set(
documentId,
data.map((doc: Partial<Document>) => doc.id)
documents.map((doc) => doc.id)
);
});
};
+5 -2
View File
@@ -3,6 +3,7 @@ import sortBy from "lodash/sortBy";
import { action, observable } from "mobx";
import Team from "~/models/Team";
import User from "~/models/User";
import { LazyComponent } from "~/components/LazyLoad";
import { useComputed } from "~/hooks/useComputed";
import Logger from "./Logger";
import isCloudHosted from "./isCloudHosted";
@@ -27,8 +28,10 @@ type PluginValueMap = {
after?: string;
/** The displayed icon of the plugin. */
icon: React.ElementType;
/** The settings screen somponent, should be lazy loaded. */
component: React.LazyExoticComponent<React.ComponentType>;
/** The lazy loaded settings screen component. */
component: LazyComponent<React.ComponentType>;
/** The description that will show on the plugins card. */
description?: string;
/** Whether the plugin is enabled in the current context. */
enabled?: (team: Team, user: User) => boolean;
};
+19 -18
View File
@@ -48,18 +48,18 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.797.0",
"@aws-sdk/lib-storage": "3.797.0",
"@aws-sdk/s3-presigned-post": "3.797.0",
"@aws-sdk/s3-request-presigner": "3.797.0",
"@aws-sdk/signature-v4-crt": "^3.796.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
"@babel/plugin-transform-destructuring": "^7.25.9",
"@babel/plugin-transform-regenerator": "^7.27.0",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@aws-sdk/client-s3": "3.803.0",
"@aws-sdk/lib-storage": "3.803.0",
"@aws-sdk/s3-presigned-post": "3.803.0",
"@aws-sdk/s3-request-presigner": "3.803.0",
"@aws-sdk/signature-v4-crt": "^3.803.0",
"@babel/core": "^7.27.1",
"@babel/plugin-proposal-decorators": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.27.1",
"@babel/plugin-transform-regenerator": "^7.27.1",
"@babel/preset-env": "^7.27.1",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
@@ -141,7 +141,7 @@
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
"jszip": "^3.10.1",
"katex": "^0.16.21",
"katex": "^0.16.22",
"kbar": "0.1.0-beta.41",
"koa": "^2.16.1",
"koa-body": "^6.0.1",
@@ -208,7 +208,7 @@
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.13",
"react-medium-image-zoom": "5.2.14",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.3.0",
"react-router-dom": "^5.3.4",
@@ -228,6 +228,7 @@
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"sequelize-encrypted": "^1.0.0",
"sequelize-strict-attributes": "^1.0.2",
"sequelize-typescript": "^2.1.6",
"slug": "^5.3.0",
"slugify": "^1.6.6",
@@ -248,7 +249,7 @@
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"validator": "13.15.0",
"vaul": "^1.1.2",
"vite": "^6.3.4",
"vite-plugin-pwa": "^0.21.2",
@@ -262,8 +263,8 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@babel/cli": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.0",
"@testing-library/react": "^12.0.0",
@@ -328,7 +329,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.12.1",
"@types/validator": "^13.15.0",
"@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
+3 -3
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
@@ -11,7 +12,6 @@ import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
@@ -38,7 +38,7 @@ function GitHub() {
}, [integrations]);
return (
<Scene title="GitHub" icon={<GitHubIcon />}>
<IntegrationScene title="GitHub" icon={<GitHubIcon />}>
<Heading>GitHub</Heading>
{error === "access_denied" && (
@@ -146,7 +146,7 @@ function GitHub() {
</Trans>
</Notice>
)}
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,7 +10,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Connect your GitHub account to Outline to enable rich, realtime, issue and pull request previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+3 -3
View File
@@ -6,12 +6,12 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
@@ -75,7 +75,7 @@ function GoogleAnalytics() {
);
return (
<Scene title={t("Google Analytics")} icon={<GoogleIcon />}>
<IntegrationScene title={t("Google Analytics")} icon={<GoogleIcon />}>
<Heading>{t("Google Analytics")}</Heading>
<Text as="p" type="secondary">
@@ -100,7 +100,7 @@ function GoogleAnalytics() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,7 +10,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Measure adoption and engagement by sending view and event analytics directly to your GA4 dashboard.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+1 -1
View File
@@ -1,5 +1,5 @@
{
"id": "googleanalytics",
"id": "google-analytics",
"name": "Google Analytics",
"priority": 30,
"description": "Adds support for reporting analytics to a Google."
+4 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,7 +10,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+3 -3
View File
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
@@ -82,7 +82,7 @@ function Matomo() {
);
return (
<Scene title="Matomo" icon={<Icon />}>
<IntegrationScene title="Matomo" icon={<Icon />}>
<Heading>Matomo</Heading>
<Text as="p" type="secondary">
@@ -121,7 +121,7 @@ function Matomo() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,7 +11,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Track your docs with a self-hosted, open-source analytics platform, link Outline to Matomo for 100% data ownership, GDPR compliance, and deep usage insights on your own servers.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
+3 -3
View File
@@ -6,6 +6,7 @@ import { IntegrationService, IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
@@ -13,7 +14,6 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -67,7 +67,7 @@ function Slack() {
const appName = env.APP_NAME;
return (
<Scene title="Slack" icon={<SlackIcon />}>
<IntegrationScene title="Slack" icon={<SlackIcon />}>
<Heading>Slack</Heading>
{error === "access_denied" && (
@@ -205,7 +205,7 @@ function Slack() {
</List>
</>
)}
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,7 +11,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Search your knowledge base directly in Slack, get /outline search, rich link previews, and notifications on new or updated docs.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) =>
[UserRole.Member, UserRole.Admin].includes(user.role),
},
+3 -3
View File
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
@@ -85,7 +85,7 @@ function Umami() {
);
return (
<Scene title="Umami" icon={<Icon />}>
<IntegrationScene title="Umami" icon={<Icon />}>
<Heading>Umami</Heading>
<Text as="p" type="secondary">
@@ -145,7 +145,7 @@ function Umami() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,7 +11,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Gain privacy-first insights into how your team consumes docs, inject your self-hosted Umami script across Outline pages to track views and engagement while retaining full control of your data.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
+6 -3
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -8,9 +8,12 @@ PluginManager.add([
...config,
type: Hook.Settings,
value: {
group: "Integrations",
group: "Workspace",
after: "Shared Links",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Automate downstream workflows with real-time JSON POSTs, subscribe to events in Outline so external systems can react instantly.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+19 -30
View File
@@ -1,4 +1,3 @@
import invariant from "invariant";
import { Op, WhereOptions } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -22,8 +21,8 @@ type Props = {
type Result = {
document: Document;
share?: Share;
collection?: Collection | null;
share: Share | null;
collection: Collection | null;
};
export default async function loadDocument({
@@ -33,9 +32,9 @@ export default async function loadDocument({
user,
includeState,
}: Props): Promise<Result> {
let document;
let collection;
let share;
let document: Document | null = null;
let collection: Collection | null = null;
let share: Share | null = null;
if (!shareId && !(id && user)) {
throw AuthenticationError(`Authentication or shareId required`);
@@ -72,20 +71,7 @@ export default async function loadDocument({
where: whereClause,
include: [
{
// unscoping here allows us to return unpublished documents
model: Document.unscoped(),
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
model: Document.scope("withDrafts"),
required: true,
as: "document",
},
@@ -129,14 +115,13 @@ export default async function loadDocument({
const canReadDocument = user && can(user, "read", document);
if (canReadDocument) {
// Cannot use document.collection here as it does not include the
// documentStructure by default through the relationship.
if (document.collectionId) {
collection = await Collection.findByPk(document.collectionId);
if (!collection) {
throw NotFoundError("Collection could not be found for document");
}
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
}
return {
@@ -155,11 +140,15 @@ export default async function loadDocument({
// It is possible to disable sharing at the collection so we must check
if (document.collectionId) {
collection = await Collection.findByPk(document.collectionId);
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
}
invariant(collection, "collection not found");
if (!collection.sharing) {
if (!collection?.sharing) {
throw AuthorizationError();
}
+17 -11
View File
@@ -1,4 +1,3 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { createContext } from "@server/context";
import { traceFunction } from "@server/logging/tracing";
@@ -66,16 +65,21 @@ async function documentMover({
result.documents.push(document);
} else {
// Load the current and the next collection upfront and lock them
const collection = await Collection.findByPk(document.collectionId!, {
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
});
const collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId!,
{
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
}
);
let newCollection = collection;
if (collectionChanged) {
if (collectionId) {
newCollection = await Collection.findByPk(collectionId, {
newCollection = await Collection.scope(
"withDocumentStructure"
).findByPk(collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -144,12 +148,14 @@ async function documentMover({
if (collectionId) {
// Reload the collection to get relationship data
newCollection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, {
newCollection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId, {
transaction,
rejectOnEmpty: true,
});
invariant(newCollection, "Collection not found");
result.collections.push(newCollection);
+35 -29
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
});
describe("#url", () => {
test("should return correct url for the collection", () => {
it("should return correct url for the collection", () => {
const collection = new Collection({
id: "1234",
});
@@ -25,7 +25,7 @@ describe("#url", () => {
});
describe("getDocumentParents", () => {
test("should return array of parent document ids", async () => {
it("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -41,7 +41,7 @@ describe("getDocumentParents", () => {
expect(result ? result[0] : undefined).toBe(parent.id);
});
test("should return array of parent document ids", async () => {
it("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -56,7 +56,7 @@ describe("getDocumentParents", () => {
expect(result?.length).toBe(0);
});
test("should not error if documentStructure is empty", async () => {
it("should not error if documentStructure is empty", async () => {
const parent = await buildDocument();
await buildDocument();
const collection = await buildCollection();
@@ -66,7 +66,7 @@ describe("getDocumentParents", () => {
});
describe("getDocumentTree", () => {
test("should return document tree", async () => {
it("should return document tree", async () => {
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [await document.toNavigationNode()],
@@ -76,7 +76,7 @@ describe("getDocumentTree", () => {
);
});
test("should return nested documents in tree", async () => {
it("should return nested documents in tree", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -99,7 +99,7 @@ describe("getDocumentTree", () => {
});
describe("#addDocumentToStructure", () => {
test("should add as last element without index", async () => {
it("should add as last element without index", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -117,7 +117,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure!.length).toBe(1);
});
test("should add with an index", async () => {
it("should add with an index", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -131,7 +131,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].id).toBe(id);
});
test("should add as a child if with parent", async () => {
it("should add as a child if with parent", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -150,7 +150,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
test("should add as a child if with parent with index", async () => {
it("should add as a child if with parent with index", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -176,7 +176,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
test("should add the document along with its nested document(s)", async () => {
it("should add the document along with its nested document(s)", async () => {
const collection = await buildCollection();
const document = await buildDocument({
@@ -204,7 +204,7 @@ describe("#addDocumentToStructure", () => {
);
});
test("should add the document along with its archived nested document(s)", async () => {
it("should add the document along with its archived nested document(s)", async () => {
const collection = await buildCollection();
const document = await buildDocument({
@@ -237,7 +237,7 @@ describe("#addDocumentToStructure", () => {
);
});
describe("options: documentJson", () => {
test("should append supplied json over document's own", async () => {
it("should append supplied json over document's own", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -268,7 +268,7 @@ describe("#addDocumentToStructure", () => {
});
describe("#updateDocument", () => {
test("should update root document's data", async () => {
it("should update root document's data", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -279,7 +279,7 @@ describe("#updateDocument", () => {
expect(collection.documentStructure![0].title).toBe("Updated title");
});
test("should update child document's data", async () => {
it("should update child document's data", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -297,7 +297,7 @@ describe("#updateDocument", () => {
newDocument.title = "Updated title";
await newDocument.save();
await collection.updateDocument(newDocument);
const reloaded = await Collection.findByPk(collection.id);
const reloaded = await collection.reload();
expect(reloaded!.documentStructure![0].children[0].title).toBe(
"Updated title"
);
@@ -305,7 +305,7 @@ describe("#updateDocument", () => {
});
describe("#removeDocument", () => {
test("should save if removing", async () => {
it("should save if removing", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -315,7 +315,7 @@ describe("#removeDocument", () => {
expect(collection.save).toBeCalled();
});
test("should remove documents from root", async () => {
it("should remove documents from root", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -331,7 +331,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(0);
});
test("should remove a document with child documents", async () => {
it("should remove a document with child documents", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -359,7 +359,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(0);
});
test("should remove a child document", async () => {
it("should remove a child document", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -380,7 +380,7 @@ describe("#removeDocument", () => {
expect(collection.documentStructure![0].children.length).toBe(1);
// Remove the document
await collection.deleteDocument(newDocument);
const reloaded = await Collection.findByPk(collection.id);
const reloaded = await collection.reload();
expect(reloaded!.documentStructure!.length).toBe(1);
expect(reloaded!.documentStructure![0].children.length).toBe(0);
const collectionDocuments = await Document.findAndCountAll({
@@ -393,7 +393,7 @@ describe("#removeDocument", () => {
});
describe("#membershipUserIds", () => {
test("should return collection and group memberships", async () => {
it("should return collection and group memberships", async () => {
const team = await buildTeam();
const teamId = team.id;
// Make 6 users
@@ -464,47 +464,53 @@ describe("#membershipUserIds", () => {
});
describe("#findByPk", () => {
test("should return collection with collection Id", async () => {
it("should return collection with collection Id", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(response!.id).toBe(collection.id);
});
test("should return collection when urlId is present", async () => {
it("should not return documentStructure by default", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(() => response!.documentStructure).toThrow();
});
it("should return collection when urlId is present", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}`;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
test("should return collection when urlId is present, but missing slug", async () => {
it("should return collection when urlId is present, but missing slug", async () => {
const collection = await buildCollection();
const id = collection.urlId;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
test("should return null when incorrect uuid type", async () => {
it("should return null when incorrect uuid type", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id + "-incorrect");
expect(response).toBe(null);
});
test("should return null when incorrect urlId length", async () => {
it("should return null when incorrect urlId length", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}incorrect`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
});
test("should return null when no collection is found with uuid", async () => {
it("should return null when no collection is found with uuid", async () => {
const response = await Collection.findByPk(
"a9e71a81-7342-4ea3-9889-9b9cc8f667da"
);
expect(response).toBe(null);
});
test("should return null when no collection is found with urlId", async () => {
it("should return null when no collection is found with urlId", async () => {
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
+13
View File
@@ -37,6 +37,7 @@ import {
AllowNull,
BeforeCreate,
BeforeUpdate,
DefaultScope,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort, ProsemirrorData } from "@shared/types";
@@ -69,6 +70,11 @@ type AdditionalFindOptions = {
rejectOnEmpty?: boolean | Error;
};
@DefaultScope(() => ({
attributes: {
exclude: ["documentStructure"],
},
}))
@Scopes(() => ({
withAllMemberships: {
include: [
@@ -121,6 +127,12 @@ type AdditionalFindOptions = {
},
],
}),
withDocumentStructure: () => ({
attributes: {
// resets to include the documentStructure column
exclude: [],
},
}),
withMembership: (userId: string) => {
if (!userId) {
return {};
@@ -238,6 +250,7 @@ class Collection extends ParanoidModel<
@Column
maintainerApprovalRequired: boolean;
@Default(null)
@Column(DataType.JSONB)
documentStructure: NavigationNode[] | null;
+2 -5
View File
@@ -11,7 +11,6 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -96,10 +95,8 @@ describe("#delete", () => {
await document.delete(user);
const [newDocument, newCollection] = await Promise.all([
Document.findByPk(document.id, {
paranoid: false,
}),
Collection.findByPk(collection.id),
document.reload({ paranoid: false }),
collection.reload(),
]);
expect(newDocument?.lastModifiedById).toEqual(user.id);
+50 -23
View File
@@ -15,6 +15,7 @@ import {
FindOptions,
WhereOptions,
EmptyResultError,
Sequelize,
} from "sequelize";
import {
ForeignKey,
@@ -71,12 +72,20 @@ import Length from "./validators/Length";
export const DOCUMENT_VERSION = 2;
// If content (JSON) is null then we still need to return the state column (BINARY)
// as it's used as a fallback for content deserialization for older documents.
// This can be removed if content is 100% backfilled.
const stateIfContentEmpty = Sequelize.literal(
`CASE WHEN document.content IS NULL THEN document.state ELSE NULL END AS state`
);
type AdditionalFindOptions = {
userId?: string;
includeState?: boolean;
rejectOnEmpty?: boolean | Error;
};
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
@DefaultScope(() => ({
include: [
{
@@ -101,13 +110,14 @@ type AdditionalFindOptions = {
},
},
attributes: {
exclude: ["state"],
include: [stateIfContentEmpty],
},
}))
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
@Scopes(() => ({
withoutState: {
attributes: {
exclude: ["state"],
include: [stateIfContentEmpty],
},
},
withCollection: {
@@ -121,7 +131,7 @@ type AdditionalFindOptions = {
withState: {
attributes: {
// resets to include the state column
exclude: [],
include: [],
},
},
withDrafts: {
@@ -162,11 +172,13 @@ type AdditionalFindOptions = {
return {
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
model: userId
? Collection.scope({
method: ["withMembership", userId],
})
? Collection.scope([
"defaultScope",
{
method: ["withMembership", userId],
},
])
: Collection,
as: "collection",
paranoid,
@@ -414,10 +426,13 @@ class Document extends ArchivableModel<
return;
}
const collection = await Collection.findByPk(model.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
const collection = await Collection.scope("withDocumentStructure").findByPk(
model.collectionId,
{
transaction,
lock: Transaction.LOCK.UPDATE,
}
);
if (!collection) {
return;
}
@@ -438,7 +453,9 @@ class Document extends ArchivableModel<
}
return this.sequelize!.transaction(async (transaction: Transaction) => {
const collection = await Collection.findByPk(model.collectionId!, {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(model.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
});
@@ -926,7 +943,9 @@ class Document extends ArchivableModel<
}
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId, {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -993,10 +1012,13 @@ class Document extends ArchivableModel<
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
})
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction.LOCK.UPDATE,
}
)
: undefined;
if (collection) {
@@ -1027,10 +1049,13 @@ class Document extends ArchivableModel<
archive = async (user: User, options?: FindOptions) => {
const { transaction } = { ...options };
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction?.LOCK.UPDATE,
})
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction?.LOCK.UPDATE,
}
)
: undefined;
if (collection) {
@@ -1051,7 +1076,7 @@ class Document extends ArchivableModel<
) => {
const { transaction } = { ...options };
const collection = collectionId
? await Collection.findByPk(collectionId, {
? await Collection.scope("withDocumentStructure").findByPk(collectionId, {
transaction,
lock: transaction?.LOCK.UPDATE,
})
@@ -1103,7 +1128,9 @@ class Document extends ArchivableModel<
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId!, {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
@@ -171,7 +171,8 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
/**
* Generates a map of document urls to their path in the zip file.
*
* @param collections
* @param collections The collections to generate the path map for.
* @param format The format of the exported documents.
*/
private createPathMap(
collections: Collection[],
+7 -5
View File
@@ -44,11 +44,13 @@ export default abstract class ExportTask extends BaseTask<Props> {
? [fileOperation.collectionId]
: await user.collectionIds();
const collections = await Collection.findAll({
where: {
id: collectionIds,
},
});
const collections = await Collection.scope("withDocumentStructure").findAll(
{
where: {
id: collectionIds,
},
}
);
let filePath: string | undefined;
+5 -3
View File
@@ -140,9 +140,11 @@ router.post(
async (ctx: APIContext<T.CollectionsDocumentsReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
const collection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(id);
authorize(user, "readDocument", collection);
@@ -977,7 +977,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collection: document.collectionId,
collectionId: document.collectionId,
},
});
const body = await res.json();
@@ -1013,7 +1013,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collection: collection.id,
collectionId: collection.id,
},
});
const body = await res.json();
+8 -4
View File
@@ -133,15 +133,19 @@ router.post(
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
where[Op.and].push({ collectionId: [collectionId] });
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.scope([
sort === "index" ? "withDocumentStructure" : "defaultScope",
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId);
authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (sort === "index") {
documentIds = (collection?.documentStructure || [])
documentIds = (collection.documentStructure || [])
.map((node) => node.id)
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit);
where[Op.and].push({ id: documentIds });
+5 -1
View File
@@ -54,7 +54,11 @@ router.post(
});
authorize(user, "read", document);
const collection = await document.$get("collection");
const collection = document.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
document.collectionId
)
: undefined;
const parentIds = collection?.getDocumentParents(documentId);
const parentShare = parentIds
? await Share.scope({
+4 -1
View File
@@ -1,5 +1,6 @@
import path from "path";
import { InferAttributes, InferCreationAttributes } from "sequelize";
import sequelizeStrictAttributes from "sequelize-strict-attributes";
import { Sequelize } from "sequelize-typescript";
import { Umzug, SequelizeStorage, MigrationError } from "umzug";
import env from "@server/env";
@@ -23,7 +24,7 @@ export function createDatabaseInstance(
}
): Sequelize {
try {
return new Sequelize(databaseUrl, {
const instance = new Sequelize(databaseUrl, {
logging: (msg) =>
process.env.DEBUG?.includes("database") &&
Logger.debug("database", msg),
@@ -47,6 +48,8 @@ export function createDatabaseInstance(
},
schema,
});
sequelizeStrictAttributes(instance);
return instance;
} catch (error) {
Logger.fatal(
"Could not connect to database",
+4 -2
View File
@@ -310,7 +310,7 @@ export async function buildCollection(
overrides.permission = CollectionPermission.ReadWrite;
}
return Collection.create({
return Collection.scope("withDocumentStructure").create({
name: faker.lorem.words(2),
description: faker.lorem.words(4),
createdById: overrides.userId,
@@ -416,7 +416,9 @@ export async function buildDocument(
if (overrides.collectionId && overrides.publishedAt !== null) {
collection = collection
? await Collection.findByPk(overrides.collectionId)
? await Collection.scope("withDocumentStructure").findByPk(
overrides.collectionId
)
: undefined;
await collection?.addDocumentToStructure(document, 0);
-1
View File
@@ -11,7 +11,6 @@ export async function collectionIndexing(
where: {
teamId,
},
attributes: ["id", "index", "name", "teamId"],
transaction,
});
+1 -1
View File
@@ -55,7 +55,7 @@ const mathStyle = (props: Props) => css`
cursor: auto;
white-space: pre-wrap;
overflow-x: auto;
overflow-y: none;
overflow-y: hidden;
}
.math-node.empty-math .math-render::before {
+6
View File
@@ -198,6 +198,12 @@ export const codeLanguages: Record<string, CodeLanguage> = {
label: "Powershell",
loader: () => import("refractor/lang/powershell").then((m) => m.default),
},
promql: {
lang: "promql",
label: "PromQL",
// @ts-expect-error PromQL is not in types but exists
loader: () => import("refractor/lang/promql").then((m) => m.default),
},
protobuf: {
lang: "protobuf",
label: "Protobuf",
+4 -1
View File
@@ -529,6 +529,7 @@
"Shared Links": "Shared Links",
"Import": "Import",
"Integrations": "Integrations",
"Install": "Install",
"Revoke token": "Revoke token",
"Revoke": "Revoke",
"Show path to document": "Show path to document",
@@ -974,6 +975,8 @@
"{{ count }} document imported_plural": "{{ count }} documents imported",
"You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload",
"Configure": "Configure",
"Connect": "Connect",
"Last active": "Last active",
"Guest": "Guest",
"Never used": "Never used",
@@ -1034,6 +1037,7 @@
"Enterprise": "Enterprise",
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.",
"Recent imports": "Recent imports",
"Configure a variety of integrations with third-party services.": "Configure a variety of integrations with third-party services.",
"Could not load members": "Could not load members",
"Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.": "Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
@@ -1144,7 +1148,6 @@
"Expires today": "Expires today",
"Expires tomorrow": "Expires tomorrow",
"Expires {{ date }}": "Expires {{ date }}",
"Connect": "Connect",
"Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?",
"Something went wrong while authenticating your request. Please try logging in again.": "Something went wrong while authenticating your request. Please try logging in again.",
"The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.": "The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.",
+851 -854
View File
File diff suppressed because it is too large Load Diff