mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54a5deb6a5 | |||
| f2ebac066a | |||
| c33fad966a | |||
| 5e5a6ec189 | |||
| acae1aecd7 | |||
| 79df75e09d | |||
| 4517cd6ab1 | |||
| 3c86b48533 | |||
| bcba35550a | |||
| 4af3ac98d1 | |||
| 7421a9fbdc | |||
| 56b9c60388 | |||
| 8fec6758b8 | |||
| 1aaabf113b | |||
| a0d78378d7 | |||
| 78bf8fd641 | |||
| 5374d32801 | |||
| 68de78ead8 | |||
| d40a3a77e0 | |||
| dbcf23d3cd | |||
| 2ee8167cdc | |||
| 8eb73b4079 | |||
| 8dfe635427 | |||
| ec8521461d | |||
| d76cb3bd17 | |||
| 05b3a39f4d | |||
| 8f3139da7a | |||
| 741f9aa796 | |||
| 338b10658b | |||
| 97284780f9 | |||
| 3998a80ae9 | |||
| d3683413af | |||
| e910ecf559 | |||
| 47a067cd19 | |||
| 11f744e7d6 | |||
| 1d2292d2a7 | |||
| 5eaab0e14a | |||
| 22ff9d394b | |||
| a2a84102a6 | |||
| e42b533b07 | |||
| 81d7492e5e | |||
| 908c147e3d | |||
| 6d0270ae37 | |||
| abf49bc04d | |||
| 58732ddd9b | |||
| 3c5ce8cb3d | |||
| cf3e29bbab | |||
| 684d622754 | |||
| 92a5954ec7 | |||
| 4afa225967 | |||
| 48feaf9bc0 | |||
| 3f2ac2d23b | |||
| 38c12bd2a9 | |||
| fafaddf07f | |||
| 25f264a763 | |||
| 085785a94c | |||
| 9c71566d66 | |||
| 4a64a767e1 | |||
| 9bc1788bc0 | |||
| e93ef8b392 | |||
| d11e15b360 | |||
| 8429c68b7a | |||
| c4e3786291 | |||
| db30d080ae | |||
| 63d70c2cd5 | |||
| 98fef1bb1f | |||
| 9cab404194 | |||
| d6459150fe | |||
| 4789ddd947 | |||
| 1c179a3c6b | |||
| b8c07eb298 | |||
| adfca1e5ca | |||
| 6ca3c25d35 | |||
| 05a2c6ae1e | |||
| 234915f4a0 | |||
| 538a1274ab | |||
| 63422373ac | |||
| 708bd8a544 | |||
| 120191d4d7 | |||
| 6a2ab299a8 | |||
| 74dc7094e1 | |||
| 5dd993adf5 | |||
| 41832bbaf1 | |||
| f448be5830 | |||
| f0fcb26b50 | |||
| ad237a619c | |||
| 5f49938267 | |||
| 68a469daa7 | |||
| 3d5a167f7f | |||
| b58671cbd1 | |||
| b3a3b0763f | |||
| a4becd66bd | |||
| 3437bd3a6c | |||
| 86cfd62afa | |||
| 85b62d3146 | |||
| 1fa0a5ea98 | |||
| 2b4c8d981c | |||
| ce55719626 | |||
| b9f0f67fb2 | |||
| 02aa4c2928 | |||
| 77e8dbefd6 | |||
| 1e5d281870 | |||
| 9b68e6835e | |||
| f17926f912 | |||
| 2397196be8 | |||
| 133db9c22c | |||
| 0dd14cdf1a | |||
| cc8ec28a39 | |||
| c8cbb9ef9c | |||
| 4af07ab6c4 | |||
| 742c138b3d | |||
| ec1eacaeea | |||
| 8b15cc45b0 | |||
| e89c32424f | |||
| a458690bfc | |||
| df03a6da8c | |||
| 6dfe7d707a | |||
| c063709f1c | |||
| dd8f6a987c | |||
| fa117870a2 | |||
| 40b1e3c8c6 | |||
| e3b0f7db86 | |||
| 6fddb29ff6 | |||
| 569a7876ae | |||
| bea56159ec | |||
| 908f053920 | |||
| 033c298bff | |||
| 22f02ad713 | |||
| 92b1c578f6 | |||
| a738ea97b5 | |||
| 7fbe442863 | |||
| e6626e1b1a | |||
| d3964875ff | |||
| 5156b92d6a | |||
| 4648a405bb | |||
| 5f8a754cd9 | |||
| ad7d808704 | |||
| 59a8d801a4 | |||
| a815f0a12c | |||
| 5ed5c24498 | |||
| a77b24cf01 | |||
| 08efb34c29 | |||
| df3dde951a | |||
| d10149ccb3 |
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots or videos to help explain your problem.
|
||||
|
||||
**Outline (please complete the following information):**
|
||||
- Install: [getoutline.com or self hosted]
|
||||
- Version: [commit sha if self hosted]
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Mobile (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Bug report
|
||||
description: File a bug to help us improve
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: This is not related to configuring Outline
|
||||
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
|
||||
options:
|
||||
- label: The issue is not related to self-hosting config
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. In this environment...
|
||||
1. With this config...
|
||||
1. Run '...'
|
||||
1. See error...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
examples:
|
||||
- **Outline**: Outline 0.80.0
|
||||
- **Browser**: Safari
|
||||
value: |
|
||||
- Outline:
|
||||
- Browser:
|
||||
render: markdown
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import {
|
||||
ExportContentType,
|
||||
TeamPreference,
|
||||
@@ -46,7 +47,6 @@ import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import Icon from "~/components/Icon";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -732,7 +732,6 @@ export const importDocument = createAction({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
|
||||
|
||||
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
const activeDocument = stores.documents.active;
|
||||
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
|
||||
@@ -34,6 +36,8 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
UserSection.priority = 0.5;
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -37,7 +37,11 @@ function Breadcrumb(
|
||||
return (
|
||||
<Flex justify="flex-start" align="center" ref={ref}>
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={String(item.to) || index}>
|
||||
<React.Fragment
|
||||
key={
|
||||
(typeof item.to === "string" ? item.to : item.to.pathname) || index
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
@@ -11,7 +12,6 @@ import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import Input from "~/components/Input";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import Switch from "~/components/Switch";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
ActiveCollectionSection,
|
||||
|
||||
@@ -8,8 +8,8 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the dialog is submitted */
|
||||
onSubmit: () => Promise<void> | void;
|
||||
/** Callback when the dialog is submitted. Return false to prevent closing. */
|
||||
onSubmit: () => Promise<void | boolean> | void;
|
||||
/** Text to display on the submit button */
|
||||
submitText?: string;
|
||||
/** Text to display while the form is saving */
|
||||
@@ -38,7 +38,10 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSubmit();
|
||||
const res = await onSubmit();
|
||||
if (res === false) {
|
||||
return;
|
||||
}
|
||||
dialogs.closeAllModals();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
||||
@@ -109,6 +109,8 @@ const Title = styled.div`
|
||||
${ellipsis()}
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
type MenuAnchorProps = {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { IconType } from "@shared/types";
|
||||
@@ -14,7 +15,6 @@ import { determineIconType } from "@shared/utils/icon";
|
||||
import Document from "~/models/Document";
|
||||
import Pin from "~/models/Pin";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -14,12 +14,12 @@ import { FixedSizeList as List } from "react-window";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
|
||||
@@ -9,13 +9,13 @@ import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Flex from "~/components/Flex";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
|
||||
@@ -185,9 +185,9 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
{t("in")}
|
||||
<strong>
|
||||
<Strong>
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</Strong>
|
||||
</span>
|
||||
)}
|
||||
{showParentDocuments && nestedDocumentsCount > 0 && (
|
||||
@@ -210,6 +210,10 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Strong = styled.strong`
|
||||
font-weight: 550;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import difference from "lodash/difference";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
@@ -9,10 +7,7 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
@@ -22,12 +17,8 @@ import useDictionary from "~/hooks/useDictionary";
|
||||
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
@@ -50,76 +41,13 @@ export type Props = Optional<
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
|
||||
props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { comments, documents } = useStores();
|
||||
const { comments } = useStores();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
async (term: string) => {
|
||||
if (isInternalUrl(term)) {
|
||||
// search for exact internal document
|
||||
const slug = parseDocumentSlug(term);
|
||||
if (!slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await documents.fetch(slug);
|
||||
const time = dateToRelative(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
shorten: true,
|
||||
locale,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
title: document.title,
|
||||
subtitle: `Updated ${time}`,
|
||||
url: document.url,
|
||||
icon: document.icon ? (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
) : undefined,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
// NotFoundError could not find document for slug
|
||||
if (!(error instanceof NotFoundError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// default search for anything that doesn't look like a URL
|
||||
const results = await documents.searchTitles({ query: term });
|
||||
|
||||
return sortBy(
|
||||
results.map(({ document }) => ({
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
icon: document.icon ? (
|
||||
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||
) : undefined,
|
||||
})),
|
||||
(document) =>
|
||||
deburr(document.title)
|
||||
.toLowerCase()
|
||||
.startsWith(deburr(term).toLowerCase())
|
||||
? -1
|
||||
: 1
|
||||
);
|
||||
},
|
||||
[locale, documents]
|
||||
);
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
@@ -263,7 +191,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
|
||||
@@ -46,7 +46,7 @@ const FilterOptions = ({
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
modal: false,
|
||||
});
|
||||
const selectedItems = options.filter((option) =>
|
||||
selectedKeys.includes(option.key)
|
||||
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
|
||||
${Outline} {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid rgb(34 40 52);
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("menuBackground")};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
membership?: GroupMembership;
|
||||
showFacepile?: boolean;
|
||||
showAvatar?: boolean;
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
|
||||
useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
}
|
||||
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
|
||||
subtitle={t("{{ count }} member", { count: memberCount })}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{showFacepile && (
|
||||
<NudeButton
|
||||
width="auto"
|
||||
height="auto"
|
||||
onClick={setMembersModalOpen}
|
||||
>
|
||||
<Facepile users={users} overflow={overflow} />
|
||||
</NudeButton>
|
||||
)}
|
||||
{renderActions({
|
||||
openMembersModal: setMembersModalOpen,
|
||||
})}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Group members")}
|
||||
onRequestClose={setMembersModalClosed}
|
||||
isOpen={membersModalOpen}
|
||||
>
|
||||
<GroupMembers group={group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Image = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${s("backgroundSecondary")};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&: ${hover} {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(GroupListItem);
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
useTabState,
|
||||
} from "reakit";
|
||||
import styled, { css } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s } from "@shared/styles";
|
||||
import theme from "@shared/styles/theme";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
|
||||
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Icon from "~/components/Icon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -197,6 +197,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Container
|
||||
id="sidebar"
|
||||
ref={ref}
|
||||
style={style}
|
||||
$hidden={hidden}
|
||||
|
||||
@@ -5,13 +5,13 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Fade from "~/components/Fade";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
|
||||
@@ -93,15 +93,11 @@ const NavLink = ({
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
|
||||
// If the page has an anchor hash then this means we're linking to an
|
||||
// anchor in the document – smooth scrolling the sidebar may the scrolling
|
||||
// to the anchor of the document so we must avoid it.
|
||||
if (!window.location.hash) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
});
|
||||
}
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
boundary: (parent) => parent.id !== "sidebar",
|
||||
});
|
||||
}
|
||||
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import includes from "lodash/includes";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Icon from "~/components/Icon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { descendants } from "~/utils/tree";
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -13,7 +14,6 @@ import GroupMembership from "~/models/GroupMembership";
|
||||
import Star from "~/models/Star";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../components/SidebarLink";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import type { Props as TableProps } from "./Table";
|
||||
|
||||
const Table = lazyWithRetry(() => import("~/components/Table"));
|
||||
|
||||
export type Props<T> = Omit<TableProps<T>, "onChangeSort">;
|
||||
|
||||
export function SortableTable<T>(props: Props<T>) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const params = useQuery();
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sort: ColumnSort) => {
|
||||
params.set("sort", sort.id);
|
||||
params.set("direction", sort.desc ? "desc" : "asc");
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
return <Table onChangeSort={handleChangeSort} {...props} />;
|
||||
}
|
||||
+302
-240
@@ -1,231 +1,283 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
SortingState,
|
||||
flexRender,
|
||||
ColumnSort,
|
||||
functionalUpdate,
|
||||
Row as TRow,
|
||||
createColumnHelper,
|
||||
AccessorFn,
|
||||
CellContext,
|
||||
} from "@tanstack/react-table";
|
||||
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTable, useSortBy, usePagination } from "react-table";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
|
||||
export type Props = {
|
||||
data: any[];
|
||||
offset?: number;
|
||||
isLoading: boolean;
|
||||
empty?: React.ReactNode;
|
||||
currentPage?: number;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
totalPages?: number;
|
||||
defaultSort?: string;
|
||||
topRef?: React.Ref<any>;
|
||||
onChangePage: (index: number) => void;
|
||||
onChangeSort: (
|
||||
sort: string | null | undefined,
|
||||
direction: "ASC" | "DESC"
|
||||
) => void;
|
||||
columns: any;
|
||||
defaultSortDirection: "ASC" | "DESC";
|
||||
const HEADER_HEIGHT = 40;
|
||||
|
||||
type DataColumn<TData> = {
|
||||
type: "data";
|
||||
header: string;
|
||||
accessor: AccessorFn<TData>;
|
||||
sortable?: boolean;
|
||||
};
|
||||
|
||||
function Table({
|
||||
data,
|
||||
isLoading,
|
||||
totalPages,
|
||||
empty,
|
||||
columns,
|
||||
page,
|
||||
pageSize = 50,
|
||||
defaultSort = "name",
|
||||
topRef,
|
||||
onChangeSort,
|
||||
onChangePage,
|
||||
defaultSortDirection,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
canNextPage,
|
||||
nextPage,
|
||||
canPreviousPage,
|
||||
previousPage,
|
||||
state: { pageIndex, sortBy },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
manualPagination: true,
|
||||
manualSortBy: true,
|
||||
autoResetSortBy: false,
|
||||
autoResetPage: false,
|
||||
pageCount: totalPages,
|
||||
initialState: {
|
||||
sortBy: [
|
||||
{
|
||||
id: defaultSort,
|
||||
desc: defaultSortDirection === "DESC" ? true : false,
|
||||
},
|
||||
],
|
||||
pageSize,
|
||||
pageIndex: page,
|
||||
},
|
||||
stateReducer: (newState, action, prevState) => {
|
||||
if (!isEqual(newState.sortBy, prevState.sortBy)) {
|
||||
return { ...newState, pageIndex: 0 };
|
||||
}
|
||||
type ActionColumn = {
|
||||
type: "action";
|
||||
header?: string;
|
||||
};
|
||||
|
||||
return newState;
|
||||
},
|
||||
},
|
||||
useSortBy,
|
||||
usePagination
|
||||
export type Column<TData> = {
|
||||
id: string;
|
||||
component: (data: TData) => React.ReactNode;
|
||||
width: string;
|
||||
} & (DataColumn<TData> | ActionColumn);
|
||||
|
||||
export type Props<TData> = {
|
||||
data: TData[];
|
||||
columns: Column<TData>[];
|
||||
sort: ColumnSort;
|
||||
onChangeSort: (sort: ColumnSort) => void;
|
||||
loading: boolean;
|
||||
page: {
|
||||
hasNext: boolean;
|
||||
fetchNext?: () => void;
|
||||
};
|
||||
rowHeight: number;
|
||||
stickyOffset?: number;
|
||||
};
|
||||
|
||||
function Table<TData>({
|
||||
data,
|
||||
columns,
|
||||
sort,
|
||||
onChangeSort,
|
||||
loading,
|
||||
page,
|
||||
rowHeight,
|
||||
stickyOffset = 0,
|
||||
}: Props<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [virtualContainerTop, setVirtualContainerTop] =
|
||||
React.useState<number>();
|
||||
|
||||
const columnHelper = React.useMemo(() => createColumnHelper<TData>(), []);
|
||||
const observedColumns = React.useMemo(
|
||||
() =>
|
||||
columns.map((column) => {
|
||||
const cell = ({ row }: CellContext<TData, unknown>) => (
|
||||
<ObservedCell data={row.original} render={column.component} />
|
||||
);
|
||||
|
||||
return column.type === "data"
|
||||
? columnHelper.accessor(column.accessor, {
|
||||
id: column.id,
|
||||
header: column.header,
|
||||
enableSorting: column.sortable ?? true,
|
||||
cell,
|
||||
})
|
||||
: columnHelper.display({
|
||||
id: column.id,
|
||||
header: column.header ?? "",
|
||||
cell,
|
||||
});
|
||||
}),
|
||||
[columns, columnHelper]
|
||||
);
|
||||
const prevSortBy = React.useRef(sortBy);
|
||||
|
||||
const gridColumns = React.useMemo(
|
||||
() => columns.map((column) => column.width).join(" "),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sortState: SortingState) => {
|
||||
const newState = functionalUpdate(sortState, [sort]);
|
||||
const newSort = newState[0];
|
||||
onChangeSort(newSort);
|
||||
},
|
||||
[sort, onChangeSort]
|
||||
);
|
||||
|
||||
const prevSort = usePrevious(sort);
|
||||
const sortChanged = sort !== prevSort;
|
||||
|
||||
const isEmpty = !loading && data.length === 0;
|
||||
const showPlaceholder = loading && data.length === 0;
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: observedColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualSorting: true,
|
||||
enableMultiSort: false,
|
||||
enableSortingRemoval: false,
|
||||
state: {
|
||||
sorting: [sort],
|
||||
},
|
||||
onSortingChange: handleChangeSort,
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useWindowVirtualizer({
|
||||
count: rows.length,
|
||||
estimateSize: () => rowHeight,
|
||||
scrollMargin: virtualContainerTop,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isEqual(sortBy, prevSortBy.current)) {
|
||||
prevSortBy.current = sortBy;
|
||||
onChangePage(0);
|
||||
onChangeSort(
|
||||
sortBy.length ? sortBy[0].id : undefined,
|
||||
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
|
||||
if (!sortChanged || !virtualContainerTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollThreshold =
|
||||
virtualContainerTop - (stickyOffset + HEADER_HEIGHT);
|
||||
const reset = window.scrollY > scrollThreshold;
|
||||
|
||||
if (reset) {
|
||||
rowVirtualizer.scrollToOffset(scrollThreshold, {
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [rowVirtualizer, sortChanged, virtualContainerTop, stickyOffset]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (virtualContainerRef.current) {
|
||||
// determine the scrollable virtual container offsetTop on mount
|
||||
setVirtualContainerTop(
|
||||
virtualContainerRef.current.getBoundingClientRect().top
|
||||
);
|
||||
}
|
||||
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
|
||||
|
||||
const handleNextPage = () => {
|
||||
nextPage();
|
||||
onChangePage(pageIndex + 1);
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
previousPage();
|
||||
onChangePage(pageIndex - 1);
|
||||
};
|
||||
|
||||
const isEmpty = !isLoading && data.length === 0;
|
||||
const showPlaceholder = isLoading && data.length === 0;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<Anchor ref={topRef} />
|
||||
<InnerTable {...getTableProps()}>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const groupProps = headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<tr {...groupProps} key={groupProps.key}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head
|
||||
{...column.getHeaderProps(column.getSortByToggleProps())}
|
||||
key={column.id}
|
||||
<>
|
||||
<InnerTable role="table">
|
||||
<THead role="rowgroup" $topPos={stickyOffset}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TR role="row" key={headerGroup.id} $columns={gridColumns}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TH role="columnheader" key={header.id}>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
gap={4}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
$sortable={header.column.getCanSort()}
|
||||
>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
$sortable={!column.disableSortBy}
|
||||
gap={4}
|
||||
>
|
||||
{column.render("Header")}
|
||||
{column.isSorted &&
|
||||
(column.isSortedDesc ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<AscSortIcon />
|
||||
))}
|
||||
</SortWrapper>
|
||||
</Head>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{rows.map((row) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<Row {...row.getRowProps()} key={row.id}>
|
||||
{row.cells.map((cell) => (
|
||||
<Cell
|
||||
{...cell.getCellProps([
|
||||
{
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'className' does not exist on type 'Colum... Remove this comment to see the full error message
|
||||
className: cell.column.className,
|
||||
},
|
||||
])}
|
||||
key={cell.column.id}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{showPlaceholder && <Placeholder columns={columns.length} />}
|
||||
</InnerTable>
|
||||
{isEmpty ? (
|
||||
empty || <Empty>{t("No results")}</Empty>
|
||||
) : (
|
||||
<Pagination
|
||||
justify={canPreviousPage ? "space-between" : "flex-end"}
|
||||
gap={8}
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getIsSorted() === "asc" ? (
|
||||
<AscSortIcon />
|
||||
) : header.column.getIsSorted() === "desc" ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</SortWrapper>
|
||||
</TH>
|
||||
))}
|
||||
</TR>
|
||||
))}
|
||||
</THead>
|
||||
|
||||
<TBody
|
||||
ref={virtualContainerRef}
|
||||
role="rowgroup"
|
||||
$height={rowVirtualizer.getTotalSize()}
|
||||
>
|
||||
{/* Note: the page > 0 check shouldn't be needed here but is */}
|
||||
{canPreviousPage && page > 0 && (
|
||||
<Button onClick={handlePreviousPage} neutral>
|
||||
{t("Previous page")}
|
||||
</Button>
|
||||
)}
|
||||
{canNextPage && (
|
||||
<Button onClick={handleNextPage} neutral>
|
||||
{t("Next page")}
|
||||
</Button>
|
||||
)}
|
||||
</Pagination>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as TRow<TData>;
|
||||
return (
|
||||
<TR
|
||||
role="row"
|
||||
key={row.id}
|
||||
data-index={virtualRow.index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translateY(${
|
||||
virtualRow.start - rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
height: `${virtualRow.size}px`,
|
||||
}}
|
||||
$columns={gridColumns}
|
||||
>
|
||||
{row.getAllCells().map((cell) => (
|
||||
<TD role="cell" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TD>
|
||||
))}
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
{showPlaceholder && (
|
||||
<Placeholder columns={columns.length} gridColumns={gridColumns} />
|
||||
)}
|
||||
</InnerTable>
|
||||
{page.hasNext && (
|
||||
<Waypoint
|
||||
key={data?.length}
|
||||
onEnter={page.fetchNext}
|
||||
bottomOffset={-rowHeight * 5}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isEmpty && <Empty>{t("No results")}</Empty>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const Placeholder = ({
|
||||
const ObservedCell = observer(function <TData>({
|
||||
data,
|
||||
render,
|
||||
}: {
|
||||
data: TData;
|
||||
render: (data: TData) => React.ReactNode;
|
||||
}) {
|
||||
return <>{render(data)}</>;
|
||||
});
|
||||
|
||||
function Placeholder({
|
||||
columns,
|
||||
rows = 3,
|
||||
gridColumns,
|
||||
}: {
|
||||
columns: number;
|
||||
rows?: number;
|
||||
}) => (
|
||||
<DelayedMount>
|
||||
<tbody>
|
||||
{new Array(rows).fill(1).map((_, row) => (
|
||||
<Row key={row}>
|
||||
{new Array(columns).fill(1).map((_, col) => (
|
||||
<Cell key={col}>
|
||||
<PlaceholderText minWidth={25} maxWidth={75} />
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</tbody>
|
||||
</DelayedMount>
|
||||
);
|
||||
|
||||
const Anchor = styled.div`
|
||||
top: -32px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Pagination = styled(Flex)`
|
||||
margin: 0 0 32px;
|
||||
`;
|
||||
gridColumns: string;
|
||||
}) {
|
||||
return (
|
||||
<DelayedMount>
|
||||
<TBody $height={150}>
|
||||
{new Array(rows).fill(1).map((_r, row) => (
|
||||
<TR key={row} $columns={gridColumns}>
|
||||
{new Array(columns).fill(1).map((_c, col) => (
|
||||
<TD key={col}>
|
||||
<PlaceholderText minWidth={25} maxWidth={75} />
|
||||
</TD>
|
||||
))}
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</DelayedMount>
|
||||
);
|
||||
}
|
||||
|
||||
const DescSortIcon = styled(CollapsedIcon)`
|
||||
margin-left: -2px;
|
||||
@@ -239,12 +291,6 @@ const AscSortIcon = styled(DescSortIcon)`
|
||||
transform: rotate(180deg);
|
||||
`;
|
||||
|
||||
const InnerTable = styled.table`
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
min-width: 100%;
|
||||
`;
|
||||
|
||||
const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
@@ -261,15 +307,66 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const Cell = styled.td`
|
||||
padding: 10px 6px;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
const InnerTable = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const THead = styled.div<{ $topPos: number }>`
|
||||
position: sticky;
|
||||
top: ${({ $topPos }) => `${$topPos}px`};
|
||||
height: ${HEADER_HEIGHT}px;
|
||||
z-index: 1;
|
||||
font-size: 14px;
|
||||
text-wrap: nowrap;
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("background")};
|
||||
`;
|
||||
|
||||
const TBody = styled.div<{ $height: number }>`
|
||||
position: relative;
|
||||
height: ${({ $height }) => `${$height}px`};
|
||||
`;
|
||||
|
||||
const TR = styled.div<{ $columns: string }>`
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: ${({ $columns }) => `${$columns}`};
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const TH = styled.span`
|
||||
padding: 6px 6px 2px;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const TD = styled.span`
|
||||
padding: 10px 6px;
|
||||
font-size: 14px;
|
||||
text-wrap: wrap;
|
||||
word-break: break-word;
|
||||
|
||||
&:first-child {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
&.actions,
|
||||
@@ -292,39 +389,4 @@ const Cell = styled.td`
|
||||
}
|
||||
`;
|
||||
|
||||
const Row = styled.tr`
|
||||
${Cell} {
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
${Cell} {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Head = styled.th`
|
||||
text-align: left;
|
||||
padding: 6px 6px 2px;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("background")};
|
||||
font-size: 14px;
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
|
||||
:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Table);
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import type { Props } from "./Table";
|
||||
|
||||
const Table = lazyWithRetry(() => import("~/components/Table"));
|
||||
|
||||
const TableFromParams = (
|
||||
props: Omit<Props, "onChangeSort" | "onChangePage" | "topRef">
|
||||
) => {
|
||||
const topRef = React.useRef();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const params = useQuery();
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sort, direction) => {
|
||||
if (sort) {
|
||||
params.set("sort", sort);
|
||||
} else {
|
||||
params.delete("sort");
|
||||
}
|
||||
|
||||
params.set("direction", direction.toLowerCase());
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleChangePage = React.useCallback(
|
||||
(page) => {
|
||||
if (page) {
|
||||
params.set("page", page.toString());
|
||||
} else {
|
||||
params.delete("page");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
|
||||
if (topRef.current) {
|
||||
scrollIntoView(topRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
topRef={topRef}
|
||||
onChangeSort={handleChangeSort}
|
||||
onChangePage={handleChangePage}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(TableFromParams);
|
||||
@@ -12,6 +12,8 @@ export type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
content?: React.ReactChild | React.ReactChild[];
|
||||
/** A keyboard shortcut to display next to the content */
|
||||
shortcut?: React.ReactNode;
|
||||
/** Whether to show the shortcut on a new line */
|
||||
shortcutOnNewline?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -21,7 +23,13 @@ export type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
* Wrap this component in a TooltipProvider to allow multiple tooltips to share the same
|
||||
* singleton instance (delay, animation, etc).
|
||||
*/
|
||||
function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
|
||||
function Tooltip({
|
||||
shortcut,
|
||||
shortcutOnNewline,
|
||||
content: tooltip,
|
||||
delay = 500,
|
||||
...rest
|
||||
}: Props) {
|
||||
const isMobile = useMobile();
|
||||
const singleton = useTooltipContext();
|
||||
|
||||
@@ -34,12 +42,13 @@ function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
|
||||
if (shortcut) {
|
||||
content = (
|
||||
<>
|
||||
{tooltip}{" "}
|
||||
{tooltip}
|
||||
{shortcutOnNewline ? <br /> : " "}
|
||||
{typeof shortcut === "string" ? (
|
||||
shortcut
|
||||
.split("+")
|
||||
.map((key) => (
|
||||
<Shortcut key={key}>
|
||||
.map((key, i) => (
|
||||
<Shortcut key={`${key}${i}`}>
|
||||
{key.length === 1 ? key.toUpperCase() : key}
|
||||
</Shortcut>
|
||||
))
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "~/components/Input";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -85,7 +89,11 @@ export function UserSuspendDialog({ user, onSubmit }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} savingText={`${t("Saving")}…`}>
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
savingText={`${t("Saving")}…`}
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
|
||||
{
|
||||
@@ -123,6 +131,68 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
|
||||
onChange={handleChange}
|
||||
error={!name ? t("Name can't be empty") : undefined}
|
||||
value={name}
|
||||
autoSelect
|
||||
required
|
||||
flex
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserChangeEmailDialog({ user, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const actor = useCurrentUser();
|
||||
const [email, setEmail] = React.useState<string>(user.email);
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await client.post(`/users.updateEmail`, { id: user.id, email });
|
||||
onSubmit();
|
||||
toast.info(
|
||||
actor.id === user.id
|
||||
? t("Check your email to verify the new address.")
|
||||
: t("The email will be changed once verified.")
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(ev.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Save")}
|
||||
savingText={`${t("Saving")}…`}
|
||||
disabled={!email || email === user.email}
|
||||
>
|
||||
<Text as="p">
|
||||
{actor.id === user.id ? (
|
||||
<Trans>
|
||||
You will receive an email to verify your new address. It must be
|
||||
unique in the workspace.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
A confirmation email will be sent to the new address before it is
|
||||
changed.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
label={t("New email")}
|
||||
onChange={handleChange}
|
||||
error={!email ? t("Email can't be empty") : error}
|
||||
value={email}
|
||||
autoSelect
|
||||
required
|
||||
flex
|
||||
/>
|
||||
|
||||
@@ -1,43 +1,24 @@
|
||||
import {
|
||||
ArrowIcon,
|
||||
DocumentIcon,
|
||||
CloseIcon,
|
||||
PlusIcon,
|
||||
OpenIcon,
|
||||
} from "outline-icons";
|
||||
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
|
||||
import { Mark } from "prosemirror-model";
|
||||
import { Selection } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s, hideScrollbars } from "@shared/styles";
|
||||
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Input from "./Input";
|
||||
import LinkSearchResult from "./LinkSearchResult";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
export type SearchResult = {
|
||||
title: string;
|
||||
subtitle?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
mark?: Mark;
|
||||
from: number;
|
||||
to: number;
|
||||
dictionary: Dictionary;
|
||||
onRemoveLink?: () => void;
|
||||
onCreateLink?: (title: string, nested?: boolean) => Promise<void>;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onSelectLink: (options: {
|
||||
href: string;
|
||||
title?: string;
|
||||
@@ -52,46 +33,25 @@ type Props = {
|
||||
};
|
||||
|
||||
type State = {
|
||||
results: {
|
||||
[keyword: string]: SearchResult[];
|
||||
};
|
||||
value: string;
|
||||
previousValue: string;
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
class LinkEditor extends React.Component<Props, State> {
|
||||
discardInputValue = false;
|
||||
initialValue = this.href;
|
||||
initialSelectionLength = this.props.to - this.props.from;
|
||||
resultsRef = React.createRef<HTMLDivElement>();
|
||||
inputRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
state: State = {
|
||||
selectedIndex: -1,
|
||||
value: this.href,
|
||||
previousValue: "",
|
||||
results: {},
|
||||
};
|
||||
|
||||
get href(): string {
|
||||
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
|
||||
}
|
||||
|
||||
get selectedText(): string {
|
||||
const { state } = this.props.view;
|
||||
const selectionText = state.doc.cut(
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
).textContent;
|
||||
|
||||
return selectionText.trim();
|
||||
}
|
||||
|
||||
get suggestedLinkTitle(): string {
|
||||
return this.state.value.trim() || this.selectedText;
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
window.addEventListener("keydown", this.handleGlobalKeyDown);
|
||||
}
|
||||
@@ -139,25 +99,12 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||
const results = this.results;
|
||||
|
||||
switch (event.key) {
|
||||
case "Enter": {
|
||||
event.preventDefault();
|
||||
const { selectedIndex, value } = this.state;
|
||||
const { onCreateLink } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
if (selectedIndex >= 0) {
|
||||
const result = results[selectedIndex];
|
||||
if (result) {
|
||||
this.save(result.url, result.title);
|
||||
} else if (onCreateLink && selectedIndex === results.length) {
|
||||
void this.handleCreateLink(this.suggestedLinkTitle);
|
||||
}
|
||||
} else {
|
||||
// saves the raw input as href
|
||||
this.save(value, value);
|
||||
}
|
||||
this.save(value, value);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
@@ -176,45 +123,9 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case "ArrowUp": {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const prevIndex = this.state.selectedIndex - 1;
|
||||
|
||||
this.setState({
|
||||
selectedIndex: Math.max(-1, prevIndex),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "ArrowDown":
|
||||
case "Tab": {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const { selectedIndex } = this.state;
|
||||
const total = results.length + 1;
|
||||
const nextIndex = selectedIndex + 1;
|
||||
|
||||
this.setState({
|
||||
selectedIndex: Math.min(nextIndex, total),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleFocusLink = (selectedIndex: number) => {
|
||||
this.setState({ selectedIndex });
|
||||
};
|
||||
|
||||
handleSearch = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
): Promise<void> => {
|
||||
@@ -222,21 +133,15 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
|
||||
this.setState({
|
||||
value,
|
||||
selectedIndex: -1,
|
||||
});
|
||||
|
||||
const trimmedValue = value.trim() || this.selectedText;
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue && this.props.onSearchLink) {
|
||||
if (trimmedValue) {
|
||||
try {
|
||||
const results = await this.props.onSearchLink(trimmedValue);
|
||||
this.setState((state) => ({
|
||||
results: {
|
||||
...state.results,
|
||||
[trimmedValue]: results,
|
||||
},
|
||||
this.setState({
|
||||
previousValue: trimmedValue,
|
||||
}));
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Error searching for link", err);
|
||||
}
|
||||
@@ -257,20 +162,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
handleCreateLink = async (title: string, nested?: boolean) => {
|
||||
this.discardInputValue = true;
|
||||
const { onCreateLink } = this.props;
|
||||
|
||||
title = title.trim();
|
||||
if (title.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreateLink) {
|
||||
return onCreateLink(title, nested);
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveLink = (): void => {
|
||||
this.discardInputValue = true;
|
||||
|
||||
@@ -285,16 +176,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
view.focus();
|
||||
};
|
||||
|
||||
handleSelectLink =
|
||||
(url: string, title: string) => (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
this.save(url, title);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
};
|
||||
|
||||
moveSelectionToEnd = () => {
|
||||
const { to, view } = this.props;
|
||||
const { state, dispatch } = view;
|
||||
@@ -305,42 +186,17 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
view.focus();
|
||||
};
|
||||
|
||||
get results() {
|
||||
const { value } = this.state;
|
||||
return (
|
||||
this.state.results[value.trim()] ||
|
||||
this.state.results[this.state.previousValue] ||
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dictionary } = this.props;
|
||||
const { value, selectedIndex } = this.state;
|
||||
const results = this.results;
|
||||
const looksLikeUrl = value.match(/^https?:\/\//i);
|
||||
const suggestedLinkTitle = this.suggestedLinkTitle;
|
||||
const { value } = this.state;
|
||||
const isInternal = isInternalUrl(value);
|
||||
|
||||
const showCreateLink =
|
||||
!!this.props.onCreateLink &&
|
||||
!(suggestedLinkTitle === this.initialValue) &&
|
||||
suggestedLinkTitle.length > 0 &&
|
||||
!looksLikeUrl;
|
||||
|
||||
const hasResults =
|
||||
!!suggestedLinkTitle && (showCreateLink || results.length > 0);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Input
|
||||
ref={this.inputRef}
|
||||
value={value}
|
||||
placeholder={
|
||||
showCreateLink
|
||||
? dictionary.findOrCreateDoc
|
||||
: dictionary.searchOrPasteLink
|
||||
}
|
||||
placeholder={dictionary.enterLink}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onPaste={this.handlePaste}
|
||||
onChange={this.handleSearch}
|
||||
@@ -360,70 +216,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
<CloseIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
|
||||
<SearchResults
|
||||
ref={this.resultsRef}
|
||||
$hasResults={hasResults}
|
||||
role="menu"
|
||||
>
|
||||
<ResizingHeightContainer>
|
||||
{hasResults && (
|
||||
<>
|
||||
{results.map((result, index) => (
|
||||
<LinkSearchResult
|
||||
key={result.url}
|
||||
title={result.title}
|
||||
subtitle={result.subtitle}
|
||||
icon={result.icon ?? <DocumentIcon />}
|
||||
onPointerMove={() => this.handleFocusLink(index)}
|
||||
onClick={this.handleSelectLink(result.url, result.title)}
|
||||
selected={index === selectedIndex}
|
||||
containerRef={this.resultsRef}
|
||||
/>
|
||||
))}
|
||||
|
||||
{showCreateLink && (
|
||||
<>
|
||||
<LinkSearchResult
|
||||
key="create"
|
||||
containerRef={this.resultsRef}
|
||||
title={suggestedLinkTitle}
|
||||
subtitle={dictionary.createNewDoc}
|
||||
icon={<PlusIcon />}
|
||||
onPointerMove={() => this.handleFocusLink(results.length)}
|
||||
onClick={async () => {
|
||||
await this.handleCreateLink(suggestedLinkTitle);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
}}
|
||||
selected={results.length === selectedIndex}
|
||||
/>
|
||||
<LinkSearchResult
|
||||
key="create-nested"
|
||||
containerRef={this.resultsRef}
|
||||
title={suggestedLinkTitle}
|
||||
subtitle={dictionary.createNewChildDoc}
|
||||
icon={<PlusIcon />}
|
||||
onPointerMove={() =>
|
||||
this.handleFocusLink(results.length + 1)
|
||||
}
|
||||
onClick={async () => {
|
||||
await this.handleCreateLink(suggestedLinkTitle, true);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
}}
|
||||
selected={results.length + 1 === selectedIndex}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
</SearchResults>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -434,29 +226,4 @@ const Wrapper = styled(Flex)`
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
|
||||
clip-path: inset(0px -100px -100px -100px);
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
left: 0;
|
||||
margin-top: -6px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: ${(props) => (props.$hasResults ? "8px 0" : "0")};
|
||||
max-height: 240px;
|
||||
${hideScrollbars()}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 40px;
|
||||
border-radius: 0;
|
||||
max-height: 50vh;
|
||||
padding: 8px 8px 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default LinkEditor;
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
icon: React.ReactNode;
|
||||
selected: boolean;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
function LinkSearchResult({
|
||||
title,
|
||||
subtitle,
|
||||
containerRef,
|
||||
selected,
|
||||
icon,
|
||||
...rest
|
||||
}: Props) {
|
||||
const ref = React.useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (selected && node) {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
block: "center",
|
||||
boundary: (parent) =>
|
||||
// Prevents body and other parent elements from being scrolled
|
||||
parent !== containerRef.current,
|
||||
});
|
||||
}
|
||||
},
|
||||
[containerRef, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
ref={ref}
|
||||
compact={!subtitle}
|
||||
selected={selected}
|
||||
role="menuitem"
|
||||
{...rest}
|
||||
>
|
||||
<IconWrapper selected={selected}>{icon}</IconWrapper>
|
||||
<Content>
|
||||
<Title title={title}>{title}</Title>
|
||||
{subtitle ? <Subtitle selected={selected}>{subtitle}</Subtitle> : null}
|
||||
</Content>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span<{ selected: boolean }>`
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
opacity: 0.8;
|
||||
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
css`
|
||||
svg {
|
||||
fill: ${s("accentText")};
|
||||
color: ${s("accentText")};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const ListItem = styled.div<{
|
||||
selected: boolean;
|
||||
compact: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin: 0 6px;
|
||||
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
|
||||
background: ${(props) => (props.selected ? s("accent") : "transparent")};
|
||||
font-family: ${s("fontFamily")};
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
cursor: var(--pointer);
|
||||
user-select: none;
|
||||
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
|
||||
height: ${(props) => (props.compact ? "28px" : "auto")};
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
${ellipsis()}
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.div<{
|
||||
selected: boolean;
|
||||
}>`
|
||||
${ellipsis()}
|
||||
font-size: 13px;
|
||||
opacity: ${(props) => (props.selected ? 0.75 : 0.5)};
|
||||
`;
|
||||
|
||||
export default LinkSearchResult;
|
||||
@@ -1,146 +0,0 @@
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
||||
import { creatingUrlPrefix } from "@shared/utils/urls";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor, { SearchResult } from "./LinkEditor";
|
||||
|
||||
type Props = {
|
||||
isActive: boolean;
|
||||
onCreateLink?: (title: string) => Promise<string>;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function isActive(view: EditorView, active: boolean): boolean {
|
||||
try {
|
||||
const { selection } = view.state;
|
||||
const paragraph = view.domAtPos(selection.from);
|
||||
return active && !!paragraph.node;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function LinkToolbar({
|
||||
onCreateLink,
|
||||
onSearchLink,
|
||||
onClickLink,
|
||||
onClose,
|
||||
...rest
|
||||
}: Props) {
|
||||
const dictionary = useDictionary();
|
||||
const { view } = useEditor();
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEventListener("mousedown", (event: Event) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
menuRef.current &&
|
||||
menuRef.current.contains(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
|
||||
const handleOnCreateLink = React.useCallback(
|
||||
async (title: string, nested?: boolean) => {
|
||||
onClose();
|
||||
view.focus();
|
||||
|
||||
if (!onCreateLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatch, state } = view;
|
||||
const { from, to } = state.selection;
|
||||
if (from !== to) {
|
||||
// selection must be collapsed
|
||||
return;
|
||||
}
|
||||
|
||||
const href = `${creatingUrlPrefix}#${title}…`;
|
||||
|
||||
// Insert a placeholder link
|
||||
dispatch(
|
||||
view.state.tr
|
||||
.insertText(title, from, to)
|
||||
.addMark(
|
||||
from,
|
||||
to + title.length,
|
||||
state.schema.marks.link.create({ href })
|
||||
)
|
||||
);
|
||||
|
||||
return createAndInsertLink(view, title, href, {
|
||||
nested,
|
||||
onCreateLink,
|
||||
dictionary,
|
||||
});
|
||||
},
|
||||
[onCreateLink, onClose, view, dictionary]
|
||||
);
|
||||
|
||||
const handleOnSelectLink = React.useCallback(
|
||||
({
|
||||
href,
|
||||
title,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}) => {
|
||||
onClose();
|
||||
view.focus();
|
||||
|
||||
const { dispatch, state } = view;
|
||||
const { from, to } = state.selection;
|
||||
if (from !== to) {
|
||||
// selection must be collapsed
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
view.state.tr
|
||||
.insertText(title, from, to)
|
||||
.addMark(
|
||||
from,
|
||||
to + title.length,
|
||||
state.schema.marks.link.create({ href })
|
||||
)
|
||||
);
|
||||
},
|
||||
[onClose, view]
|
||||
);
|
||||
|
||||
const { selection } = view.state;
|
||||
const active = isActive(view, rest.isActive);
|
||||
|
||||
return (
|
||||
<FloatingToolbar ref={menuRef} active={active} width={336}>
|
||||
{active && (
|
||||
<LinkEditor
|
||||
key={`${selection.from}-${selection.to}`}
|
||||
from={selection.from}
|
||||
to={selection.to}
|
||||
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
|
||||
onSelectLink={handleOnSelectLink}
|
||||
onRemoveLink={onClose}
|
||||
onClickLink={onClickLink}
|
||||
onSearchLink={onSearchLink}
|
||||
dictionary={dictionary}
|
||||
view={view}
|
||||
/>
|
||||
)}
|
||||
</FloatingToolbar>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { v4 } from "uuid";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { DocumentsSection, UserSection } from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import MentionMenuItem from "./MentionMenuItem";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
name: string;
|
||||
user: User;
|
||||
appendSpace: boolean;
|
||||
attrs: {
|
||||
id: string;
|
||||
type: MentionType;
|
||||
@@ -40,17 +42,22 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [items, setItems] = React.useState<MentionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const { auth, users } = useStores();
|
||||
const { auth, documents, users } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
const { data, loading, request } = useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
documentId
|
||||
? users.fetchPage({ id: documentId, query: search })
|
||||
: Promise.resolve([]),
|
||||
[users, documentId, search]
|
||||
)
|
||||
const { data, loading, request } = useRequest<{
|
||||
documents: Document[];
|
||||
users: User[];
|
||||
}>(
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query: search });
|
||||
|
||||
return {
|
||||
documents: res.data.documents.map(documents.add),
|
||||
users: res.data.users.map(users.add),
|
||||
};
|
||||
}, [search, documents, users])
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -60,28 +67,92 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
}, [request, isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data && !loading) {
|
||||
const items = data.map((user) => ({
|
||||
name: "mention",
|
||||
user,
|
||||
title: user.name,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId: auth.currentUserId ?? undefined,
|
||||
label: user.name,
|
||||
},
|
||||
}));
|
||||
if (data && actorId && !loading) {
|
||||
const items = data.users
|
||||
.map(
|
||||
(user) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={user}
|
||||
showBorder={false}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
title: user.name,
|
||||
section: UserSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
label: user.name,
|
||||
},
|
||||
} as MentionItem)
|
||||
)
|
||||
.concat(
|
||||
data.documents.map(
|
||||
(doc) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: doc.icon ? (
|
||||
<Icon value={doc.icon} color={doc.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: doc.collection?.name,
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.Document,
|
||||
modelId: doc.id,
|
||||
actorId,
|
||||
label: doc.title,
|
||||
},
|
||||
} as MentionItem)
|
||||
)
|
||||
)
|
||||
.concat([
|
||||
{
|
||||
name: "link",
|
||||
icon: <PlusIcon />,
|
||||
title: search?.trim(),
|
||||
section: DocumentsSection,
|
||||
subtitle: t("Create a new doc"),
|
||||
visible: !!search && !isEmail(search),
|
||||
priority: -1,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.Document,
|
||||
modelId: v4(),
|
||||
actorId,
|
||||
label: search,
|
||||
},
|
||||
} as MentionItem,
|
||||
]);
|
||||
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [auth.currentUserId, loading, data]);
|
||||
}, [t, actorId, loading, search, data]);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (item.attrs.type === MentionType.Document) {
|
||||
return;
|
||||
}
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
@@ -122,25 +193,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<MentionMenuItem
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
subtitle={item.subtitle}
|
||||
title={item.title}
|
||||
label={item.attrs.label}
|
||||
icon={
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={item.user}
|
||||
showBorder={false}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
items={items}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as React from "react";
|
||||
import SuggestionsMenuItem, {
|
||||
Props as SuggestionsMenuItemProps,
|
||||
} from "./SuggestionsMenuItem";
|
||||
|
||||
type MentionMenuItemProps = Omit<
|
||||
SuggestionsMenuItemProps,
|
||||
"shortcut" | "theme"
|
||||
> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export default function MentionMenuItem({
|
||||
label,
|
||||
...rest
|
||||
}: MentionMenuItemProps) {
|
||||
return <SuggestionsMenuItem {...rest} title={label} />;
|
||||
}
|
||||
@@ -25,4 +25,9 @@ export class NodeViewRenderer<T extends object> {
|
||||
this.props = props;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
public setProp<K extends keyof T>(key: K, value: T[K]) {
|
||||
this.props[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import some from "lodash/some";
|
||||
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
@@ -9,7 +8,6 @@ import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { creatingUrlPrefix } from "@shared/utils/urls";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -26,7 +24,7 @@ import getTableColMenuItems from "../menus/tableCol";
|
||||
import getTableRowMenuItems from "../menus/tableRow";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor, { SearchResult } from "./LinkEditor";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
|
||||
type Props = {
|
||||
@@ -37,12 +35,10 @@ type Props = {
|
||||
canUpdate?: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onCreateLink?: (title: string) => Promise<string>;
|
||||
};
|
||||
|
||||
function useIsActive(state: EditorState) {
|
||||
@@ -149,40 +145,6 @@ export default function SelectionToolbar(props: Props) {
|
||||
};
|
||||
}, [isActive, previousIsActive, readOnly, view]);
|
||||
|
||||
const handleOnCreateLink = async (
|
||||
title: string,
|
||||
nested?: boolean
|
||||
): Promise<void> => {
|
||||
const { onCreateLink } = props;
|
||||
|
||||
if (!onCreateLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatch, state } = view;
|
||||
const { from, to } = state.selection;
|
||||
if (from === to) {
|
||||
// Do not display a selection toolbar for collapsed selections
|
||||
return;
|
||||
}
|
||||
|
||||
const href = `${creatingUrlPrefix}${title}…`;
|
||||
const markType = state.schema.marks.link;
|
||||
|
||||
// Insert a placeholder link
|
||||
dispatch(
|
||||
view.state.tr
|
||||
.removeMark(from, to, markType)
|
||||
.addMark(from, to, markType.create({ href }))
|
||||
);
|
||||
|
||||
return createAndInsertLink(view, title, href, {
|
||||
nested,
|
||||
onCreateLink,
|
||||
dictionary,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnSelectLink = ({
|
||||
href,
|
||||
from,
|
||||
@@ -203,8 +165,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
);
|
||||
};
|
||||
|
||||
const { onCreateLink, isTemplate, rtl, canComment, canUpdate, ...rest } =
|
||||
props;
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
@@ -283,8 +244,6 @@ export default function SelectionToolbar(props: Props) {
|
||||
from={link.from}
|
||||
to={link.to}
|
||||
onClickLink={props.onClickLink}
|
||||
onSearchLink={props.onSearchLink}
|
||||
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
|
||||
onSelectLink={handleOnSelectLink}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import commandScore from "command-score";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
@@ -13,6 +14,7 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Header from "~/components/ContextMenu/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
@@ -78,8 +80,9 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
};
|
||||
|
||||
function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const { view, commands } = useEditor();
|
||||
const { view, commands, props: editorProps } = useEditor();
|
||||
const dictionary = useDictionary();
|
||||
const { t } = useTranslation();
|
||||
const hasActivated = React.useRef(false);
|
||||
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
|
||||
clientX: 0,
|
||||
@@ -250,6 +253,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
props.onSelect?.(item);
|
||||
|
||||
switch (item.name) {
|
||||
case "link":
|
||||
insertNode({
|
||||
...item,
|
||||
name: "mention",
|
||||
});
|
||||
void editorProps.onCreateLink?.({
|
||||
title: item.attrs.label,
|
||||
id: item.attrs.modelId,
|
||||
});
|
||||
return;
|
||||
case "image":
|
||||
return triggerFilePick(
|
||||
AttachmentValidation.imageContentTypes.join(", ")
|
||||
@@ -264,7 +277,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
insertNode(item);
|
||||
}
|
||||
},
|
||||
[insertNode]
|
||||
[editorProps, props, insertNode]
|
||||
);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
@@ -414,6 +427,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.visible === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some extensions may be disabled, remove corresponding menu items
|
||||
if (
|
||||
item.name &&
|
||||
@@ -445,16 +462,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
});
|
||||
|
||||
return filterExcessSeparators(
|
||||
filtered
|
||||
.map((item) => ({
|
||||
orderBy(
|
||||
filtered.map((item) => ({
|
||||
item,
|
||||
section:
|
||||
"section" in item && item.section && "priority" in item.section
|
||||
? (item.section.priority as number) ?? 0
|
||||
: 0,
|
||||
priority: "priority" in item ? item.priority : 0,
|
||||
score:
|
||||
searchInput && item.title
|
||||
? commandScore(item.title, searchInput)
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ item }) => item)
|
||||
})),
|
||||
["section", "priority", "score"],
|
||||
["desc", "desc", "desc"]
|
||||
).map(({ item }) => item)
|
||||
);
|
||||
}, [commands, props]);
|
||||
|
||||
@@ -555,6 +578,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
let previousHeading: string | undefined;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
@@ -614,18 +638,29 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<>
|
||||
{currentHeading !== previousHeading && (
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
)}
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
|
||||
previousHeading = currentHeading;
|
||||
return response;
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<ListItem>
|
||||
|
||||
@@ -15,6 +15,8 @@ export type Props = {
|
||||
icon?: React.ReactElement;
|
||||
/** The title of the item */
|
||||
title: React.ReactNode;
|
||||
/** An optional subtitle for the item */
|
||||
subtitle?: React.ReactNode;
|
||||
/** A string representing the keyboard shortcut for the item */
|
||||
shortcut?: string;
|
||||
};
|
||||
@@ -24,6 +26,7 @@ function SuggestionsMenuItem({
|
||||
disabled,
|
||||
onClick,
|
||||
title,
|
||||
subtitle,
|
||||
shortcut,
|
||||
icon,
|
||||
}: Props) {
|
||||
@@ -53,11 +56,17 @@ function SuggestionsMenuItem({
|
||||
icon={icon}
|
||||
>
|
||||
{title}
|
||||
{subtitle && <Subtitle $active={selected}>· {subtitle}</Subtitle>}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Subtitle = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
|
||||
@@ -12,6 +12,7 @@ const WrappedTooltip: React.FC<Props> = ({
|
||||
delay={150}
|
||||
content={content}
|
||||
placement="top"
|
||||
shortcutOnNewline
|
||||
{...rest}
|
||||
>
|
||||
<TooltipContent>{children}</TooltipContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
|
||||
|
||||
/**
|
||||
* A plugin that allows overriding the default behavior of the editor to allow
|
||||
@@ -13,11 +14,7 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const textSerializers = Object.fromEntries(
|
||||
Object.entries(this.editor.schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
const textSerializers = getTextSerializers(this.editor.schema);
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { action } from "mobx";
|
||||
import * as React from "react";
|
||||
import { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { isBrowser } from "@shared/utils/browser";
|
||||
import Suggestion from "~/editor/extensions/Suggestion";
|
||||
import EmojiMenu from "../components/EmojiMenu";
|
||||
|
||||
@@ -13,10 +14,9 @@ const languagesUsingColon = ["fr"];
|
||||
|
||||
export default class EmojiMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
const languageIsUsingColon =
|
||||
typeof window === "undefined"
|
||||
? false
|
||||
: languagesUsingColon.includes(window.navigator.language.slice(0, 2));
|
||||
const languageIsUsingColon = isBrowser
|
||||
? languagesUsingColon.includes(window.navigator.language.slice(0, 2))
|
||||
: false;
|
||||
|
||||
return {
|
||||
openRegex: new RegExp(
|
||||
|
||||
@@ -79,15 +79,17 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
}
|
||||
|
||||
private get decorations() {
|
||||
return this.results.map((deco, index) =>
|
||||
Decoration.inline(deco.from, deco.to, {
|
||||
return this.results.map((deco, index) => {
|
||||
const decorationType =
|
||||
deco.type === "node" ? Decoration.node : Decoration.inline;
|
||||
return decorationType(deco.from, deco.to, {
|
||||
class:
|
||||
this.options.resultClassName +
|
||||
(this.currentResultIndex === index
|
||||
? ` ${this.options.resultCurrentClassName}`
|
||||
: ""),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public replace(replace: string): Command {
|
||||
@@ -175,7 +177,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
private goToMatch(direction: number): Command {
|
||||
return (state, dispatch) => {
|
||||
if (direction > 0) {
|
||||
if (this.currentResultIndex === this.results.length - 1) {
|
||||
if (this.currentResultIndex >= this.results.length - 1) {
|
||||
this.currentResultIndex = 0;
|
||||
} else {
|
||||
this.currentResultIndex += 1;
|
||||
@@ -212,11 +214,12 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
const { from: currentFrom, to: currentTo } = this.results[index];
|
||||
const offset = currentTo - currentFrom - replace.length + lastOffset;
|
||||
const { from, to } = this.results[nextIndex];
|
||||
const { from, to, type } = this.results[nextIndex];
|
||||
|
||||
this.results[nextIndex] = {
|
||||
to: to - offset,
|
||||
from: from - offset,
|
||||
type,
|
||||
};
|
||||
|
||||
return offset;
|
||||
@@ -224,10 +227,19 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
private search(doc: Node) {
|
||||
this.results = [];
|
||||
const mergedTextNodes: {
|
||||
text: string | undefined;
|
||||
pos: number;
|
||||
}[] = [];
|
||||
const mergedTextNodes: (
|
||||
| {
|
||||
text: string | undefined;
|
||||
pos: number;
|
||||
type: "inline";
|
||||
}
|
||||
| {
|
||||
text: string | undefined;
|
||||
pos: number;
|
||||
type: "node";
|
||||
nodeSize: number;
|
||||
}
|
||||
)[] = [];
|
||||
let index = 0;
|
||||
|
||||
if (!this.searchTerm) {
|
||||
@@ -238,21 +250,32 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
if (node.isText) {
|
||||
if (mergedTextNodes[index]) {
|
||||
mergedTextNodes[index] = {
|
||||
type: "inline",
|
||||
text: mergedTextNodes[index].text + (node.text ?? ""),
|
||||
pos: mergedTextNodes[index].pos,
|
||||
};
|
||||
} else {
|
||||
mergedTextNodes[index] = {
|
||||
type: "inline",
|
||||
text: node.text,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
} else if (node.type.name === "mention") {
|
||||
mergedTextNodes[++index] = {
|
||||
type: "node",
|
||||
nodeSize: node.nodeSize,
|
||||
text: node.attrs.label,
|
||||
pos,
|
||||
};
|
||||
++index;
|
||||
} else {
|
||||
index += 1;
|
||||
++index;
|
||||
}
|
||||
});
|
||||
|
||||
mergedTextNodes.forEach(({ text = "", pos }) => {
|
||||
mergedTextNodes.forEach((node) => {
|
||||
const { text = "", pos, type } = node;
|
||||
try {
|
||||
let m;
|
||||
const search = this.findRegExp;
|
||||
@@ -266,8 +289,8 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
// Reconstruct the correct match position
|
||||
const i = m.index >= text.length ? m.index - text.length : m.index;
|
||||
const from = pos + i;
|
||||
const to = from + m[0].length;
|
||||
const from = type === "inline" ? pos + i : pos;
|
||||
const to = from + (type === "inline" ? m[0].length : node.nodeSize);
|
||||
|
||||
// Check if already exists in results, possible due to duplicated
|
||||
// search string on L257
|
||||
@@ -275,7 +298,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.results.push({ from, to });
|
||||
this.results.push({ from, to, type });
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid RegExp
|
||||
@@ -349,7 +372,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
private open = false;
|
||||
|
||||
@observable
|
||||
private results: { from: number; to: number }[] = [];
|
||||
private results: { from: number; to: number; type: "inline" | "node" }[] = [];
|
||||
|
||||
@observable
|
||||
private currentResultIndex = 0;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { Slice } from "prosemirror-model";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { v4 } from "uuid";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { IconType } from "@shared/types";
|
||||
import { IconType, MentionType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
@@ -185,15 +186,31 @@ export default class PasteHandler extends Extension {
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
const { hash } = new URL(text);
|
||||
if (state.schema.nodes.mention) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
state.selection.to,
|
||||
state.schema.nodes.mention.create({
|
||||
type: MentionType.Document,
|
||||
modelId: document.id,
|
||||
label: document.titleWithDefault,
|
||||
id: v4(),
|
||||
})
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const { hash } = new URL(text);
|
||||
const hasEmoji =
|
||||
determineIconType(document.icon) ===
|
||||
IconType.Emoji;
|
||||
|
||||
const hasEmoji =
|
||||
determineIconType(document.icon) === IconType.Emoji;
|
||||
const title = `${
|
||||
hasEmoji ? document.icon + " " : ""
|
||||
}${document.titleWithDefault}`;
|
||||
|
||||
const title = `${
|
||||
hasEmoji ? document.icon + " " : ""
|
||||
}${document.titleWithDefault}`;
|
||||
insertLink(`${document.path}${hash}`, title);
|
||||
insertLink(`${document.path}${hash}`, title);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
+23
-56
@@ -1,5 +1,7 @@
|
||||
/* global File Promise */
|
||||
import { PluginSimple } from "markdown-it";
|
||||
import { observable } from "mobx";
|
||||
import { Observer } from "mobx-react";
|
||||
import { darken, transparentize } from "polished";
|
||||
import { baseKeymap } from "prosemirror-commands";
|
||||
import { dropCursor } from "prosemirror-dropcursor";
|
||||
@@ -33,22 +35,23 @@ import Extension, {
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import { ComponentProps, EventType } from "@shared/editor/types";
|
||||
import { ComponentProps } from "@shared/editor/types";
|
||||
import { ProsemirrorData, UserPreferences } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
import Document from "~/models/Document";
|
||||
import Flex from "~/components/Flex";
|
||||
import { PortalContext } from "~/components/Portal";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { Properties } from "~/types";
|
||||
import Logger from "~/utils/Logger";
|
||||
import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
import { SearchResult } from "./components/LinkEditor";
|
||||
import LinkToolbar from "./components/LinkToolbar";
|
||||
import { NodeViewRenderer } from "./components/NodeViewRenderer";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
import WithTheme from "./components/WithTheme";
|
||||
@@ -115,13 +118,11 @@ export type Props = {
|
||||
/** Callback when a file upload ends */
|
||||
onFileUploadStop?: () => void;
|
||||
/** Callback when a link is created, should return url to created document */
|
||||
onCreateLink?: (title: string) => Promise<string>;
|
||||
/** Callback when user searches for documents from link insert interface */
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onCreateLink?: (params: Properties<Document>) => Promise<string>;
|
||||
/** Callback when user clicks on any link in the document */
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
event?: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
/** Callback when user presses any key with document focused */
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
@@ -145,8 +146,6 @@ type State = {
|
||||
isEditorFocused: boolean;
|
||||
/** If the toolbar for a text selection is visible */
|
||||
selectionToolbarOpen: boolean;
|
||||
/** If the insert link toolbar is visible */
|
||||
linkToolbarOpen: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -176,7 +175,6 @@ export class Editor extends React.PureComponent<
|
||||
isRTL: false,
|
||||
isEditorFocused: false,
|
||||
selectionToolbarOpen: false,
|
||||
linkToolbarOpen: false,
|
||||
};
|
||||
|
||||
isInitialized = false;
|
||||
@@ -197,7 +195,7 @@ export class Editor extends React.PureComponent<
|
||||
};
|
||||
|
||||
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
|
||||
renderers: Set<NodeViewRenderer<ComponentProps>> = new Set();
|
||||
renderers: Set<NodeViewRenderer<ComponentProps>> = observable.set();
|
||||
nodes: { [name: string]: NodeSpec };
|
||||
marks: { [name: string]: MarkSpec };
|
||||
commands: Record<string, CommandFactory>;
|
||||
@@ -205,11 +203,6 @@ export class Editor extends React.PureComponent<
|
||||
events = new EventEmitter();
|
||||
mutationObserver?: MutationObserver;
|
||||
|
||||
public constructor(props: Props & ThemeProps<DefaultTheme>) {
|
||||
super(props);
|
||||
this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar);
|
||||
}
|
||||
|
||||
/**
|
||||
* We use componentDidMount instead of constructor as the init method requires
|
||||
* that the dom is already mounted.
|
||||
@@ -246,6 +239,12 @@ export class Editor extends React.PureComponent<
|
||||
...this.view.props,
|
||||
editable: () => !this.props.readOnly,
|
||||
});
|
||||
|
||||
// NodeView will not automatically render when editable changes so we must trigger an update
|
||||
// manually, see: https://discuss.prosemirror.net/t/re-render-custom-nodeview-when-view-editable-changes/6441
|
||||
Array.from(this.renderers).forEach((view) =>
|
||||
view.setProp("isEditable", !this.props.readOnly)
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.scrollTo && this.props.scrollTo !== prevProps.scrollTo) {
|
||||
@@ -265,7 +264,6 @@ export class Editor extends React.PureComponent<
|
||||
if (
|
||||
!this.isBlurred &&
|
||||
!this.state.isEditorFocused &&
|
||||
!this.state.linkToolbarOpen &&
|
||||
!this.state.selectionToolbarOpen
|
||||
) {
|
||||
this.isBlurred = true;
|
||||
@@ -274,9 +272,7 @@ export class Editor extends React.PureComponent<
|
||||
|
||||
if (
|
||||
this.isBlurred &&
|
||||
(this.state.isEditorFocused ||
|
||||
this.state.linkToolbarOpen ||
|
||||
this.state.selectionToolbarOpen)
|
||||
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
|
||||
) {
|
||||
this.isBlurred = false;
|
||||
this.props.onFocus?.();
|
||||
@@ -713,11 +709,7 @@ export class Editor extends React.PureComponent<
|
||||
*/
|
||||
public getPlainText = () => {
|
||||
const { doc } = this.view.state;
|
||||
const textSerializers = Object.fromEntries(
|
||||
Object.entries(this.schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
const textSerializers = getTextSerializers(this.schema);
|
||||
|
||||
return textBetween(doc, 0, doc.content.size, textSerializers);
|
||||
};
|
||||
@@ -779,25 +771,8 @@ export class Editor extends React.PureComponent<
|
||||
}));
|
||||
};
|
||||
|
||||
private handleOpenLinkToolbar = () => {
|
||||
if (this.state.selectionToolbarOpen) {
|
||||
return;
|
||||
}
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
linkToolbarOpen: true,
|
||||
}));
|
||||
};
|
||||
|
||||
private handleCloseLinkToolbar = () => {
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
linkToolbarOpen: false,
|
||||
}));
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { dir, readOnly, canUpdate, grow, style, className, onKeyDown } =
|
||||
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
|
||||
this.props;
|
||||
const { isRTL } = this.state;
|
||||
|
||||
@@ -814,7 +789,6 @@ export class Editor extends React.PureComponent<
|
||||
column
|
||||
>
|
||||
<EditorContainer
|
||||
dir={dir}
|
||||
rtl={isRTL}
|
||||
grow={grow}
|
||||
readOnly={readOnly}
|
||||
@@ -834,25 +808,18 @@ export class Editor extends React.PureComponent<
|
||||
isTemplate={this.props.template === true}
|
||||
onOpen={this.handleOpenSelectionToolbar}
|
||||
onClose={this.handleCloseSelectionToolbar}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && this.view && this.marks.link && (
|
||||
<LinkToolbar
|
||||
isActive={this.state.linkToolbarOpen}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onClose={this.handleCloseLinkToolbar}
|
||||
/>
|
||||
)}
|
||||
{this.widgets &&
|
||||
Object.values(this.widgets).map((Widget, index) => (
|
||||
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
|
||||
))}
|
||||
{Array.from(this.renderers).map((view) => view.content)}
|
||||
<Observer>
|
||||
{() => (
|
||||
<>{Array.from(this.renderers).map((view) => view.content)}</>
|
||||
)}
|
||||
</Observer>
|
||||
</Flex>
|
||||
</EditorContext.Provider>
|
||||
</PortalContext.Provider>
|
||||
|
||||
@@ -241,7 +241,11 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+⌥+M`,
|
||||
icon: <CommentIcon />,
|
||||
label: isCodeBlock ? dictionary.comment : undefined,
|
||||
active: isMarkActive(schema.marks.comment, { resolved: false }),
|
||||
active: isMarkActive(
|
||||
schema.marks.comment,
|
||||
{ resolved: false },
|
||||
{ exact: true }
|
||||
),
|
||||
visible: !isMobile || !isEmpty,
|
||||
},
|
||||
{
|
||||
@@ -252,6 +256,7 @@ export default function formattingMenuItems(
|
||||
name: "copyToClipboard",
|
||||
icon: <CopyIcon />,
|
||||
tooltip: dictionary.copy,
|
||||
shortcut: `${metaDisplay}+C`,
|
||||
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function useDictionary() {
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
file: t("File attachment"),
|
||||
findOrCreateDoc: `${t("Paste a link, search, or create")}…`,
|
||||
enterLink: `${t("Enter a link")}…`,
|
||||
h1: t("Big heading"),
|
||||
h2: t("Medium heading"),
|
||||
h3: t("Small heading"),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import useStores from "./useStores";
|
||||
|
||||
type Params = {
|
||||
/** The share ID of the document being viewed, if any */
|
||||
@@ -12,8 +13,9 @@ type Params = {
|
||||
|
||||
export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
const history = useHistory();
|
||||
const { documents } = useStores();
|
||||
const handleClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
(href: string, event?: MouseEvent) => {
|
||||
// on page hash
|
||||
if (isHash(href)) {
|
||||
window.location.href = href;
|
||||
@@ -49,13 +51,20 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
navigateTo = sharedDocumentPath(shareId, navigateTo);
|
||||
}
|
||||
|
||||
if (isDocumentUrl(navigateTo)) {
|
||||
const document = documents.getByUrl(navigateTo);
|
||||
if (document) {
|
||||
navigateTo = document.path;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're navigating to a share link from a non-share link then open it in a new tab
|
||||
if (!shareId && navigateTo.startsWith("/s/")) {
|
||||
window.open(href, "_blank");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isModKey(event) && !event.shiftKey) {
|
||||
if (!event || (!isModKey(event) && !event.shiftKey)) {
|
||||
history.push(navigateTo, { sidebarContext: "collections" }); // optimistic preference of "collections"
|
||||
} else {
|
||||
window.open(navigateTo, "_blank");
|
||||
|
||||
@@ -45,12 +45,16 @@ export default function useImportDocument(
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const doc = await documents.import(file, documentId, cId, {
|
||||
publish: true,
|
||||
});
|
||||
try {
|
||||
const doc = await documents.import(file, documentId, cId, {
|
||||
publish: true,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
history.push(documentPath(doc));
|
||||
if (redirect) {
|
||||
history.push(documentPath(doc));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { isBrowser } from "@shared/utils/browser";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
@@ -41,7 +42,7 @@ export default function usePersistedState<T extends Primitive | object>(
|
||||
options?: Options
|
||||
): [T, (value: T) => void] {
|
||||
const [storedValue, setStoredValue] = React.useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
if (!isBrowser) {
|
||||
return defaultValue;
|
||||
}
|
||||
return Storage.get(key) ?? defaultValue;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export default function useQuery() {
|
||||
return new URLSearchParams(useLocation().search);
|
||||
const location = useLocation();
|
||||
|
||||
const query = React.useMemo(
|
||||
() => new URLSearchParams(location.search),
|
||||
[location.search]
|
||||
);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import React from "react";
|
||||
import {
|
||||
FetchPageParams,
|
||||
PaginatedResponse,
|
||||
PAGINATION_SYMBOL,
|
||||
} from "~/stores/base/Store";
|
||||
import useRequest from "./useRequest";
|
||||
|
||||
const INITIAL_OFFSET = 0;
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
type Props<T> = {
|
||||
data: T[];
|
||||
sort: ColumnSort;
|
||||
reqFn: (params: FetchPageParams) => Promise<PaginatedResponse<T>>;
|
||||
reqParams: Omit<FetchPageParams, "offset" | "limit">;
|
||||
};
|
||||
|
||||
type Response<T> = {
|
||||
data: T[] | undefined;
|
||||
error: unknown;
|
||||
loading: boolean;
|
||||
next: (() => void) | undefined;
|
||||
};
|
||||
|
||||
export function useTableRequest<T extends { id: string }>({
|
||||
data,
|
||||
sort,
|
||||
reqFn,
|
||||
reqParams,
|
||||
}: Props<T>): Response<T> {
|
||||
const [total, setTotal] = React.useState<number>();
|
||||
const [offset, setOffset] = React.useState({ value: INITIAL_OFFSET });
|
||||
const prevParamsRef = React.useRef(reqParams);
|
||||
const sortRef = React.useRef<ColumnSort>(sort);
|
||||
|
||||
const fetchPage = React.useCallback(
|
||||
() => reqFn({ ...reqParams, offset: offset.value, limit: PAGE_SIZE }),
|
||||
[reqFn, reqParams, offset]
|
||||
);
|
||||
|
||||
const { request, loading, error } = useRequest(fetchPage);
|
||||
|
||||
const nextPage = React.useCallback(
|
||||
() =>
|
||||
setOffset((prev) => ({
|
||||
value: prev.value + PAGE_SIZE,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
const sortedData = data
|
||||
? orderBy(data, sortRef.current.id, sortRef.current.desc ? "desc" : "asc")
|
||||
: undefined;
|
||||
|
||||
const next =
|
||||
!loading && total && sortedData && sortedData.length < total
|
||||
? nextPage
|
||||
: undefined;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prevParamsRef.current !== reqParams) {
|
||||
prevParamsRef.current = reqParams;
|
||||
setOffset({ value: INITIAL_OFFSET });
|
||||
return;
|
||||
}
|
||||
|
||||
let ignore = false;
|
||||
|
||||
const handleRequest = async () => {
|
||||
const response = await request();
|
||||
if (!response || ignore) {
|
||||
return;
|
||||
}
|
||||
|
||||
sortRef.current = sort; // Change sort once we receive a response from server - avoids flicker with stale data.
|
||||
setTotal(response[PAGINATION_SYMBOL]?.total);
|
||||
};
|
||||
|
||||
void handleRequest();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [sort, reqParams, offset, request]);
|
||||
|
||||
return {
|
||||
data: sortedData,
|
||||
error,
|
||||
loading,
|
||||
next,
|
||||
};
|
||||
}
|
||||
@@ -123,7 +123,6 @@ function CollectionMenu({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
ev.target.value = "";
|
||||
}
|
||||
|
||||
+47
-24
@@ -4,44 +4,57 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Group from "~/models/Group";
|
||||
import GroupDelete from "~/scenes/GroupDelete";
|
||||
import GroupEdit from "~/scenes/GroupEdit";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import Modal from "~/components/Modal";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onMembers: () => void;
|
||||
};
|
||||
|
||||
function GroupMenu({ group, onMembers }: Props) {
|
||||
function GroupMenu({ group }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleViewMembers = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
fullscreen: true,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleEditGroup = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleDeleteGroup = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Edit group")}
|
||||
onRequestClose={() => setEditModalOpen(false)}
|
||||
isOpen={editModalOpen}
|
||||
>
|
||||
<GroupEdit group={group} onSubmit={() => setEditModalOpen(false)} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete group")}
|
||||
onRequestClose={() => setDeleteModalOpen(false)}
|
||||
isOpen={deleteModalOpen}
|
||||
>
|
||||
<GroupDelete group={group} onSubmit={() => setDeleteModalOpen(false)} />
|
||||
</Modal>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group options")}>
|
||||
<Template
|
||||
@@ -51,7 +64,7 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
type: "button",
|
||||
title: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
onClick: onMembers,
|
||||
onClick: handleViewMembers,
|
||||
visible: !!(group && can.read),
|
||||
},
|
||||
{
|
||||
@@ -61,7 +74,7 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
type: "button",
|
||||
title: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
onClick: () => setEditModalOpen(true),
|
||||
onClick: handleEditGroup,
|
||||
visible: !!(group && can.update),
|
||||
},
|
||||
{
|
||||
@@ -69,9 +82,19 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
title: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
onClick: () => setDeleteModalOpen(true),
|
||||
onClick: handleDeleteGroup,
|
||||
visible: !!(group && can.delete),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "",
|
||||
title: group.externalId,
|
||||
disabled: true,
|
||||
visible: !!group.externalId,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { DocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
+25
-1
@@ -11,6 +11,7 @@ import Template from "~/components/ContextMenu/Template";
|
||||
import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import {
|
||||
@@ -49,6 +50,22 @@ function UserMenu({ user }: Props) {
|
||||
[dialogs, t, user]
|
||||
);
|
||||
|
||||
const handleChangeEmail = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog
|
||||
user={user}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
[dialogs, t, user]
|
||||
);
|
||||
|
||||
const handleSuspend = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
@@ -117,7 +134,13 @@ function UserMenu({ user }: Props) {
|
||||
type: "button",
|
||||
title: `${t("Change name")}…`,
|
||||
onClick: handleChangeName,
|
||||
visible: can.update && user.role !== "admin",
|
||||
visible: can.update,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Change email")}…`,
|
||||
onClick: handleChangeEmail,
|
||||
visible: can.update,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
@@ -144,6 +167,7 @@ function UserMenu({ user }: Props) {
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Suspend user")}…`,
|
||||
dangerous: true,
|
||||
onClick: handleSuspend,
|
||||
visible: !user.isInvited && !user.isSuspended,
|
||||
},
|
||||
|
||||
@@ -10,6 +10,9 @@ class Group extends Model {
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@observable
|
||||
externalId: string | undefined;
|
||||
|
||||
@observable
|
||||
memberCount: number;
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ class Share extends Model {
|
||||
@observable
|
||||
allowIndexing: boolean;
|
||||
|
||||
@observable
|
||||
views: number;
|
||||
|
||||
/** The user that shared the document. */
|
||||
@Relation(() => User, { onDelete: "null" })
|
||||
createdBy: User;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
|
||||
import { s } from "@shared/styles";
|
||||
import { StatusFilter } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -22,7 +23,6 @@ import CenteredContent from "~/components/CenteredContent";
|
||||
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
|
||||
import CollectionDescription from "~/components/CollectionDescription";
|
||||
import Heading from "~/components/Heading";
|
||||
import Icon, { IconTitleWrapper } from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSearchPage from "~/components/InputSearchPage";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
|
||||
@@ -25,6 +25,7 @@ import useBuildTheme from "~/hooks/useBuildTheme";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, OfflineError } from "~/utils/errors";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { changeLanguage, detectLanguage } from "~/utils/language";
|
||||
@@ -109,6 +110,16 @@ function SharedDocumentScene(props: Props) {
|
||||
: undefined;
|
||||
const theme = useBuildTheme(response?.team?.customTheme, themeOverride);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shareId) {
|
||||
client.setShareId(shareId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
client.setShareId(undefined);
|
||||
};
|
||||
}, [shareId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!user) {
|
||||
void changeLanguage(detectLanguage(), i18n);
|
||||
|
||||
@@ -19,19 +19,23 @@ const CommentSortMenu = () => {
|
||||
const user = useCurrentUser();
|
||||
const params = useQuery();
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const value = viewingResolved
|
||||
? "resolved"
|
||||
: user.getPreference(UserPreference.SortCommentsByOrderInDocument)
|
||||
const preferredSortType = user.getPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument
|
||||
)
|
||||
? CommentSortType.OrderInDocument
|
||||
: CommentSortType.MostRecent;
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const value = viewingResolved ? "resolved" : preferredSortType;
|
||||
|
||||
const handleSortTypeChange = (type: CommentSortType) => {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
type === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
if (type !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
type === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
};
|
||||
|
||||
const showResolved = () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
@@ -74,10 +75,10 @@ function CommentThread({
|
||||
|
||||
const canReply = can.comment && !thread.isResolved;
|
||||
|
||||
const highlightedCommentMarks = editor
|
||||
?.getComments()
|
||||
.filter((comment) => comment.id === thread.id);
|
||||
const highlightedText = highlightedCommentMarks?.map((c) => c.text).join("");
|
||||
const highlightedText = ProsemirrorHelper.getAnchorTextForComment(
|
||||
editor?.getComments() ?? [],
|
||||
thread.id
|
||||
);
|
||||
|
||||
const commentsInThread = comments
|
||||
.inThread(thread.id)
|
||||
|
||||
@@ -14,6 +14,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Properties } from "~/types";
|
||||
import Logger from "~/utils/Logger";
|
||||
import {
|
||||
NotFoundError,
|
||||
@@ -46,7 +47,10 @@ type Children = (options: {
|
||||
revision: Revision | undefined;
|
||||
abilities: Record<string, boolean>;
|
||||
readOnly: boolean;
|
||||
onCreateLink: (title: string, nested?: boolean) => Promise<string>;
|
||||
onCreateLink: (
|
||||
params: Properties<Document>,
|
||||
nested?: boolean
|
||||
) => Promise<string>;
|
||||
sharedTree: NavigationNode | undefined;
|
||||
}) => React.ReactNode;
|
||||
|
||||
@@ -143,7 +147,7 @@ function DataLoader({ match, children }: Props) {
|
||||
}, [document?.id, document?.isDeleted, revisionId, views]);
|
||||
|
||||
const onCreateLink = React.useCallback(
|
||||
async (title: string, nested?: boolean) => {
|
||||
async (params: Properties<Document>, nested?: boolean) => {
|
||||
if (!document) {
|
||||
throw new Error("Document not loaded yet");
|
||||
}
|
||||
@@ -152,8 +156,8 @@ function DataLoader({ match, children }: Props) {
|
||||
{
|
||||
collectionId: nested ? undefined : document.collectionId,
|
||||
parentDocumentId: nested ? document.id : document.parentDocumentId,
|
||||
title,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
...params,
|
||||
},
|
||||
{
|
||||
publish: document.isDraft ? undefined : true,
|
||||
|
||||
@@ -45,7 +45,7 @@ import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import withStores from "~/components/withStores";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import { SearchResult } from "~/editor/components/LinkEditor";
|
||||
import { Properties } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { emojiToUrl } from "~/utils/emoji";
|
||||
|
||||
@@ -90,8 +90,10 @@ type Props = WithTranslation &
|
||||
readOnly: boolean;
|
||||
shareId?: string;
|
||||
tocPosition?: TOCPosition;
|
||||
onCreateLink?: (title: string, nested?: boolean) => Promise<string>;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onCreateLink?: (
|
||||
params: Properties<Document>,
|
||||
nested?: boolean
|
||||
) => Promise<string>;
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -571,7 +573,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
onSynced={this.onSynced}
|
||||
onFileUploadStart={this.onFileUploadStart}
|
||||
onFileUploadStop={this.onFileUploadStop}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.handleChangeTitle}
|
||||
onChangeIcon={this.handleChangeIcon}
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { extraArea, s } from "@shared/styles";
|
||||
@@ -19,7 +20,6 @@ import { isModKey } from "@shared/utils/keyboard";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Icon, { IconTitleWrapper } from "~/components/Icon";
|
||||
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
|
||||
@@ -221,6 +221,8 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
[setEditorInitialized]
|
||||
);
|
||||
|
||||
const direction = titleRef.current?.getComputedDirection();
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
<DocumentTitle
|
||||
@@ -250,9 +252,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
: documentHistoryPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
rtl={
|
||||
titleRef.current?.getComputedDirection() === "rtl" ? true : false
|
||||
}
|
||||
rtl={direction === "rtl"}
|
||||
/>
|
||||
)}
|
||||
<EditorComponent
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
@@ -24,7 +25,6 @@ import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Flex from "~/components/Flex";
|
||||
import Header from "~/components/Header";
|
||||
import Icon from "~/components/Icon";
|
||||
import Star from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { publishDocument } from "~/actions/definitions/documents";
|
||||
@@ -199,6 +199,7 @@ function DocumentHeader({
|
||||
if (shareId) {
|
||||
return (
|
||||
<StyledHeader
|
||||
ref={ref}
|
||||
$hidden={isEditingFocus}
|
||||
title={
|
||||
<Flex gap={4}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import useComponentSize from "@shared/editor/components/hooks/useComponentSize";
|
||||
import useComponentSize from "@shared/hooks/useComponentSize";
|
||||
|
||||
export const MeasuredContainer = <T extends React.ElementType>({
|
||||
as: As,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Icon from "~/components/Icon";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { IconType, NavigationNode } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Document from "~/models/Document";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import { hover } from "~/styles";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Group from "~/models/Group";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function GroupDelete({ group, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await group.delete();
|
||||
history.push(settingsPath("groups"));
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GroupDelete);
|
||||
@@ -1,73 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Group from "~/models/Group";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function GroupEdit({ group, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState(group.name);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await group.save({
|
||||
name,
|
||||
});
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[group, onSubmit, name]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
You can edit the name of this group at any time, however doing so too
|
||||
often might confuse your team mates.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GroupEdit);
|
||||
@@ -1,148 +0,0 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import Modal from "~/components/Modal";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMemberListItem from "./components/GroupMemberListItem";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function AddPeopleToGroup(props: Props) {
|
||||
const { group } = props;
|
||||
|
||||
const { users, groupUsers } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(team);
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [inviteModalOpen, handleInviteModalOpen, handleInviteModalClose] =
|
||||
useBoolean(false);
|
||||
|
||||
const { fetchPage: fetchUsers } = users;
|
||||
const debouncedFetch = React.useMemo(
|
||||
() => debounce((query) => fetchUsers({ query }), 250),
|
||||
[fetchUsers]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedQuery = ev.target.value;
|
||||
setQuery(updatedQuery);
|
||||
void debouncedFetch(updatedQuery);
|
||||
},
|
||||
[debouncedFetch]
|
||||
);
|
||||
|
||||
const handleAddUser = async (user: User) => {
|
||||
try {
|
||||
await groupUsers.create({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t(`{{userName}} was added to the group`, {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t("Could not add user"));
|
||||
}
|
||||
};
|
||||
|
||||
const { loading } = useRequest(
|
||||
React.useCallback(
|
||||
() => groupUsers.fetchAll({ id: group.id }),
|
||||
[groupUsers, group]
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Add members below to give them access to the group. Need to add someone who’s not yet a member?"
|
||||
)}{" "}
|
||||
{can.inviteUser ? (
|
||||
<ButtonLink onClick={handleInviteModalOpen}>
|
||||
{t("Invite them to {{teamName}}", {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</ButtonLink>
|
||||
) : (
|
||||
t("Ask an admin to invite them first")
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search people")}
|
||||
labelHidden
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
{loading ? (
|
||||
<DelayedMount>
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
) : (
|
||||
<PaginatedList
|
||||
empty={
|
||||
query ? (
|
||||
<Empty>{t("No people matching your search")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No people left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={users.notInGroup(group.id, query)}
|
||||
fetch={query ? undefined : users.fetchPage}
|
||||
renderItem={(item: User) => (
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onAdd={() => handleAddUser(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(AddPeopleToGroup);
|
||||
@@ -1,126 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Modal from "~/components/Modal";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Text from "~/components/Text";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AddPeopleToGroup from "./AddPeopleToGroup";
|
||||
import GroupMemberListItem from "./components/GroupMemberListItem";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
};
|
||||
|
||||
function GroupMembers({ group }: Props) {
|
||||
const [addModalOpen, setAddModalOpen] = React.useState(false);
|
||||
const { users, groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleAddModal = (state: boolean) => {
|
||||
setAddModalOpen(state);
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (user: User) => {
|
||||
try {
|
||||
await groupUsers.delete({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
toast.success(
|
||||
t(`{{userName}} was removed from the group`, {
|
||||
userName: user.name,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t("Could not remove user"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{can.update ? (
|
||||
<>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleAddModal(true)}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Add people")}…
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Listing members of the <em>{{groupName}}</em> group."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Subheading>
|
||||
<Trans>Members</Trans>
|
||||
</Subheading>
|
||||
<PaginatedList
|
||||
items={users.inGroup(group.id)}
|
||||
fetch={groupUsers.fetchPage}
|
||||
options={{
|
||||
id: group.id,
|
||||
}}
|
||||
empty={<Empty>{t("This group has no members.")}</Empty>}
|
||||
renderItem={(item: User) => (
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onRemove={can.update ? () => handleRemoveUser(item) : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{can.update && (
|
||||
<Modal
|
||||
title={t(`Add people to {{groupName}}`, {
|
||||
groupName: group.name,
|
||||
})}
|
||||
onRequestClose={() => handleAddModal(false)}
|
||||
isOpen={addModalOpen}
|
||||
>
|
||||
<AddPeopleToGroup
|
||||
group={group}
|
||||
onSubmit={() => handleAddModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GroupMembers);
|
||||
@@ -1,53 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import User from "~/models/User";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import GroupMemberMenu from "~/menus/GroupMemberMenu";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
onAdd?: () => Promise<void>;
|
||||
onRemove?: () => Promise<void>;
|
||||
};
|
||||
|
||||
const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
subtitle={
|
||||
<>
|
||||
{user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
}
|
||||
image={<Avatar model={user} size={32} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
|
||||
{onAdd && (
|
||||
<Button onClick={onAdd} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(GroupMemberListItem);
|
||||
@@ -1,3 +0,0 @@
|
||||
import GroupMembers from "./GroupMembers";
|
||||
|
||||
export default GroupMembers;
|
||||
@@ -1,90 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Modal from "~/components/Modal";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function GroupNew({ onSubmit }: Props) {
|
||||
const { groups } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState<string | undefined>();
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [group, setGroup] = React.useState<Group | undefined>();
|
||||
|
||||
const handleSubmit = async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
const group = new Group(
|
||||
{
|
||||
name,
|
||||
},
|
||||
groups
|
||||
);
|
||||
|
||||
try {
|
||||
await group.save();
|
||||
setGroup(group);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Groups are for organizing your team. They work best when centered
|
||||
around a function or a responsibility — Support or Engineering for
|
||||
example.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label="Name"
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>You’ll be able to add people to the group next.</Trans>
|
||||
</Text>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Creating")}…` : t("Continue")}
|
||||
</Button>
|
||||
</form>
|
||||
<Modal
|
||||
title={t("Group members")}
|
||||
onRequestClose={onSubmit}
|
||||
isOpen={!!group}
|
||||
>
|
||||
{group && <GroupMembers group={group} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GroupNew);
|
||||
+116
-37
@@ -1,31 +1,115 @@
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import deburr from "lodash/deburr";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon, GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import Group from "~/models/Group";
|
||||
import GroupNew from "~/scenes/GroupNew";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import GroupListItem from "~/components/GroupListItem";
|
||||
import Heading from "~/components/Heading";
|
||||
import Modal from "~/components/Modal";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMenu from "~/menus/GroupMenu";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { CreateGroupDialog } from "./components/GroupDialogs";
|
||||
import { GroupsTable } from "./components/GroupsTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
|
||||
function getFilteredGroups(groups: Group[], query?: string) {
|
||||
if (!query?.length) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
const normalizedQuery = deburr(query.toLocaleLowerCase());
|
||||
return groups.filter((group) =>
|
||||
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
}
|
||||
|
||||
function Groups() {
|
||||
const { t } = useTranslation();
|
||||
const { groups } = useStores();
|
||||
const { dialogs, groups } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
const [newGroupModalOpen, handleNewGroupModalOpen, handleNewGroupModalClose] =
|
||||
useBoolean();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const params = useQuery();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const reqParams = React.useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
sort: params.get("sort") || "name",
|
||||
direction: (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC",
|
||||
}),
|
||||
[params]
|
||||
);
|
||||
|
||||
const sort: ColumnSort = React.useMemo(
|
||||
() => ({
|
||||
id: reqParams.sort,
|
||||
desc: reqParams.direction === "DESC",
|
||||
}),
|
||||
[reqParams.sort, reqParams.direction]
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: getFilteredGroups(groups.orderedData, reqParams.query),
|
||||
sort,
|
||||
reqFn: groups.fetchPage,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
const isEmpty = !loading && !groups.orderedData.length;
|
||||
|
||||
const updateQuery = React.useCallback(
|
||||
(value: string) => {
|
||||
if (value) {
|
||||
params.set("query", value);
|
||||
} else {
|
||||
params.delete("query");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSearch = React.useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
}, []);
|
||||
|
||||
const handleNewGroup = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Create a group"),
|
||||
content: <CreateGroupDialog />,
|
||||
});
|
||||
}, [t, dialogs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load groups"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => updateQuery(query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateQuery]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -37,7 +121,7 @@ function Groups() {
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNewGroupModalOpen}
|
||||
onClick={handleNewGroup}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{`${t("New group")}…`}
|
||||
@@ -46,6 +130,7 @@ function Groups() {
|
||||
)}
|
||||
</>
|
||||
}
|
||||
wide
|
||||
>
|
||||
<Heading>{t("Groups")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
@@ -53,34 +138,28 @@ function Groups() {
|
||||
Groups can be used to organize and manage the people on your team.
|
||||
</Trans>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
items={groups.orderedData}
|
||||
empty={<Empty>{t("No groups have been created yet")}</Empty>}
|
||||
fetch={groups.fetchPage}
|
||||
heading={
|
||||
<h2>
|
||||
<Trans>All</Trans>
|
||||
</h2>
|
||||
}
|
||||
renderItem={(item: Group) => (
|
||||
<GroupListItem
|
||||
key={item.id}
|
||||
group={item}
|
||||
renderActions={({ openMembersModal }) => (
|
||||
<GroupMenu group={item} onMembers={openMembersModal} />
|
||||
)}
|
||||
showFacepile
|
||||
{isEmpty ? (
|
||||
<Empty>{t("No groups have been created yet")}</Empty>
|
||||
) : (
|
||||
<>
|
||||
<StickyFilters>
|
||||
<InputSearch
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<GroupsTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t("Create a group")}
|
||||
onRequestClose={handleNewGroupModalClose}
|
||||
isOpen={newGroupModalOpen}
|
||||
>
|
||||
<GroupNew onSubmit={handleNewGroupModalClose} />
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
+119
-122
@@ -1,15 +1,15 @@
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon, UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
|
||||
import User from "~/models/User";
|
||||
import UsersStore, { queriedUsers } from "~/stores/UsersStore";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Fade from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
@@ -21,11 +21,14 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import PeopleTable from "./components/PeopleTable";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { PeopleTable } from "./components/PeopleTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import UserRoleFilter from "./components/UserRoleFilter";
|
||||
import UserStatusFilter from "./components/UserStatusFilter";
|
||||
|
||||
function Members() {
|
||||
const appName = env.APP_NAME;
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const team = useCurrentTeam();
|
||||
@@ -33,83 +36,48 @@ function Members() {
|
||||
const { users } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const params = useQuery();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [data, setData] = React.useState<User[]>([]);
|
||||
const [totalPages, setTotalPages] = React.useState(0);
|
||||
const [userIds, setUserIds] = React.useState<string[]>([]);
|
||||
const can = usePolicy(team);
|
||||
const query = params.get("query") || undefined;
|
||||
const filter = params.get("filter") || undefined;
|
||||
const role = params.get("role") || undefined;
|
||||
const sort = params.get("sort") || "name";
|
||||
const direction = (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC";
|
||||
const page = parseInt(params.get("page") || "0", 10);
|
||||
const limit = 25;
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
const reqParams = React.useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
filter: params.get("filter") || "active",
|
||||
role: params.get("role") || undefined,
|
||||
sort: params.get("sort") || "name",
|
||||
direction: (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC",
|
||||
}),
|
||||
[params]
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await users.fetchPage({
|
||||
offset: page * limit,
|
||||
limit,
|
||||
sort,
|
||||
direction,
|
||||
query,
|
||||
filter,
|
||||
role,
|
||||
});
|
||||
if (response[PAGINATION_SYMBOL]) {
|
||||
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
|
||||
}
|
||||
setUserIds(response.map((u: User) => u.id));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
const sort: ColumnSort = React.useMemo(
|
||||
() => ({
|
||||
id: reqParams.sort,
|
||||
desc: reqParams.direction === "DESC",
|
||||
}),
|
||||
[reqParams.sort, reqParams.direction]
|
||||
);
|
||||
|
||||
void fetchData();
|
||||
}, [query, sort, filter, role, page, direction, users]);
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: getFilteredUsers({
|
||||
users,
|
||||
query: reqParams.query,
|
||||
filter: reqParams.filter,
|
||||
role: reqParams.role,
|
||||
}),
|
||||
sort,
|
||||
reqFn: users.fetchPage,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let filtered = users.orderedData;
|
||||
|
||||
if (!filter) {
|
||||
filtered = users.active.filter((u) => userIds.includes(u.id));
|
||||
} else if (filter === "all") {
|
||||
filtered = users.orderedData.filter((u) => userIds.includes(u.id));
|
||||
} else if (filter === "suspended") {
|
||||
filtered = users.suspended.filter((u) => userIds.includes(u.id));
|
||||
} else if (filter === "invited") {
|
||||
filtered = users.invited.filter((u) => userIds.includes(u.id));
|
||||
}
|
||||
|
||||
if (role) {
|
||||
filtered = filtered.filter((u) => u.role === role);
|
||||
}
|
||||
|
||||
// sort the resulting data by the original order from the server
|
||||
setData(sortBy(filtered, (item) => userIds.indexOf(item.id)));
|
||||
}, [
|
||||
filter,
|
||||
role,
|
||||
users.active,
|
||||
users.orderedData,
|
||||
users.suspended,
|
||||
users.invited,
|
||||
userIds,
|
||||
]);
|
||||
|
||||
const handleStatusFilter = React.useCallback(
|
||||
(f) => {
|
||||
if (f) {
|
||||
params.set("filter", f);
|
||||
params.delete("page");
|
||||
const updateParams = React.useCallback(
|
||||
(name: string, value: string) => {
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete("filter");
|
||||
params.delete(name);
|
||||
}
|
||||
|
||||
history.replace({
|
||||
@@ -120,43 +88,31 @@ function Members() {
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleStatusFilter = React.useCallback(
|
||||
(status) => updateParams("filter", status),
|
||||
[updateParams]
|
||||
);
|
||||
|
||||
const handleRoleFilter = React.useCallback(
|
||||
(r) => {
|
||||
if (r) {
|
||||
params.set("role", r);
|
||||
params.delete("page");
|
||||
} else {
|
||||
params.delete("role");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
(role) => updateParams("role", role),
|
||||
[updateParams]
|
||||
);
|
||||
|
||||
const handleSearch = React.useCallback(
|
||||
(event) => {
|
||||
const { value } = event.target;
|
||||
const handleSearch = React.useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
}, []);
|
||||
|
||||
if (value) {
|
||||
params.set("query", event.target.value);
|
||||
params.delete("page");
|
||||
} else {
|
||||
params.delete("query");
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load members"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const appName = env.APP_NAME;
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => updateParams("query", query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateParams]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -191,35 +147,76 @@ function Members() {
|
||||
{{ signinMethods: team.signinMethods }} but haven’t signed in yet.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex gap={8}>
|
||||
<StickyFilters gap={8}>
|
||||
<InputSearch
|
||||
short
|
||||
value={query ?? ""}
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<LargeUserStatusFilter
|
||||
activeKey={filter ?? ""}
|
||||
activeKey={reqParams.filter ?? ""}
|
||||
onSelect={handleStatusFilter}
|
||||
/>
|
||||
<LargeUserRoleFilter
|
||||
activeKey={role ?? ""}
|
||||
activeKey={reqParams.role ?? ""}
|
||||
onSelect={handleRoleFilter}
|
||||
/>
|
||||
</Flex>
|
||||
<PeopleTable
|
||||
data={data}
|
||||
canManage={can.update}
|
||||
isLoading={isLoading}
|
||||
page={page}
|
||||
pageSize={limit}
|
||||
totalPages={totalPages}
|
||||
defaultSortDirection="ASC"
|
||||
/>
|
||||
</StickyFilters>
|
||||
<Fade>
|
||||
<PeopleTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
canManage={can.update}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
function getFilteredUsers({
|
||||
users,
|
||||
query,
|
||||
filter,
|
||||
role,
|
||||
}: {
|
||||
users: UsersStore;
|
||||
query?: string;
|
||||
filter?: string;
|
||||
role?: string;
|
||||
}) {
|
||||
let filteredUsers;
|
||||
|
||||
switch (filter) {
|
||||
case "all":
|
||||
filteredUsers = users.orderedData;
|
||||
break;
|
||||
case "suspended":
|
||||
filteredUsers = users.suspended;
|
||||
break;
|
||||
case "invited":
|
||||
filteredUsers = users.invited;
|
||||
break;
|
||||
default:
|
||||
filteredUsers = users.active;
|
||||
}
|
||||
|
||||
if (role) {
|
||||
filteredUsers = filteredUsers.filter((user) => user.role === role);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
filteredUsers = queriedUsers(filteredUsers, query);
|
||||
}
|
||||
|
||||
return filteredUsers;
|
||||
}
|
||||
|
||||
const LargeUserStatusFilter = styled(UserStatusFilter)`
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
@@ -19,19 +19,22 @@ import { toast } from "sonner";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Notifications() {
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
const options = [
|
||||
{
|
||||
@@ -161,17 +164,7 @@ function Notifications() {
|
||||
<Trans>Manage when and where you receive email notifications.</Trans>
|
||||
</Text>
|
||||
|
||||
{env.EMAIL_ENABLED ? (
|
||||
<SettingRow
|
||||
label={t("Email address")}
|
||||
name="email"
|
||||
description={t(
|
||||
"Your email address should be updated in your SSO provider."
|
||||
)}
|
||||
>
|
||||
<Input type="email" value={user.email} readOnly />
|
||||
</SettingRow>
|
||||
) : (
|
||||
{env.EMAIL_ENABLED && can.manage && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The email integration is currently disabled. Please set the
|
||||
|
||||
@@ -8,14 +8,18 @@ import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { UserChangeEmailDialog } from "~/components/UserDialogs";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ImageInput from "./components/ImageInput";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
const Profile = () => {
|
||||
const user = useCurrentUser();
|
||||
const { dialogs } = useStores();
|
||||
const form = React.useRef<HTMLFormElement>(null);
|
||||
const [name, setName] = React.useState<string>(user.name || "");
|
||||
const [name, setName] = React.useState<string>(user.name);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (ev: React.SyntheticEvent) => {
|
||||
@@ -29,6 +33,15 @@ const Profile = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeEmail = () => {
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
};
|
||||
@@ -81,6 +94,17 @@ const Profile = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{env.EMAIL_ENABLED && (
|
||||
<SettingRow label={t("Email address")} name="email">
|
||||
<Input
|
||||
type="email"
|
||||
value={user.email}
|
||||
readOnly
|
||||
onClick={handleChangeEmail}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSaving || !isValid}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { GlobeIcon, WarningIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
|
||||
import Share from "~/models/Share";
|
||||
import { toast } from "sonner";
|
||||
import Fade from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import Notice from "~/components/Notice";
|
||||
@@ -15,7 +14,8 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SharesTable from "./components/SharesTable";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { SharesTable } from "./components/SharesTable";
|
||||
|
||||
function Shares() {
|
||||
const team = useCurrentTeam();
|
||||
@@ -23,51 +23,38 @@ function Shares() {
|
||||
const { shares, auth } = useStores();
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
const can = usePolicy(team);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [data, setData] = React.useState<Share[]>([]);
|
||||
const [totalPages, setTotalPages] = React.useState(0);
|
||||
const [shareIds, setShareIds] = React.useState<string[]>([]);
|
||||
const params = useQuery();
|
||||
const query = params.get("query") || "";
|
||||
const sort = params.get("sort") || "createdAt";
|
||||
const direction = (params.get("direction") || "desc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC";
|
||||
const page = parseInt(params.get("page") || "0", 10);
|
||||
const limit = 25;
|
||||
|
||||
const reqParams = React.useMemo(
|
||||
() => ({
|
||||
sort: params.get("sort") || "createdAt",
|
||||
direction: (params.get("direction") || "desc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC",
|
||||
}),
|
||||
[params]
|
||||
);
|
||||
|
||||
const sort: ColumnSort = React.useMemo(
|
||||
() => ({
|
||||
id: reqParams.sort,
|
||||
desc: reqParams.direction === "DESC",
|
||||
}),
|
||||
[reqParams.sort, reqParams.direction]
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: shares.orderedData,
|
||||
sort,
|
||||
reqFn: shares.fetchPage,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await shares.fetchPage({
|
||||
offset: page * limit,
|
||||
limit,
|
||||
sort,
|
||||
direction,
|
||||
});
|
||||
if (response[PAGINATION_SYMBOL]) {
|
||||
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
|
||||
}
|
||||
setShareIds(response.map((u: Share) => u.id));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchData();
|
||||
}, [query, sort, page, direction, shares]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// sort the resulting data by the original order from the server
|
||||
setData(
|
||||
sortBy(
|
||||
shares.orderedData.filter((item) => shareIds.includes(item.id)),
|
||||
(item) => shareIds.indexOf(item.id)
|
||||
)
|
||||
);
|
||||
}, [shares.orderedData, shareIds]);
|
||||
if (error) {
|
||||
toast.error(t("Could not load shares"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
return (
|
||||
<Scene title={t("Shared Links")} icon={<GlobeIcon />} wide>
|
||||
@@ -96,16 +83,17 @@ function Shares() {
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{data.length ? (
|
||||
{data?.length ? (
|
||||
<Fade>
|
||||
<SharesTable
|
||||
data={data}
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
canManage={can.update}
|
||||
isLoading={isLoading}
|
||||
page={page}
|
||||
pageSize={limit}
|
||||
totalPages={totalPages}
|
||||
defaultSortDirection="ASC"
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { ListItem } from "~/components/Sharing/components/ListItem";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMemberMenu from "~/menus/GroupMemberMenu";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function CreateGroupDialog() {
|
||||
const { dialogs, groups } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState<string | undefined>();
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
const group = new Group(
|
||||
{
|
||||
name,
|
||||
},
|
||||
groups
|
||||
);
|
||||
|
||||
try {
|
||||
await group.save();
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
fullscreen: true,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[t, dialogs, groups, name]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Groups are for organizing your team. They work best when centered
|
||||
around a function or a responsibility — Support or Engineering for
|
||||
example.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label="Name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>You’ll be able to add people to the group next.</Trans>
|
||||
</Text>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Creating")}…` : t("Continue")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState(group.name);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await group.save({
|
||||
name,
|
||||
});
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[group, onSubmit, name]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
You can edit the name of this group at any time, however doing so too
|
||||
often might confuse your team mates.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteGroupDialog({ group, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await group.delete();
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const ViewGroupMembersDialog = observer(function ({
|
||||
group,
|
||||
}: Pick<Props, "group">) {
|
||||
const { dialogs, users, groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleAddPeople = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t(`Add people to {{groupName}}`, {
|
||||
groupName: group.name,
|
||||
}),
|
||||
content: <AddPeopleToGroupDialog group={group} />,
|
||||
fullscreen: true,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleRemoveUser = React.useCallback(
|
||||
async (user: User) => {
|
||||
try {
|
||||
await groupUsers.delete({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
toast.success(
|
||||
t(`{{userName}} was removed from the group`, {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t("Could not remove user"));
|
||||
}
|
||||
},
|
||||
[t, groupUsers, group.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{can.update ? (
|
||||
<>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{can.update && (
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddPeople}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Add people")}…
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Listing members of the <em>{{groupName}}</em> group."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Subheading>
|
||||
<Trans>Members</Trans>
|
||||
</Subheading>
|
||||
<PaginatedList
|
||||
items={users.inGroup(group.id)}
|
||||
fetch={groupUsers.fetchPage}
|
||||
options={{
|
||||
id: group.id,
|
||||
}}
|
||||
empty={<Empty>{t("This group has no members.")}</Empty>}
|
||||
renderItem={(user: User) => (
|
||||
<GroupMemberListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
onRemove={can.update ? () => handleRemoveUser(user) : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
const AddPeopleToGroupDialog = observer(function ({
|
||||
group,
|
||||
}: Pick<Props, "group">) {
|
||||
const { dialogs, users, groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const debouncedFetch = React.useMemo(
|
||||
() => debounce((q) => users.fetchPage({ query: q }), 250),
|
||||
[users]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedQuery = ev.target.value;
|
||||
setQuery(updatedQuery);
|
||||
void debouncedFetch(updatedQuery);
|
||||
},
|
||||
[debouncedFetch]
|
||||
);
|
||||
|
||||
const handleAddUser = React.useCallback(
|
||||
async (user: User) => {
|
||||
try {
|
||||
await groupUsers.create({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t(`{{userName}} was added to the group`, {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t("Could not add user"));
|
||||
}
|
||||
},
|
||||
[t, groupUsers, group.id]
|
||||
);
|
||||
|
||||
const handleInvitePeople = React.useCallback(() => {
|
||||
const id = uuidv4();
|
||||
dialogs.openModal({
|
||||
id,
|
||||
title: t("Invite people"),
|
||||
content: <Invite onSubmit={() => dialogs.closeModal(id)} />,
|
||||
});
|
||||
}, [t, dialogs]);
|
||||
|
||||
const { loading } = useRequest(
|
||||
React.useCallback(
|
||||
() => groupUsers.fetchAll({ id: group.id }),
|
||||
[groupUsers, group]
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Add members below to give them access to the group. Need to add someone who’s not yet a member?"
|
||||
)}{" "}
|
||||
{can.inviteUser ? (
|
||||
<ButtonLink onClick={handleInvitePeople}>
|
||||
{t("Invite them to {{teamName}}", {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</ButtonLink>
|
||||
) : (
|
||||
t("Ask an admin to invite them first")
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search people")}
|
||||
labelHidden
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
{loading ? (
|
||||
<DelayedMount>
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
) : (
|
||||
<PaginatedList
|
||||
empty={
|
||||
query ? (
|
||||
<Empty>{t("No people matching your search")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No people left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={users.notInGroup(group.id, query)}
|
||||
fetch={query ? undefined : users.fetchPage}
|
||||
renderItem={(item: User) => (
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onAdd={() => handleAddUser(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
type GroupMemberListItemProps = {
|
||||
user: User;
|
||||
onAdd?: () => Promise<void>;
|
||||
onRemove?: () => Promise<void>;
|
||||
};
|
||||
|
||||
const GroupMemberListItem = observer(function ({
|
||||
user,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}: GroupMemberListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
subtitle={
|
||||
<>
|
||||
{user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
}
|
||||
image={<Avatar model={user} size={32} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
|
||||
{onAdd && (
|
||||
<Button onClick={onAdd} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import compact from "lodash/compact";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import Group from "~/models/Group";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import {
|
||||
type Props as TableProps,
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMenu from "~/menus/GroupMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { ViewGroupMembersDialog } from "./GroupDialogs";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<Group>, "columns" | "rowHeight">;
|
||||
|
||||
export function GroupsTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
const handleViewMembers = React.useCallback(
|
||||
(group: Group) => {
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
fullscreen: true,
|
||||
});
|
||||
},
|
||||
[t, dialogs]
|
||||
);
|
||||
|
||||
const columns = React.useMemo<TableColumn<Group>[]>(
|
||||
() =>
|
||||
compact<TableColumn<Group>>([
|
||||
{
|
||||
type: "data",
|
||||
id: "name",
|
||||
header: t("Name"),
|
||||
accessor: (group) => group.name,
|
||||
component: (group) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
<Flex column>
|
||||
<Title onClick={() => handleViewMembers(group)}>
|
||||
{group.name}
|
||||
</Title>
|
||||
<Text type="tertiary" size="small">
|
||||
<Trans
|
||||
defaults="{{ count }} member"
|
||||
values={{ count: group.memberCount }}
|
||||
/>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
),
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "members",
|
||||
header: t("Members"),
|
||||
accessor: (group) => `${group.memberCount} members`,
|
||||
component: (group) => {
|
||||
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
|
||||
const overflow = group.memberCount - users.length;
|
||||
|
||||
return (
|
||||
<Flex>
|
||||
<Facepile users={users} overflow={overflow} />
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
width: "1fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
header: t("Date created"),
|
||||
accessor: (group) => group.createdAt,
|
||||
component: (group) =>
|
||||
group.createdAt ? (
|
||||
<Time dateTime={group.createdAt} addSuffix />
|
||||
) : null,
|
||||
width: "1fr",
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (group) => <GroupMenu group={group} />,
|
||||
width: "50px",
|
||||
},
|
||||
]),
|
||||
[t, handleViewMembers]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Image = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${s("backgroundSecondary")};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&: ${hover} {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
`;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import compact from "lodash/compact";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -6,94 +6,111 @@ import User from "~/models/User";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import Flex from "~/components/Flex";
|
||||
import TableFromParams from "~/components/TableFromParams";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import {
|
||||
type Props as TableProps,
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import UserMenu from "~/menus/UserMenu";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof TableFromParams>, "columns"> & {
|
||||
data: User[];
|
||||
const ROW_HEIGHT = 60;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
function PeopleTable({ canManage, ...rest }: Props) {
|
||||
export function PeopleTable({ canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const columns = React.useMemo(
|
||||
|
||||
const columns = React.useMemo<TableColumn<User>[]>(
|
||||
() =>
|
||||
[
|
||||
compact<TableColumn<User>>([
|
||||
{
|
||||
type: "data",
|
||||
id: "name",
|
||||
Header: t("Name"),
|
||||
accessor: "name",
|
||||
Cell: observer(
|
||||
({ value, row }: { value: string; row: { original: User } }) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar model={row.original} size={32} /> {value}{" "}
|
||||
{currentUser.id === row.original.id && `(${t("You")})`}
|
||||
</Flex>
|
||||
)
|
||||
header: t("Name"),
|
||||
accessor: (user) => user.name,
|
||||
component: (user) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar model={user} size={32} /> {user.name}{" "}
|
||||
{currentUser.id === user.id && `(${t("You")})`}
|
||||
</Flex>
|
||||
),
|
||||
width: "4fr",
|
||||
},
|
||||
canManage
|
||||
? {
|
||||
type: "data",
|
||||
id: "email",
|
||||
Header: t("Email"),
|
||||
accessor: "email",
|
||||
Cell: observer(({ value }: { value: string }) => <>{value}</>),
|
||||
header: t("Email"),
|
||||
accessor: (user) => user.email,
|
||||
component: (user) => <>{user.email}</>,
|
||||
width: "4fr",
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
type: "data",
|
||||
id: "lastActiveAt",
|
||||
Header: t("Last active"),
|
||||
accessor: "lastActiveAt",
|
||||
Cell: observer(({ value }: { value: string }) =>
|
||||
value ? <Time dateTime={value} addSuffix /> : null
|
||||
),
|
||||
header: t("Last active"),
|
||||
accessor: (user) => user.lastActiveAt,
|
||||
component: (user) =>
|
||||
user.lastActiveAt ? (
|
||||
<Time dateTime={user.lastActiveAt} addSuffix />
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "role",
|
||||
Header: t("Role"),
|
||||
accessor: "rank",
|
||||
Cell: observer(({ row }: { row: { original: User } }) => (
|
||||
<Badges>
|
||||
{!row.original.lastActiveAt && <Badge>{t("Invited")}</Badge>}
|
||||
{row.original.isAdmin ? (
|
||||
header: t("Role"),
|
||||
accessor: (user) => user.role,
|
||||
component: (user) => (
|
||||
<Badges wrap>
|
||||
{!user.lastActiveAt && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin ? (
|
||||
<Badge primary>{t("Admin")}</Badge>
|
||||
) : row.original.isViewer ? (
|
||||
) : user.isViewer ? (
|
||||
<Badge>{t("Viewer")}</Badge>
|
||||
) : row.original.isGuest ? (
|
||||
<Badge yellow>{t("Guest")}</Badge>
|
||||
) : user.isGuest ? (
|
||||
<Badge>{t("Guest")}</Badge>
|
||||
) : (
|
||||
<Badge>{t("Editor")}</Badge>
|
||||
)}
|
||||
{row.original.isSuspended && <Badge>{t("Suspended")}</Badge>}
|
||||
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
|
||||
</Badges>
|
||||
)),
|
||||
),
|
||||
width: "2fr",
|
||||
},
|
||||
canManage
|
||||
? {
|
||||
Header: " ",
|
||||
accessor: "id",
|
||||
className: "actions",
|
||||
disableSortBy: true,
|
||||
Cell: observer(
|
||||
({ row, value }: { value: string; row: { original: User } }) =>
|
||||
currentUser.id !== value ? (
|
||||
<UserMenu user={row.original} />
|
||||
) : null
|
||||
),
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (user) =>
|
||||
currentUser.id !== user.id ? <UserMenu user={user} /> : null,
|
||||
width: "50px",
|
||||
}
|
||||
: undefined,
|
||||
].filter((i) => i),
|
||||
[t, canManage, currentUser]
|
||||
]),
|
||||
[t, currentUser, canManage]
|
||||
);
|
||||
|
||||
return <TableFromParams columns={columns} {...rest} />;
|
||||
return (
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Badges = styled.div`
|
||||
const Badges = styled(Flex)`
|
||||
margin-left: -10px;
|
||||
row-gap: 4px;
|
||||
`;
|
||||
|
||||
export default observer(PeopleTable);
|
||||
|
||||
@@ -1,108 +1,132 @@
|
||||
import { observer } from "mobx-react";
|
||||
import compact from "lodash/compact";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
|
||||
import Share from "~/models/Share";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import TableFromParams from "~/components/TableFromParams";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import {
|
||||
type Props as TableProps,
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Time from "~/components/Time";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import ShareMenu from "~/menus/ShareMenu";
|
||||
import { formatNumber } from "~/utils/language";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof TableFromParams>, "columns"> & {
|
||||
data: Share[];
|
||||
const ROW_HEIGHT = 50;
|
||||
|
||||
type Props = Omit<TableProps<Share>, "columns" | "rowHeight"> & {
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
function SharesTable({ canManage, data, ...rest }: Props) {
|
||||
export function SharesTable({ data, canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const language = useUserLocale();
|
||||
const hasDomain = data.some((share) => share.domain);
|
||||
|
||||
const columns = React.useMemo(
|
||||
const columns = React.useMemo<TableColumn<Share>[]>(
|
||||
() =>
|
||||
[
|
||||
compact<TableColumn<Share>>([
|
||||
{
|
||||
id: "documentTitle",
|
||||
Header: t("Document"),
|
||||
accessor: "documentTitle",
|
||||
disableSortBy: true,
|
||||
Cell: observer(({ value }: { value: string }) => <>{value}</>),
|
||||
type: "data",
|
||||
id: "title",
|
||||
header: t("Document"),
|
||||
accessor: (share) => share.documentTitle,
|
||||
sortable: false,
|
||||
component: (share) => <>{share.documentTitle}</>,
|
||||
width: "4fr",
|
||||
},
|
||||
{
|
||||
id: "who",
|
||||
Header: t("Shared by"),
|
||||
accessor: "createdById",
|
||||
disableSortBy: true,
|
||||
Cell: observer(
|
||||
({ row }: { value: string; row: { original: Share } }) => (
|
||||
<Flex align="center" gap={4}>
|
||||
{row.original.createdBy && (
|
||||
<Avatar model={row.original.createdBy} />
|
||||
)}
|
||||
{row.original.createdBy.name}
|
||||
</Flex>
|
||||
)
|
||||
type: "data",
|
||||
id: "createdBy",
|
||||
header: t("Shared by"),
|
||||
accessor: (share) => share.createdBy,
|
||||
sortable: false,
|
||||
component: (share) => (
|
||||
<Flex align="center" gap={4}>
|
||||
{share.createdBy && (
|
||||
<>
|
||||
<Avatar model={share.createdBy} />
|
||||
{share.createdBy.name}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
Header: t("Date shared"),
|
||||
accessor: "createdAt",
|
||||
Cell: observer(({ value }: { value: string }) =>
|
||||
value ? <Time dateTime={value} addSuffix /> : null
|
||||
),
|
||||
header: t("Date shared"),
|
||||
accessor: (share) => share.createdAt,
|
||||
component: (share) =>
|
||||
share.createdAt ? (
|
||||
<Time dateTime={share.createdAt} addSuffix />
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "lastAccessedAt",
|
||||
Header: t("Last accessed"),
|
||||
accessor: "lastAccessedAt",
|
||||
Cell: observer(({ value }: { value: string }) =>
|
||||
value ? <Time dateTime={value} addSuffix /> : null
|
||||
),
|
||||
header: t("Last accessed"),
|
||||
accessor: (share) => share.lastAccessedAt,
|
||||
component: (share) =>
|
||||
share.lastAccessedAt ? (
|
||||
<Time dateTime={share.lastAccessedAt} addSuffix />
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
hasDomain
|
||||
? {
|
||||
type: "data",
|
||||
id: "domain",
|
||||
Header: t("Domain"),
|
||||
accessor: "domain",
|
||||
disableSortBy: true,
|
||||
header: t("Domain"),
|
||||
accessor: (share) => share.domain,
|
||||
sortable: false,
|
||||
component: (share) => <>{share.domain}</>,
|
||||
width: "1.5fr",
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
type: "data",
|
||||
id: "views",
|
||||
Header: t("Views"),
|
||||
accessor: "views",
|
||||
Cell: observer(({ value }: { value: number }) => (
|
||||
header: t("Views"),
|
||||
accessor: (share) => share.views,
|
||||
component: (share) => (
|
||||
<>
|
||||
{language
|
||||
? formatNumber(value, unicodeCLDRtoBCP47(language))
|
||||
: value}
|
||||
? formatNumber(share.views, unicodeCLDRtoBCP47(language))
|
||||
: share.views}
|
||||
</>
|
||||
)),
|
||||
),
|
||||
width: "150px",
|
||||
},
|
||||
canManage
|
||||
? {
|
||||
Header: " ",
|
||||
accessor: "id",
|
||||
className: "actions",
|
||||
disableSortBy: true,
|
||||
Cell: observer(
|
||||
({ row }: { value: string; row: { original: Share } }) => (
|
||||
<Flex align="center">
|
||||
<ShareMenu share={row.original} />
|
||||
</Flex>
|
||||
)
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (share) => (
|
||||
<Flex align="center">
|
||||
<ShareMenu share={share} />
|
||||
</Flex>
|
||||
),
|
||||
width: "50px",
|
||||
}
|
||||
: undefined,
|
||||
].filter((i) => i),
|
||||
[t, hasDomain, canManage]
|
||||
]),
|
||||
[t, language, hasDomain, canManage]
|
||||
);
|
||||
|
||||
return <TableFromParams columns={columns} data={data} {...rest} />;
|
||||
return (
|
||||
<SortableTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={HEADER_HEIGHT}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SharesTable;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
|
||||
export const FILTER_HEIGHT = 40;
|
||||
|
||||
export const StickyFilters = styled(Flex)`
|
||||
height: ${FILTER_HEIGHT}px;
|
||||
position: sticky;
|
||||
top: ${HEADER_HEIGHT}px;
|
||||
z-index: ${depths.header};
|
||||
background: ${s("background")};
|
||||
`;
|
||||
@@ -44,12 +44,14 @@ export default class DialogsStore {
|
||||
};
|
||||
|
||||
openModal = ({
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
fullscreen,
|
||||
replace,
|
||||
style,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
fullscreen?: boolean;
|
||||
content: React.ReactNode;
|
||||
@@ -58,13 +60,11 @@ export default class DialogsStore {
|
||||
}) => {
|
||||
setTimeout(
|
||||
action(() => {
|
||||
const id = uuidv4();
|
||||
|
||||
if (replace) {
|
||||
this.modalStack.clear();
|
||||
}
|
||||
|
||||
this.modalStack.set(id, {
|
||||
this.modalStack.set(id ?? uuidv4(), {
|
||||
title,
|
||||
content,
|
||||
fullscreen,
|
||||
|
||||
@@ -825,7 +825,10 @@ export default class DocumentsStore extends Store<Document> {
|
||||
};
|
||||
|
||||
getByUrl = (url = ""): Document | undefined =>
|
||||
find(this.orderedData, (doc) => url.endsWith(doc.urlId));
|
||||
find(
|
||||
this.orderedData,
|
||||
(doc) => url.endsWith(doc.urlId) || url.endsWith(doc.id)
|
||||
);
|
||||
|
||||
getCollectionForDocument(document: Document) {
|
||||
return document.collectionId
|
||||
|
||||
@@ -208,7 +208,7 @@ export default class UsersStore extends Store<User> {
|
||||
};
|
||||
}
|
||||
|
||||
function queriedUsers(users: User[], query?: string) {
|
||||
export function queriedUsers(users: User[], query?: string) {
|
||||
const normalizedQuery = deburr((query || "").toLocaleLowerCase());
|
||||
|
||||
return normalizedQuery
|
||||
|
||||
+35
-17
@@ -54,6 +54,8 @@ export default abstract class Store<T extends Model> {
|
||||
@observable
|
||||
isLoaded = false;
|
||||
|
||||
requests: Map<string, Promise<any>> = new Map();
|
||||
|
||||
model: typeof Model;
|
||||
|
||||
modelName: string;
|
||||
@@ -302,27 +304,43 @@ export default abstract class Store<T extends Model> {
|
||||
if (item && !options.force) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (this.requests.has(id)) {
|
||||
return this.requests.get(id);
|
||||
}
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/${this.apiEndpoint}.info`, {
|
||||
id,
|
||||
});
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
client
|
||||
.post(`/${this.apiEndpoint}.info`, {
|
||||
id,
|
||||
})
|
||||
.then((res) =>
|
||||
runInAction(`info#${this.modelName}`, () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
this.addPolicies(res.policies);
|
||||
resolve(this.add(accessor(res)));
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
this.remove(id);
|
||||
}
|
||||
|
||||
return runInAction(`info#${this.modelName}`, () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
this.addPolicies(res.policies);
|
||||
return this.add(accessor(res));
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
|
||||
this.remove(id);
|
||||
}
|
||||
reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
this.requests.delete(id);
|
||||
this.isFetching = false;
|
||||
});
|
||||
});
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
this.requests.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
Vendored
-55
@@ -1,55 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import {
|
||||
UsePaginationInstanceProps,
|
||||
UsePaginationOptions,
|
||||
UsePaginationState,
|
||||
UseSortByColumnOptions,
|
||||
UseSortByColumnProps,
|
||||
UseSortByHooks,
|
||||
UseSortByInstanceProps,
|
||||
UseSortByOptions,
|
||||
UseSortByState,
|
||||
} from "react-table";
|
||||
|
||||
declare module "react-table" {
|
||||
export interface TableOptions<D extends object>
|
||||
extends UseExpandedOptions<D>,
|
||||
UsePaginationOptions<D>,
|
||||
UseSortByOptions<D>,
|
||||
// note that having Record here allows you to add anything to the options, this matches the spirit of the
|
||||
// underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
|
||||
// feature set, this is a safe default.
|
||||
Record<string, any> {}
|
||||
|
||||
export interface Hooks<D extends object = {}>
|
||||
extends UseExpandedHooks<D>,
|
||||
UseSortByHooks<D> {}
|
||||
|
||||
export interface TableInstance<D extends object = {}>
|
||||
extends UsePaginationInstanceProps<D>,
|
||||
UseSortByInstanceProps<D> {}
|
||||
|
||||
export interface TableState<D extends object = {}>
|
||||
extends UseColumnOrderState<D>,
|
||||
UseExpandedState<D>,
|
||||
UsePaginationState<D>,
|
||||
UseSortByState<D> {}
|
||||
|
||||
export interface ColumnInterface<D extends object = {}>
|
||||
extends UseResizeColumnsColumnOptions<D>,
|
||||
UseSortByColumnOptions<D> {}
|
||||
|
||||
export interface ColumnInstance<D extends object = {}>
|
||||
extends UseResizeColumnsColumnProps<D>,
|
||||
UseSortByColumnProps<D> {}
|
||||
|
||||
export interface Cell<D extends object = {}>
|
||||
extends UseGroupByCellProps<D>,
|
||||
UseRowStateCellProps<D> {}
|
||||
|
||||
export interface Row<D extends object = {}>
|
||||
extends UseExpandedRowProps<D>,
|
||||
UseGroupByRowProps<D>,
|
||||
UseRowSelectRowProps<D>,
|
||||
UseRowStateRowProps<D> {}
|
||||
}
|
||||
@@ -36,10 +36,16 @@ const fetchWithRetry = retry(fetch);
|
||||
class ApiClient {
|
||||
baseUrl: string;
|
||||
|
||||
shareId?: string;
|
||||
|
||||
constructor(options: Options = {}) {
|
||||
this.baseUrl = options.baseUrl || "/api";
|
||||
}
|
||||
|
||||
setShareId = (shareId: string | undefined) => {
|
||||
this.shareId = shareId;
|
||||
};
|
||||
|
||||
fetch = async <T = any>(
|
||||
path: string,
|
||||
method: string,
|
||||
@@ -51,6 +57,14 @@ class ApiClient {
|
||||
let urlToFetch;
|
||||
let isJson;
|
||||
|
||||
if (this.shareId) {
|
||||
// add to data
|
||||
data = {
|
||||
...(data || {}),
|
||||
shareId: this.shareId,
|
||||
};
|
||||
}
|
||||
|
||||
if (method === "GET") {
|
||||
if (data) {
|
||||
modifiedPath = `${path}?${data && queryString.stringify(data)}`;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* @param callback The callback to call inside the view transition.
|
||||
*/
|
||||
export const startViewTransition = (callback: UpdateCallback) => {
|
||||
export const startViewTransition = (callback: ViewTransitionUpdateCallback) => {
|
||||
if (self.document.startViewTransition) {
|
||||
self.document.startViewTransition(callback);
|
||||
} else {
|
||||
|
||||
+21
-21
@@ -59,7 +59,7 @@
|
||||
"@babel/plugin-transform-destructuring": "^7.24.8",
|
||||
"@babel/plugin-transform-regenerator": "^7.25.9",
|
||||
"@babel/preset-env": "^7.25.8",
|
||||
"@babel/preset-react": "^7.25.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^4.2.2",
|
||||
"@bull-board/koa": "^4.12.2",
|
||||
@@ -83,8 +83,10 @@
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.119.0",
|
||||
"@sentry/react": "^7.119.0",
|
||||
"@sentry/node": "^7.120.3",
|
||||
"@sentry/react": "^7.120.3",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@tanstack/react-virtual": "^3.10.9",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.0",
|
||||
"@types/mailparser": "^3.4.4",
|
||||
@@ -124,8 +126,8 @@
|
||||
"glob": "^8.1.0",
|
||||
"http-errors": "2.0.0",
|
||||
"i18next": "^22.5.1",
|
||||
"i18next-fs-backend": "^2.3.2",
|
||||
"i18next-http-backend": "^2.5.0",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"i18next-http-backend": "^2.7.1",
|
||||
"invariant": "^2.2.4",
|
||||
"ioredis": "^5.4.1",
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
@@ -150,14 +152,14 @@
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^2.0.0",
|
||||
"mermaid": "11.4.0",
|
||||
"mermaid": "11.4.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
"mobx-utils": "^4.0.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"nodemailer": "^6.9.16",
|
||||
"octokit": "^3.2.1",
|
||||
"outline-icons": "^3.10.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
@@ -179,12 +181,12 @@
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.1",
|
||||
"prosemirror-model": "^1.23.0",
|
||||
"prosemirror-model": "^1.24.0",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.36.0",
|
||||
"prosemirror-view": "^1.37.1",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.0",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
@@ -203,7 +205,6 @@
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.2.2",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-table": "^7.8.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.21",
|
||||
"react-waypoint": "^10.3.0",
|
||||
"react-window": "^1.8.10",
|
||||
@@ -226,7 +227,7 @@
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"socket.io-redis": "^6.1.1",
|
||||
"sonner": "^1.0.3",
|
||||
"sonner": "^1.7.1",
|
||||
"stoppable": "^1.1.0",
|
||||
"string-replace-to-array": "^2.1.1",
|
||||
"styled-components": "^5.3.11",
|
||||
@@ -237,7 +238,7 @@
|
||||
"tmp": "^0.2.3",
|
||||
"turndown": "^7.2.0",
|
||||
"umzug": "^3.8.1",
|
||||
"utility-types": "^3.10.0",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vite": "^5.4.11",
|
||||
@@ -252,7 +253,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.25.9",
|
||||
"@babel/cli": "^7.26.4",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.13",
|
||||
@@ -290,7 +291,7 @@
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-oauth2": "^1.4.17",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
@@ -303,13 +304,12 @@
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@types/react-portal": "^4.0.7",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-table": "^7.7.18",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/readable-stream": "^4.0.18",
|
||||
"@types/redis-info": "^3.0.3",
|
||||
"@types/refractor": "^3.4.1",
|
||||
"@types/resolve-path": "^1.4.2",
|
||||
"@types/resolve-path": "^1.4.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/sequelize": "^4.28.20",
|
||||
"@types/slug": "^5.0.7",
|
||||
@@ -333,14 +333,14 @@
|
||||
"discord-api-types": "^0.37.102",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.3",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"eslint-plugin-es": "^4.1.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^7.9.0",
|
||||
@@ -348,14 +348,14 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"lint-staged": "^13.3.0",
|
||||
"nodemon": "^3.1.7",
|
||||
"nodemon": "^3.1.9",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^0.4.1",
|
||||
"terser": "^5.36.0",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
buildAdmin,
|
||||
buildUser,
|
||||
buildWebhookSubscription,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("#webhookSubscriptions.list", () => {
|
||||
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||
const res = await server.post("/api/webhookSubscriptions.list", {
|
||||
body: {},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body.message).toEqual("Authentication required");
|
||||
});
|
||||
|
||||
it("should fail with status 403 forbidden for non-admin user", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.message).toEqual("Admin role required");
|
||||
});
|
||||
|
||||
it("should return the webhook subscriptions for the user's team", async () => {
|
||||
const user = await buildAdmin();
|
||||
const webhookSubscriptions = await Promise.all(
|
||||
Array(20)
|
||||
.fill(1)
|
||||
.map(() =>
|
||||
buildWebhookSubscription({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(webhookSubscriptions.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#webhookSubscriptions.create", () => {
|
||||
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||
const res = await server.post("/api/webhookSubscriptions.create", {
|
||||
body: {},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body.message).toEqual("Authentication required");
|
||||
});
|
||||
|
||||
it("should fail with status 403 forbidden for non-admin user", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.create", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.message).toEqual("Admin role required");
|
||||
});
|
||||
|
||||
it("should successfully create a webhook subscription", async () => {
|
||||
const user = await buildAdmin();
|
||||
const name = "Test webhook";
|
||||
const url = "https://www.example.com";
|
||||
const events = ["comments"];
|
||||
const secret = "Test secret";
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
secret,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
const webhook = body.data;
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(webhook.name).toEqual(name);
|
||||
expect(webhook.url).toEqual(url);
|
||||
expect(webhook.events).toEqual(events);
|
||||
expect(webhook.secret).toEqual(secret);
|
||||
expect(webhook.enabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#webhookSubscriptions.update", () => {
|
||||
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||
const res = await server.post("/api/webhookSubscriptions.update", {
|
||||
body: {},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body.message).toEqual("Authentication required");
|
||||
});
|
||||
|
||||
it("should fail with status 403 forbidden for non-admin user", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.update", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.message).toEqual("Admin role required");
|
||||
});
|
||||
|
||||
it("should successfully update a webhook subscription", async () => {
|
||||
const user = await buildAdmin();
|
||||
const name = "Updated webhook name";
|
||||
const url = "https://www.example.com/update";
|
||||
const events = ["comments"];
|
||||
|
||||
const existingWebhook = await buildWebhookSubscription({
|
||||
name: "Created webhook name",
|
||||
url: "https://www.example.com/create",
|
||||
events: ["*"],
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: existingWebhook.id,
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
const webhook = body.data;
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(webhook.name).toEqual(name);
|
||||
expect(webhook.url).toEqual(url);
|
||||
expect(webhook.events).toEqual(events);
|
||||
expect(webhook.enabled).toEqual(true);
|
||||
});
|
||||
|
||||
it("should activate a disabled webhook subscription when it's updated", async () => {
|
||||
const user = await buildAdmin();
|
||||
const name = "Updated webhook name";
|
||||
const url = "https://www.example.com/update";
|
||||
const events = ["comments"];
|
||||
|
||||
const disabledWebhook = await buildWebhookSubscription({
|
||||
name: "Created webhook name",
|
||||
url: "https://www.example.com/create",
|
||||
events: ["*"],
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: disabledWebhook.id,
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
const webhook = body.data;
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(webhook.name).toEqual(name);
|
||||
expect(webhook.url).toEqual(url);
|
||||
expect(webhook.events).toEqual(events);
|
||||
expect(webhook.enabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#webhookSubscriptions.delete", () => {
|
||||
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||
const res = await server.post("/api/webhookSubscriptions.delete", {
|
||||
body: {},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body.message).toEqual("Authentication required");
|
||||
});
|
||||
|
||||
it("should fail with status 403 forbidden for non-admin user", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.delete", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.message).toEqual("Admin role required");
|
||||
});
|
||||
|
||||
it("should successfully delete a webhook subscription", async () => {
|
||||
const user = await buildAdmin();
|
||||
const createdWebhook = await buildWebhookSubscription({
|
||||
name: "Test webhook",
|
||||
url: "https://www.example.com",
|
||||
events: ["*"],
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.delete", {
|
||||
body: { token: user.getJwtToken(), id: createdWebhook.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { UserRole } from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { WebhookSubscription, Event } from "@server/models";
|
||||
import { WebhookSubscription } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import pagination from "@server/routes/api/middlewares/pagination";
|
||||
import { APIContext } from "@server/types";
|
||||
@@ -20,7 +20,9 @@ router.post(
|
||||
pagination(),
|
||||
async (ctx: APIContext) => {
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
authorize(user, "listWebhookSubscription", user.team);
|
||||
|
||||
const webhooks = await WebhookSubscription.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
@@ -43,34 +45,19 @@ router.post(
|
||||
validate(T.WebhookSubscriptionsCreateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.WebhookSubscriptionsCreateReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { name, url, secret, events } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
authorize(user, "createWebhookSubscription", user.team);
|
||||
|
||||
const { name, url, secret } = ctx.input.body;
|
||||
const events: string[] = compact(ctx.input.body.events);
|
||||
|
||||
const webhookSubscription = await WebhookSubscription.create(
|
||||
{
|
||||
name,
|
||||
events,
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
url,
|
||||
enabled: true,
|
||||
secret: isEmpty(secret) ? undefined : secret,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "webhookSubscriptions.create",
|
||||
modelId: webhookSubscription.id,
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
},
|
||||
const webhookSubscription = await WebhookSubscription.createWithCtx(ctx, {
|
||||
name,
|
||||
url,
|
||||
events: compact(events),
|
||||
enabled: true,
|
||||
secret: isEmpty(secret) ? undefined : secret,
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
@@ -88,6 +75,7 @@ router.post(
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const webhookSubscription = await WebhookSubscription.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
@@ -96,17 +84,7 @@ router.post(
|
||||
|
||||
authorize(user, "delete", webhookSubscription);
|
||||
|
||||
await webhookSubscription.destroy({ transaction });
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "webhookSubscriptions.delete",
|
||||
modelId: webhookSubscription.id,
|
||||
data: {
|
||||
name: webhookSubscription.name,
|
||||
url: webhookSubscription.url,
|
||||
events: webhookSubscription.events,
|
||||
},
|
||||
});
|
||||
await webhookSubscription.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
@@ -120,10 +98,10 @@ router.post(
|
||||
validate(T.WebhookSubscriptionsUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.WebhookSubscriptionsUpdateReq>) => {
|
||||
const { id, name, url, secret } = ctx.input.body;
|
||||
const { id, name, url, secret, events } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
const events: string[] = compact(ctx.input.body.events);
|
||||
|
||||
const webhookSubscription = await WebhookSubscription.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
@@ -132,25 +110,12 @@ router.post(
|
||||
|
||||
authorize(user, "update", webhookSubscription);
|
||||
|
||||
await webhookSubscription.update(
|
||||
{
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
enabled: true,
|
||||
secret: isEmpty(secret) ? undefined : secret,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "webhookSubscriptions.update",
|
||||
modelId: webhookSubscription.id,
|
||||
data: {
|
||||
name: webhookSubscription.name,
|
||||
url: webhookSubscription.url,
|
||||
events: webhookSubscription.events,
|
||||
},
|
||||
await webhookSubscription.updateWithCtx(ctx, {
|
||||
name,
|
||||
url,
|
||||
events: compact(events),
|
||||
enabled: true,
|
||||
secret: isEmpty(secret) ? undefined : secret,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Event } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import commentCreator from "./commentCreator";
|
||||
|
||||
describe("commentCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create comment", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const comment = await commentCreator({
|
||||
documentId: document.id,
|
||||
data: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
content: [],
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
expect(comment.documentId).toEqual(document.id);
|
||||
expect(comment.createdById).toEqual(user.id);
|
||||
expect(event!.name).toEqual("comments.create");
|
||||
expect(event!.modelId).toEqual(comment.id);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { Comment, User, Event } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
/** The user creating the comment */
|
||||
user: User;
|
||||
/** The comment as data in Prosemirror schema format */
|
||||
data: ProsemirrorData;
|
||||
/** The document to comment within */
|
||||
documentId: string;
|
||||
/** The parent comment we're replying to, if any */
|
||||
parentCommentId?: string;
|
||||
/** The IP address of the user creating the comment */
|
||||
ip: string;
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command creates a comment inside a document.
|
||||
*
|
||||
* @param Props The properties of the comment to create
|
||||
* @returns Comment The comment that was created
|
||||
*/
|
||||
export default async function commentCreator({
|
||||
id,
|
||||
user,
|
||||
data,
|
||||
documentId,
|
||||
parentCommentId,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Comment> {
|
||||
// TODO: Parse data to validate
|
||||
|
||||
const comment = await Comment.create(
|
||||
{
|
||||
id,
|
||||
createdById: user.id,
|
||||
documentId,
|
||||
parentCommentId,
|
||||
data,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
comment.createdBy = user;
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "comments.create",
|
||||
modelId: comment.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user