Compare commits

..

2 Commits

Author SHA1 Message Date
Tom Moor 036b584184 fix: Members table always fades in 2025-03-01 11:14:03 -05:00
Tom Moor 575f1da115 PeopleTable -> MemberTable 2025-03-01 10:53:25 -05:00
287 changed files with 2500 additions and 19330 deletions
+5 -5
View File
@@ -147,10 +147,6 @@ DISCORD_SERVER_ID=
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
@@ -205,10 +201,14 @@ SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_SERVICE=
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
-4
View File
@@ -171,10 +171,6 @@
"description": "smtp.example.com (optional)",
"required": false
},
"SMTP_SERVICE": {
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
"required": false
},
"SMTP_PORT": {
"description": "1234 (optional)",
"required": false
+1 -1
View File
@@ -149,7 +149,7 @@ export const searchInCollection = createAction({
},
perform: ({ activeCollectionId }) => {
history.push(searchPath({ collectionId: activeCollectionId }));
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
},
});
+4 -6
View File
@@ -683,7 +683,6 @@ export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
shortcut: [`Meta+/`],
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -693,7 +692,7 @@ export const searchInDocument = createAction({
return !!document?.isActive;
},
perform: ({ activeDocumentId }) => {
history.push(searchPath({ documentId: activeDocumentId }));
history.push(searchPath(undefined, { documentId: activeDocumentId }));
},
});
@@ -806,15 +805,15 @@ export const openRandomDocument = createAction({
},
});
export const searchDocumentsForQuery = (query: string) =>
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query })),
perform: () => history.push(searchPath(searchQuery)),
visible: ({ location }) => location.pathname !== searchPath(),
});
@@ -1211,7 +1210,6 @@ export const rootDocumentActions = [
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
+1 -10
View File
@@ -50,7 +50,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query: searchQuery.query })),
perform: () => history.push(searchPath(searchQuery.query)),
});
export const navigateToDrafts = createAction({
@@ -62,15 +62,6 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath()),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
-2
View File
@@ -2,8 +2,6 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
+1 -1
View File
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
-4
View File
@@ -80,10 +80,6 @@ const RealButton = styled(ActionButton)<RealProps>`
} 0 0 0 1px inset;
}
&:focus-visible {
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
}
&:disabled {
color: ${props.theme.textTertiary};
background: none;
+2 -2
View File
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
() =>
orderBy(
filter(
users.all,
users.orderedData,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
),
[document.collaboratorIds, users.all, presentIds]
[document.collaboratorIds, users.orderedData, presentIds]
);
// load any users we don't yet have in memory
-3
View File
@@ -11,7 +11,6 @@ import Collection from "~/models/Collection";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
@@ -25,7 +24,6 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const can = usePolicy(collection);
const handleSave = React.useMemo(
@@ -67,7 +65,6 @@ function CollectionDescription({ collection }: Props) {
maxLength={CollectionValidation.maxDescriptionLength}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
editorStyle={editorStyle}
embedsDisabled
/>
+1 -2
View File
@@ -3,7 +3,6 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -71,7 +70,7 @@ function CommandBarItem(
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
<Key key={key}>{key}</Key>
))}
</React.Fragment>
))}
+4 -10
View File
@@ -4,12 +4,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -20,21 +14,21 @@ function ConnectionStatus() {
const { t } = useTranslation();
const codeToMessage = {
[DocumentTooLarge.code]: {
1009: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
[AuthenticationFailed.code]: {
4401: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
[AuthorizationFailed.code]: {
4403: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
[TooManyConnections.code]: {
4503: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
-3
View File
@@ -161,9 +161,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
+29 -14
View File
@@ -2,11 +2,16 @@ import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Optional } from "utility-types";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = {
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null;
};
@@ -14,6 +19,7 @@ type DefaultCollectionInputSelectProps = {
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
@@ -41,26 +47,36 @@ const DefaultCollectionInputSelect = ({
void fetchData();
}, [fetchError, t, fetching, collections]);
const options: Option[] = React.useMemo(
const options = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
...acc,
{
type: "item",
label: collection.name,
label: (
<Flex align="center">
<IconWrapper>
<CollectionIcon collection={collection} />
</IconWrapper>
{collection.name}
</Flex>
),
value: collection.id,
icon: <CollectionIcon collection={collection} />,
},
],
[
{
type: "item",
label: t("Home"),
label: (
<Flex align="center">
<IconWrapper>
<HomeIcon />
</IconWrapper>
{t("Home")}
</Flex>
),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
]
),
[collections.nonPrivate, t]
);
@@ -70,14 +86,13 @@ const DefaultCollectionInputSelect = ({
}
return (
<InputSelectNew
options={options}
<InputSelect
value={defaultCollectionId ?? "home"}
options={options}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
label={t("Start view")}
hideLabel
short
{...rest}
/>
);
};
+1 -4
View File
@@ -23,10 +23,7 @@ function Dialogs() {
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
style={modal.style}
>
+13 -39
View File
@@ -6,9 +6,7 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { getDataTransferFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
@@ -185,46 +183,22 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[updateComments]
);
const paragraphs = React.useMemo(() => {
if (props.readOnly && typeof props.value === "object") {
return ProsemirrorHelper.getPlainParagraphs(props.value);
}
return undefined;
}, [props.readOnly, props.value]);
return (
<ErrorBoundary component="div" reloadOnChunkMissing>
<>
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
<p key={index} dir="auto">
{paragraph.content?.map((content) => content.text)}
</p>
))}
</div>
</EditorContainer>
) : (
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
)}
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
+5 -5
View File
@@ -1,9 +1,9 @@
import styled from "styled-components";
import Text from "~/components/Text";
import { s } from "@shared/styles";
const Empty = styled(Text).attrs({
type: "tertiary",
selectable: false,
})``;
const Empty = styled.p`
color: ${s("textTertiary")};
user-select: none;
`;
export default Empty;
+4 -5
View File
@@ -7,7 +7,6 @@ import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
@@ -78,9 +77,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Module failed to load")} />
<Heading>
<h1>
<Trans>Loading Failed</Trans>
</Heading>
</h1>
</>
)}
<Text as="p" type="secondary">
@@ -102,9 +101,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Something Unexpected Happened")} />
<Heading>
<h1>
<Trans>Something Unexpected Happened</Trans>
</Heading>
</h1>
</>
)}
<Text as="p" type="secondary">
+2 -2
View File
@@ -234,7 +234,7 @@ const lineStyle = css`
width: 1px;
height: calc(50% - 14px + 8px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
mix-blend-mode: multiply;
z-index: 1;
}
@@ -255,7 +255,7 @@ const lineStyle = css`
width: 1px;
height: calc(50% - 14px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
mix-blend-mode: multiply;
z-index: 1;
}
+16 -13
View File
@@ -23,6 +23,7 @@ type Props = {
options: TFilterOption[];
selectedKeys: (string | null | undefined)[];
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
@@ -34,6 +35,7 @@ const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
selectedPrefix = "",
className,
onSelect,
showFilter,
@@ -52,7 +54,9 @@ const FilterOptions = ({
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems.map((selected) => selected.label).join(", ")
? selectedItems
.map((selected) => `${selectedPrefix} ${selected.label}`)
.join(", ")
: "";
const renderItem = React.useCallback(
@@ -66,7 +70,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon}
{option.icon && <Icon>{option.icon}</Icon>}
{option.note ? (
<LabelWithNote>
{option.label}
@@ -159,16 +163,10 @@ const FilterOptions = ({
const showFilterInput = showFilter || options.length > 10;
return (
<>
<div>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
disclosure
>
<StyledButton {...props} className={className} neutral disclosure>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
@@ -195,7 +193,7 @@ const FilterOptions = ({
/>
)}
</ContextMenu>
</>
</div>
);
};
@@ -233,7 +231,6 @@ const SearchInput = styled(Input)`
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
margin: 0;
}
${NativeInput} {
@@ -270,9 +267,15 @@ export const StyledButton = styled(Button)`
}
${Inner} {
line-height: 28px;
line-height: 24px;
min-height: auto;
}
`;
const Icon = styled.div`
margin-right: 8px;
width: 18px;
height: 18px;
`;
export default FilterOptions;
+1 -1
View File
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
right: 0;
bottom: 0;
background-color: ${s("backdrop")} !important;
z-index: ${depths.overlay};
z-index: ${depths.modalOverlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
+1 -2
View File
@@ -60,8 +60,7 @@ function InputSearchPage({
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchPath({
query: ev.currentTarget.value,
searchPath(ev.currentTarget.value, {
collectionId,
ref: source,
})
-354
View File
@@ -1,354 +0,0 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { transparentize } from "polished";
import React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import Scrollable from "./Scrollable";
import { IconWrapper } from "./Sidebar/components/SidebarLink";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import {
InputSelectRoot,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./primitives/components/InputSelect";
type Separator = {
/* Denotes a horizontal divider line to be rendered in the menu, */
type: "separator";
};
export type Item = {
/* Denotes a selectable option in the menu. */
type: "item";
/* Representative text shown in the menu for this option. */
label: string;
/* Actual value of this option. */
value: string;
/* Additional info shown alongside the label. */
description?: string;
/* An icon shown alongside the label. */
icon?: React.ReactElement;
};
export type Option = Item | Separator;
type Props = {
/* Options to display in the select menu. */
options: Option[];
/* Current chosen value. */
value?: string;
/* Callback when an option is selected. */
onChange: (value: string) => void;
/* ARIA label for accessibility. */
ariaLabel: string;
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
short?: boolean;
} & TriggerButtonProps;
export function InputSelectNew(props: Props) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
...triggerProps
} = props;
const [localValue, setLocalValue] = React.useState(value);
const [open, setOpen] = React.useState(false);
const triggerRef =
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
const contentRef =
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const isMobile = useMobile();
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
const optionsHaveIcon = options.some(
(opt) => opt.type === "item" && !!opt.icon
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator />;
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
</InputSelectItem>
);
},
[optionsHaveIcon]
);
const onValueChange = React.useCallback(
async (val: string) => {
setLocalValue(val);
onChange(val);
},
[onChange, setLocalValue]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
if (isMobile) {
return (
<MobileSelect
{...props}
value={localValue}
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
value={localValue}
onValueChange={onValueChange}
>
<InputSelectTrigger
ref={triggerRef}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
);
}
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
};
function MobileSelect(props: MobileSelectProps) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
placeholder,
optionsHaveIcon,
...triggerProps
} = props;
const [open, setOpen] = React.useState(false);
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const selectedOption = React.useMemo(
() =>
value
? options.find((opt) => opt.type === "item" && opt.value === value)
: undefined,
[value, options]
);
const handleSelect = React.useCallback(
async (val: string) => {
setOpen(false);
onChange(val);
},
[onChange]
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <Separator />;
}
const isSelected = option === selectedOption;
return (
<SelectItemWrapper
key={option.value}
onClick={() => handleSelect(option.value)}
data-state={isSelected ? "checked" : "unchecked"}
>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
{isSelected && <SelectItemIndicator />}
</SelectItemWrapper>
);
},
[handleSelect, selectedOption, optionsHaveIcon]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
/>
) : (
<>{placeholder}</>
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
function Label({ text, hidden }: { text: string; hidden: boolean }) {
const labelText = <LabelText>{text}</LabelText>;
return hidden ? (
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
) : (
labelText
);
}
function Option({
option,
optionsHaveIcon,
}: {
option: Item;
optionsHaveIcon: boolean;
}) {
const icon = optionsHaveIcon ? (
option.icon ? (
<IconWrapper>{option.icon}</IconWrapper>
) : (
<IconSpacer />
)
) : null;
return (
<OptionContainer align="center">
{icon}
{option.label}
{option.description && (
<>
&nbsp;
<Description type="tertiary" size="small" ellipsis>
{option.description}
</Description>
</>
)}
</OptionContainer>
);
}
const Wrapper = styled.label<{ short?: boolean }>`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const OptionContainer = styled(Flex)`
min-height: 24px;
`;
const Description = styled(Text)`
@media (hover: hover) {
&:hover,
&:focus {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
`;
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
+9 -11
View File
@@ -33,7 +33,6 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
ellipsis?: boolean;
};
const ListItem = (
@@ -46,7 +45,6 @@ const ListItem = (
border,
to,
keyboardNavigation,
ellipsis,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -85,9 +83,7 @@ const ListItem = (
column={!compact}
$selected={selected}
>
<Heading $small={small} $ellipsis={ellipsis}>
{title}
</Heading>
<Heading $small={small}>{title}</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
@@ -109,7 +105,7 @@ const ListItem = (
$border={border}
$small={small}
activeStyle={{
background: theme.sidebarActiveBackground,
background: theme.accent,
}}
{...rest}
{...rovingTabIndex}
@@ -212,10 +208,10 @@ const Image = styled(Flex)`
color: ${s("text")};
`;
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
const Heading = styled.p<{ $small?: boolean }>`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
${ellipsis()}
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
@@ -223,13 +219,14 @@ const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
const Content = styled(Flex)<{ $selected: boolean }>`
flex-direction: column;
flex-grow: 1;
color: ${s("text")};
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${s("textTertiary")};
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
margin-top: -2px;
`;
@@ -237,7 +234,8 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
align-self: center;
justify-content: center;
flex-shrink: 0;
color: ${s("textSecondary")};
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default React.forwardRef(ListItem);
+69 -5
View File
@@ -1,7 +1,24 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { locales } from "@shared/utils/date";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import Tooltip from "~/components/Tooltip";
import { useLocaleTime } from "~/hooks/useLocaleTime";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
export type Props = {
children?: React.ReactNode;
@@ -12,12 +29,59 @@ export type Props = {
format?: Partial<Record<keyof typeof locales, string>>;
};
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
const { tooltipContent, content } = useLocaleTime(rest);
const LocaleTime: React.FC<Props> = ({
addSuffix,
children,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
return (
<Tooltip content={tooltipContent} placement="bottom">
<time dateTime={rest.dateTime}>{children || content}</time>
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
};
+1 -1
View File
@@ -147,7 +147,7 @@ const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
props.$fullscreen
? transparentize(0.25, props.theme.background)
: props.theme.modalBackdrop} !important;
z-index: ${depths.overlay};
z-index: ${depths.modalOverlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -96,7 +96,7 @@ export const Suggestions = observer(
? users.notInDocument(document.id, query)
: collection
? users.notInCollection(collection.id, query)
: users.activeOrInvited
: users.orderedData
).filter((u) => !u.isSuspended && u.id !== user.id);
if (isEmail(query)) {
@@ -114,7 +114,7 @@ export const Suggestions = observer(
}, [
getSuggestionForEmail,
users,
users.activeOrInvited,
users.orderedData,
groups,
groups.orderedData,
document?.id,
@@ -10,7 +10,6 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
@@ -22,6 +21,7 @@ import CollectionMenu from "~/menus/CollectionMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
@@ -12,7 +12,6 @@ import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -29,6 +28,7 @@ import {
} from "../hooks/useDragAndDrop";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
@@ -73,13 +73,15 @@ function EditableTitle(
return;
}
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
if (document) {
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
}
},
[originalValue, value, onCancel, onSubmit]
@@ -125,10 +127,7 @@ function EditableTitle(
/>
</form>
) : (
<span
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
className={rest.className}
>
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
{value}
</span>
)}
+18 -29
View File
@@ -1,16 +1,15 @@
import invariant from "invariant";
import find from "lodash/find";
import isObject from "lodash/isObject";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import semver from "semver";
import { io, Socket } from "socket.io-client";
import { toast } from "sonner";
import {
FileOperationState,
FileOperationType,
ImportState,
} from "@shared/types";
import EDITOR_VERSION from "@shared/editor/version";
import { FileOperationState, FileOperationType } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
@@ -19,7 +18,6 @@ import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupUser from "~/models/GroupUser";
import Import from "~/models/Import";
import Membership from "~/models/Membership";
import Notification from "~/models/Notification";
import Pin from "~/models/Pin";
@@ -105,7 +103,6 @@ class WebsocketProvider extends React.Component<Props> {
subscriptions,
fileOperations,
notifications,
imports,
} = this.props;
const currentUserId = auth?.user?.id;
@@ -120,10 +117,23 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("authenticated", () => {
this.socket.on("authenticated", (data) => {
if (this.socket) {
this.socket.authenticated = true;
}
if (isObject(data) && "editorVersion" in data) {
const parsedClientVersion = semver.parse(EDITOR_VERSION);
const parsedCurrentVersion = semver.parse(String(data.editorVersion));
if (
parsedClientVersion &&
parsedCurrentVersion &&
(parsedClientVersion.major < parsedCurrentVersion.major ||
parsedClientVersion.minor < parsedCurrentVersion.minor)
) {
window.location.reload();
}
}
});
this.socket.on("unauthorized", (err: Error) => {
@@ -626,23 +636,6 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => {
imports.add(event);
});
this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => {
imports.add(event);
if (
event.state === ImportState.Completed &&
event.createdBy?.id === auth.user?.id
) {
toast.success(event.name, {
description: this.props.t("Your import completed"),
});
}
});
this.socket.on(
"subscriptions.create",
(event: PartialExcept<Subscription, "id">) => {
@@ -668,10 +661,6 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => {
users.remove(event.modelId);
});
this.socket.on(
"userMemberships.update",
async (event: PartialExcept<UserMembership, "id">) => {
-88
View File
@@ -1,88 +0,0 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import React from "react";
import styled from "styled-components";
import { Drawer as DrawerPrimitive } from "vaul";
import { depths, s } from "@shared/styles";
import Flex from "../Flex";
import Text from "../Text";
import { Overlay } from "./components/Overlay";
/** Root Drawer component - all the other components are rendered inside it. */
const Drawer = (props: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root {...props} />
);
Drawer.displayName = "Drawer";
/** Drawer's trigger. */
const DrawerTrigger = DrawerPrimitive.Trigger;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DrawerPrimitive.Portal>
<DrawerPrimitive.Overlay asChild>
<Overlay />
</DrawerPrimitive.Overlay>
<StyledContent ref={ref} {...rest}>
{children}
</StyledContent>
</DrawerPrimitive.Portal>
);
});
DrawerContent.displayName = DrawerPrimitive.Content.displayName;
/** Drawer's title shown in the center. */
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>((props, ref) => {
const { hidden, children, ...rest } = props;
const title = (
<TitleWrapper justify="center">
<Text size="medium" weight="bold">
{children}
</Text>
</TitleWrapper>
);
return (
<DrawerPrimitive.Title ref={ref} {...rest} asChild>
{hidden ? (
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
) : (
<>{title}</>
)}
</DrawerPrimitive.Title>
);
});
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
/** Styled components. */
const StyledContent = styled(DrawerPrimitive.Content)`
z-index: ${depths.menu};
position: fixed;
left: 0;
right: 0;
bottom: 0;
min-width: 180px;
max-width: 100%;
min-height: 44px;
max-height: 90vh;
padding: 6px;
border-radius: 6px;
background: ${s("menuBackground")};
`;
const TitleWrapper = styled(Flex)`
padding: 8px 0;
`;
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
-135
View File
@@ -1,135 +0,0 @@
import * as InputSelectPrimitive from "@radix-ui/react-select";
import React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { Props as ButtonProps } from "~/components/Button";
import Separator from "~/components/ContextMenu/Separator";
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./components/InputSelect";
/** Root InputSelect component - all the other components are rendered inside it. */
const InputSelectRoot = InputSelectPrimitive.Root;
/** InputSelect's trigger. */
export type TriggerButtonProps = {
/** When true, "nude" variant of Button is rendered. */
nude?: boolean;
/** Optional css class names to pass to the trigger. */
className?: string;
} & Pick<ButtonProps<unknown>, "borderOnHover">;
type InputSelectTriggerProps = { placeholder: string } & TriggerButtonProps &
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Trigger>;
const InputSelectTrigger = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Trigger>,
InputSelectTriggerProps
>((props, ref) => {
const { placeholder, children, ...buttonProps } = props;
return (
<InputSelectPrimitive.Trigger ref={ref} asChild>
<SelectButton neutral disclosure {...buttonProps}>
<InputSelectPrimitive.Value placeholder={placeholder} />
</SelectButton>
</InputSelectPrimitive.Trigger>
);
});
InputSelectTrigger.displayName = InputSelectPrimitive.Trigger.displayName;
/** InputSelect's content - renders the options in a scrollable element. */
type ContentProps = Omit<
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Content>,
"position"
>;
const InputSelectContent = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Content>,
ContentProps
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Portal>
<StyledContent ref={ref} position={"popper"} {...rest}>
<InputSelectPrimitive.Viewport style={{ overscrollBehavior: "none" }}>
{children}
</InputSelectPrimitive.Viewport>
</StyledContent>
</InputSelectPrimitive.Portal>
);
});
InputSelectContent.displayName = InputSelectPrimitive.Content.displayName;
/** Individual InputSelect option rendered in the menu. */
const InputSelectItem = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Item>
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Item ref={ref} {...rest} asChild>
<SelectItemWrapper>
<InputSelectPrimitive.ItemText>
{children}
</InputSelectPrimitive.ItemText>
<InputSelectPrimitive.ItemIndicator asChild>
<SelectItemIndicator />
</InputSelectPrimitive.ItemIndicator>
</SelectItemWrapper>
</InputSelectPrimitive.Item>
);
});
InputSelectItem.displayName = InputSelectPrimitive.Item.displayName;
/** Horizontal separator rendered between the options. */
const InputSelectSeparator = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Separator>
>((props, ref) => (
<InputSelectPrimitive.Separator ref={ref} asChild>
<Separator {...props} />
</InputSelectPrimitive.Separator>
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
min-width: var(--radix-select-trigger-width);
max-width: 400px;
min-height: 44px;
max-height: 350px;
padding: 4px;
border-radius: 6px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
transform-origin: 50% 0;
&[data-side="bottom"] {
animation: ${fadeAndSlideDown} 200ms ease;
}
&[data-side="top"] {
animation: ${fadeAndSlideUp} 200ms ease;
}
@media print {
display: none;
}
`;
export {
InputSelectRoot,
InputSelectTrigger,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
};
@@ -1,136 +0,0 @@
/**
* Reusable components for InputSelect abstraction.
*/
import { CheckmarkIcon } from "outline-icons";
import React from "react";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Button, { Inner } from "~/components/Button";
import Flex from "~/components/Flex";
export const SelectItem = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div">
>((props, ref) => {
const { children, ...rest } = props;
return (
<ItemContainer
ref={ref}
justify="space-between"
align="center"
gap={8}
{...rest}
>
{children}
<IconSpacer />
</ItemContainer>
);
});
SelectItem.displayName = "SelectItem";
export const SelectItemIndicator = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>((props, ref) => (
<IndicatorContainer ref={ref} {...props}>
<CheckmarkIcon />
</IndicatorContainer>
));
SelectItemIndicator.displayName = "SelectItemIndicator";
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
export const SelectButton = styled(Button)<{ $nude?: boolean }>`
display: block;
font-weight: normal;
text-transform: none;
width: 100%;
cursor: var(--pointer);
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
}
&:focus-visible {
outline: none;
}
${(props) =>
props.$nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 12px;
padding-right: 4px;
}
svg {
justify-self: flex-end;
margin-left: auto;
}
&[data-placeholder=""] {
color: ${s("placeholder")};
}
`;
const ItemContainer = styled(Flex)`
position: relative;
width: 100%;
font-size: 16px;
cursor: var(--pointer);
color: ${s("textSecondary")};
background: none;
margin: 0;
padding: 12px;
border: 0;
border-radius: 4px;
outline: 0;
user-select: none;
white-space: nowrap;
svg {
flex-shrink: 0;
}
@media (hover: hover) {
&:hover,
&:focus {
color: ${s("accentText")};
background: ${s("accent")};
svg {
color: ${s("accentText")};
fill: ${s("accentText")};
}
}
}
&[data-state="checked"] {
${IconSpacer} {
display: none;
}
}
${breakpoint("tablet")`
font-size: 14px;
padding: 4px;
padding-left: 8px;
`}
`;
const IndicatorContainer = styled.span`
width: 24px;
height: 24px;
`;
@@ -1,15 +0,0 @@
import styled from "styled-components";
import { depths, s } from "@shared/styles";
export const Overlay = styled.div`
position: fixed;
inset: 0;
background: ${s("backdrop")};
z-index: ${depths.overlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
&[data-state="open"] {
opacity: 1;
}
`;
+4 -41
View File
@@ -1,6 +1,6 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import { DocumentIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -12,11 +12,7 @@ import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import {
DocumentsSection,
UserSection,
CollectionsSection,
} from "~/actions/sections";
import { DocumentsSection, UserSection } from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
@@ -44,7 +40,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users, collections } = useStores();
const { auth, documents, users } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
@@ -53,10 +49,8 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const { loading, request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users])
);
@@ -125,34 +119,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
} as MentionItem)
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
@@ -180,10 +146,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
if (item.attrs.type === MentionType.Document) {
return;
}
if (!documentId) {
@@ -1,4 +1,3 @@
import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
@@ -69,16 +68,12 @@ function SuggestionsMenuItem({
const Subtitle = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
props.$active ? props.theme.white50 : props.theme.textTertiary};
`;
const Shortcut = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
props.$active ? props.theme.white50 : props.theme.textTertiary};
flex-grow: 1;
text-align: right;
`;
+4 -52
View File
@@ -10,8 +10,8 @@ import {
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import { v4 } from "uuid";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { codeLanguages } from "@shared/editor/lib/code";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
@@ -20,9 +20,8 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
@@ -122,8 +121,6 @@ export default class PasteHandler extends Extension {
}
// Is the link a link to a document? If so, we can grab the title and insert it.
const containsHash = text.includes("#");
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
@@ -135,7 +132,7 @@ export default class PasteHandler extends Extension {
return;
}
if (document) {
if (state.schema.nodes.mention && !containsHash) {
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
@@ -169,51 +166,6 @@ export default class PasteHandler extends Extension {
this.insertLink(text);
});
}
} else if (isCollectionUrl(text)) {
const slug = parseCollectionSlug(text);
if (slug) {
stores.collections
.fetch(slug)
.then((collection) => {
if (view.isDestroyed) {
return;
}
if (collection) {
if (state.schema.nodes.mention && !containsHash) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(collection.icon) ===
IconType.Emoji;
const title = `${
hasEmoji ? collection.icon + " " : ""
}${collection.name}`;
this.insertLink(`${collection.path}${hash}`, title);
}
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
this.insertLink(text);
});
}
} else {
this.insertLink(text);
}
@@ -228,7 +180,7 @@ export default class PasteHandler extends Extension {
state.tr
.replaceSelectionWith(
state.schema.nodes.code_block.create({
language: Object.keys(codeLanguages).includes(
language: Object.keys(LANGUAGES).includes(
vscodeMeta.mode
)
? vscodeMeta.mode
+1 -1
View File
@@ -23,7 +23,7 @@ export default class Suggestion extends Extension {
this.options.trigger
)}(${`[\\p{L}\/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.\\-_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
);
}
+1 -1
View File
@@ -843,7 +843,7 @@ const EditorContainer = styled(Styles)<{
${(props) =>
props.userId &&
css`
.mention[data-id="${props.userId}"] {
.mention[data-id=${props.userId}] {
color: ${props.theme.textHighlightForeground};
background: ${props.theme.textHighlight};
+12 -13
View File
@@ -2,11 +2,8 @@ import { CopyIcon, ExpandedIcon } from "outline-icons";
import { Node as ProseMirrorNode } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import {
getFrequentCodeLanguages,
codeLanguages,
getLabelForLanguage,
} from "@shared/editor/lib/code";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import { getFrequentCodeLanguages } from "@shared/editor/lib/code";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
@@ -17,19 +14,20 @@ export default function codeMenuItems(
): MenuItem[] {
const node = state.selection.$from.node();
const allLanguages = Object.entries(LANGUAGES) as [
keyof typeof LANGUAGES,
string
][];
const frequentLanguages = getFrequentCodeLanguages();
const frequentLangMenuItems = frequentLanguages.map((value) => {
const label = codeLanguages[value]?.label;
const label = LANGUAGES[value];
return langToMenuItem({ node, value, label });
});
const remainingLangMenuItems = Object.entries(codeLanguages)
.filter(
([value]) =>
!frequentLanguages.includes(value as keyof typeof codeLanguages)
)
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
const remainingLangMenuItems = allLanguages
.filter(([value]) => !frequentLanguages.includes(value))
.map(([value, label]) => langToMenuItem({ node, value, label }));
const languageMenuItems = frequentLangMenuItems.length
? [
@@ -54,7 +52,8 @@ export default function codeMenuItems(
visible: !readOnly,
name: "code_block",
icon: <ExpandedIcon />,
label: getLabelForLanguage(node.attrs.language ?? "none"),
// @ts-expect-error We have a fallback for incorrect mapping
label: LANGUAGES[node.attrs.language ?? "none"],
children: languageMenuItems,
},
];
-83
View File
@@ -1,83 +0,0 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
export type Props = {
dateTime: string;
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
format?: Partial<Record<keyof typeof locales, string>>;
};
export const useLocaleTime = ({
addSuffix,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
return {
content,
tooltipContent,
};
};
+2 -3
View File
@@ -18,7 +18,6 @@ export default function usePolicy(entity?: string | Model | null) {
? entity
: entity.id
: "";
const policy = policies.get(entityId);
React.useEffect(() => {
if (
@@ -29,11 +28,11 @@ export default function usePolicy(entity?: string | Model | null) {
) {
// The policy for this model is missing and we have an authenticated session, attempt to
// reload relationships for this model.
if (!policy && user) {
if (!policies.get(entity.id) && user) {
void entity.loadRelations();
}
}
}, [policies, policy, user, entity]);
}, [policies, user, entity]);
return policies.abilities(entityId);
}
-8
View File
@@ -24,14 +24,6 @@ export default function useQueryNotices() {
);
break;
}
case QueryNotices.UnsubscribeCollection: {
toast.success(
t("Unsubscribed from collection", {
type: "success",
})
);
break;
}
default:
}
}, [t, notice]);
-62
View File
@@ -1,62 +0,0 @@
import { observer } from "mobx-react";
import { CrossIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import Import from "~/models/Import";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
import { MenuItem } from "~/types";
type Props = {
/** Import to which actions will be applied. */
importModel: Import;
/** Callback to handle import cancellation. */
onCancel: () => Promise<void>;
/** Callback to handle import deletion. */
onDelete: () => Promise<void>;
};
export const ImportMenu = observer(
({ importModel, onCancel, onDelete }: Props) => {
const { t } = useTranslation();
const can = usePolicy(importModel);
const menu = useMenuState({
modal: true,
});
const items = React.useMemo(
() =>
[
{
type: "button",
title: t("Cancel"),
visible: can.cancel,
icon: <CrossIcon />,
dangerous: true,
onClick: onCancel,
},
{
type: "button",
title: t("Delete"),
visible: can.delete,
icon: <TrashIcon />,
dangerous: true,
onClick: onDelete,
},
] satisfies MenuItem[],
[t, can.delete, can.cancel, onCancel, onDelete]
);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Import menu options")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
);
-5
View File
@@ -92,11 +92,6 @@ export default class Collection extends ParanoidModel {
@observable
archivedBy?: User;
@computed
get searchContent(): string {
return this.name;
}
/** Returns whether the collection is empty, or undefined if not loaded. */
@computed
get isEmpty(): boolean | undefined {
+2 -3
View File
@@ -188,10 +188,9 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
collaboratorIds: string[];
@Relation(() => User)
@observable
createdBy: User | undefined;
@Relation(() => User)
@observable
updatedBy: User | undefined;
@@ -600,7 +599,7 @@ export default class Document extends ArchivableModel implements Searchable {
*/
getSummary = (blocks = 4) => ({
...this.data,
content: this.data.content?.slice(0, blocks),
content: this.data.content.slice(0, blocks),
});
@computed
-2
View File
@@ -7,7 +7,6 @@ import {
import { bytesToHumanReadable } from "@shared/utils/files";
import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
class FileOperation extends Model {
static modelName = "FileOperation";
@@ -28,7 +27,6 @@ class FileOperation extends Model {
format: FileOperationFormat;
@Relation(() => User)
user: User;
@computed
-54
View File
@@ -1,54 +0,0 @@
import { observable } from "mobx";
import { ImportableIntegrationService, ImportState } from "@shared/types";
import ImportsStore from "~/stores/ImportsStore";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterChange } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
class Import extends Model {
static modelName = "Import";
store: ImportsStore;
/** The name of the import. */
name: string;
/** The current state of the import. */
@Field
@observable
state: ImportState;
/** The external service from which the import is created. */
service: ImportableIntegrationService;
/** The count of documents created in the import. */
@observable
documentCount: number;
/** The user who created the import. */
@Relation(() => User, {})
createdBy: User;
/** The ID of the user who created the import. */
createdById: string;
/**
* Cancel the import this will stop the import process and mark it as
* cancelled at the first opportunity.
*/
cancel = async () => this.store.cancel(this);
// hooks
@AfterChange
static removePolicies(model: Import, previousAttributes: Partial<Import>) {
if (previousAttributes.state && previousAttributes.state !== model.state) {
const { policies } = model.store.rootStore;
policies.remove(model.id);
}
}
}
export default Import;
-5
View File
@@ -4,7 +4,6 @@ import { isRTL } from "@shared/utils/rtl";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class Revision extends Model {
@@ -20,10 +19,6 @@ class Revision extends Model {
/** The document title when the revision was created */
title: string;
/** An optional name for the revision */
@Field
name: string | null;
/** Prosemirror data of the content when revision was created */
data: ProsemirrorData;
+2 -8
View File
@@ -1,13 +1,12 @@
import { computed, observable } from "mobx";
import { observable } from "mobx";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import { Searchable } from "./interfaces/Searchable";
class Share extends Model implements Searchable {
class Share extends Model {
static modelName = "Share";
@Field
@@ -66,11 +65,6 @@ class Share extends Model implements Searchable {
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
@computed
get searchContent(): string[] {
return [this.document?.title ?? this.documentTitle];
}
}
export default Share;
+14 -14
View File
@@ -43,6 +43,12 @@ export default abstract class Model {
this: Model,
options: { withoutPolicies?: boolean } = {}
): Promise<any> {
const relations = getRelationsForModelClass(
this.constructor as typeof Model
);
if (!relations) {
return;
}
// this is to ensure that multiple loads dont happen in parallel
if (this.loadingRelations) {
return this.loadingRelations;
@@ -50,20 +56,14 @@ export default abstract class Model {
const promises = [];
const relations = getRelationsForModelClass(
this.constructor as typeof Model
);
if (relations) {
for (const properties of relations.values()) {
const store = this.store.rootStore.getStoreForModelName(
properties.relationClassResolver().modelName
);
if ("fetch" in store) {
const id = this[properties.idKey];
if (id) {
promises.push(store.fetch(id as string));
}
for (const properties of relations.values()) {
const store = this.store.rootStore.getStoreForModelName(
properties.relationClassResolver().modelName
);
if ("fetch" in store) {
const id = this[properties.idKey];
if (id) {
promises.push(store.fetch(id as string));
}
}
}
+1 -6
View File
@@ -1,12 +1,7 @@
import { computed, observable } from "mobx";
import { observable } from "mobx";
import Model from "./Model";
export default abstract class ParanoidModel extends Model {
@observable
deletedAt: string | undefined;
@computed
get isDeleted(): boolean {
return !!this.deletedAt;
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
import DocumentNew from "~/scenes/DocumentNew";
import Error404 from "~/scenes/Errors/Error404";
import Error404 from "~/scenes/Error404";
import AuthenticatedLayout from "~/components/AuthenticatedLayout";
import CenteredContent from "~/components/CenteredContent";
import PlaceholderDocument from "~/components/PlaceholderDocument";
@@ -83,7 +83,7 @@ function AuthenticatedRoutes() {
<Route exact path={`/doc/${slug}/insights`} component={Document} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path={`${searchPath()}/:query?`} component={Search} />
<Route exact path={`${searchPath()}/:term?`} component={Search} />
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={Error404} />
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { RouteComponentProps, Switch } from "react-router-dom";
import DocumentNew from "~/scenes/DocumentNew";
import Error404 from "~/scenes/Errors/Error404";
import Error404 from "~/scenes/Error404";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
+2 -2
View File
@@ -17,6 +17,7 @@ import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Search from "~/scenes/Search";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
@@ -40,7 +41,6 @@ import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
import Error404 from "../Errors/Error404";
import Actions from "./components/Actions";
import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
@@ -139,7 +139,7 @@ const CollectionScene = observer(function _CollectionScene() {
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
if (!collection && error) {
return <Error404 />;
return <Search notFound />;
}
const hasOverview = can.update || collection?.hasDescription;
+2 -2
View File
@@ -9,8 +9,8 @@ import { s } from "@shared/styles";
import { NavigationNode, PublicTeam, TOCPosition } from "@shared/types";
import type { Theme } from "~/stores/UiStore";
import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Errors/Error404";
import ErrorOffline from "~/scenes/Errors/ErrorOffline";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import ClickablePadding from "~/components/ClickablePadding";
import {
DocumentContextProvider,
@@ -5,7 +5,7 @@ import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useQuery from "~/hooks/useQuery";
@@ -28,80 +28,66 @@ const CommentSortMenu = () => {
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved ? "resolved" : preferredSortType;
const handleChange = React.useCallback(
(val: string) => {
if (val === "resolved") {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
return;
}
const handleSortTypeChange = (type: CommentSortType) => {
if (type !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
}
};
const sortType = val as CommentSortType;
if (sortType !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
sortType === CommentSortType.OrderInDocument
);
void user.save();
}
const showResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
};
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
},
[history, location, sidebarContext, user, preferredSortType]
);
const options: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Most recent"),
value: CommentSortType.MostRecent,
},
{
type: "item",
label: t("Order in doc"),
value: CommentSortType.OrderInDocument,
},
{
type: "separator",
},
{
type: "item",
label: t("Resolved"),
value: "resolved",
},
] satisfies Option[],
[t]
);
const showUnresolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
};
return (
<Select
options={options}
value={value}
onChange={handleChange}
style={{ margin: 0 }}
ariaLabel={t("Sort comments")}
label={t("Sort comments")}
hideLabel
value={value}
onChange={(ev) => {
if (ev === "resolved") {
showResolved();
} else {
handleSortTypeChange(ev as CommentSortType);
showUnresolved();
}
}}
borderOnHover
options={[
{ value: CommentSortType.MostRecent, label: t("Most recent") },
{ value: CommentSortType.OrderInDocument, label: t("Order in doc") },
{
divider: true,
value: "resolved",
label: t("Resolved"),
},
]}
/>
);
};
const Select = styled(InputSelectNew)`
const Select = styled(InputSelect)`
color: ${s("textSecondary")};
`;
@@ -6,10 +6,9 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Error402 from "~/scenes/Errors/Error402";
import Error403 from "~/scenes/Errors/Error403";
import Error404 from "~/scenes/Errors/Error404";
import ErrorOffline from "~/scenes/Errors/ErrorOffline";
import Error402 from "~/scenes/Error402";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import { useDocumentContext } from "~/components/DocumentContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -18,7 +17,6 @@ import useStores from "~/hooks/useStores";
import { Properties } from "~/types";
import Logger from "~/utils/Logger";
import {
AuthorizationError,
NotFoundError,
OfflineError,
PaymentRequiredError,
@@ -218,8 +216,6 @@ function DataLoader({ match, children }: Props) {
<ErrorOffline />
) : error instanceof PaymentRequiredError ? (
<Error402 />
) : error instanceof AuthorizationError ? (
<Error403 />
) : (
<Error404 />
);
@@ -174,7 +174,6 @@ class DocumentScene extends React.Component<Props> {
if (template instanceof Document) {
this.props.document.templateId = template.id;
this.props.document.fullWidth = template.fullWidth;
}
if (!this.title) {
@@ -552,11 +551,6 @@ class DocumentScene extends React.Component<Props> {
>
<Notices document={document} readOnly={readOnly} />
{showContents && (
<PrintContentsContainer>
<Contents />
</PrintContentsContainer>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
@@ -671,19 +665,6 @@ const ContentsContainer = styled.div<ContentsContainerProps>`
justify-self: ${({ position }: ContentsContainerProps) =>
position === TOCPosition.Left ? "end" : "start"};
`};
@media print {
display: none;
}
`;
const PrintContentsContainer = styled.div`
display: none;
margin: 0 -12px;
@media print {
display: block;
}
`;
type EditorContainerProps = {
@@ -6,12 +6,6 @@ import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
AuthenticationFailed,
DocumentTooLarge,
EditorUpdateError,
} from "@shared/collaboration/CloseEvents";
import EDITOR_VERSION from "@shared/editor/version";
import { supportsPassiveListener } from "@shared/utils/browser";
import Editor, { Props as EditorProps } from "~/components/Editor";
import MultiplayerExtension from "~/editor/extensions/Multiplayer";
@@ -71,9 +65,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const name = `document.${documentId}`;
const localProvider = new IndexeddbPersistence(name, ydoc);
const provider = new HocuspocusProvider({
parameters: {
editorVersion: EDITOR_VERSION,
},
url: `${env.COLLABORATION_URL}/collaboration`,
name,
document: ydoc,
@@ -108,11 +99,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
});
provider.on("awarenessChange", (event: AwarenessChangeEvent) => {
presence.updateFromAwarenessChangeEvent(
documentId,
provider.awareness.clientID,
event
);
presence.updateFromAwarenessChangeEvent(documentId, event);
event.states.forEach(({ user, scrollY }) => {
if (user) {
@@ -149,14 +136,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
provider.on("close", (ev: MessageEvent) => {
if ("code" in ev.event) {
provider.shouldConnect =
ev.event.code !== DocumentTooLarge.code &&
ev.event.code !== AuthenticationFailed.code &&
ev.event.code !== EditorUpdateError.code;
ev.event.code !== 1009 && ev.event.code !== 4401;
ui.setMultiplayerStatus("disconnected", ev.event.code);
if (ev.event.code === EditorUpdateError.code) {
window.location.reload();
}
}
});
@@ -1,9 +1,7 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import Flex from "@shared/components/Flex";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
@@ -14,15 +12,13 @@ const Error402 = () => {
return (
<Scene title={title}>
<Heading>{title}</Heading>
<Flex style={{ maxWidth: 500 }} column>
<Empty size="large">
<Notice>
This document cannot be viewed with the current edition. Please
upgrade to a paid license to restore access.
</Notice>
</Empty>
</Flex>
<h1>{title}</h1>
<Empty>
<Notice>
This document cannot be viewed with the current edition. Please
upgrade to a paid license to restore access.
</Notice>
</Empty>
</Scene>
);
};
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import Empty from "~/components/Empty";
import Scene from "~/components/Scene";
import { homePath } from "~/utils/routeHelpers";
const Error404 = () => {
const { t } = useTranslation();
return (
<Scene title={t("Not Found")}>
<h1>{t("Not Found")}</h1>
<Empty>
<Trans>
We were unable to find the page youre looking for. Go to the{" "}
<Link to={homePath()}>homepage</Link>?
</Trans>
</Empty>
</Scene>
);
};
export default Error404;
@@ -2,7 +2,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "~/components/CenteredContent";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
const ErrorOffline = () => {
@@ -10,7 +9,7 @@ const ErrorOffline = () => {
return (
<CenteredContent>
<PageTitle title={t("Offline")} />
<Heading>{t("Offline")}</Heading>
<h1>{t("Offline")}</h1>
<Empty>{t("We were unable to load the document while offline.")}</Empty>
</CenteredContent>
);
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import useStores from "~/hooks/useStores";
@@ -13,12 +12,12 @@ const ErrorSuspended = () => {
return (
<CenteredContent>
<PageTitle title={t("Your account has been suspended")} />
<Heading>
<h1>
<span role="img" aria-label={t("Warning Sign")}>
</span>{" "}
{t("Your account has been suspended")}
</Heading>
</h1>
<p>
<Trans
-40
View File
@@ -1,40 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import Flex from "@shared/components/Flex";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import { navigateToHome } from "~/actions/definitions/navigation";
import useActionContext from "~/hooks/useActionContext";
const Error403 = () => {
const { t } = useTranslation();
const history = useHistory();
const context = useActionContext();
return (
<Scene title={t("No access to this doc")}>
<Heading>{t("No access to this doc")}</Heading>
<Flex gap={20} style={{ maxWidth: 500 }} column>
<Empty size="large">
{t(
"It doesnt look like you have permission to access this document."
)}{" "}
{t("Please request access from the document owner.")}
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} context={context} hideIcon>
{t("Home")}
</Button>
<Button onClick={history.goBack} neutral>
{t("Go back")}
</Button>
</Flex>
</Flex>
</Scene>
);
};
export default Error403;
-41
View File
@@ -1,41 +0,0 @@
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Flex from "@shared/components/Flex";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import {
navigateToHome,
navigateToSearch,
} from "~/actions/definitions/navigation";
import useActionContext from "~/hooks/useActionContext";
const Error404 = () => {
const { t } = useTranslation();
const context = useActionContext();
return (
<Scene title={t("Not found")}>
<Heading>{t("Not found")}</Heading>
<Flex gap={20} style={{ maxWidth: 500 }} column>
<Empty size="large">
<Trans>
The page youre looking for cannot be found. It might have been
deleted or the link is incorrect.
</Trans>
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} context={context} hideIcon>
{t("Home")}
</Button>
<Button action={navigateToSearch} context={context} neutral>
{t("Search")}
</Button>
</Flex>
</Flex>
</Scene>
);
};
export default Error404;
+1 -1
View File
@@ -462,7 +462,7 @@ function KeyboardShortcuts() {
items: [
{
shortcut: "@",
label: t("Mention users and more"),
label: t("Mention user or document"),
},
{
shortcut: ":",
+27 -13
View File
@@ -8,13 +8,14 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { v4 as uuidv4 } from "uuid";
import { Pagination } from "@shared/constants";
import { hideScrollbars } from "@shared/styles";
import { hover, hideScrollbars } from "@shared/styles";
import {
DateFilter as TDateFilter,
StatusFilter as TStatusFilter,
} from "@shared/types";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DocumentListItem from "~/components/DocumentListItem";
import Empty from "~/components/Empty";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
@@ -37,7 +38,9 @@ import RecentSearches from "./components/RecentSearches";
import SearchInput from "./components/SearchInput";
import UserFilter from "./components/UserFilter";
function Search() {
type Props = { notFound?: boolean };
function Search(props: Props) {
const { t } = useTranslation();
const { documents, searches } = useStores();
@@ -45,7 +48,7 @@ function Search() {
const params = useQuery();
const location = useLocation();
const history = useHistory();
const routeMatch = useRouteMatch<{ query: string }>();
const routeMatch = useRouteMatch<{ term: string }>();
// refs
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
@@ -54,13 +57,13 @@ function Search() {
// filters
const decodedQuery = decodeURIComponentSafe(
routeMatch.params.query ?? params.get("q") ?? params.get("query") ?? ""
routeMatch.params.term ?? params.get("query") ?? ""
).trim();
const query = decodedQuery !== "" ? decodedQuery : undefined;
const collectionId = params.get("collectionId") ?? "";
const userId = params.get("userId") ?? "";
const collectionId = params.get("collectionId") ?? undefined;
const userId = params.get("userId") ?? undefined;
const documentId = params.get("documentId") ?? undefined;
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? "";
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? undefined;
const statusFilter = params.getAll("statusFilter")?.length
? (params.getAll("statusFilter") as TStatusFilter[])
: [TStatusFilter.Published, TStatusFilter.Draft];
@@ -127,9 +130,9 @@ function Search() {
const updateLocation = (query: string) => {
history.replace({
pathname: location.pathname,
pathname: searchPath(query),
search: queryString.stringify(
{ ...queryString.parse(location.search), q: query },
{ ...queryString.parse(location.search), query: undefined },
{
skipEmptyString: true,
}
@@ -150,7 +153,7 @@ function Search() {
history.replace({
pathname: location.pathname,
search: queryString.stringify(
{ ...queryString.parse(location.search), ...search },
{ ...queryString.parse(location.search), query: undefined, ...search },
{
skipEmptyString: true,
}
@@ -208,6 +211,14 @@ function Search() {
<Scene textTitle={query ? `${query} ${t("Search")}` : t("Search")}>
<RegisterKeyDown trigger="Escape" handler={history.goBack} />
{loading && <LoadingIndicator />}
{props.notFound && (
<div>
<h1>{t("Not Found")}</h1>
<Empty>
{t("We were unable to find the page youre looking for.")}
</Empty>
</div>
)}
<ResultsWrapper column auto>
<form
method="GET"
@@ -364,24 +375,27 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
const Filters = styled(Flex)`
margin-bottom: 12px;
opacity: 0.85;
transition: opacity 100ms ease-in-out;
overflow-y: hidden;
overflow-x: auto;
padding: 8px 0;
height: 28px;
gap: 8px;
${hideScrollbars()}
${breakpoint("tablet")`
padding: 0;
`};
&: ${hover} {
opacity: 1;
}
`;
const SearchTitlesFilter = styled(Switch)`
white-space: nowrap;
margin-left: 8px;
margin-top: 4px;
margin-top: 2px;
font-size: 14px;
font-weight: 400;
`;
@@ -21,13 +21,13 @@ function CollectionFilter(props: Props) {
const collectionOptions = collections.orderedData.map((collection) => ({
key: collection.id,
label: collection.name,
icon: <CollectionIcon collection={collection} size={24} />,
icon: <CollectionIcon collection={collection} size={18} />,
}));
return [
{
key: "",
label: t("Any collection"),
icon: <SVGCollectionIcon size={24} />,
icon: <SVGCollectionIcon size={18} />,
},
...collectionOptions,
];
@@ -39,6 +39,7 @@ function CollectionFilter(props: Props) {
selectedKeys={[collectionId]}
onSelect={onSelect}
defaultLabel={t("Any collection")}
selectedPrefix={`${t("Collection")}:`}
showFilter
/>
);
+1 -1
View File
@@ -16,7 +16,7 @@ const DateFilter = ({ dateFilter, onSelect }: Props) => {
() => [
{
key: "",
label: t("All time"),
label: t("Any time"),
},
{
key: "day",
@@ -19,7 +19,7 @@ export function DocumentFilter(props: Props) {
<div>
<Tooltip content={t("Remove document filter")}>
<StyledButton onClick={props.onClick} icon={<CloseIcon />} neutral>
{props.document.titleWithDefault}
{props.document.title}
</StyledButton>
</Tooltip>
</div>
@@ -27,7 +27,7 @@ function RecentSearchListItem({ searchQuery }: Props) {
return (
<RecentSearch
to={searchPath({ query: searchQuery.query })}
to={searchPath(searchQuery.query)}
ref={ref}
{...rovingTabIndex}
>
@@ -51,9 +51,7 @@ const RemoveButton = styled(NudeButton)`
opacity: 0;
color: ${s("textTertiary")};
&:focus,
&:${hover} {
opacity: 1;
&:hover {
color: ${s("text")};
}
`;
@@ -63,11 +61,17 @@ const RecentSearch = styled(Link)`
justify-content: space-between;
color: ${s("textSecondary")};
cursor: var(--pointer);
padding: 1px 8px;
padding: 1px 4px;
border-radius: 4px;
line-height: 24px;
position: relative;
font-size: 14px;
margin: 0 -8px;
&:before {
content: "·";
color: ${s("textTertiary")};
position: absolute;
left: -8px;
}
&:focus-visible {
outline: none;
@@ -59,7 +59,7 @@ const Heading = styled.h2`
font-size: 14px;
line-height: 1.5;
color: ${s("textSecondary")};
margin: 12px 0 0;
margin-bottom: 0;
`;
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
+5 -4
View File
@@ -25,13 +25,13 @@ function UserFilter(props: Props) {
const userOptions = users.all.map((user) => ({
key: user.id,
label: user.name,
icon: <StyledAvatar model={user} size={AvatarSize.Small} />,
icon: <Avatar model={user} size={AvatarSize.Small} />,
}));
return [
{
key: "",
label: t("Any author"),
icon: <UserIcon size={20} />,
icon: <NoAuthor size={20} />,
},
...userOptions,
];
@@ -43,6 +43,7 @@ function UserFilter(props: Props) {
selectedKeys={[userId]}
onSelect={onSelect}
defaultLabel={t("Any author")}
selectedPrefix={`${t("Author")}:`}
fetchQuery={users.fetchPage}
fetchQueryOptions={fetchQueryOptions}
showFilter
@@ -50,8 +51,8 @@ function UserFilter(props: Props) {
);
}
const StyledAvatar = styled(Avatar)`
margin: 2px;
const NoAuthor = styled(UserIcon)`
margin-left: -2px;
`;
export default observer(UserFilter);
+18 -31
View File
@@ -16,7 +16,7 @@ import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSel
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import InputColor from "~/components/InputColor";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -64,27 +64,6 @@ function Details() {
team.getPreference(TeamPreference.TocPosition) as TOCPosition
);
const tocPositionOptions: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Left"),
value: TOCPosition.Left,
},
{
type: "item",
label: t("Right"),
value: TOCPosition.Right,
},
] satisfies Option[],
[t]
);
const handleTocPositionChange = React.useCallback((position: string) => {
setTocPosition(position as TOCPosition);
}, []);
const handleSubmit = React.useCallback(
async (event?: React.SyntheticEvent) => {
if (event) {
@@ -144,9 +123,9 @@ function Details() {
});
};
const onSelectCollection = React.useCallback((value: string) => {
const selectedValue = value === "home" ? null : value;
setDefaultCollectionId(selectedValue);
const onSelectCollection = React.useCallback(async (value: string) => {
const defaultCollectionId = value === "home" ? null : value;
setDefaultCollectionId(defaultCollectionId);
}, []);
const isValid = form.current?.checkValidity();
@@ -263,13 +242,20 @@ function Details() {
"The side to display the table of contents in relation to the main content."
)}
>
<InputSelectNew
options={tocPositionOptions}
value={tocPosition}
onChange={handleTocPositionChange}
<InputSelect
ariaLabel={t("Table of contents position")}
label={t("Table of contents position")}
hideLabel
options={[
{
label: t("Left"),
value: TOCPosition.Left,
},
{
label: t("Right"),
value: TOCPosition.Right,
},
]}
value={tocPosition}
onChange={(p: TOCPosition) => setTocPosition(p)}
/>
</SettingRow>
@@ -312,6 +298,7 @@ function Details() {
)}
>
<DefaultCollectionInputSelect
id="defaultCollectionId"
onSelectCollection={onSelectCollection}
defaultCollectionId={defaultCollectionId}
/>
+88 -154
View File
@@ -1,13 +1,10 @@
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import { NewDocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Pagination } from "@shared/constants";
import { FileOperationType } from "@shared/types";
import { cdnPath } from "@shared/utils/urls";
import FileOperation from "~/models/FileOperation";
import ImportModel from "~/models/Import";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
@@ -18,146 +15,16 @@ import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { Hook, PluginManager } from "~/utils/PluginManager";
import FileOperationListItem from "./components/FileOperationListItem";
import ImportJSONDialog from "./components/ImportJSONDialog";
import { ImportListItem } from "./components/ImportListItem";
import ImportMarkdownDialog from "./components/ImportMarkdownDialog";
type Config = {
/** The title of the import. */
title: string;
/** The auxiliary descriptive text of the import. */
subtitle: string;
/** An icon to denote the kind of import. */
icon: React.ReactElement;
/** Trigger for the import. */
action: React.ReactElement;
};
function useImportsConfig() {
const { t } = useTranslation();
const { dialogs } = useStores();
const appName = env.APP_NAME;
return React.useMemo(() => {
const items: Config[] = [
{
title: t("Markdown"),
subtitle: t(
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
),
icon: <MarkdownIcon size={28} />,
action: (
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportMarkdownDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
),
},
{
title: "JSON",
subtitle: t(
"Import a JSON data file exported from another {{ appName }} instance",
{
appName,
}
),
icon: <OutlineIcon size={28} cover />,
action: (
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportJSONDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
),
},
];
PluginManager.getHooks(Hook.Imports).forEach((plugin) => {
items.push({ ...plugin.value });
});
items.push({
title: "Confluence",
subtitle: t("Import pages from a Confluence instance"),
icon: <img src={cdnPath("/images/confluence.png")} width={28} />,
action: (
<Button type="submit" disabled neutral>
{t("Enterprise")}
</Button>
),
});
return items;
}, [t, dialogs, appName]);
}
import ImportNotionDialog from "./components/ImportNotionDialog";
function Import() {
const { t } = useTranslation();
const { fileOperations, imports } = useStores();
const configs = useImportsConfig();
const { dialogs, fileOperations } = useStores();
const appName = env.APP_NAME;
const [, setForceRender] = React.useState(0);
const offset = React.useMemo(() => ({ imports: 0, fileOperations: 0 }), []);
const fetchImports = React.useCallback(async () => {
const [importsArr, fileOpsArr] = await Promise.all([
imports.fetchPage({
offset: offset.imports,
limit: Pagination.defaultLimit,
}),
fileOperations.fetchPage({
type: FileOperationType.Import,
offset: offset.fileOperations,
limit: Pagination.defaultLimit,
}),
]);
const pageImports = orderBy(
[...importsArr, ...fileOpsArr],
"createdAt",
"desc"
).slice(0, Pagination.defaultLimit);
const apiImportsCount = pageImports.filter(
(item) => item instanceof ImportModel
).length;
offset.imports += apiImportsCount;
offset.fileOperations += pageImports.length - apiImportsCount;
// needed to re-render after mobx store and offset is updated
setForceRender((s) => ++s);
return pageImports;
}, [imports, fileOperations, offset]);
const allImports = orderBy(
[
...imports.orderedData,
...fileOperations.filter({ type: FileOperationType.Import }),
],
"createdAt",
"desc"
).slice(0, offset.imports + offset.fileOperations);
return (
<Scene title={t("Import")} icon={<NewDocumentIcon />}>
<Heading>{t("Import")}</Heading>
@@ -171,33 +38,100 @@ function Import() {
</Text>
<div>
{configs.map((config) => (
<Item
key={config.title}
title={config.title}
subtitle={config.subtitle}
image={config.icon}
actions={config.action}
border={false}
/>
))}
<Item
border={false}
image={<MarkdownIcon size={28} />}
title={t("Markdown")}
subtitle={t(
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
)}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportMarkdownDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<OutlineIcon size={28} cover />}
title="JSON"
subtitle={t(
"Import a JSON data file exported from another {{ appName }} instance",
{
appName,
}
)}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportJSONDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/notion.png")} width={28} />}
title="Notion"
subtitle={t("Import pages exported from Notion")}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportNotionDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/confluence.png")} width={28} />}
title="Confluence"
subtitle={t("Import pages from a Confluence instance")}
actions={
<Button type="submit" disabled neutral>
{t("Enterprise")}
</Button>
}
/>
</div>
<br />
<PaginatedList
items={allImports}
fetch={fetchImports}
items={fileOperations.imports}
fetch={fileOperations.fetchPage}
options={{
type: FileOperationType.Import,
}}
heading={
<h2>
<Trans>Recent imports</Trans>
</h2>
}
renderItem={(item: ImportModel | FileOperation) =>
item instanceof ImportModel ? (
<ImportListItem key={item.id} importModel={item} />
) : (
<FileOperationListItem key={item.id} fileOperation={item} />
)
}
renderItem={(item: FileOperation) => (
<FileOperationListItem key={item.id} fileOperation={item} />
)}
/>
</Scene>
);
+1 -1
View File
@@ -194,7 +194,7 @@ function getFilteredUsers({
switch (filter) {
case "all":
filteredUsers = users.all;
filteredUsers = users.orderedData;
break;
case "suspended":
filteredUsers = users.suspended;
+19 -49
View File
@@ -3,12 +3,12 @@ import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { languageOptions as availableLanguages } from "@shared/i18n";
import { languageOptions } from "@shared/i18n";
import { TeamPreference, UserPreference } from "@shared/types";
import { Theme } from "~/stores/UiStore";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -26,29 +26,6 @@ function Preferences() {
const team = useCurrentTeam();
const can = usePolicy(user.id);
const languageOptions: Option[] = React.useMemo(
() =>
availableLanguages.map(
(lang) =>
({
type: "item",
label: lang.label,
value: lang.value,
} satisfies Option)
),
[]
);
const themeOptions: Option[] = React.useMemo(
() =>
[
{ type: "item", label: t("Light"), value: Theme.Light },
{ type: "item", label: t("Dark"), value: Theme.Dark },
{ type: "item", label: t("System"), value: Theme.System },
] satisfies Option[],
[t]
);
const handlePreferenceChange =
(inverted = false) =>
async (ev: React.ChangeEvent<HTMLInputElement>) => {
@@ -60,21 +37,10 @@ function Preferences() {
toast.success(t("Preferences saved"));
};
const handleLanguageChange = React.useCallback(
async (language: string) => {
await user.save({ language });
toast.success(t("Preferences saved"));
},
[t, user]
);
const handleThemeChange = React.useCallback(
(theme) => {
ui.setTheme(theme as Theme);
toast.success(t("Preferences saved"));
},
[t, ui]
);
const handleLanguageChange = async (language: string) => {
await user.save({ language });
toast.success(t("Preferences saved"));
};
const showDeleteAccount = () => {
dialogs.openModal({
@@ -111,13 +77,12 @@ function Preferences() {
</>
}
>
<InputSelectNew
<InputSelect
id="language"
options={languageOptions}
value={user.language}
onChange={handleLanguageChange}
ariaLabel={t("Language")}
label={t("Language")}
hideLabel
/>
</SettingRow>
<SettingRow
@@ -125,13 +90,18 @@ function Preferences() {
label={t("Appearance")}
description={t("Choose your preferred interface color scheme.")}
>
<InputSelectNew
options={themeOptions}
value={ui.theme}
onChange={handleThemeChange}
<InputSelect
ariaLabel={t("Appearance")}
label={t("Appearance")}
hideLabel
options={[
{ label: t("Light"), value: Theme.Light },
{ label: t("Dark"), value: Theme.Dark },
{ label: t("System"), value: Theme.System },
]}
value={ui.resolvedTheme}
onChange={(theme) => {
ui.setTheme(theme as Theme);
toast.success(t("Preferences saved"));
}}
/>
</SettingRow>
<SettingRow
+13 -22
View File
@@ -10,7 +10,7 @@ import { TeamPreference } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import PluginIcon from "~/components/PluginIcon";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
@@ -44,23 +44,6 @@ function Security() {
request,
} = useRequest(authenticationProviders.fetchPage);
const userRoleOptions: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Editor"),
value: "member",
},
{
type: "item",
label: t("Viewer"),
value: "viewer",
},
] satisfies Option[],
[t]
);
React.useEffect(() => {
if (!providers && !loading) {
void request();
@@ -246,13 +229,21 @@ function Security() {
)}
border={false}
>
<InputSelectNew
<InputSelect
id="defaultUserRole"
value={data.defaultUserRole}
options={userRoleOptions}
options={[
{
label: t("Editor"),
value: "member",
},
{
label: t("Viewer"),
value: "viewer",
},
]}
onChange={handleDefaultRoleChange}
ariaLabel={t("Default role")}
label={t("Default role")}
hideLabel
short
/>
</SettingRow>
+2 -42
View File
@@ -3,11 +3,10 @@ import { observer } from "mobx-react";
import { GlobeIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link, useHistory, useLocation } from "react-router-dom";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
@@ -17,22 +16,17 @@ import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { SharesTable } from "./components/SharesTable";
import { StickyFilters } from "./components/StickyFilters";
function Shares() {
const team = useCurrentTeam();
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const { shares, auth } = useStores();
const canShareDocuments = auth.team && auth.team.sharing;
const can = usePolicy(team);
const params = useQuery();
const [query, setQuery] = React.useState("");
const reqParams = React.useMemo(
() => ({
query: params.get("query") || undefined,
sort: params.get("sort") || "createdAt",
direction: (params.get("direction") || "desc").toUpperCase() as
| "ASC"
@@ -50,44 +44,18 @@ function Shares() {
);
const { data, error, loading, next } = useTableRequest({
data: shares.findByQuery(reqParams.query ?? ""),
data: shares.orderedData,
sort,
reqFn: shares.fetchPage,
reqParams,
});
const updateParams = React.useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = React.useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
React.useEffect(() => {
if (error) {
toast.error(t("Could not load shares"));
}
}, [t, error]);
React.useEffect(() => {
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateParams]);
return (
<Scene title={t("Shared Links")} icon={<GlobeIcon />} wide>
<Heading>{t("Shared Links")}</Heading>
@@ -115,14 +83,6 @@ function Shares() {
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<SharesTable
data={data ?? []}
@@ -1,148 +0,0 @@
import capitalize from "lodash/capitalize";
import { observer } from "mobx-react";
import { CrossIcon, DoneIcon, WarningIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import { ImportState } from "@shared/types";
import Import from "~/models/Import";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { ImportMenu } from "~/menus/ImportMenu";
type Props = {
/** Import that's displayed as list item. */
importModel: Import;
};
export const ImportListItem = observer(({ importModel }: Props) => {
const { t } = useTranslation();
const { dialogs } = useStores();
const user = useCurrentUser();
const theme = useTheme();
const showProgress =
importModel.state !== ImportState.Canceled &&
importModel.state !== ImportState.Errored;
const stateMap = React.useMemo(
() => ({
[ImportState.Created]: t("Processing"),
[ImportState.InProgress]: t("Processing"),
[ImportState.Processed]: t("Processing"),
[ImportState.Completed]: t("Completed"),
[ImportState.Errored]: t("Failed"),
[ImportState.Canceled]: t("Canceled"),
}),
[t]
);
const iconMap = React.useMemo(
() => ({
[ImportState.Created]: <Spinner />,
[ImportState.InProgress]: <Spinner />,
[ImportState.Processed]: <Spinner />,
[ImportState.Completed]: <DoneIcon color={theme.accent} />,
[ImportState.Errored]: <WarningIcon color={theme.danger} />,
[ImportState.Canceled]: <CrossIcon color={theme.textTertiary} />,
}),
[theme]
);
const handleCancel = React.useCallback(async () => {
const onCancel = async () => {
try {
await importModel.cancel();
toast.success(t("Import canceled"));
} catch (err) {
toast.error(err.message);
}
};
dialogs.openModal({
title: t("Are you sure you want to cancel this import?"),
content: (
<ConfirmationDialog
onSubmit={onCancel}
submitText={t("Cancel")}
savingText={`${t("Canceling")}`}
danger
>
{t(
"Canceling this import will discard any progress made. This cannot be undone."
)}
</ConfirmationDialog>
),
});
}, [t, dialogs, importModel]);
const handleDelete = React.useCallback(async () => {
const onDelete = async () => {
try {
await importModel.delete();
toast.success(t("Import deleted"));
} catch (err) {
toast.error(err.message);
}
};
dialogs.openModal({
title: t("Are you sure you want to delete this import?"),
content: (
<ConfirmationDialog
onSubmit={onDelete}
savingText={`${t("Deleting")}`}
danger
>
{t(
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone."
)}
</ConfirmationDialog>
),
});
}, [t, dialogs, importModel]);
return (
<ListItem
title={importModel.name}
image={iconMap[importModel.state]}
subtitle={
<>
{stateMap[importModel.state]}&nbsp;&nbsp;
{t(`{{userName}} requested`, {
userName:
user.id === importModel.createdBy.id
? t("You")
: importModel.createdBy.name,
})}
&nbsp;
<Time dateTime={importModel.createdAt} addSuffix shorten />
&nbsp;&nbsp;
{capitalize(importModel.service)}
{showProgress && (
<>
&nbsp;&nbsp;
{t("{{ count }} document imported", {
count: importModel.documentCount,
})}
</>
)}
</>
}
actions={
<Action>
<ImportMenu
importModel={importModel}
onCancel={handleCancel}
onDelete={handleDelete}
/>
</Action>
}
/>
);
});
@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Share from "~/models/Share";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { HEADER_HEIGHT } from "~/components/Header";
import {
@@ -46,10 +46,10 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
accessor: (share) => share.createdBy,
sortable: false,
component: (share) => (
<Flex align="center" gap={8}>
<Flex align="center" gap={4}>
{share.createdBy && (
<>
<Avatar model={share.createdBy} size={AvatarSize.Small} />
<Avatar model={share.createdBy} />
{share.createdBy.name}
</>
)}
+1 -3
View File
@@ -69,9 +69,7 @@ export default class CollectionsStore extends Store<Collection> {
*/
@computed
get nonPrivate(): Collection[] {
return this.all.filter(
(collection) => collection.isActive && !collection.isPrivate
);
return this.all.filter((collection) => !collection.isPrivate);
}
/**
-4
View File
@@ -8,7 +8,6 @@ type DialogDefinition = {
isOpen: boolean;
fullscreen?: boolean;
style?: React.CSSProperties;
onClose?: () => void;
};
export default class DialogsStore {
@@ -51,7 +50,6 @@ export default class DialogsStore {
fullscreen,
replace,
style,
onClose,
}: {
id?: string;
title: string;
@@ -59,7 +57,6 @@ export default class DialogsStore {
content: React.ReactNode;
style?: React.CSSProperties;
replace?: boolean;
onClose?: () => void;
}) => {
setTimeout(
action(() => {
@@ -73,7 +70,6 @@ export default class DialogsStore {
fullscreen,
style,
isOpen: true,
onClose,
});
}),
0
+8 -42
View File
@@ -14,16 +14,17 @@ export default class PresenceStore {
@observable
data: Map<string, DocumentPresence> = new Map();
timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
offlineTimeout = 30000;
private rootStore: RootStore;
constructor(rootStore: RootStore) {
this.rootStore = rootStore;
}
/**
* Removes a user from the presence store
*
* @param documentId ID of the document to remove the user from
* @param userId ID of the user to remove
*/
// called when a user leaves the document
@action
public leave(documentId: string, userId: string) {
const existing = this.data.get(documentId);
@@ -33,16 +34,8 @@ export default class PresenceStore {
}
}
/**
* Updates the presence store based on an awareness event from YJS
*
* @param documentId ID of the document the event is for
* @param clientId ID of the client the event is for
* @param event The awareness event
*/
public updateFromAwarenessChangeEvent(
documentId: string,
clientId: number,
event: AwarenessChangeEvent
) {
const presence = this.data.get(documentId);
@@ -52,13 +45,7 @@ export default class PresenceStore {
event.states.forEach((state) => {
const { user, cursor } = state;
// To avoid loops we only want to update the presence for the current user
// if it is also the current client.
const isCurrentUser = this.rootStore.auth.currentUserId === user?.id;
const isCurrentClient = clientId === state.clientId;
if (user && (!isCurrentUser || !isCurrentClient)) {
if (user && this.rootStore.auth.currentUserId !== user.id) {
this.update(documentId, user.id, !!cursor);
existingUserIds = existingUserIds.filter((id) => id !== user.id);
}
@@ -69,14 +56,6 @@ export default class PresenceStore {
});
}
/**
* Updates the presence store to indicate that a user is present in a document
* and then removes the user after a timeout of inactivity.
*
* @param documentId ID of the document to update
* @param userId ID of the user to update
* @param isEditing Whether the user is "editing" the document
*/
public touch(documentId: string, userId: string, isEditing: boolean) {
const id = `${documentId}-${userId}`;
let timeout = this.timeouts.get(id);
@@ -94,13 +73,6 @@ export default class PresenceStore {
this.timeouts.set(id, timeout);
}
/**
* Updates the presence store to indicate that a user is present in a document.
*
* @param documentId ID of the document to update
* @param userId ID of the user to update
* @param isEditing Whether the user is "editing" the document
*/
@action
private update(documentId: string, userId: string, isEditing: boolean) {
const presence = this.data.get(documentId) || new Map();
@@ -123,10 +95,4 @@ export default class PresenceStore {
public clear() {
this.data.clear();
}
private timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
private offlineTimeout = 30000;
private rootStore: RootStore;
}
-25
View File
@@ -1,25 +0,0 @@
import invariant from "invariant";
import { action, runInAction } from "mobx";
import Import from "~/models/Import";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store from "./base/Store";
export default class ImportsStore extends Store<Import> {
constructor(rootStore: RootStore) {
super(rootStore, Import);
}
@action
cancel = async (importModel: Import) => {
const res = await client.post("/imports.cancel", {
id: importModel.id,
});
runInAction("Import#cancel", () => {
invariant(res?.data, "Data should be available");
importModel.updateData(res.data);
this.addPolicies(res.policies);
});
};
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
export default class RevisionsStore extends Store<Revision> {
actions = [RPCAction.List, RPCAction.Update, RPCAction.Info];
actions = [RPCAction.List, RPCAction.Info];
constructor(rootStore: RootStore) {
super(rootStore, Revision);
-3
View File
@@ -14,7 +14,6 @@ import FileOperationsStore from "./FileOperationsStore";
import GroupMembershipsStore from "./GroupMembershipsStore";
import GroupUsersStore from "./GroupUsersStore";
import GroupsStore from "./GroupsStore";
import ImportsStore from "./ImportsStore";
import IntegrationsStore from "./IntegrationsStore";
import MembershipsStore from "./MembershipsStore";
import NotificationsStore from "./NotificationsStore";
@@ -44,7 +43,6 @@ export default class RootStore {
events: EventsStore;
groups: GroupsStore;
groupUsers: GroupUsersStore;
imports: ImportsStore;
integrations: IntegrationsStore;
memberships: MembershipsStore;
notifications: NotificationsStore;
@@ -74,7 +72,6 @@ export default class RootStore {
this.registerStore(EventsStore);
this.registerStore(GroupsStore);
this.registerStore(GroupUsersStore);
this.registerStore(ImportsStore);
this.registerStore(IntegrationsStore);
this.registerStore(MembershipsStore);
this.registerStore(NotificationsStore);
+10 -8
View File
@@ -27,44 +27,46 @@ export default class UsersStore extends Store<User> {
@computed
get active(): User[] {
return this.all.filter((user) => !user.isSuspended && !user.isInvited);
return this.orderedData.filter(
(user) => !user.isSuspended && user.lastActiveAt
);
}
@computed
get suspended(): User[] {
return this.all.filter((user) => user.isSuspended);
return this.orderedData.filter((user) => user.isSuspended);
}
@computed
get activeOrInvited(): User[] {
return this.all.filter((user) => !user.isSuspended);
return this.orderedData.filter((user) => !user.isSuspended);
}
@computed
get invited(): User[] {
return this.all.filter((user) => user.isInvited);
return this.orderedData.filter((user) => user.isInvited);
}
@computed
get admins(): User[] {
return this.all.filter((user) => user.isAdmin && !user.isInvited);
return this.orderedData.filter((user) => user.isAdmin);
}
@computed
get members(): User[] {
return this.all.filter(
return this.orderedData.filter(
(user) => !user.isViewer && !user.isAdmin && !user.isInvited
);
}
@computed
get viewers(): User[] {
return this.all.filter((user) => user.isViewer && !user.isInvited);
return this.orderedData.filter((user) => user.isViewer);
}
@computed
get all(): User[] {
return this.orderedData.filter((user) => !user.isDeleted);
return this.orderedData.filter((user) => user.lastActiveAt);
}
@computed
+1 -5
View File
@@ -1,7 +1,6 @@
import commandScore from "command-score";
import invariant from "invariant";
import type { ObjectIterateeCustom } from "lodash";
import deburr from "lodash/deburr";
import { deburr, type ObjectIterateeCustom } from "lodash";
import filter from "lodash/filter";
import find from "lodash/find";
import flatten from "lodash/flatten";
@@ -103,9 +102,6 @@ export default abstract class Store<T extends Model> {
return this.orderedData
.filter((item: T & Searchable) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("searchContent" in item) {
const seachables =
typeof item.searchContent === "string"
+1 -24
View File
@@ -206,31 +206,8 @@ export type WebsocketEvent =
| WebsocketEntitiesEvent
| WebsocketCommentReactionEvent;
type CursorPosition = {
type: {
client: number;
clock: number;
};
tname: string | null;
item: {
client: number;
clock: number;
};
assoc: number;
};
type Cursor = {
anchor: CursorPosition;
head: CursorPosition;
};
export type AwarenessChangeEvent = {
states: {
clientId: number;
user?: { id: string };
cursor: Cursor;
scrollY: number | undefined;
}[];
states: { user?: { id: string }; cursor: any; scrollY: number | undefined }[];
};
export const EmptySelectValue = "__empty__";
-5
View File
@@ -17,7 +17,6 @@ import {
RateLimitExceededError,
RequestError,
ServiceUnavailableError,
UnprocessableEntityError,
UpdateRequiredError,
} from "./errors";
@@ -215,10 +214,6 @@ class ApiClient {
throw new ServiceUnavailableError(error.message);
}
if (response.status === 422) {
throw new UnprocessableEntityError(error.message);
}
if (response.status === 429) {
throw new RateLimitExceededError(
`Too many requests, try again in a minute.`
-11
View File
@@ -12,7 +12,6 @@ import isCloudHosted from "./isCloudHosted";
*/
export enum Hook {
Settings = "settings",
Imports = "imports",
Icon = "icon",
}
@@ -32,16 +31,6 @@ type PluginValueMap = {
/** Whether the plugin is enabled in the current context. */
enabled?: (team: Team, user: User) => boolean;
};
[Hook.Imports]: {
/** The title of the import. */
title: string;
/** The auxiliary descriptive text of the import. */
subtitle: string;
/** An icon to denote the kind of import. */
icon: React.ReactElement;
/** Trigger for the import. */
action: React.ReactElement;
};
[Hook.Icon]: React.ElementType;
};
-2
View File
@@ -16,8 +16,6 @@ export class ServiceUnavailableError extends ExtendableError {}
export class BadGatewayError extends ExtendableError {}
export class UnprocessableEntityError extends ExtendableError {}
export class RateLimitExceededError extends ExtendableError {}
export class RequestError extends ExtendableError {}
+15 -18
View File
@@ -111,26 +111,23 @@ export function newNestedDocumentPath(parentDocumentId?: string): string {
return `/doc/new?${queryString.stringify({ parentDocumentId })}`;
}
export function searchPath({
query,
collectionId,
documentId,
ref,
}: {
query?: string;
collectionId?: string;
documentId?: string;
ref?: string;
} = {}): string {
let search = queryString.stringify({
q: query,
collectionId,
documentId,
ref,
});
export function searchPath(
query?: string,
params: {
collectionId?: string;
documentId?: string;
ref?: string;
} = {}
): string {
let search = queryString.stringify(params);
let route = "/search";
if (query) {
route += `/${encodeURIComponent(query.replace(/%/g, "%25"))}`;
}
search = search ? `?${search}` : "";
return `/search${search}`;
return `${route}${search}`;
}
export function sharedDocumentPath(shareId: string, docPath?: string) {
+31 -38
View File
@@ -48,21 +48,21 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.774.0",
"@aws-sdk/lib-storage": "3.774.0",
"@aws-sdk/s3-presigned-post": "3.774.0",
"@aws-sdk/s3-request-presigner": "3.774.0",
"@aws-sdk/signature-v4-crt": "^3.774.0",
"@babel/core": "^7.26.10",
"@aws-sdk/client-s3": "3.750.0",
"@aws-sdk/lib-storage": "3.750.0",
"@aws-sdk/s3-presigned-post": "3.750.0",
"@aws-sdk/s3-request-presigner": "3.750.0",
"@aws-sdk/signature-v4-crt": "^3.750.0",
"@babel/core": "^7.26.9",
"@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/plugin-transform-regenerator": "^7.25.9",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "^4.12.2",
"@css-inline/css-inline-wasm": "^0.14.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
@@ -79,24 +79,20 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@notionhq/client": "^2.2.16",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-visually-hidden": "^1.1.2",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.3",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.2",
"@types/form-data": "^2.5.0",
"@types/mailparser": "^3.4.5",
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react": "^3.1.0",
"addressparser": "^1.0.1",
"async-sema": "^3.1.1",
"autotrack": "^2.4.1",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-class-properties": "^6.24.1",
@@ -110,9 +106,9 @@
"copy-to-clipboard": "^3.3.3",
"core-js": "^3.37.0",
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"datadog-metrics": "^0.11.2",
"date-fns": "^3.6.0",
"dd-trace": "^5.40.0",
"dd-trace": "^3.58.0",
"diff": "^5.2.0",
"dotenv": "^16.4.7",
"email-providers": "^1.14.0",
@@ -129,12 +125,11 @@
"fuzzy-search": "^3.2.1",
"glob": "^8.1.0",
"http-errors": "2.0.0",
"https-proxy-agent": "^7.0.6",
"i18next": "^22.5.1",
"i18next-fs-backend": "^2.6.0",
"i18next-http-backend": "^2.7.3",
"invariant": "^2.2.4",
"ioredis": "^5.6.0",
"ioredis": "^5.4.1",
"is-printable-key-event": "^1.0.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
@@ -173,13 +168,13 @@
"passport-oauth2": "^1.8.0",
"passport-slack-oauth2": "^1.2.0",
"patch-package": "^7.0.2",
"pg": "^8.14.1",
"pg": "^8.12.0",
"pg-tsquery": "^8.4.2",
"pluralize": "^8.0.0",
"png-chunks-extract": "^1.0.0",
"polished": "^4.3.1",
"prosemirror-codemark": "^0.4.2",
"prosemirror-commands": "^1.7.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
@@ -189,9 +184,9 @@
"prosemirror-model": "^1.24.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-tables": "^1.4.0",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.38.1",
"prosemirror-view": "^1.37.1",
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
@@ -230,7 +225,7 @@
"slug": "^5.3.0",
"slugify": "^1.6.6",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"socket.io-client": "^4.8.0",
"socket.io-redis": "^6.1.1",
"sonner": "^1.7.1",
"stoppable": "^1.1.0",
@@ -247,8 +242,7 @@
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^5.4.15",
"vite": "^5.4.14",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -257,11 +251,11 @@
"y-protocols": "^1.0.6",
"yauzl": "^2.10.0",
"yjs": "^13.6.1",
"zod": "^3.24.2"
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@babel/cli": "^7.26.4",
"@babel/preset-typescript": "^7.26.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.14",
"@testing-library/react": "^12.0.0",
@@ -296,7 +290,7 @@
"@types/markdown-it-emoji": "^2.0.4",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.17.27",
"@types/node": "20.17.16",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
@@ -305,8 +299,8 @@
"@types/quoted-printable": "^1.0.2",
"@types/randomstring": "^1.3.0",
"@types/react": "^17.0.34",
"@types/react-avatar-editor": "^13.0.4",
"@types/react-color": "^3.0.13",
"@types/react-avatar-editor": "^13.0.3",
"@types/react-color": "^3.0.12",
"@types/react-dom": "^17.0.11",
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
@@ -337,7 +331,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.37.119",
"discord-api-types": "^0.37.102",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.8.0",
@@ -350,7 +344,7 @@
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"i18next-parser": "^7.9.0",
"jest-cli": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
@@ -360,8 +354,8 @@
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.3",
"terser": "^5.39.0",
"rollup-plugin-webpack-stats": "^2.0.1",
"terser": "^5.37.0",
"typescript": "^5.7.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
@@ -374,8 +368,7 @@
"node-fetch": "^2.7.0",
"js-yaml": "^3.14.1",
"qs": "6.9.7",
"rollup": "^4.5.1",
"prismjs": "1.30.0"
"rollup": "^4.5.1"
},
"version": "0.82.0"
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { Hook, PluginManager } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./auth/email";
const enabled = !!(env.SMTP_HOST || env.SMTP_SERVICE) || env.isDevelopment;
const enabled = !!env.SMTP_HOST || env.isDevelopment;
if (enabled) {
PluginManager.add({
-93
View File
@@ -1,93 +0,0 @@
import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import env from "@shared/env";
import { IntegrationService } from "@shared/types";
import Button from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { redirectTo } from "~/utils/urls";
import { NotionUtils } from "../shared/NotionUtils";
import { ImportDialog } from "./components/ImportDialog";
export const Notion = observer(() => {
const { t } = useTranslation();
const { dialogs } = useStores();
const team = useCurrentTeam();
const history = useHistory();
const location = useLocation();
const queryParams = useQuery();
const appName = env.APP_NAME;
const authUrl = NotionUtils.authUrl({ state: { teamId: team.id } });
const service = queryParams.get("service");
const oauthSuccess = queryParams.get("success") === "";
const oauthError = queryParams.get("error");
const integrationId = queryParams.get("integrationId");
const clearQueryParams = React.useCallback(() => {
history.replace({
pathname: location.pathname,
search: "",
});
}, [history, location]);
const handleSubmit = React.useCallback(() => {
dialogs.closeAllModals();
clearQueryParams();
}, [dialogs, clearQueryParams]);
React.useEffect(() => {
if (
integrationId &&
oauthSuccess &&
service === IntegrationService.Notion
) {
dialogs.openModal({
title: t("Import data"),
content: (
<ImportDialog integrationId={integrationId} onSubmit={handleSubmit} />
),
onClose: clearQueryParams,
});
}
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
React.useEffect(() => {
if (!oauthError) {
return;
}
if (oauthError === "access_denied") {
toast.error(
t(
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
{
appName,
}
)
);
} else {
toast.error(
t(
"Something went wrong while authenticating your request. Please try logging in again."
)
);
}
}, [t, appName, oauthError]);
return (
<Button
type="submit"
onClick={() => redirectTo(authUrl)}
disabled={!env.NOTION_CLIENT_ID}
neutral
>
{t("Import")}
</Button>
);
});
@@ -1,78 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ImportInput } from "@shared/schema";
import { CollectionPermission, IntegrationService } from "@shared/types";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import InputSelectPermission from "~/components/InputSelectPermission";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
type Props = {
/** The integrationId associated with this import flow. */
integrationId: string;
/** Callback to handle import creation. */
onSubmit: () => void;
};
export function ImportDialog({ integrationId, onSubmit }: Props) {
const { t } = useTranslation();
const { imports } = useStores();
const [submitting, setSubmitting, resetSubmitting] = useBoolean();
const [permission, setPermission] = React.useState<CollectionPermission>();
const handlePermissionChange = React.useCallback(
(value: CollectionPermission | typeof EmptySelectValue) => {
setPermission(value === EmptySelectValue ? undefined : value);
},
[]
);
const handleStartImport = React.useCallback(async () => {
setSubmitting();
// TODO: This can send the page info + permission once we overcome the search timeout issues.
const input: ImportInput<IntegrationService.Notion> = [{ permission }];
try {
await imports.create(
{ service: IntegrationService.Notion },
{ integrationId, input }
);
toast.success(
t("Your import is being processed, you can safely leave this page")
);
onSubmit();
} catch (err) {
toast.error(err.message);
resetSubmitting();
}
}, [permission, onSubmit]);
return (
<Flex column gap={12}>
<div>
<InputSelectPermission
value={permission}
onChange={handlePermissionChange}
/>
<Text as="span" type="secondary">
{t(
"Set the default permission level for collections created from the import"
)}
.
</Text>
</div>
<Flex justify="flex-end">
<Button onClick={handleStartImport} disabled={submitting}>
{t("Start import")}
</Button>
</Flex>
</Flex>
);
}

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