mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b383cbdf8 | |||
| 114436a585 | |||
| a41a21726b | |||
| 65be484d99 | |||
| 1eae330775 | |||
| 3ac9a644ea | |||
| 0d30474af1 | |||
| b1be4a9f15 | |||
| 4555e3b957 | |||
| ac919db914 | |||
| c6e5e46544 | |||
| 9abb279a32 | |||
| bb5a471df8 |
@@ -88,7 +88,7 @@ jobs:
|
||||
- run:
|
||||
name: test
|
||||
command: |
|
||||
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
|
||||
TESTFILES=$(circleci tests glob "server/**/*.test.ts" | circleci tests split)
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
|
||||
@@ -20,11 +20,6 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
# Install wget to healthcheck the server
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
@@ -41,7 +36,5 @@ VOLUME /var/lib/outline/data
|
||||
|
||||
USER nodejs
|
||||
|
||||
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
@@ -6,6 +6,10 @@ WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./patches ./patches
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
@@ -19,3 +23,4 @@ RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 &
|
||||
yarn cache clean
|
||||
|
||||
ENV PORT=3000
|
||||
HEALTHCHECK CMD wget -qO- http://localhost:${PORT}/_health | grep -q "OK" || exit 1
|
||||
|
||||
@@ -3,8 +3,8 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.79.1
|
||||
The Licensed Work is (c) 2024 General Outline, Inc.
|
||||
Licensed Work: Outline 0.71.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
Service.
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2028-09-05
|
||||
Change Date: 2027-08-18
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
"description": "Open source wiki and knowledge base for growing teams",
|
||||
"website": "https://www.getoutline.com/",
|
||||
"repository": "https://github.com/outline/outline",
|
||||
"keywords": [
|
||||
"wiki",
|
||||
"team",
|
||||
"node",
|
||||
"markdown",
|
||||
"slack"
|
||||
],
|
||||
"keywords": ["wiki", "team", "node", "markdown", "slack"],
|
||||
"success_url": "/",
|
||||
"formation": {
|
||||
"web": {
|
||||
@@ -218,4 +212,4 @@
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
UnpublishIcon,
|
||||
PublishIcon,
|
||||
CommentIcon,
|
||||
GlobeIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -37,7 +37,6 @@ import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DuplicateDialog from "~/components/DuplicateDialog";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -105,9 +104,9 @@ export const createDocument = createAction({
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
||||
perform: ({ activeCollectionId, inStarredSection }) =>
|
||||
history.push(newDocumentPath(activeCollectionId), {
|
||||
sidebarContext,
|
||||
starred: inStarredSection,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -122,11 +121,11 @@ export const createDocumentFromTemplate = createAction({
|
||||
!!activeDocumentId &&
|
||||
!!stores.documents.get(activeDocumentId)?.template &&
|
||||
stores.policies.abilities(currentTeamId).createDocument,
|
||||
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
|
||||
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
|
||||
{
|
||||
sidebarContext,
|
||||
starred: inStarredSection,
|
||||
}
|
||||
),
|
||||
});
|
||||
@@ -142,9 +141,9 @@ export const createNestedDocument = createAction({
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
stores.policies.abilities(activeDocumentId).createChildDocument,
|
||||
perform: ({ activeDocumentId, sidebarContext }) =>
|
||||
perform: ({ activeDocumentId, inStarredSection }) =>
|
||||
history.push(newNestedDocumentPath(activeDocumentId), {
|
||||
sidebarContext,
|
||||
starred: inStarredSection,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -332,14 +331,10 @@ export const unsubscribeDocument = createAction({
|
||||
});
|
||||
|
||||
export const shareDocument = createAction({
|
||||
name: ({ t }) => `${t("Permissions")}…`,
|
||||
name: ({ t }) => t("Share"),
|
||||
analyticsName: "Share document",
|
||||
section: DocumentSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId!);
|
||||
return can.manageUsers || can.share;
|
||||
},
|
||||
icon: <GlobeIcon />,
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
if (!activeDocumentId || !currentUserId) {
|
||||
return;
|
||||
@@ -663,21 +658,15 @@ export const importDocument = createAction({
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
file,
|
||||
activeDocumentId,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
const document = await documents.import(
|
||||
file,
|
||||
activeDocumentId,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
};
|
||||
|
||||
input.click();
|
||||
@@ -852,7 +841,7 @@ export const moveTemplate = createAction({
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Archive document",
|
||||
section: DocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
@@ -863,30 +852,14 @@ export const archiveDocument = createAction({
|
||||
return !!stores.policies.abilities(activeDocumentId).archive;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
const { dialogs, documents } = stores;
|
||||
|
||||
if (activeDocumentId) {
|
||||
const document = documents.get(activeDocumentId);
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure you want to archive this document?"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await document.archive();
|
||||
toast.success(t("Document archived"));
|
||||
}}
|
||||
savingText={`${t("Archiving")}…`}
|
||||
>
|
||||
{t(
|
||||
"Archiving this document will remove it from the collection and search results."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
await document.archive();
|
||||
toast.success(t("Document archived"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,9 +216,7 @@ export const logout = createAction({
|
||||
perform: async () => {
|
||||
await stores.auth.logout();
|
||||
if (env.OIDC_LOGOUT_URI) {
|
||||
setTimeout(() => {
|
||||
window.location.replace(env.OIDC_LOGOUT_URI);
|
||||
}, 200);
|
||||
window.location.replace(env.OIDC_LOGOUT_URI);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -106,24 +106,6 @@ const Analytics: React.FC = ({ children }: Props) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Umami
|
||||
React.useEffect(() => {
|
||||
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
|
||||
if (integration.service !== IntegrationService.Umami) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.defer = true;
|
||||
script.src = `${integration.settings?.instanceUrl}${integration.settings?.scriptName}`;
|
||||
script.setAttribute(
|
||||
"data-website-id",
|
||||
integration.settings?.measurementId
|
||||
);
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ function ArrowKeyNavigation(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape" || ev.key === "Backspace") {
|
||||
if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import Group from "~/models/Group";
|
||||
import { AvatarSize } from "../Avatar/Avatar";
|
||||
|
||||
type Props = {
|
||||
/** The group to show an avatar for */
|
||||
group: Group;
|
||||
/** The size of the icon, 24px is default to match standard avatars */
|
||||
size?: number;
|
||||
/** The color of the avatar */
|
||||
color?: string;
|
||||
/** The background color of the avatar */
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function GroupAvatar({
|
||||
color,
|
||||
backgroundColor,
|
||||
size = AvatarSize.Medium,
|
||||
className,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Squircle color={color ?? theme.text} size={size} className={className}>
|
||||
<GroupIcon
|
||||
color={backgroundColor ?? theme.background}
|
||||
size={size * 0.75}
|
||||
/>
|
||||
</Squircle>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import Avatar, { IAvatar, AvatarSize } from "./Avatar";
|
||||
import Avatar from "./Avatar";
|
||||
import AvatarWithPresence from "./AvatarWithPresence";
|
||||
import { GroupAvatar } from "./GroupAvatar";
|
||||
|
||||
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence };
|
||||
export { AvatarWithPresence };
|
||||
|
||||
export type { IAvatar };
|
||||
export default Avatar;
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarWithPresence } from "~/components/Avatar";
|
||||
import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { DisconnectedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -11,6 +11,7 @@ import useStores from "~/hooks/useStores";
|
||||
|
||||
function ConnectionStatus() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const codeToMessage = {
|
||||
@@ -60,7 +61,7 @@ function ConnectionStatus() {
|
||||
>
|
||||
<Button>
|
||||
<Fade>
|
||||
<DisconnectedIcon />
|
||||
<DisconnectedIcon color={theme.sidebarText} />
|
||||
</Fade>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -71,7 +72,7 @@ const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
margin: 20px;
|
||||
margin: 24px;
|
||||
transform: translateX(-32px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -6,7 +6,6 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "../Text";
|
||||
import MenuIconWrapper from "./MenuIconWrapper";
|
||||
|
||||
@@ -75,9 +74,9 @@ const MenuItem = (
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<SelectedWrapper aria-hidden>
|
||||
<MenuIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
</SelectedWrapper>
|
||||
</MenuIconWrapper>
|
||||
)}
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
<Title>{children}</Title>
|
||||
@@ -197,13 +196,4 @@ export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
const SelectedWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
margin-left: -8px;
|
||||
flex-shrink: 0;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
||||
|
||||
@@ -51,8 +51,6 @@ type Props = MenuStateReturn & {
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
/** The maximum width of the context menu. */
|
||||
maxWidth?: number;
|
||||
/** The minimum height of the context menu. */
|
||||
minHeight?: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -137,7 +135,6 @@ type InnerContextMenuProps = MenuStateReturn & {
|
||||
menuProps: { style?: React.CSSProperties; placement: string };
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -223,7 +220,6 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
<Background
|
||||
dir="auto"
|
||||
maxWidth={props.maxWidth}
|
||||
minHeight={props.minHeight}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
@@ -261,23 +257,6 @@ export const Position = styled.div`
|
||||
transition-delay: 250ms;
|
||||
transition-property: outline-width;
|
||||
transition-duration: 0;
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
bottom: 1px;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
|
||||
outline-color: ${s("accent")};
|
||||
outline-width: initial;
|
||||
outline-offset: -1px;
|
||||
outline-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -298,7 +277,6 @@ type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
@@ -310,7 +288,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
min-width: 180px;
|
||||
min-height: ${(props) => props.minHeight || 44}px;
|
||||
min-height: 44px;
|
||||
max-height: 75vh;
|
||||
font-weight: normal;
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import {
|
||||
@@ -68,15 +67,14 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = usePolicy(collection);
|
||||
|
||||
React.useEffect(() => {
|
||||
void document.loadRelations({ withoutPolicies: true });
|
||||
void document.loadRelations();
|
||||
}, [document]);
|
||||
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
|
||||
if (collection && can.readDocument) {
|
||||
if (collection) {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
|
||||
@@ -16,7 +16,7 @@ import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -3,8 +3,9 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { AvatarSize } from "./Avatar/Avatar";
|
||||
|
||||
type Props = {
|
||||
users: User[];
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { FetchPageParams } from "~/stores/base/Store";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import Text from "~/components/Text";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import PaginatedList, { PaginatedItem } from "./PaginatedList";
|
||||
|
||||
interface TFilterOption extends PaginatedItem {
|
||||
type TFilterOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
options: TFilterOption[];
|
||||
@@ -26,9 +21,6 @@ type Props = {
|
||||
selectedPrefix?: string;
|
||||
className?: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
showFilter?: boolean;
|
||||
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
|
||||
fetchQueryOptions?: Record<string, string>;
|
||||
};
|
||||
|
||||
const FilterOptions = ({
|
||||
@@ -38,20 +30,13 @@ const FilterOptions = ({
|
||||
selectedPrefix = "",
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
fetchQuery,
|
||||
fetchQueryOptions,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const selectedItems = options.filter((option) =>
|
||||
selectedKeys.includes(option.key)
|
||||
);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const selectedLabel = selectedItems.length
|
||||
? selectedItems
|
||||
@@ -59,109 +44,6 @@ const FilterOptions = ({
|
||||
.join(", ")
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option: TFilterOption) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
),
|
||||
[menu, onSelect, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
};
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
const normalizedQuery = deburr(query.toLowerCase());
|
||||
|
||||
return query
|
||||
? options
|
||||
.filter((option) =>
|
||||
deburr(option.label).toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
// sort options starting with query first
|
||||
.sort((a, b) => {
|
||||
const aStartsWith = deburr(a.label)
|
||||
.toLowerCase()
|
||||
.startsWith(normalizedQuery);
|
||||
const bStartsWith = deburr(b.label)
|
||||
.toLowerCase()
|
||||
.startsWith(normalizedQuery);
|
||||
|
||||
if (aStartsWith && !bStartsWith) {
|
||||
return -1;
|
||||
}
|
||||
if (!aStartsWith && bStartsWith) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
: options;
|
||||
}, [options, query]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
if (ev.nativeEvent.isComposing || ev.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (ev.key) {
|
||||
case "Escape":
|
||||
menu.hide();
|
||||
break;
|
||||
case "Enter":
|
||||
if (filteredOptions.length === 1) {
|
||||
ev.preventDefault();
|
||||
onSelect(filteredOptions[0].key);
|
||||
menu.hide();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
ev.preventDefault();
|
||||
(listRef.current?.firstElementChild as HTMLElement)?.focus();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[filteredOptions, menu, onSelect]
|
||||
);
|
||||
|
||||
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
|
||||
searchInputRef.current?.focus();
|
||||
|
||||
if (ev.key === "Backspace") {
|
||||
setQuery((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (menu.visible) {
|
||||
searchInputRef.current?.focus();
|
||||
} else {
|
||||
setQuery("");
|
||||
}
|
||||
}, [menu.visible]);
|
||||
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MenuButton {...menu}>
|
||||
@@ -171,73 +53,33 @@ const FilterOptions = ({
|
||||
</StyledButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
|
||||
<PaginatedList
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<ContextMenu aria-label={defaultLabel} {...menu}>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Empty = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spacer />
|
||||
<Text size="small" type="tertiary" style={{ marginLeft: 6 }}>
|
||||
{t("No results")}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.div`
|
||||
height: 30px;
|
||||
`;
|
||||
|
||||
const SearchInput = styled(Input)`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
${Outline} {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
background: ${s("menuBackground")};
|
||||
}
|
||||
|
||||
${NativeInput} {
|
||||
font-size: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Note = styled(Text)`
|
||||
display: block;
|
||||
margin: 2px 0;
|
||||
|
||||
@@ -13,6 +13,7 @@ import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
@@ -25,11 +26,15 @@ type Props = {
|
||||
};
|
||||
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
|
||||
useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
|
||||
const membershipsInGroup = groupUsers.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
.slice(0, MAX_AVATAR_DISPLAY)
|
||||
.map((gm) => gm.user);
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Avatar from "../Avatar";
|
||||
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Avatar from "../Avatar";
|
||||
import { PullRequestIcon } from "../Icons/PullRequestIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { IconType } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -82,7 +83,7 @@ const SVGIcon = observer(
|
||||
}: Props) => {
|
||||
const { ui } = useStores();
|
||||
|
||||
let color = inputColor ?? colorPalette[0];
|
||||
let color = inputColor ?? randomElement(colorPalette);
|
||||
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
if (!forceColor) {
|
||||
|
||||
@@ -80,8 +80,8 @@ const BuiltinColors = ({
|
||||
{colorPalette.map((color) => (
|
||||
<ColorButton
|
||||
key={color}
|
||||
$color={color}
|
||||
$active={color === activeColor}
|
||||
color={color}
|
||||
active={color === activeColor}
|
||||
onClick={() => onClick(color)}
|
||||
>
|
||||
<Selected />
|
||||
@@ -156,22 +156,22 @@ const Selected = styled.span`
|
||||
transform: translateY(-25%) rotate(-45deg);
|
||||
`;
|
||||
|
||||
const ColorButton = styled(NudeButton)<{ $color: string; $active: boolean }>`
|
||||
const ColorButton = styled(NudeButton)<{ color: string; active: boolean }>`
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: ${({ $color }) => $color};
|
||||
background-color: ${({ color }) => color};
|
||||
|
||||
&: ${hover} {
|
||||
outline: 2px solid ${s("menuBackground")} !important;
|
||||
box-shadow: ${({ $color }) => `0px 0px 3px 3px ${$color}`};
|
||||
box-shadow: ${({ color }) => `0px 0px 3px 3px ${color}`};
|
||||
}
|
||||
|
||||
& ${Selected} {
|
||||
display: ${({ $active }) => ($active ? "block" : "none")};
|
||||
display: ${({ active }) => (active ? "block" : "none")};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -309,4 +309,4 @@ const StyledTabPanel = styled(TabPanel)`
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
export default React.memo(IconPicker);
|
||||
export default IconPicker;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
|
||||
import { CollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -40,11 +40,8 @@ function ResolvedCollectionIcon({
|
||||
: "currentColor"
|
||||
: collectionColor);
|
||||
|
||||
const Component = collection.isPrivate
|
||||
? PrivateCollectionIcon
|
||||
: CollectionIcon;
|
||||
return (
|
||||
<Component
|
||||
<CollectionIcon
|
||||
color={color}
|
||||
expanded={expanded}
|
||||
size={size}
|
||||
|
||||
@@ -41,6 +41,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
|
||||
<SkipNavLink />
|
||||
|
||||
@@ -9,7 +9,8 @@ import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover, truncateMultiline } from "~/styles";
|
||||
import { Avatar, AvatarSize } from "../Avatar";
|
||||
import Avatar from "../Avatar";
|
||||
import { AvatarSize } from "../Avatar/Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
|
||||
@@ -29,6 +29,7 @@ const PageTitle = ({ title, favicon }: Props) => {
|
||||
href={favicon ?? originalShortcutHref}
|
||||
key={favicon ?? originalShortcutHref}
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,9 +13,9 @@ import withStores from "~/components/withStores";
|
||||
import { dateToHeading } from "~/utils/date";
|
||||
|
||||
export interface PaginatedItem {
|
||||
id?: string;
|
||||
updatedAt?: string;
|
||||
id: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
type Props<T> = WithTranslation &
|
||||
@@ -36,7 +36,6 @@ type Props<T> = WithTranslation &
|
||||
}) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
listRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -197,7 +196,6 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
onEscape={onEscape}
|
||||
className={this.props.className}
|
||||
items={this.itemsToRender}
|
||||
ref={this.props.listRef}
|
||||
>
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
@@ -213,11 +211,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
"updatedAt" in item && item.updatedAt
|
||||
? item.updatedAt
|
||||
: "createdAt" in item && item.createdAt
|
||||
? item.createdAt
|
||||
: previousHeading;
|
||||
item.updatedAt || item.createdAt || previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
this.props.t,
|
||||
@@ -233,9 +227,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment
|
||||
key={"id" in item && item.id ? item.id : index}
|
||||
>
|
||||
<React.Fragment key={item.id}>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -5,9 +5,8 @@ import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { pulsate } from "~/styles/animations";
|
||||
|
||||
export type Props = React.ComponentProps<typeof Flex> & {
|
||||
export type Props = {
|
||||
header?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
@@ -18,22 +17,16 @@ function PlaceholderText({ minWidth, maxWidth, ...restProps }: Props) {
|
||||
// We only want to compute the width once so we are storing it inside ref
|
||||
const widthRef = React.useRef(randomInteger(minWidth || 75, maxWidth || 100));
|
||||
|
||||
return (
|
||||
<Mask
|
||||
width={`${widthRef.current / (restProps.header ? 2 : 1)}%`}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
return <Mask width={widthRef.current} {...restProps} />;
|
||||
}
|
||||
|
||||
const Mask = styled(Flex)<{
|
||||
width: number | string;
|
||||
width: number;
|
||||
height?: number;
|
||||
delay?: number;
|
||||
header?: boolean;
|
||||
}>`
|
||||
width: ${(props) =>
|
||||
typeof props.width === "number" ? `${props.width}px` : props.width};
|
||||
width: ${(props) => (props.header ? props.width / 2 : props.width)}%;
|
||||
height: ${(props) =>
|
||||
props.height ? props.height : props.header ? 24 : 18}px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { m, TargetAndTransition } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
|
||||
type Props = {
|
||||
@@ -19,37 +18,35 @@ type Props = {
|
||||
/**
|
||||
* Automatically animates the height of a container based on it's contents.
|
||||
*/
|
||||
export const ResizingHeightContainer = React.forwardRef<HTMLDivElement, Props>(
|
||||
function ResizingHeightContainer_(props, forwardedRef) {
|
||||
const {
|
||||
hideOverflow,
|
||||
children,
|
||||
config = {
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
export function ResizingHeightContainer(props: Props) {
|
||||
const {
|
||||
hideOverflow,
|
||||
children,
|
||||
config = {
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
style,
|
||||
} = props;
|
||||
},
|
||||
style,
|
||||
} = props;
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const { height } = useComponentSize(ref);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const { height } = useComponentSize(ref);
|
||||
|
||||
return (
|
||||
<m.div
|
||||
animate={{
|
||||
...config,
|
||||
height: Math.round(height),
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
overflow: hideOverflow ? "hidden" : "inherit",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div ref={mergeRefs([ref, forwardedRef])}>{children}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<m.div
|
||||
animate={{
|
||||
...config,
|
||||
height: Math.round(height),
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
overflow: hideOverflow ? "hidden" : "inherit",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Empty from "~/components/Empty";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Placeholder from "~/components/List/Placeholder";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import PaginatedList, { PaginatedItem } from "~/components/PaginatedList";
|
||||
import Popover from "~/components/Popover";
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
@@ -36,11 +36,11 @@ function SearchPopover({ shareId }: Props) {
|
||||
const { show, hide } = popover;
|
||||
|
||||
const [searchResults, setSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
PaginatedItem[] | undefined
|
||||
>();
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
PaginatedItem[] | undefined
|
||||
>(searchResults);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -54,7 +54,7 @@ function SearchPopover({ shareId }: Props) {
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query, ...options }) => {
|
||||
if (query?.length > 0) {
|
||||
const response = await documents.search(query, {
|
||||
const response: PaginatedItem[] = await documents.search(query, {
|
||||
shareId,
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { UserIcon } from "outline-icons";
|
||||
import { GroupIcon, UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
|
||||
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -16,7 +17,6 @@ import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import { Placeholder } from "../components/Placeholder";
|
||||
|
||||
type Props = {
|
||||
/** Collection to which team members are supposed to be invited */
|
||||
@@ -35,31 +35,21 @@ export const AccessControlList = observer(
|
||||
const theme = useTheme();
|
||||
const collectionId = collection.id;
|
||||
|
||||
const { request: fetchMemberships, loading: membershipLoading } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => memberships.fetchAll({ id: collectionId }),
|
||||
[memberships, collectionId]
|
||||
)
|
||||
);
|
||||
const { request: fetchMemberships, data: membershipData } = useRequest(
|
||||
React.useCallback(
|
||||
() => memberships.fetchAll({ id: collectionId }),
|
||||
[memberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
const { request: fetchGroupMemberships, loading: groupMembershipLoading } =
|
||||
const { request: fetchGroupMemberships, data: groupMembershipData } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => groupMemberships.fetchAll({ collectionId }),
|
||||
() => groupMemberships.fetchAll({ id: collectionId }),
|
||||
[groupMemberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
const groupMembershipsInCollection =
|
||||
groupMemberships.inCollection(collectionId);
|
||||
const membershipsInCollection = memberships.inCollection(collectionId);
|
||||
const hasMemberships =
|
||||
groupMembershipsInCollection.length > 0 ||
|
||||
membershipsInCollection.length > 0;
|
||||
const showLoading =
|
||||
!hasMemberships && (membershipLoading || groupMembershipLoading);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchMemberships();
|
||||
void fetchGroupMemberships();
|
||||
@@ -105,144 +95,132 @@ export const AccessControlList = observer(
|
||||
hiddenScrollbars
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{showLoading ? (
|
||||
<Placeholder count={2} />
|
||||
) : (
|
||||
<>
|
||||
{(!membershipData || !groupMembershipData) && <LoadingIndicator />}
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<UserIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("All members")}
|
||||
subtitle={t("Everyone in the workspace")}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputSelectPermission
|
||||
style={{ margin: 0 }}
|
||||
onChange={(
|
||||
value: CollectionPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
void collection.save({
|
||||
permission: value === EmptySelectValue ? null : value,
|
||||
});
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={collection?.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{groupMemberships
|
||||
.inCollection(collection.id)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
||||
).localeCompare(b.group.name)
|
||||
)
|
||||
.map((membership) => (
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<UserIcon color={theme.accentText} size={16} />
|
||||
<Squircle color={theme.text} size={AvatarSize.Medium}>
|
||||
<GroupIcon color={theme.background} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("All members")}
|
||||
subtitle={t("Everyone in the workspace")}
|
||||
title={membership.group.name}
|
||||
subtitle={t("{{ count }} member", {
|
||||
count: membership.group.memberCount,
|
||||
})}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputSelectPermission
|
||||
<InputMemberPermissionSelect
|
||||
style={{ margin: 0 }}
|
||||
onChange={(
|
||||
value: CollectionPermission | typeof EmptySelectValue
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission: CollectionPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
void collection.save({
|
||||
permission: value === EmptySelectValue ? null : value,
|
||||
});
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={collection?.permission}
|
||||
value={membership.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{groupMembershipsInCollection
|
||||
.filter((membership) => membership.group)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") +
|
||||
a.group.name
|
||||
).localeCompare(b.group.name)
|
||||
)
|
||||
.map((membership) => (
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<GroupAvatar
|
||||
group={membership.group}
|
||||
backgroundColor={theme.modalBackground}
|
||||
/>
|
||||
}
|
||||
title={membership.group.name}
|
||||
subtitle={t("{{ count }} member", {
|
||||
count: membership.group.memberCount,
|
||||
})}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
style={{ margin: 0 }}
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission:
|
||||
| CollectionPermission
|
||||
| typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
))}
|
||||
{memberships
|
||||
.inCollection(collection.id)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.user.id) ? "_" : "") + a.user.name
|
||||
).localeCompare(b.user.name)
|
||||
)
|
||||
.map((membership) => (
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<Avatar
|
||||
model={membership.user}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
))}
|
||||
{membershipsInCollection
|
||||
.filter((membership) => membership.user)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.user.id) ? "_" : "") +
|
||||
a.user.name
|
||||
).localeCompare(b.user.name)
|
||||
)
|
||||
.map((membership) => (
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<Avatar
|
||||
model={membership.user}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
title={membership.user.name}
|
||||
subtitle={membership.user.email}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
style={{ margin: 0 }}
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission:
|
||||
| CollectionPermission
|
||||
| typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await memberships.delete({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
});
|
||||
} else {
|
||||
await memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
title={membership.user.name}
|
||||
subtitle={membership.user.email}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
style={{ margin: 0 }}
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission: CollectionPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await memberships.delete({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
});
|
||||
} else {
|
||||
await memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ScrollableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { createAction } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
@@ -357,6 +357,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
addPendingId={handleAddPendingId}
|
||||
removePendingId={handleRemovePendingId}
|
||||
onEscape={handleEscape}
|
||||
showGroups
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
@@ -19,12 +20,12 @@ import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Avatar, AvatarSize } from "../../Avatar";
|
||||
import Avatar from "../../Avatar";
|
||||
import { AvatarSize } from "../../Avatar/Avatar";
|
||||
import CollectionIcon from "../../Icons/CollectionIcon";
|
||||
import Tooltip from "../../Tooltip";
|
||||
import { Separator } from "../components";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import { Placeholder } from "../components/Placeholder";
|
||||
import DocumentMemberList from "./DocumentMemberList";
|
||||
import PublicAccess from "./PublicAccess";
|
||||
|
||||
@@ -57,12 +58,10 @@ export const AccessControlList = observer(
|
||||
const collection = document.collection;
|
||||
const usersInCollection = useUsersInCollection(collection);
|
||||
const user = useCurrentUser();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const { userMemberships } = useStores();
|
||||
const collectionSharingDisabled = document.collection?.sharing === false;
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(document);
|
||||
const canCollection = usePolicy(collection);
|
||||
const documentId = document.id;
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { maxHeight, calcMaxHeight } = useMaxHeight({
|
||||
@@ -71,36 +70,21 @@ export const AccessControlList = observer(
|
||||
margin: 24,
|
||||
});
|
||||
|
||||
const { loading: userMembershipLoading, request: fetchUserMemberships } =
|
||||
const { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
userMemberships.fetchDocumentMemberships({
|
||||
id: documentId,
|
||||
id: document.id,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
[userMemberships, documentId]
|
||||
[userMemberships, document.id]
|
||||
)
|
||||
);
|
||||
|
||||
const { loading: groupMembershipLoading, request: fetchGroupMemberships } =
|
||||
useRequest(
|
||||
React.useCallback(
|
||||
() => groupMemberships.fetchAll({ documentId }),
|
||||
[groupMemberships, documentId]
|
||||
)
|
||||
);
|
||||
|
||||
const hasMemberships =
|
||||
groupMemberships.inDocument(documentId)?.length > 0 ||
|
||||
document.members.length > 0;
|
||||
const showLoading =
|
||||
!hasMemberships && (groupMembershipLoading || userMembershipLoading);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchUserMemberships();
|
||||
void fetchGroupMemberships();
|
||||
}, [fetchUserMemberships, fetchGroupMemberships]);
|
||||
void fetchDocumentMembers();
|
||||
}, [fetchDocumentMembers]);
|
||||
|
||||
React.useEffect(() => {
|
||||
calcMaxHeight();
|
||||
@@ -112,92 +96,84 @@ export const AccessControlList = observer(
|
||||
hiddenScrollbars
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{showLoading ? (
|
||||
<Placeholder />
|
||||
{loadingDocumentMembers && <LoadingIndicator />}
|
||||
{collection ? (
|
||||
<>
|
||||
{collection.permission ? (
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<UserIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("All members")}
|
||||
subtitle={t("Everyone in the workspace")}
|
||||
actions={
|
||||
<AccessTooltip>
|
||||
{collection?.permission === CollectionPermission.ReadWrite
|
||||
? t("Can edit")
|
||||
: t("Can view")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
) : usersInCollection ? (
|
||||
<ListItem
|
||||
image={<CollectionSquircle collection={collection} />}
|
||||
title={collection.name}
|
||||
subtitle={t("Everyone in the collection")}
|
||||
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
image={<Avatar model={user} showBorder={false} />}
|
||||
title={user.name}
|
||||
subtitle={t("You have full access")}
|
||||
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
|
||||
/>
|
||||
)}
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
</>
|
||||
) : document.isDraft ? (
|
||||
<>
|
||||
<ListItem
|
||||
image={<Avatar model={document.createdBy} showBorder={false} />}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
{t("Can edit")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{collection && canCollection.readDocument ? (
|
||||
<>
|
||||
{collection.permission ? (
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<UserIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("All members")}
|
||||
subtitle={t("Everyone in the workspace")}
|
||||
actions={
|
||||
<AccessTooltip>
|
||||
{collection?.permission ===
|
||||
CollectionPermission.ReadWrite
|
||||
? t("Can edit")
|
||||
: t("Can view")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
) : usersInCollection ? (
|
||||
<ListItem
|
||||
image={<CollectionSquircle collection={collection} />}
|
||||
title={collection.name}
|
||||
subtitle={t("Everyone in the collection")}
|
||||
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
image={<Avatar model={user} showBorder={false} />}
|
||||
title={user.name}
|
||||
subtitle={t("You have full access")}
|
||||
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
|
||||
/>
|
||||
)}
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<MoreIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("Other people")}
|
||||
subtitle={t("Other workspace members may have access")}
|
||||
actions={
|
||||
<AccessTooltip
|
||||
content={t(
|
||||
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : document.isDraft ? (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Avatar model={document.createdBy} showBorder={false} />
|
||||
}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
{t("Can edit")}
|
||||
</AccessTooltip>
|
||||
}
|
||||
/>
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DocumentMemberList
|
||||
document={document}
|
||||
invitedInSession={invitedInSession}
|
||||
/>
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={theme.accent} size={AvatarSize.Medium}>
|
||||
<MoreIcon color={theme.accentText} size={16} />
|
||||
</Squircle>
|
||||
}
|
||||
title={t("Other people")}
|
||||
subtitle={t("Other workspace members may have access")}
|
||||
actions={
|
||||
<AccessTooltip
|
||||
content={t(
|
||||
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{team.sharing && can.share && !collectionSharingDisabled && visible && (
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import { GroupAvatar } from "~/components/Avatar";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
import DocumentMemberListItem from "./DocumentMemberListItem";
|
||||
import MemberListItem from "./DocumentMemberListItem";
|
||||
|
||||
type Props = {
|
||||
/** Document to which team members are supposed to be invited */
|
||||
@@ -29,13 +22,12 @@ type Props = {
|
||||
};
|
||||
|
||||
function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const { userMemberships } = useStores();
|
||||
|
||||
const user = useCurrentUser();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(document);
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleRemoveUser = React.useCallback(
|
||||
async (item) => {
|
||||
@@ -58,7 +50,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
toast.error(t("Could not remove user"));
|
||||
}
|
||||
},
|
||||
[t, history, userMemberships, user, document]
|
||||
[history, userMemberships, user, document]
|
||||
);
|
||||
|
||||
const handleUpdateUser = React.useCallback(
|
||||
@@ -78,7 +70,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
toast.error(t("Could not update user"));
|
||||
}
|
||||
},
|
||||
[t, userMemberships, document]
|
||||
[userMemberships, document]
|
||||
);
|
||||
|
||||
// Order newly added users first during the current editing session, on reload members are
|
||||
@@ -95,101 +87,10 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
[document.members, invitedInSession]
|
||||
);
|
||||
|
||||
const permissions = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("View only"),
|
||||
value: DocumentPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("Can edit"),
|
||||
value: DocumentPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("Manage"),
|
||||
value: DocumentPermission.Admin,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
label: t("Remove"),
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
] as Permission[],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupMemberships
|
||||
.inDocument(document.id)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
||||
).localeCompare(b.group.name)
|
||||
)
|
||||
.map((membership) => {
|
||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||
return (
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<GroupAvatar
|
||||
group={membership.group}
|
||||
backgroundColor={theme.modalBackground}
|
||||
/>
|
||||
}
|
||||
title={membership.group.name}
|
||||
subtitle={
|
||||
membership.sourceId ? (
|
||||
<Trans>
|
||||
Has access through{" "}
|
||||
<MaybeLink
|
||||
// @ts-expect-error to prop does not exist on React.Fragment
|
||||
to={membership.source?.document?.path ?? ""}
|
||||
>
|
||||
parent
|
||||
</MaybeLink>
|
||||
</Trans>
|
||||
) : (
|
||||
t("{{ count }} member", {
|
||||
count: membership.group.memberCount,
|
||||
})
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
style={{ margin: 0 }}
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission: DocumentPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
documentId: document.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
documentId: document.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!can.manageUsers}
|
||||
value={membership.permission}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{members.map((item) => (
|
||||
<DocumentMemberListItem
|
||||
<MemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
membership={item.getMembership(document)}
|
||||
@@ -208,9 +109,4 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const StyledLink = styled(Link)`
|
||||
color: ${s("textTertiary")};
|
||||
text-decoration: underline;
|
||||
`;
|
||||
|
||||
export default observer(DocumentMembersList);
|
||||
|
||||
@@ -7,9 +7,9 @@ import { s } from "@shared/styles";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import Time from "~/components/Time";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
|
||||
@@ -68,6 +68,7 @@ const DocumentMemberListItem = ({
|
||||
if (!currentPermission) {
|
||||
return null;
|
||||
}
|
||||
const disabled = !onUpdate && !onLeave;
|
||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||
|
||||
return (
|
||||
@@ -89,35 +90,36 @@ const DocumentMemberListItem = ({
|
||||
</Trans>
|
||||
) : user.isSuspended ? (
|
||||
t("Suspended")
|
||||
) : user.email ? (
|
||||
user.email
|
||||
) : user.isInvited ? (
|
||||
t("Invited")
|
||||
) : user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : user.isViewer ? (
|
||||
t("Viewer")
|
||||
) : (
|
||||
t("Never signed in")
|
||||
t("Editor")
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
permissions={
|
||||
onLeave
|
||||
? [
|
||||
currentPermission,
|
||||
{
|
||||
label: `${t("Leave")}…`,
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
]
|
||||
: permissions
|
||||
}
|
||||
value={membership?.permission}
|
||||
onChange={handleChange}
|
||||
disabled={!onUpdate && !onLeave}
|
||||
/>
|
||||
</div>
|
||||
disabled ? null : (
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
permissions={
|
||||
onLeave
|
||||
? [
|
||||
currentPermission,
|
||||
{
|
||||
label: `${t("Leave")}…`,
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
]
|
||||
: permissions
|
||||
}
|
||||
value={membership?.permission}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import Switch from "~/components/Switch";
|
||||
import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AvatarSize } from "../../Avatar";
|
||||
import { AvatarSize } from "../../Avatar/Avatar";
|
||||
import CopyToClipboard from "../../CopyToClipboard";
|
||||
import NudeButton from "../../NudeButton";
|
||||
import { ResizingHeightContainer } from "../../ResizingHeightContainer";
|
||||
|
||||
@@ -7,10 +7,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Group from "~/models/Group";
|
||||
import Share from "~/models/Share";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { createAction } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
@@ -53,7 +53,7 @@ function SharePopover({
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(document);
|
||||
const [hasRendered, setHasRendered] = React.useState(visible);
|
||||
const { users, userMemberships, groups, groupMemberships } = useStores();
|
||||
const { users, userMemberships } = useStores();
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [picker, showPicker, hidePicker] = useBoolean();
|
||||
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
|
||||
@@ -129,9 +129,9 @@ function SharePopover({
|
||||
name: t("Invite"),
|
||||
section: UserSection,
|
||||
perform: async () => {
|
||||
const invited = await Promise.all(
|
||||
const usersInvited = await Promise.all(
|
||||
pendingIds.map(async (idOrEmail) => {
|
||||
let user, group;
|
||||
let user;
|
||||
|
||||
// convert email to user
|
||||
if (isEmail(idOrEmail)) {
|
||||
@@ -145,77 +145,38 @@ function SharePopover({
|
||||
user = response[0];
|
||||
} else {
|
||||
user = users.get(idOrEmail);
|
||||
group = groups.get(idOrEmail);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
await userMemberships.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission,
|
||||
});
|
||||
return user;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (group) {
|
||||
await groupMemberships.create({
|
||||
documentId: document.id,
|
||||
groupId: group.id,
|
||||
permission,
|
||||
});
|
||||
return group;
|
||||
}
|
||||
await userMemberships.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission,
|
||||
});
|
||||
|
||||
return;
|
||||
return user;
|
||||
})
|
||||
);
|
||||
|
||||
const invitedUsers = invited.filter(
|
||||
(item) => item instanceof User
|
||||
) as User[];
|
||||
const invitedGroups = invited.filter(
|
||||
(item) => item instanceof Group
|
||||
) as Group[];
|
||||
|
||||
if (invitedUsers.length > 0) {
|
||||
// Special case for the common action of adding a single user.
|
||||
if (invitedUsers.length === 1) {
|
||||
const user = invitedUsers[0];
|
||||
toast.message(
|
||||
t("{{ userName }} was added to the document", {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.message(
|
||||
t("{{ count }} people added to the document", {
|
||||
count: invitedUsers.length,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
if (invitedGroups.length > 0) {
|
||||
// Special case for the common action of adding a single group.
|
||||
if (invitedGroups.length === 1) {
|
||||
const group = invitedGroups[0];
|
||||
toast.message(
|
||||
t("{{ userName }} was added to the document", {
|
||||
userName: group.name,
|
||||
}),
|
||||
{
|
||||
icon: <GroupAvatar group={group} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.message(
|
||||
t("{{ count }} groups added to the document", {
|
||||
count: invitedGroups.length,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (usersInvited.length === 1) {
|
||||
const user = usersInvited[0] as User;
|
||||
toast.message(
|
||||
t("{{ userName }} was invited to the document", {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t("{{ count }} people invited to the document", {
|
||||
count: pendingIds.length,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setInvitedInSession((prev) => [...prev, ...pendingIds]);
|
||||
@@ -224,16 +185,14 @@ function SharePopover({
|
||||
},
|
||||
}),
|
||||
[
|
||||
document.id,
|
||||
groupMemberships,
|
||||
groups,
|
||||
t,
|
||||
pendingIds,
|
||||
hidePicker,
|
||||
userMemberships,
|
||||
pendingIds,
|
||||
document.id,
|
||||
permission,
|
||||
t,
|
||||
team.defaultUserRole,
|
||||
users,
|
||||
team.defaultUserRole,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import times from "lodash/times";
|
||||
import * as React from "react";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Fade from "~/components/Fade";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import { ListItem } from "../components/ListItem";
|
||||
|
||||
type Props = {
|
||||
count?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholder for a list item in the share popover.
|
||||
*/
|
||||
export function Placeholder({ count = 1 }: Props) {
|
||||
return (
|
||||
<Fade>
|
||||
{times(count, (index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
image={
|
||||
<PlaceholderText
|
||||
width={AvatarSize.Medium}
|
||||
height={AvatarSize.Medium}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<PlaceholderText
|
||||
maxWidth={50}
|
||||
minWidth={30}
|
||||
height={14}
|
||||
style={{ marginTop: 4, marginBottom: 4 }}
|
||||
/>
|
||||
}
|
||||
subtitle={
|
||||
<PlaceholderText
|
||||
maxWidth={75}
|
||||
minWidth={50}
|
||||
height={12}
|
||||
style={{ marginBottom: 4 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import concat from "lodash/concat";
|
||||
import { observer } from "mobx-react";
|
||||
import { CheckmarkIcon, CloseIcon } from "outline-icons";
|
||||
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s } from "@shared/styles";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -12,7 +13,8 @@ import Document from "~/models/Document";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import { Avatar, GroupAvatar, AvatarSize, IAvatar } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
|
||||
import Empty from "~/components/Empty";
|
||||
import Placeholder from "~/components/List/Placeholder";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
@@ -40,6 +42,8 @@ type Props = {
|
||||
addPendingId: (id: string) => void;
|
||||
/** Callback to remove a user from the pending list. */
|
||||
removePendingId: (id: string) => void;
|
||||
/** Show group suggestions. */
|
||||
showGroups?: boolean;
|
||||
/** Handles escape from suggestions list */
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
@@ -53,6 +57,7 @@ export const Suggestions = observer(
|
||||
pendingIds,
|
||||
addPendingId,
|
||||
removePendingId,
|
||||
showGroups,
|
||||
onEscape,
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
@@ -61,6 +66,7 @@ export const Suggestions = observer(
|
||||
const { users, groups } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const theme = useTheme();
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { maxHeight } = useMaxHeight({
|
||||
elementRef: containerRef,
|
||||
@@ -70,7 +76,10 @@ export const Suggestions = observer(
|
||||
const fetchUsersByQuery = useThrottledCallback(
|
||||
(query: string) => {
|
||||
void users.fetchPage({ query });
|
||||
void groups.fetchPage({ query });
|
||||
|
||||
if (showGroups) {
|
||||
void groups.fetchPage({ query });
|
||||
}
|
||||
},
|
||||
250,
|
||||
undefined,
|
||||
@@ -98,20 +107,17 @@ export const Suggestions = observer(
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.orderedData
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
).filter((u) => !u.isSuspended);
|
||||
|
||||
if (isEmail(query)) {
|
||||
filtered.push(getSuggestionForEmail(query));
|
||||
}
|
||||
|
||||
return [
|
||||
...(document
|
||||
? groups.notInDocument(document.id, query)
|
||||
: collection
|
||||
? groups.notInCollection(collection.id, query)
|
||||
: []),
|
||||
...filtered,
|
||||
];
|
||||
if (collection?.id) {
|
||||
return [...groups.notInCollection(collection.id, query), ...filtered];
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [
|
||||
getSuggestionForEmail,
|
||||
users,
|
||||
@@ -135,7 +141,7 @@ export const Suggestions = observer(
|
||||
: users.get(id) ?? groups.get(id)
|
||||
)
|
||||
.filter(Boolean) as User[],
|
||||
[users, groups, getSuggestionForEmail, pendingIds]
|
||||
[users, getSuggestionForEmail, pendingIds]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -149,7 +155,11 @@ export const Suggestions = observer(
|
||||
subtitle: t("{{ count }} member", {
|
||||
count: suggestion.memberCount,
|
||||
}),
|
||||
image: <GroupAvatar group={suggestion} />,
|
||||
image: (
|
||||
<Squircle color={theme.text} size={AvatarSize.Medium}>
|
||||
<GroupIcon color={theme.background} size={16} />
|
||||
</Squircle>
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -94,37 +94,37 @@ function AppSidebar() {
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Scrollable flex shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Section>
|
||||
<Starred />
|
||||
</Section>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useLocation } from "react-router-dom";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
@@ -14,6 +13,7 @@ import AccountMenu from "~/menus/AccountMenu";
|
||||
import { fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Avatar from "../Avatar";
|
||||
import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -21,8 +22,8 @@ import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { useStarredContext } from "./StarredContext";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -38,13 +39,16 @@ const CollectionLink: React.FC<Props> = ({
|
||||
onDisclosureClick,
|
||||
isDraggingAnyCollection,
|
||||
}: Props) => {
|
||||
const itemRef = React.useRef<
|
||||
NavigationNode & { depth: number; active: boolean; collectionId: string }
|
||||
>();
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const inStarredSection = useStarredContext();
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
@@ -82,6 +86,8 @@ const CollectionLink: React.FC<Props> = ({
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
itemRef.current = item;
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Move document"),
|
||||
content: (
|
||||
@@ -110,69 +116,78 @@ const CollectionLink: React.FC<Props> = ({
|
||||
}),
|
||||
});
|
||||
|
||||
const handleTitleEditing = React.useCallback((value: boolean) => {
|
||||
setIsEditing(value);
|
||||
}, []);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void collection.fetchDocuments();
|
||||
}, [collection]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeCollectionId: collection.id,
|
||||
sidebarContext,
|
||||
inStarredSection,
|
||||
});
|
||||
|
||||
return (
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={<CollectionIcon collection={collection} expanded={expanded} />}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
action={createDocument}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={() => editableTitleRef.current?.setIsEditing(true)}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
<>
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { starred: inStarredSection },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
!!match && location.state?.starred === inStarredSection
|
||||
}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={handleTitleEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
action={createDocument}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={() =>
|
||||
editableTitleRef.current?.setIsEditing(true)
|
||||
}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const Button = styled(NudeButton)<{ $root?: boolean }>`
|
||||
props.$root &&
|
||||
css`
|
||||
opacity: 0;
|
||||
left: -18px;
|
||||
left: -16px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
@@ -24,13 +27,9 @@ import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import {
|
||||
useDragDocument,
|
||||
useDropToReorderDocument,
|
||||
useDropToReparentDocument,
|
||||
} from "./useDragAndDrop";
|
||||
import { useSharedContext } from "./SharedContext";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { useStarredContext } from "./StarredContext";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
@@ -66,23 +65,26 @@ function InnerDocumentLink(
|
||||
const { fetchChildDocuments } = documents;
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
const sidebarContext = useSidebarContext();
|
||||
const inStarredSection = useStarredContext();
|
||||
const inSharedSection = useSharedContext();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
isActiveDocument &&
|
||||
(hasChildDocuments || sidebarContext !== "collections")
|
||||
) {
|
||||
if (isActiveDocument && (hasChildDocuments || inSharedSection)) {
|
||||
void fetchChildDocuments(node.id);
|
||||
}
|
||||
}, [
|
||||
fetchChildDocuments,
|
||||
node.id,
|
||||
hasChildDocuments,
|
||||
sidebarContext,
|
||||
inSharedSection,
|
||||
isActiveDocument,
|
||||
]);
|
||||
|
||||
const pathToNode = React.useMemo(
|
||||
() => collection?.pathToDocument(node.id).map((entry) => entry.id),
|
||||
[collection, node]
|
||||
);
|
||||
|
||||
const showChildren = React.useMemo(
|
||||
() =>
|
||||
!!(
|
||||
@@ -98,27 +100,27 @@ function InnerDocumentLink(
|
||||
[hasChildDocuments, activeDocument, isActiveDocument, node, collection]
|
||||
);
|
||||
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded();
|
||||
setExpanded(showChildren);
|
||||
}
|
||||
}, [setExpanded, showChildren]);
|
||||
}, [showChildren]);
|
||||
|
||||
// when the last child document is removed auto-close the local folder state
|
||||
React.useEffect(() => {
|
||||
if (expanded && !hasChildDocuments) {
|
||||
setCollapsed();
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [setCollapsed, expanded, hasChildDocuments]);
|
||||
}, [expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev) => {
|
||||
ev?.preventDefault();
|
||||
expanded ? setCollapsed() : setExpanded();
|
||||
setExpanded(!expanded);
|
||||
},
|
||||
[setCollapsed, setExpanded, expanded]
|
||||
[expanded]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
@@ -139,39 +141,139 @@ function InnerDocumentLink(
|
||||
);
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const isMoving = documents.movingDocumentId === node.id;
|
||||
const manualSort = collection?.sort.field === "index";
|
||||
const can = policies.abilities(node.id);
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
|
||||
const [{ isDragging }, drag, preview] = useDrag({
|
||||
type: "document",
|
||||
item: () => ({
|
||||
...node,
|
||||
depth,
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
active: isActiveDocument,
|
||||
collectionId: collection?.id || "",
|
||||
}),
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => can.move || can.archive || can.delete,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
preview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, [preview]);
|
||||
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
const resetHoverExpanding = React.useCallback(() => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Drop to re-parent
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
|
||||
useDropToReparentDocument(node, setExpanded, parentRef);
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] =
|
||||
useDropToReorderDocument(node, collection, (item) => {
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
setExpanded(true);
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
!isDraft &&
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem<DragObject>().id) &&
|
||||
item.id !== node.id,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
if (
|
||||
hasChildDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = undefined;
|
||||
|
||||
if (
|
||||
monitor.isOver({
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: monitor.isOver({
|
||||
shallow: true,
|
||||
}),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort) {
|
||||
toast.message(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
if (item.id === node.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
return {
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: node.id,
|
||||
index: 0,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
return {
|
||||
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: parentId,
|
||||
index: index + 1,
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: monitor.isOver(),
|
||||
isDraggingAnyDocument: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
const nodeChildren = React.useMemo(() => {
|
||||
const insertDraftDocument =
|
||||
@@ -207,18 +309,18 @@ function InnerDocumentLink(
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowRight" && !expanded) {
|
||||
setExpanded();
|
||||
setExpanded(true);
|
||||
}
|
||||
if (ev.key === "ArrowLeft" && expanded) {
|
||||
setCollapsed();
|
||||
setExpanded(false);
|
||||
}
|
||||
},
|
||||
[setExpanded, setCollapsed, hasChildren, expanded]
|
||||
[hasChildren, expanded]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative ref={parentRef}>
|
||||
<Relative onDragLeave={resetHoverExpanding}>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
ref={drag}
|
||||
@@ -236,7 +338,7 @@ function InnerDocumentLink(
|
||||
pathname: node.url,
|
||||
state: {
|
||||
title: node.title,
|
||||
sidebarContext,
|
||||
starred: inStarredSection,
|
||||
},
|
||||
}}
|
||||
icon={icon && <Icon value={icon} color={color} />}
|
||||
@@ -250,25 +352,16 @@ function InnerDocumentLink(
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{
|
||||
sidebarContext?: SidebarContextType;
|
||||
}>
|
||||
) => {
|
||||
if (sidebarContext !== location.state?.sidebarContext) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(document && location.pathname.endsWith(document.urlId)) ||
|
||||
!!match
|
||||
);
|
||||
}}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
((document && location.pathname.endsWith(document.urlId)) ||
|
||||
!!match) &&
|
||||
location.state?.starred === inStarredSection
|
||||
}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
scrollIntoViewIfNeeded={sidebarContext === "collections"}
|
||||
scrollIntoViewIfNeeded={!inStarredSection}
|
||||
isDraft={isDraft}
|
||||
ref={ref}
|
||||
menu={
|
||||
@@ -304,7 +397,7 @@ function InnerDocumentLink(
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
{isDraggingAnyDocument && collection?.isManualSort && (
|
||||
{isDraggingAnyDocument && manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</Relative>
|
||||
|
||||
@@ -3,17 +3,17 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -23,19 +23,25 @@ type Props = {
|
||||
belowCollection: Collection | void;
|
||||
};
|
||||
|
||||
function useLocationStateStarred() {
|
||||
const location = useLocation<{
|
||||
starred?: boolean;
|
||||
}>();
|
||||
return location.state?.starred;
|
||||
}
|
||||
|
||||
function DraggableCollectionLink({
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
belowCollection,
|
||||
}: Props) {
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const { ui, policies, collections } = useStores();
|
||||
const locationStateStarred = useLocationStateStarred();
|
||||
const { ui, collections } = useStores();
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
collection.id === ui.activeCollectionId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
collection.id === ui.activeCollectionId && !locationStateStarred
|
||||
);
|
||||
const can = usePolicy(collection);
|
||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||
|
||||
// Drop to reorder collection
|
||||
@@ -52,8 +58,7 @@ function DraggableCollectionLink({
|
||||
},
|
||||
canDrop: (item) =>
|
||||
collection.id !== item.id &&
|
||||
(!belowCollection || item.id !== belowCollection.id) &&
|
||||
policies.abilities(item.id)?.move,
|
||||
(!belowCollection || item.id !== belowCollection.id),
|
||||
collect: (monitor: DropTargetMonitor<Collection, Collection>) => ({
|
||||
isCollectionDropping: monitor.isOver(),
|
||||
isDraggingAnyCollection: monitor.canDrop(),
|
||||
@@ -71,6 +76,7 @@ function DraggableCollectionLink({
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => can.move,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -80,18 +86,10 @@ function DraggableCollectionLink({
|
||||
// If the current collection is active and relevant to the sidebar section we
|
||||
// are in then expand it automatically
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
collection.id === ui.activeCollectionId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
) {
|
||||
if (collection.id === ui.activeCollectionId && !locationStateStarred) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [
|
||||
collection.id,
|
||||
ui.activeCollectionId,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
]);
|
||||
}, [collection.id, ui.activeCollectionId, locationStateStarred]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Group from "~/models/Group";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
/** The group to render */
|
||||
group: Group;
|
||||
};
|
||||
|
||||
const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
<SidebarLink
|
||||
label={group.name}
|
||||
icon={<GroupIcon />}
|
||||
expanded={expanded}
|
||||
onClick={handleDisclosureClick}
|
||||
depth={0}
|
||||
/>
|
||||
<SidebarContext.Provider value={group.id}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
key={membership.id}
|
||||
membership={membership}
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarContext.Provider>
|
||||
</Relative>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(GroupLink);
|
||||
@@ -44,12 +44,12 @@ function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
return (
|
||||
<Navigation gap={4} {...props}>
|
||||
<Tooltip content={t("Go back")} delay={500}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goBack()}>
|
||||
<NudeButton onClick={() => Desktop.bridge.goBack()}>
|
||||
<Back $active={back} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Go forward")} delay={500}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goForward()}>
|
||||
<NudeButton onClick={() => Desktop.bridge.goForward()}>
|
||||
<Forward $active={forward} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
const SharedContext = React.createContext<boolean | undefined>(undefined);
|
||||
|
||||
export const useSharedContext = () => React.useContext(SharedContext);
|
||||
|
||||
export default SharedContext;
|
||||
@@ -4,7 +4,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -12,30 +11,27 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DropCursor from "./DropCursor";
|
||||
import GroupLink from "./GroupLink";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SharedContext from "./SharedContext";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { useDropToReorderUserMembership } from "./useDragAndDrop";
|
||||
|
||||
function SharedWithMe() {
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const { userMemberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
|
||||
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
|
||||
|
||||
const { loading, next, end, error, page } =
|
||||
usePaginatedRequest<UserMembership>(userMemberships.fetchPage, {
|
||||
limit: Pagination.sidebarLimit,
|
||||
});
|
||||
|
||||
// Drop to reorder document
|
||||
const [reorderProps, dropToReorderRef] = useDropToReorderUserMembership(() =>
|
||||
fractionalIndex(null, user.documentMemberships[0].index)
|
||||
const [reorderMonitor, dropToReorderRef] = useDropToReorderUserMembership(
|
||||
() => fractionalIndex(null, user.memberships[0].index)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -44,32 +40,29 @@ function SharedWithMe() {
|
||||
}
|
||||
}, [error, t]);
|
||||
|
||||
if (
|
||||
!user.documentMemberships.length &&
|
||||
!user.groupsWithDocumentMemberships.length
|
||||
) {
|
||||
if (!user.memberships.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value="shared">
|
||||
<SharedContext.Provider value={true}>
|
||||
<Flex column>
|
||||
<Header id="shared" title={t("Shared with me")}>
|
||||
{user.groupsWithDocumentMemberships.map((group) => (
|
||||
<GroupLink key={group.id} group={group} />
|
||||
))}
|
||||
<Relative>
|
||||
{reorderProps.isDragging && (
|
||||
{reorderMonitor.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderProps.isOverCursor}
|
||||
isActiveDrop={reorderMonitor.isOverCursor}
|
||||
innerRef={dropToReorderRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{user.documentMemberships
|
||||
{user.memberships
|
||||
.slice(0, page * Pagination.sidebarLimit)
|
||||
.map((membership) => (
|
||||
<SharedWithMeLink key={membership.id} membership={membership} />
|
||||
<SharedWithMeLink
|
||||
key={membership.id}
|
||||
userMembership={membership}
|
||||
/>
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
@@ -89,7 +82,7 @@ function SharedWithMe() {
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
</SharedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +1,44 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { IconType, NotificationEventType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import {
|
||||
useDragMembership,
|
||||
useDragUserMembership,
|
||||
useDropToReorderUserMembership,
|
||||
useDropToReparentDocument,
|
||||
} from "./useDragAndDrop";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
type Props = {
|
||||
membership: UserMembership | GroupMembership;
|
||||
depth?: number;
|
||||
userMembership: UserMembership;
|
||||
};
|
||||
|
||||
function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
function SharedWithMeLink({ userMembership }: Props) {
|
||||
const { ui, collections, documents } = useStores();
|
||||
const { fetchChildDocuments } = documents;
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId } = membership;
|
||||
const { documentId } = userMembership;
|
||||
const isActiveDocument = documentId === ui.activeDocumentId;
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(
|
||||
membership.documentId === ui.activeDocumentId &&
|
||||
locationSidebarContext === sidebarContext
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
userMembership.documentId === ui.activeDocumentId
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
membership.documentId === ui.activeDocumentId &&
|
||||
locationSidebarContext === sidebarContext
|
||||
) {
|
||||
setExpanded();
|
||||
if (userMembership.documentId === ui.activeDocumentId) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [
|
||||
membership.documentId,
|
||||
ui.activeDocumentId,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
setExpanded,
|
||||
]);
|
||||
}, [userMembership.documentId, ui.activeDocumentId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (documentId) {
|
||||
@@ -67,45 +47,38 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
}, [documentId, documents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocument && membership.documentId) {
|
||||
void fetchChildDocuments(membership.documentId);
|
||||
if (isActiveDocument && userMembership.documentId) {
|
||||
void fetchChildDocuments(userMembership.documentId);
|
||||
}
|
||||
}, [fetchChildDocuments, isActiveDocument, membership.documentId]);
|
||||
}, [fetchChildDocuments, isActiveDocument, userMembership.documentId]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
setExpanded();
|
||||
}
|
||||
setExpanded((prevExpanded) => !prevExpanded);
|
||||
},
|
||||
[expanded, setExpanded, setCollapsed]
|
||||
[]
|
||||
);
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const node = React.useMemo(() => document?.asNavigationNode, [document]);
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
|
||||
useDropToReparentDocument(node, setExpanded, parentRef);
|
||||
|
||||
const { icon } = useSidebarLabelAndIcon(membership);
|
||||
const [{ isDragging }, draggableRef] = useDragMembership(membership);
|
||||
const { icon } = useSidebarLabelAndIcon(userMembership);
|
||||
const [{ isDragging }, draggableRef] = useDragUserMembership(userMembership);
|
||||
|
||||
const getIndex = () => {
|
||||
if (membership instanceof UserMembership) {
|
||||
const next = membership?.next();
|
||||
return fractionalIndex(membership?.index || null, next?.index || null);
|
||||
}
|
||||
return "";
|
||||
const next = userMembership?.next();
|
||||
return fractionalIndex(userMembership?.index || null, next?.index || null);
|
||||
};
|
||||
const [reorderProps, dropToReorderRef] =
|
||||
const [reorderMonitor, dropToReorderRef] =
|
||||
useDropToReorderUserMembership(getIndex);
|
||||
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
|
||||
if (document) {
|
||||
if (documentId) {
|
||||
const document = documents.get(documentId);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { icon: docIcon } = document;
|
||||
const label =
|
||||
determineIconType(docIcon) === IconType.Emoji
|
||||
@@ -121,75 +94,63 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative ref={parentRef}>
|
||||
<Draggable
|
||||
key={membership.id}
|
||||
ref={draggableRef}
|
||||
$isDragging={isDragging}
|
||||
>
|
||||
<div ref={dropToReparent}>
|
||||
<SidebarLink
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
to={{
|
||||
pathname: document.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={
|
||||
hasChildDocuments && !isDragging ? expanded : undefined
|
||||
}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) =>
|
||||
!!match && location.state?.sidebarContext === sidebarContext
|
||||
}
|
||||
label={label}
|
||||
exact={false}
|
||||
unreadBadge={
|
||||
document.unreadNotifications.filter(
|
||||
(notification) =>
|
||||
notification.event ===
|
||||
NotificationEventType.AddUserToDocument
|
||||
).length > 0
|
||||
}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Draggable>
|
||||
</Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{reorderProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderProps.isOverCursor}
|
||||
innerRef={dropToReorderRef}
|
||||
<Draggable
|
||||
key={userMembership.id}
|
||||
ref={draggableRef}
|
||||
$isDragging={isDragging}
|
||||
>
|
||||
<SidebarLink
|
||||
depth={0}
|
||||
to={{
|
||||
pathname: document.path,
|
||||
state: { starred: true },
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
icon={icon}
|
||||
label={label}
|
||||
exact={false}
|
||||
unreadBadge={
|
||||
document.unreadNotifications.filter(
|
||||
(notification) =>
|
||||
notification.event === NotificationEventType.AddUserToDocument
|
||||
).length > 0
|
||||
}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{reorderMonitor.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderMonitor.isOverCursor}
|
||||
innerRef={dropToReorderRef}
|
||||
/>
|
||||
)}
|
||||
</Relative>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
export type SidebarContextType = "collections" | "starred" | string | undefined;
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextType>(undefined);
|
||||
|
||||
export const useSidebarContext = () => React.useContext(SidebarContext);
|
||||
|
||||
export default SidebarContext;
|
||||
@@ -14,6 +14,7 @@ import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
active: boolean;
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import StarredContext from "./StarredContext";
|
||||
import StarredLink from "./StarredLink";
|
||||
import { useDropToCreateStar, useDropToReorderStar } from "./useDragAndDrop";
|
||||
|
||||
@@ -25,8 +25,8 @@ function Starred() {
|
||||
const { loading, next, end, error, page } = usePaginatedRequest<Star>(
|
||||
stars.fetchPage
|
||||
);
|
||||
const [reorderStarProps, dropToReorder] = useDropToReorderStar();
|
||||
const [createStarProps, dropToStarRef] = useDropToCreateStar();
|
||||
const [reorderStarMonitor, dropToReorder] = useDropToReorderStar();
|
||||
const [createStarMonitor, dropToStarRef] = useDropToCreateStar();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
@@ -39,20 +39,20 @@ function Starred() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value="starred">
|
||||
<StarredContext.Provider value={true}>
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
{reorderStarMonitor.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
isActiveDrop={reorderStarMonitor.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
{createStarMonitor.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
isActiveDrop={createStarMonitor.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
@@ -80,7 +80,7 @@ function Starred() {
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
</StarredContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
const StarredContext = React.createContext<boolean | undefined>(undefined);
|
||||
|
||||
export const useStarredContext = () => React.useContext(StarredContext);
|
||||
|
||||
export default StarredContext;
|
||||
@@ -4,23 +4,19 @@ import { observer } from "mobx-react";
|
||||
import { StarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Star from "~/models/Star";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext, {
|
||||
SidebarContextType,
|
||||
useSidebarContext,
|
||||
} from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import {
|
||||
useDragStar,
|
||||
@@ -33,32 +29,29 @@ type Props = {
|
||||
star: Star;
|
||||
};
|
||||
|
||||
function useLocationStateStarred() {
|
||||
const location = useLocation<{
|
||||
starred?: boolean;
|
||||
}>();
|
||||
return location.state?.starred;
|
||||
}
|
||||
|
||||
function StarredLink({ star }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui, collections, documents } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId, collectionId } = star;
|
||||
const collection = collections.get(collectionId);
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const locationStateStarred = useLocationStateStarred();
|
||||
const [expanded, setExpanded] = useState(
|
||||
star.collectionId === ui.activeCollectionId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
star.collectionId === ui.activeCollectionId && !!locationStateStarred
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
star.collectionId === ui.activeCollectionId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
) {
|
||||
if (star.collectionId === ui.activeCollectionId && locationStateStarred) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [
|
||||
star.collectionId,
|
||||
ui.activeCollectionId,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
]);
|
||||
}, [star.collectionId, ui.activeCollectionId, locationStateStarred]);
|
||||
|
||||
useEffect(() => {
|
||||
if (documentId) {
|
||||
@@ -84,22 +77,22 @@ function StarredLink({ star }: Props) {
|
||||
<StarredIcon color={theme.yellow} />
|
||||
);
|
||||
const [{ isDragging }, draggableRef] = useDragStar(star);
|
||||
const [reorderStarProps, dropToReorderRef] = useDropToReorderStar(getIndex);
|
||||
const [createStarProps, dropToStarRef] = useDropToCreateStar(getIndex);
|
||||
const [reorderStarMonitor, dropToReorderRef] = useDropToReorderStar(getIndex);
|
||||
const [createStarMonitor, dropToStarRef] = useDropToCreateStar(getIndex);
|
||||
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
|
||||
const cursor = (
|
||||
<>
|
||||
{reorderStarProps.isDragging && (
|
||||
{reorderStarMonitor.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
isActiveDrop={reorderStarMonitor.isOverCursor}
|
||||
innerRef={dropToReorderRef}
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
{createStarMonitor.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
isActiveDrop={createStarMonitor.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
/>
|
||||
)}
|
||||
@@ -127,15 +120,14 @@ function StarredLink({ star }: Props) {
|
||||
depth={0}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { sidebarContext },
|
||||
state: { starred: true },
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
!!match && location.state?.starred === true
|
||||
}
|
||||
label={label}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
@@ -152,24 +144,22 @@ function StarredLink({ star }: Props) {
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={document.id}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -183,18 +173,16 @@ function StarredLink({ star }: Props) {
|
||||
expanded={isDragging ? undefined : displayChildDocuments}
|
||||
activeDocument={documents.active}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
isDraggingAnyCollection={reorderStarProps.isDragging}
|
||||
isDraggingAnyCollection={reorderStarMonitor.isDragging}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={collection.id}>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import Modal from "~/components/Modal";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { trashPath } from "~/utils/routeHelpers";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
|
||||
function TrashLink() {
|
||||
const { policies, dialogs, documents } = useStores();
|
||||
const { policies, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [document, setDocument] = useState<Document>();
|
||||
|
||||
const [{ isDocumentDropping }, dropToTrashRef] = useDrop({
|
||||
const [{ isDocumentDropping }, dropToTrashDocument] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject) => {
|
||||
const document = documents.get(item.id);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
drop: (item: DragObject) => {
|
||||
const doc = documents.get(item.id);
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: document?.noun,
|
||||
}),
|
||||
content: (
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
// without setTimeout it was not working in firefox v89.0.2-ubuntu
|
||||
// on dropping mouseup is considered as clicking outside the modal, and it immediately closes
|
||||
setTimeout(() => doc && setDocument(doc), 1);
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).delete,
|
||||
collect: (monitor) => ({
|
||||
@@ -39,16 +32,32 @@ function TrashLink() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={dropToTrashRef}>
|
||||
<SidebarLink
|
||||
to={trashPath()}
|
||||
icon={<TrashIcon open={isDocumentDropping} />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={documents.active?.isDeleted}
|
||||
isActiveDrop={isDocumentDropping}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div ref={dropToTrashDocument}>
|
||||
<SidebarLink
|
||||
to={trashPath()}
|
||||
icon={<TrashIcon open={isDocumentDropping} />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={documents.active?.isDeleted}
|
||||
isActiveDrop={isDocumentDropping}
|
||||
/>
|
||||
</div>
|
||||
{document && (
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setDocument(undefined)}
|
||||
isOpen
|
||||
>
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={() => setDocument(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,9 @@ import { StarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { ConnectDragSource, useDrag, useDrop } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import Star from "~/models/Star";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
@@ -38,6 +31,7 @@ export function useDragStar(
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => true,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -53,41 +47,21 @@ export function useDragStar(
|
||||
* @param getIndex A function to get the index of the current item where the star should be inserted.
|
||||
*/
|
||||
export function useDropToCreateStar(getIndex?: () => string) {
|
||||
const accept = [
|
||||
"document",
|
||||
"collection",
|
||||
"userMembership",
|
||||
"groupMembership",
|
||||
];
|
||||
const { documents, stars, collections, userMemberships, groupMemberships } =
|
||||
useStores();
|
||||
const { documents, stars, collections } = useStores();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverCursor: boolean; isDragging: boolean }
|
||||
>({
|
||||
accept,
|
||||
drop: async (item, monitor) => {
|
||||
const type = monitor.getItemType();
|
||||
let model;
|
||||
|
||||
if (type === "collection") {
|
||||
model = collections.get(item.id);
|
||||
} else if (type === "userMembership") {
|
||||
model = userMemberships.get(item.id)?.document;
|
||||
} else if (type === "groupMembership") {
|
||||
model = groupMemberships.get(item.id)?.document;
|
||||
} else {
|
||||
model = documents.get(item.id);
|
||||
}
|
||||
return useDrop({
|
||||
accept: ["document", "collection"],
|
||||
drop: async (item: DragObject) => {
|
||||
const model = documents.get(item.id) ?? collections?.get(item.id);
|
||||
await model?.star(
|
||||
getIndex?.() ?? fractionalIndex(null, stars.orderedData[0].index)
|
||||
);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverCursor: !!monitor.isOver(),
|
||||
isDragging: accept.includes(String(monitor.getItemType())),
|
||||
isDragging: ["document", "collection"].includes(
|
||||
String(monitor.getItemType())
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -100,13 +74,9 @@ export function useDropToCreateStar(getIndex?: () => string) {
|
||||
export function useDropToReorderStar(getIndex?: () => string) {
|
||||
const { stars } = useStores();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverCursor: boolean; isDragging: boolean }
|
||||
>({
|
||||
return useDrop({
|
||||
accept: "star",
|
||||
drop: async (item) => {
|
||||
drop: async (item: DragObject) => {
|
||||
const star = stars.get(item.id);
|
||||
void star?.save({
|
||||
index:
|
||||
@@ -120,229 +90,30 @@ export function useDropToReorderStar(getIndex?: () => string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dragging documents.
|
||||
*
|
||||
* @param node The NavigationNode model to drag.
|
||||
* @param depth The depth of the node in the sidebar.
|
||||
* @param document The related Document model.
|
||||
*/
|
||||
export function useDragDocument(
|
||||
node: NavigationNode,
|
||||
depth: number,
|
||||
document?: Document
|
||||
) {
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
export function useDragUserMembership(
|
||||
userMembership: UserMembership
|
||||
): [{ isDragging: boolean }, ConnectDragSource] {
|
||||
const id = userMembership.id;
|
||||
const { label: title, icon } = useSidebarLabelAndIcon(userMembership);
|
||||
|
||||
const [{ isDragging }, draggableRef, preview] = useDrag<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isDragging: boolean }
|
||||
>({
|
||||
type: "document",
|
||||
item: () =>
|
||||
({
|
||||
...node,
|
||||
depth,
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
} as DragObject),
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
const [{ isDragging }, draggableRef, preview] = useDrag({
|
||||
type: "userMembership",
|
||||
item: () => ({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
}),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
preview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, [preview]);
|
||||
|
||||
return [{ isDragging }, draggableRef] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents to reparent
|
||||
*
|
||||
* @param node The NavigationNode model to drop.
|
||||
* @param setExpanded A function to expand the parent node.
|
||||
* @param parentRef A ref to the parent element that will be used to detect when the user is no longer hovering..
|
||||
*/
|
||||
export function useDropToReparentDocument(
|
||||
node: NavigationNode | undefined,
|
||||
setExpanded: () => void,
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { documents, policies } = useStores();
|
||||
const hasChildDocuments = !!node?.children.length;
|
||||
const document = node ? documents.get(node.id) : undefined;
|
||||
const pathToNode = React.useMemo(
|
||||
() => document?.pathTo.map((item) => item.id),
|
||||
[document]
|
||||
);
|
||||
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const resetHoverExpanding = () => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
parentRef.current?.addEventListener("dragleave", resetHoverExpanding);
|
||||
|
||||
return () => {
|
||||
parentRef.current?.removeEventListener("dragleave", resetHoverExpanding);
|
||||
};
|
||||
}, [parentRef]);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverReparent: boolean; canDropToReparent: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (monitor.didDrop() || !node) {
|
||||
return;
|
||||
}
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
setExpanded();
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
!!node &&
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem().id) &&
|
||||
item.id !== node.id &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
if (
|
||||
hasChildDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = undefined;
|
||||
|
||||
if (monitor.isOver({ shallow: true })) {
|
||||
setExpanded();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: monitor.isOver({ shallow: true }),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents to reorder
|
||||
*
|
||||
* @param node The NavigationNode model to drop.
|
||||
* @param collection The related Collection model, if published
|
||||
* @param getMoveParams A function to get the move parameters for the document.
|
||||
*/
|
||||
export function useDropToReorderDocument(
|
||||
node: NavigationNode,
|
||||
collection: Collection | undefined,
|
||||
getMoveParams: (item: DragObject) =>
|
||||
| undefined
|
||||
| {
|
||||
documentId: string;
|
||||
collectionId: string;
|
||||
parentDocumentId: string | undefined;
|
||||
index: number;
|
||||
}
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, policies } = useStores();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverReorder: boolean; isDraggingAnyDocument: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (item.id === node.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return policies.abilities(item.id)?.move;
|
||||
},
|
||||
drop: async (item) => {
|
||||
if (!collection?.isManualSort && item.collectionId === collection?.id) {
|
||||
toast.message(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = getMoveParams(item);
|
||||
if (params) {
|
||||
void documents.move(params);
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: monitor.isOver(),
|
||||
isDraggingAnyDocument: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dragging user memberships.
|
||||
*
|
||||
* @param membership The UserMembership or GroupMembership model to drag.
|
||||
*/
|
||||
export function useDragMembership(
|
||||
membership: UserMembership | GroupMembership
|
||||
) {
|
||||
const id = membership.id;
|
||||
const { label: title, icon } = useSidebarLabelAndIcon(membership);
|
||||
|
||||
const [{ isDragging }, draggableRef, preview] = useDrag<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isDragging: boolean }
|
||||
>({
|
||||
type:
|
||||
membership instanceof UserMembership
|
||||
? "userMembership"
|
||||
: "groupMembership",
|
||||
item: () =>
|
||||
({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
} as DragObject),
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => true,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
preview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, [preview]);
|
||||
|
||||
return [{ isDragging }, draggableRef] as const;
|
||||
return [{ isDragging }, draggableRef];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,18 +125,12 @@ export function useDropToReorderUserMembership(getIndex?: () => string) {
|
||||
const { userMemberships } = useStores();
|
||||
const user = useCurrentUser();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverCursor: boolean; isDragging: boolean }
|
||||
>({
|
||||
return useDrop({
|
||||
accept: "userMembership",
|
||||
drop: async (item) => {
|
||||
drop: async (item: DragObject) => {
|
||||
const userMembership = userMemberships.get(item.id);
|
||||
void userMembership?.save({
|
||||
index:
|
||||
getIndex?.() ??
|
||||
fractionalIndex(null, user.documentMemberships[0].index),
|
||||
index: getIndex?.() ?? fractionalIndex(null, user.memberships[0].index),
|
||||
});
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { SidebarContextType } from "../components/SidebarContext";
|
||||
|
||||
/**
|
||||
* Hook to retrieve the sidebar context from the current location state.
|
||||
*/
|
||||
export function useLocationState() {
|
||||
const location = useLocation<{
|
||||
sidebarContext?: SidebarContextType;
|
||||
}>();
|
||||
return location.state?.sidebarContext;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Avatar } from "./Avatar";
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
const TeamLogo = styled(Avatar)`
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSelect, { Option } from "~/components/InputSelect";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
|
||||
@@ -13,9 +13,6 @@ import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import GroupUser from "~/models/GroupUser";
|
||||
import Membership from "~/models/Membership";
|
||||
import Notification from "~/models/Notification";
|
||||
import Pin from "~/models/Pin";
|
||||
import Star from "~/models/Star";
|
||||
@@ -27,6 +24,7 @@ import withStores from "~/components/withStores";
|
||||
import {
|
||||
PartialWithId,
|
||||
WebsocketCollectionUpdateIndexEvent,
|
||||
WebsocketCollectionUserEvent,
|
||||
WebsocketEntitiesEvent,
|
||||
WebsocketEntityDeletedEvent,
|
||||
} from "~/types";
|
||||
@@ -87,8 +85,6 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
documents,
|
||||
collections,
|
||||
groups,
|
||||
groupUsers,
|
||||
groupMemberships,
|
||||
pins,
|
||||
stars,
|
||||
memberships,
|
||||
@@ -101,8 +97,6 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
notifications,
|
||||
} = this.props;
|
||||
|
||||
const currentUserId = auth?.user?.id;
|
||||
|
||||
// on reconnection, reset the transports option, as the Websocket
|
||||
// connection may have failed (caused by proxy, firewall, browser, ...)
|
||||
this.socket.io.on("reconnect_attempt", () => {
|
||||
@@ -202,6 +196,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeAll({ collectionId });
|
||||
collections.remove(collectionId);
|
||||
return;
|
||||
@@ -263,78 +258,44 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
// received when a user is given access to a document
|
||||
this.socket.on(
|
||||
"documents.add_user",
|
||||
async (event: PartialWithId<UserMembership>) => {
|
||||
(event: PartialWithId<UserMembership>) => {
|
||||
userMemberships.add(event);
|
||||
|
||||
// Any existing child policies are now invalid
|
||||
if (event.userId === currentUserId) {
|
||||
const document = documents.get(event.documentId!);
|
||||
if (document) {
|
||||
document.childDocuments.forEach((childDocument) => {
|
||||
policies.remove(childDocument.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await documents.fetch(event.documentId!, {
|
||||
force: event.userId === currentUserId,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.remove_user",
|
||||
(event: PartialWithId<UserMembership>) => {
|
||||
userMemberships.remove(event.id);
|
||||
if (event.userId) {
|
||||
const userMembership = userMemberships.get(event.id);
|
||||
|
||||
// Any existing child policies are now invalid
|
||||
if (event.userId === currentUserId) {
|
||||
const document = documents.get(event.documentId!);
|
||||
if (document) {
|
||||
document.childDocuments.forEach((childDocument) => {
|
||||
policies.remove(childDocument.id);
|
||||
});
|
||||
// TODO: Possibly replace this with a one-to-many relation decorator.
|
||||
if (userMembership) {
|
||||
userMemberships
|
||||
.filter({
|
||||
userId: event.userId,
|
||||
sourceId: userMembership.id,
|
||||
})
|
||||
.forEach((m) => {
|
||||
m.documentId && documents.remove(m.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
userMemberships.removeAll({
|
||||
userId: event.userId,
|
||||
documentId: event.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
const policy = policies.get(event.documentId!);
|
||||
if (policy && policy.abilities.read === false) {
|
||||
documents.remove(event.documentId!);
|
||||
if (event.documentId && event.userId === auth.user?.id) {
|
||||
documents.remove(event.documentId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.add_group",
|
||||
(event: PartialWithId<GroupMembership>) => {
|
||||
groupMemberships.add(event);
|
||||
|
||||
const group = groups.get(event.groupId!);
|
||||
|
||||
// Any existing child policies are now invalid
|
||||
if (
|
||||
currentUserId &&
|
||||
group?.users.map((u) => u.id).includes(currentUserId)
|
||||
) {
|
||||
const document = documents.get(event.documentId!);
|
||||
if (document) {
|
||||
document.childDocuments.forEach((childDocument) => {
|
||||
policies.remove(childDocument.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.remove_group",
|
||||
(event: PartialWithId<GroupMembership>) => {
|
||||
groupMemberships.remove(event.id);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
|
||||
comments.add(event);
|
||||
});
|
||||
@@ -359,22 +320,20 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
groups.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("groups.add_user", (event: PartialWithId<GroupUser>) => {
|
||||
groupUsers.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("groups.remove_user", (event: PartialWithId<GroupUser>) => {
|
||||
groupUsers.removeAll({
|
||||
groupId: event.groupId,
|
||||
userId: event.userId,
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {
|
||||
collections.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("collections.update", (event: PartialWithId<Collection>) => {
|
||||
if (
|
||||
"sharing" in event &&
|
||||
event.sharing !== collections.get(event.id)?.sharing
|
||||
) {
|
||||
documents.all.forEach((document) => {
|
||||
policies.remove(document.id);
|
||||
});
|
||||
}
|
||||
|
||||
collections.add(event);
|
||||
});
|
||||
|
||||
@@ -393,6 +352,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
policies.remove(doc.id);
|
||||
});
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeAll({ collectionId });
|
||||
collections.remove(collectionId);
|
||||
})
|
||||
@@ -453,35 +413,56 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("collections.add_user", async (event: Membership) => {
|
||||
memberships.add(event);
|
||||
await collections.fetch(event.collectionId, {
|
||||
force: event.userId === currentUserId,
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on("collections.remove_user", (event: Membership) => {
|
||||
memberships.remove(event.id);
|
||||
|
||||
const policy = policies.get(event.collectionId);
|
||||
if (policy && policy.abilities.read === false) {
|
||||
collections.remove(event.collectionId);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("collections.add_group", async (event: GroupMembership) => {
|
||||
groupMemberships.add(event);
|
||||
await collections.fetch(event.collectionId!);
|
||||
});
|
||||
|
||||
// received when a user is given access to a collection
|
||||
// if the user is us then we go ahead and load the collection from API.
|
||||
this.socket.on(
|
||||
"collections.remove_group",
|
||||
async (event: GroupMembership) => {
|
||||
groupMemberships.remove(event.id);
|
||||
"collections.add_user",
|
||||
async (event: WebsocketCollectionUserEvent) => {
|
||||
if (event.userId === auth.user?.id) {
|
||||
await collections.fetch(event.collectionId, {
|
||||
force: true,
|
||||
});
|
||||
|
||||
const policy = policies.get(event.collectionId!);
|
||||
if (policy && policy.abilities.read === false) {
|
||||
collections.remove(event.collectionId!);
|
||||
// Document policies might need updating as the permission changes
|
||||
documents.inCollection(event.collectionId).forEach((document) => {
|
||||
policies.remove(document.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// received when a user is removed from having access to a collection
|
||||
// to keep state in sync we must update our UI if the user is us,
|
||||
// or otherwise just remove any membership state we have for that user.
|
||||
this.socket.on(
|
||||
"collections.remove_user",
|
||||
async (event: WebsocketCollectionUserEvent) => {
|
||||
if (event.userId === auth.user?.id) {
|
||||
// check if we still have access to the collection
|
||||
try {
|
||||
await collections.fetch(event.collectionId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
collections.remove(event.collectionId);
|
||||
memberships.removeAll({
|
||||
userId: event.userId,
|
||||
collectionId: event.collectionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
documents.removeCollectionDocuments(event.collectionId);
|
||||
} else {
|
||||
memberships.removeAll({
|
||||
userId: event.userId,
|
||||
collectionId: event.collectionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -490,7 +471,10 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
"collections.update_index",
|
||||
action((event: WebsocketCollectionUpdateIndexEvent) => {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.updateIndex(event.index);
|
||||
|
||||
if (collection) {
|
||||
collection.updateIndex(event.index);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
initialValue = this.href;
|
||||
initialSelectionLength = this.props.to - this.props.from;
|
||||
resultsRef = React.createRef<HTMLDivElement>();
|
||||
inputRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
state: State = {
|
||||
selectedIndex: -1,
|
||||
@@ -92,13 +91,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
return this.state.value.trim() || this.selectedText;
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
window.addEventListener("keydown", this.handleGlobalKeyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
window.removeEventListener("keydown", this.handleGlobalKeyDown);
|
||||
|
||||
// If we discarded the changes then nothing to do
|
||||
if (this.discardInputValue) {
|
||||
return;
|
||||
@@ -118,12 +111,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
this.save(href, href);
|
||||
};
|
||||
|
||||
handleGlobalKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "k" && event.metaKey) {
|
||||
this.inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
|
||||
save = (href: string, title?: string): void => {
|
||||
href = href.trim();
|
||||
|
||||
@@ -334,7 +321,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Input
|
||||
ref={this.inputRef}
|
||||
value={value}
|
||||
placeholder={
|
||||
showCreateLink
|
||||
|
||||
@@ -2,17 +2,16 @@ import { observer } from "mobx-react";
|
||||
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 { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import MentionMenuItem from "./MentionMenuItem";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
@@ -47,7 +46,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
React.useCallback(
|
||||
() =>
|
||||
documentId
|
||||
? users.fetchPage({ id: documentId, query: search })
|
||||
? users.fetchDocumentUsers({ id: documentId, query: search })
|
||||
: Promise.resolve([]),
|
||||
[users, documentId, search]
|
||||
)
|
||||
@@ -80,33 +79,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
}
|
||||
}, [auth.currentUserId, loading, data]);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (item: MentionItem) => {
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
userId: item.attrs.modelId,
|
||||
});
|
||||
|
||||
if (!res.data.length) {
|
||||
const user = users.get(item.attrs.modelId);
|
||||
toast.message(
|
||||
t(
|
||||
"{{ userName }} won't by notified as they do not have access to this document",
|
||||
{
|
||||
userName: item.attrs.label,
|
||||
}
|
||||
),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
duration: 10000,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[t, users, documentId]
|
||||
);
|
||||
|
||||
// Prevent showing the menu until we have data otherwise it will be positioned
|
||||
// incorrectly due to the height being unknown.
|
||||
if (!loaded) {
|
||||
@@ -120,7 +92,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
filterable={false}
|
||||
trigger="@"
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<MentionMenuItem
|
||||
onClick={options.onClick}
|
||||
|
||||
@@ -60,10 +60,7 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
onFileUploadStart?: () => void;
|
||||
onFileUploadStop?: () => void;
|
||||
/** Callback when the menu is closed */
|
||||
onClose: (insertNewLine?: boolean) => void;
|
||||
/** Optional callback when a suggestion is selected */
|
||||
onSelect?: (item: MenuItem) => void;
|
||||
embeds?: EmbedDescriptor[];
|
||||
renderMenuItem: (
|
||||
item: T,
|
||||
@@ -247,8 +244,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
const handleClickItem = React.useCallback(
|
||||
(item) => {
|
||||
props.onSelect?.(item);
|
||||
|
||||
switch (item.name) {
|
||||
case "image":
|
||||
return triggerFilePick(
|
||||
|
||||
@@ -624,8 +624,7 @@ export class Editor extends React.PureComponent<
|
||||
*
|
||||
* @returns A list of headings in the document
|
||||
*/
|
||||
public getHeadings = () =>
|
||||
ProsemirrorHelper.getHeadings(this.view.state.doc, this.schema);
|
||||
public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc);
|
||||
|
||||
/**
|
||||
* Return the images in the current editor.
|
||||
@@ -766,9 +765,6 @@ export class Editor extends React.PureComponent<
|
||||
};
|
||||
|
||||
private handleOpenLinkToolbar = () => {
|
||||
if (this.state.selectionToolbarOpen) {
|
||||
return;
|
||||
}
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
linkToolbarOpen: true,
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
tooltip: dictionary.mark,
|
||||
icon: highlight ? (
|
||||
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
|
||||
<CircleIcon color={highlight.mark.attrs.color} />
|
||||
) : (
|
||||
<HighlightIcon />
|
||||
),
|
||||
|
||||
@@ -16,7 +16,7 @@ let isReloaded = false;
|
||||
export default function useAutoRefresh() {
|
||||
const [minutes, setMinutes] = React.useState(0);
|
||||
const isVisible = usePageVisibility();
|
||||
const isIdle = useIdle(15 * Minute.ms);
|
||||
const isIdle = useIdle(15 * Minute);
|
||||
|
||||
useInterval(() => {
|
||||
setMinutes((prev) => prev + 1);
|
||||
@@ -39,5 +39,5 @@ export default function useAutoRefresh() {
|
||||
window.location.reload();
|
||||
isReloaded = true;
|
||||
}
|
||||
}, Minute.ms);
|
||||
}, Minute);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Desktop from "~/utils/Desktop";
|
||||
|
||||
export const useDesktopTitlebar = () => {
|
||||
React.useEffect(() => {
|
||||
if (!Desktop.bridge) {
|
||||
if (!Desktop.isElectron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const useDesktopTitlebar = () => {
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await Desktop.bridge?.onTitlebarDoubleClick();
|
||||
await Desktop.bridge.onTitlebarDoubleClick();
|
||||
};
|
||||
|
||||
window.addEventListener("dblclick", handleDoubleClick);
|
||||
|
||||
@@ -23,7 +23,7 @@ const activityEvents = [
|
||||
* @returns boolean if the user is idle
|
||||
*/
|
||||
export default function useIdle(
|
||||
timeToIdle: number = 3 * Minute.ms,
|
||||
timeToIdle: number = 3 * Minute,
|
||||
events = activityEvents
|
||||
) {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
@@ -34,16 +34,8 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
|
||||
* @param path The path to set as the post login path.
|
||||
*/
|
||||
export function setPostLoginPath(path: string) {
|
||||
const key = "postLoginRedirectPath";
|
||||
|
||||
if (isValidPostLoginRedirect(path)) {
|
||||
setCookie(key, path, { expires: 1 });
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(key, path);
|
||||
} catch (e) {
|
||||
// If the session storage is full or inaccessible, we can't do anything about it.
|
||||
}
|
||||
setCookie("postLoginRedirectPath", path, { expires: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +49,7 @@ export function usePostLoginPath() {
|
||||
const key = "postLoginRedirectPath";
|
||||
|
||||
const getter = React.useCallback(() => {
|
||||
let path;
|
||||
try {
|
||||
path = sessionStorage.getItem(key) || getCookie(key);
|
||||
} catch (e) {
|
||||
// If the session storage is inaccessible, we can't do anything about it.
|
||||
}
|
||||
const path = getCookie(key);
|
||||
|
||||
if (path) {
|
||||
Logger.info("lifecycle", "Spending post login path", { path });
|
||||
|
||||
@@ -27,13 +27,13 @@ const useTemplatesActions = () => {
|
||||
<NewDocumentIcon />
|
||||
),
|
||||
keywords: "create",
|
||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
||||
perform: ({ activeCollectionId, inStarredSection }) =>
|
||||
history.push(
|
||||
newDocumentPath(item.collectionId ?? activeCollectionId, {
|
||||
templateId: item.id,
|
||||
}),
|
||||
{
|
||||
sidebarContext,
|
||||
starred: inStarredSection,
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
+195
-268
@@ -1,6 +1,4 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -50,7 +48,6 @@ import {
|
||||
moveTemplate,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -58,108 +55,68 @@ import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { MenuContext, useMenuContext } from "./MenuContext";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the menu is to be shown */
|
||||
document: Document;
|
||||
className?: string;
|
||||
isRevision?: boolean;
|
||||
/** Pass true if the document is currently being displayed */
|
||||
showDisplayOptions?: boolean;
|
||||
/** Whether to display menu as a modal */
|
||||
modal?: boolean;
|
||||
/** Whether to include the option of toggling embeds as menu item */
|
||||
showToggleEmbeds?: boolean;
|
||||
showPin?: boolean;
|
||||
/** Label for menu button */
|
||||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||||
/** Invoked when the "Find and replace" menu item is clicked */
|
||||
onFindAndReplace?: () => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Invoked when menu is opened */
|
||||
onOpen?: () => void;
|
||||
/** Invoked when menu is closed */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
type MenuTriggerProps = {
|
||||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||||
onTrigger: () => void;
|
||||
};
|
||||
|
||||
const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
|
||||
function DocumentMenu({
|
||||
document,
|
||||
className,
|
||||
modal = true,
|
||||
showToggleEmbeds,
|
||||
showDisplayOptions,
|
||||
label,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { policies, collections, documents, subscriptions } = useStores();
|
||||
const menu = useMenuState({
|
||||
modal,
|
||||
unstable_preventOverflow: true,
|
||||
unstable_fixed: true,
|
||||
unstable_flip: true,
|
||||
});
|
||||
const history = useHistory();
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId: document.collectionId ?? undefined,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { subscriptions } = useStores();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
|
||||
const { data, loading, error, request } = useRequest(() =>
|
||||
const isMobile = useMobile();
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
const { data, loading, request } = useRequest(() =>
|
||||
subscriptions.fetchPage({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
})
|
||||
);
|
||||
|
||||
const handlePointerEnter = React.useCallback(() => {
|
||||
if (isUndefined(data ?? error) && !loading) {
|
||||
void request();
|
||||
void document.loadRelations();
|
||||
const handleOpen = React.useCallback(async () => {
|
||||
if (!data && !loading) {
|
||||
await request();
|
||||
}
|
||||
}, [data, error, loading, request, document]);
|
||||
|
||||
return label ? (
|
||||
<MenuButton
|
||||
{...menuState}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onClick={onTrigger}
|
||||
>
|
||||
{label}
|
||||
</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton
|
||||
aria-label={t("Show document menu")}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onClick={onTrigger}
|
||||
{...menuState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type MenuContentProps = {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onFindAndReplace?: () => void;
|
||||
onRename?: () => void;
|
||||
showDisplayOptions?: boolean;
|
||||
showToggleEmbeds?: boolean;
|
||||
};
|
||||
|
||||
const MenuContent: React.FC<MenuContentProps> = ({
|
||||
onOpen,
|
||||
onClose,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
showDisplayOptions,
|
||||
showToggleEmbeds,
|
||||
}) => {
|
||||
const user = useCurrentUser();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
const can = usePolicy(document);
|
||||
const { t } = useTranslation();
|
||||
const { policies, collections } = useStores();
|
||||
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId: document.collectionId ?? undefined,
|
||||
});
|
||||
|
||||
const isMobile = useMobile();
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}, [data, loading, onOpen, request]);
|
||||
|
||||
const handleRestore = React.useCallback(
|
||||
async (
|
||||
@@ -178,6 +135,10 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
[t, document]
|
||||
);
|
||||
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = usePolicy(document);
|
||||
const restoreItems = React.useMemo(
|
||||
() => [
|
||||
...collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||||
@@ -200,182 +161,6 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
],
|
||||
[collections.orderedData, handleRestore, policies]
|
||||
);
|
||||
|
||||
return !isEmpty(can) ? (
|
||||
<ContextMenu
|
||||
{...menuState}
|
||||
aria-label={t("Document options")}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Template
|
||||
{...menuState}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
((document.isWorkspaceTemplate || !!collection) && can.restore) ||
|
||||
!!can.unarchive,
|
||||
onClick: (ev) => handleRestore(ev),
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
{
|
||||
type: "submenu",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!document.isWorkspaceTemplate &&
|
||||
!collection &&
|
||||
!!can.restore &&
|
||||
restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
},
|
||||
icon: <RestoreIcon />,
|
||||
hover: true,
|
||||
items: [
|
||||
{
|
||||
type: "heading",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
...restoreItems,
|
||||
],
|
||||
},
|
||||
actionToMenuItem(starDocument, context),
|
||||
actionToMenuItem(unstarDocument, context),
|
||||
actionToMenuItem(subscribeDocument, context),
|
||||
actionToMenuItem(unsubscribeDocument, context),
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Find and replace")}…`,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
onClick: () => onFindAndReplace?.(),
|
||||
icon: <SearchIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
title: t("Edit"),
|
||||
to: documentEditPath(document),
|
||||
visible:
|
||||
!!can.update && user.separateEditMode && !document.template,
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Rename")}…`,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
onClick: () => onRename?.(),
|
||||
icon: <InputIcon />,
|
||||
},
|
||||
actionToMenuItem(shareDocument, context),
|
||||
actionToMenuItem(createNestedDocument, context),
|
||||
actionToMenuItem(importDocument, context),
|
||||
actionToMenuItem(createTemplateFromDocument, context),
|
||||
actionToMenuItem(duplicateDocument, context),
|
||||
actionToMenuItem(publishDocument, context),
|
||||
actionToMenuItem(unpublishDocument, context),
|
||||
actionToMenuItem(archiveDocument, context),
|
||||
actionToMenuItem(moveDocument, context),
|
||||
actionToMenuItem(moveTemplate, context),
|
||||
actionToMenuItem(pinDocument, context),
|
||||
actionToMenuItem(createDocumentFromTemplate, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(openDocumentComments, context),
|
||||
actionToMenuItem(openDocumentHistory, context),
|
||||
actionToMenuItem(openDocumentInsights, context),
|
||||
actionToMenuItem(downloadDocument, context),
|
||||
actionToMenuItem(copyDocument, context),
|
||||
actionToMenuItem(printDocument, context),
|
||||
actionToMenuItem(searchInDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
]}
|
||||
/>
|
||||
{(showDisplayOptions || showToggleEmbeds) && can.update && (
|
||||
<>
|
||||
<Separator />
|
||||
<DisplayOptions>
|
||||
{showToggleEmbeds && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Enable embeds")}
|
||||
labelPosition="left"
|
||||
checked={!document.embedsDisabled}
|
||||
onChange={
|
||||
document.embedsDisabled
|
||||
? document.enableEmbeds
|
||||
: document.disableEmbeds
|
||||
}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
{showDisplayOptions && !isMobile && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Full width")}
|
||||
labelPosition="left"
|
||||
checked={document.fullWidth}
|
||||
onChange={(ev) => {
|
||||
const fullWidth = ev.currentTarget.checked;
|
||||
user.setPreference(
|
||||
UserPreference.FullWidthDocuments,
|
||||
fullWidth
|
||||
);
|
||||
void user.save();
|
||||
document.fullWidth = fullWidth;
|
||||
void document.save();
|
||||
}}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
</DisplayOptions>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
) : null;
|
||||
};
|
||||
|
||||
function DocumentMenu({
|
||||
document,
|
||||
modal = true,
|
||||
showToggleEmbeds,
|
||||
showDisplayOptions,
|
||||
label,
|
||||
onRename,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { collections, documents } = useStores();
|
||||
const menuState = useMenuState({
|
||||
modal,
|
||||
unstable_preventOverflow: true,
|
||||
unstable_fixed: true,
|
||||
unstable_flip: true,
|
||||
});
|
||||
const history = useHistory();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [isMenuVisible, showMenu] = useBoolean(false);
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
const stopPropagation = React.useCallback((ev: React.SyntheticEvent) => {
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
@@ -428,18 +213,160 @@ function DocumentMenu({
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
<MenuContext.Provider value={{ model: document, menuState }}>
|
||||
<MenuTrigger label={label} onTrigger={showMenu} />
|
||||
{isMenuVisible ? (
|
||||
<MenuContent
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
onRename={onRename}
|
||||
showDisplayOptions={showDisplayOptions}
|
||||
showToggleEmbeds={showToggleEmbeds}
|
||||
/>
|
||||
) : null}
|
||||
</MenuContext.Provider>
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton
|
||||
className={className}
|
||||
aria-label={t("Show menu")}
|
||||
{...menu}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Document options")}
|
||||
onOpen={handleOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
((document.isWorkspaceTemplate || !!collection) &&
|
||||
can.restore) ||
|
||||
!!can.unarchive,
|
||||
onClick: (ev) => handleRestore(ev),
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
{
|
||||
type: "submenu",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!document.isWorkspaceTemplate &&
|
||||
!collection &&
|
||||
!!can.restore &&
|
||||
restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
},
|
||||
icon: <RestoreIcon />,
|
||||
hover: true,
|
||||
items: [
|
||||
{
|
||||
type: "heading",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
...restoreItems,
|
||||
],
|
||||
},
|
||||
actionToMenuItem(starDocument, context),
|
||||
actionToMenuItem(unstarDocument, context),
|
||||
actionToMenuItem(subscribeDocument, context),
|
||||
actionToMenuItem(unsubscribeDocument, context),
|
||||
...(isMobile ? [actionToMenuItem(shareDocument, context)] : []),
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Find and replace")}…`,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
onClick: () => onFindAndReplace?.(),
|
||||
icon: <SearchIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
title: t("Edit"),
|
||||
to: documentEditPath(document),
|
||||
visible:
|
||||
!!can.update && user.separateEditMode && !document.template,
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Rename")}…`,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
onClick: () => onRename?.(),
|
||||
icon: <InputIcon />,
|
||||
},
|
||||
actionToMenuItem(createNestedDocument, context),
|
||||
actionToMenuItem(importDocument, context),
|
||||
actionToMenuItem(createTemplateFromDocument, context),
|
||||
actionToMenuItem(duplicateDocument, context),
|
||||
actionToMenuItem(publishDocument, context),
|
||||
actionToMenuItem(unpublishDocument, context),
|
||||
actionToMenuItem(archiveDocument, context),
|
||||
actionToMenuItem(moveDocument, context),
|
||||
actionToMenuItem(moveTemplate, context),
|
||||
actionToMenuItem(pinDocument, context),
|
||||
actionToMenuItem(createDocumentFromTemplate, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(openDocumentComments, context),
|
||||
actionToMenuItem(openDocumentHistory, context),
|
||||
actionToMenuItem(openDocumentInsights, context),
|
||||
actionToMenuItem(downloadDocument, context),
|
||||
actionToMenuItem(copyDocument, context),
|
||||
actionToMenuItem(printDocument, context),
|
||||
actionToMenuItem(searchInDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
]}
|
||||
/>
|
||||
{(showDisplayOptions || showToggleEmbeds) && can.update && (
|
||||
<>
|
||||
<Separator />
|
||||
<DisplayOptions>
|
||||
{showToggleEmbeds && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Enable embeds")}
|
||||
labelPosition="left"
|
||||
checked={!document.embedsDisabled}
|
||||
onChange={
|
||||
document.embedsDisabled
|
||||
? document.enableEmbeds
|
||||
: document.disableEmbeds
|
||||
}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
{showDisplayOptions && !isMobile && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Full width")}
|
||||
labelPosition="left"
|
||||
checked={document.fullWidth}
|
||||
onChange={(ev) => {
|
||||
const fullWidth = ev.currentTarget.checked;
|
||||
user.setPreference(
|
||||
UserPreference.FullWidthDocuments,
|
||||
fullWidth
|
||||
);
|
||||
void user.save();
|
||||
document.fullWidth = fullWidth;
|
||||
void document.save();
|
||||
}}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
</DisplayOptions>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { MenuStateReturn } from "reakit";
|
||||
import Model from "~/models/base/Model";
|
||||
|
||||
export type MenuContext<T extends Model> = {
|
||||
/** Model for which the menu is to be designed. */
|
||||
model: T;
|
||||
/** Menu state */
|
||||
menuState: MenuStateReturn;
|
||||
};
|
||||
|
||||
export const MenuContext = React.createContext<MenuContext<Model>>(
|
||||
{} as MenuContext<Model>
|
||||
);
|
||||
|
||||
export const useMenuContext = <T extends Model>() =>
|
||||
React.useContext<MenuContext<T>>(
|
||||
MenuContext as unknown as React.Context<MenuContext<T>>
|
||||
);
|
||||
@@ -12,9 +12,7 @@ import type CollectionsStore from "~/stores/CollectionsStore";
|
||||
import Document from "~/models/Document";
|
||||
import ParanoidModel from "~/models/base/ParanoidModel";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import User from "./User";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterChange } from "./decorators/Lifecycle";
|
||||
|
||||
export default class Collection extends ParanoidModel {
|
||||
static modelName = "Collection";
|
||||
@@ -154,11 +152,6 @@ export default class Collection extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isManualSort(): boolean {
|
||||
return this.sort.field === "index";
|
||||
}
|
||||
|
||||
@computed
|
||||
get sortedDocuments(): NavigationNode[] | undefined {
|
||||
if (!this.documents) {
|
||||
@@ -182,19 +175,6 @@ export default class Collection extends ParanoidModel {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns users that have been individually given access to the collection.
|
||||
*
|
||||
* @returns A list of users that have been given access to the collection.
|
||||
*/
|
||||
@computed
|
||||
get members(): User[] {
|
||||
return this.store.rootStore.memberships.orderedData
|
||||
.filter((m) => m.collectionId === this.id)
|
||||
.map((m) => m.user)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
fetchDocuments = async (options?: { force: boolean }) => {
|
||||
if (this.isFetching) {
|
||||
return;
|
||||
@@ -225,9 +205,7 @@ export default class Collection extends ParanoidModel {
|
||||
* @param document The document properties stored in the collection
|
||||
*/
|
||||
@action
|
||||
updateDocument(
|
||||
document: Pick<Document, "id" | "title" | "url" | "color" | "icon">
|
||||
) {
|
||||
updateDocument(document: Pick<Document, "id" | "title" | "url">) {
|
||||
if (!this.documents) {
|
||||
return;
|
||||
}
|
||||
@@ -235,8 +213,6 @@ export default class Collection extends ParanoidModel {
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.id === document.id) {
|
||||
node.color = document.color ?? undefined;
|
||||
node.icon = document.icon ?? undefined;
|
||||
node.title = document.title;
|
||||
node.url = document.url;
|
||||
} else {
|
||||
@@ -348,20 +324,4 @@ export default class Collection extends ParanoidModel {
|
||||
format,
|
||||
includeAttachments,
|
||||
});
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterChange
|
||||
static removePolicies(
|
||||
model: Collection,
|
||||
previousAttributes: Partial<Collection>
|
||||
) {
|
||||
if (previousAttributes && model.sharing !== previousAttributes?.sharing) {
|
||||
const { documents, policies } = model.store.rootStore;
|
||||
|
||||
documents.inCollection(model.id).forEach((document) => {
|
||||
policies.remove(document.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-44
@@ -116,7 +116,7 @@ export default class Document extends ParanoidModel {
|
||||
collectionId?: string | null;
|
||||
|
||||
/**
|
||||
* The collection that this document belongs to.
|
||||
* The comment that this comment is a reply to.
|
||||
*/
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
collection?: Collection;
|
||||
@@ -175,9 +175,6 @@ export default class Document extends ParanoidModel {
|
||||
@observable
|
||||
parentDocumentId: string | undefined;
|
||||
|
||||
@Relation(() => Document)
|
||||
parentDocument?: Document;
|
||||
|
||||
@observable
|
||||
collaboratorIds: string[];
|
||||
|
||||
@@ -308,24 +305,6 @@ export default class Document extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the document is currently publicly shared, taking into account
|
||||
* the document's and team's sharing settings.
|
||||
*
|
||||
* @returns True if the document is publicly shared, false otherwise.
|
||||
*/
|
||||
get isPubliclyShared(): boolean {
|
||||
const { shares, auth } = this.store.rootStore;
|
||||
const share = shares.getByDocumentId(this.id);
|
||||
const sharedParent = shares.getByDocumentParents(this.id);
|
||||
|
||||
return !!(
|
||||
auth.team?.sharing !== false &&
|
||||
this.collection?.sharing !== false &&
|
||||
(share?.published || (sharedParent?.published && !this.isDraft))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns users that have been individually given access to the document.
|
||||
*
|
||||
@@ -397,26 +376,9 @@ export default class Document extends ParanoidModel {
|
||||
return floor((this.tasks.completed / this.tasks.total) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the document, using the collection structure if available.
|
||||
* otherwise if we're viewing a shared document we can iterate up the parentDocument tree.
|
||||
*
|
||||
* @returns path to the document
|
||||
*/
|
||||
@computed
|
||||
get pathTo() {
|
||||
if (this.collection?.documents) {
|
||||
return this.collection.pathToDocument(this.id);
|
||||
}
|
||||
|
||||
// find root parent document we have access to
|
||||
const path: Document[] = [this];
|
||||
|
||||
while (path[0]?.parentDocument) {
|
||||
path.unshift(path[0].parentDocument);
|
||||
}
|
||||
|
||||
return path.map((item) => item.asNavigationNode);
|
||||
return this.collection?.pathToDocument(this.id) ?? [];
|
||||
}
|
||||
|
||||
@computed
|
||||
@@ -611,8 +573,7 @@ export default class Document extends ParanoidModel {
|
||||
@computed
|
||||
get childDocuments() {
|
||||
return this.store.orderedData.filter(
|
||||
(doc) =>
|
||||
doc.parentDocumentId === this.id && this.isActive === doc.isActive
|
||||
(doc) => doc.parentDocumentId === this.id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -621,8 +582,6 @@ export default class Document extends ParanoidModel {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
color: this.color ?? undefined,
|
||||
icon: this.icon ?? undefined,
|
||||
children: this.childDocuments.map((doc) => doc.asNavigationNode),
|
||||
url: this.url,
|
||||
isDraft: this.isDraft,
|
||||
|
||||
+1
-42
@@ -1,5 +1,4 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import GroupMembership from "./GroupMembership";
|
||||
import { observable } from "mobx";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
@@ -16,46 +15,6 @@ class Group extends Model {
|
||||
|
||||
@observable
|
||||
memberCount: number;
|
||||
|
||||
/**
|
||||
* Returns the users that are members of this group.
|
||||
*/
|
||||
@computed
|
||||
get users() {
|
||||
const { users } = this.store.rootStore;
|
||||
return users.inGroup(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the direct memberships that this group has to documents. Documents that the current
|
||||
* user already has access to through a collection and trashed documents are not included.
|
||||
*
|
||||
* @returns A list of group memberships
|
||||
*/
|
||||
@computed
|
||||
get documentMemberships(): GroupMembership[] {
|
||||
const { groupMemberships, groupUsers, documents, policies, auth } =
|
||||
this.store.rootStore;
|
||||
|
||||
return groupMemberships.orderedData
|
||||
.filter((groupMembership) =>
|
||||
groupUsers.orderedData.some(
|
||||
(groupUser) =>
|
||||
groupUser.groupId === groupMembership.groupId &&
|
||||
groupUser.userId === auth.user?.id
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
(m) => m.groupId === this.id && m.sourceId === null && m.documentId
|
||||
)
|
||||
.filter((m) => {
|
||||
const document = documents.get(m.documentId!);
|
||||
const policy = document?.collectionId
|
||||
? policies.get(document.collectionId)
|
||||
: undefined;
|
||||
return !policy?.abilities?.readDocument && !document?.isDeleted;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Group;
|
||||
|
||||
@@ -4,7 +4,6 @@ import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Group from "./Group";
|
||||
import Model from "./base/Model";
|
||||
import { AfterRemove } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
/**
|
||||
@@ -34,23 +33,9 @@ class GroupMembership extends Model {
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
collection: Collection | undefined;
|
||||
|
||||
/** The source ID points to the root membership from which this inherits */
|
||||
sourceId?: string;
|
||||
|
||||
/** The source points to the root membership from which this inherits */
|
||||
@Relation(() => GroupMembership, { onDelete: "cascade" })
|
||||
source?: GroupMembership;
|
||||
|
||||
/** The permission level granted to the group. */
|
||||
@observable
|
||||
permission: CollectionPermission | DocumentPermission;
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterRemove
|
||||
public static removeFromPolicies(model: GroupMembership) {
|
||||
model.store.rootStore.policies.removeForMembership(model.id);
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupMembership;
|
||||
|
||||
@@ -25,6 +25,8 @@ class Integration<T = unknown> extends Model {
|
||||
@Relation(() => User, { onDelete: "cascade" })
|
||||
user: User;
|
||||
|
||||
teamId: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
events: string[];
|
||||
|
||||
@@ -3,7 +3,6 @@ import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "./Collection";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import { AfterRemove } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Membership extends Model {
|
||||
@@ -23,13 +22,6 @@ class Membership extends Model {
|
||||
|
||||
@observable
|
||||
permission: CollectionPermission;
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterRemove
|
||||
public static removeFromPolicies(model: Membership) {
|
||||
model.store.rootStore.policies.removeForMembership(model.id);
|
||||
}
|
||||
}
|
||||
|
||||
export default Membership;
|
||||
|
||||
+4
-45
@@ -1,54 +1,13 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import { observable } from "mobx";
|
||||
import Model from "./base/Model";
|
||||
import { AfterChange } from "./decorators/Lifecycle";
|
||||
|
||||
class Policy extends Model {
|
||||
static modelName = "Policy";
|
||||
|
||||
/**
|
||||
* An object containing keys representing abilities and values that are either
|
||||
* a boolean or an array of membership IDs that have provided access to the ability.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
@observable
|
||||
abilities: Record<string, boolean | string[]>;
|
||||
|
||||
/**
|
||||
* Abilities flattened to an object with boolean values.
|
||||
*/
|
||||
@computed
|
||||
get flattenedAbilities() {
|
||||
const abilities: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(this.abilities)) {
|
||||
if (Array.isArray(value)) {
|
||||
// Array should never be empty, but we check as a safety measure.
|
||||
abilities[key] = value.length > 0;
|
||||
} else {
|
||||
abilities[key] = value as boolean;
|
||||
}
|
||||
}
|
||||
return abilities;
|
||||
}
|
||||
|
||||
@AfterChange
|
||||
public static removeChildPolicies(model: Policy) {
|
||||
const { documents, collections, policies } = model.store.rootStore;
|
||||
|
||||
const collection = collections.get(model.id);
|
||||
if (collection) {
|
||||
documents.inCollection(collection.id).forEach((i) => {
|
||||
policies.remove(i.id);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const document = documents.get(model.id);
|
||||
if (document) {
|
||||
document.childDocuments.forEach((i) => {
|
||||
policies.remove(i.id);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
abilities: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export default Policy;
|
||||
|
||||
+5
-25
@@ -13,7 +13,6 @@ import {
|
||||
import type { NotificationSettings } from "@shared/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Document from "./Document";
|
||||
import Group from "./Group";
|
||||
import UserMembership from "./UserMembership";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
@@ -128,40 +127,21 @@ class User extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the direct memberships that this user has to documents. Documents that the
|
||||
* user already has access to through a collection and trashed documents are not included.
|
||||
*
|
||||
* @returns A list of user memberships
|
||||
*/
|
||||
@computed
|
||||
get documentMemberships(): UserMembership[] {
|
||||
const { userMemberships, documents, policies } = this.store.rootStore;
|
||||
return userMemberships.orderedData
|
||||
get memberships(): UserMembership[] {
|
||||
return this.store.rootStore.userMemberships.orderedData
|
||||
.filter(
|
||||
(m) => m.userId === this.id && m.sourceId === null && m.documentId
|
||||
)
|
||||
.filter((m) => {
|
||||
const document = documents.get(m.documentId!);
|
||||
const document = this.store.rootStore.documents.get(m.documentId!);
|
||||
const policy = document?.collectionId
|
||||
? policies.get(document.collectionId)
|
||||
? this.store.rootStore.policies.get(document.collectionId)
|
||||
: undefined;
|
||||
return !policy?.abilities?.readDocument && !document?.isDeleted;
|
||||
return !policy?.abilities?.readDocument;
|
||||
});
|
||||
}
|
||||
|
||||
@computed
|
||||
get groupsWithDocumentMemberships() {
|
||||
const { groups, groupUsers } = this.store.rootStore;
|
||||
|
||||
return groupUsers.orderedData
|
||||
.filter((groupUser) => groupUser.userId === this.id)
|
||||
.map((groupUser) => groups.get(groupUser.groupId))
|
||||
.filter(Boolean)
|
||||
.filter((group) => group && group.documentMemberships.length > 0)
|
||||
.sort((a, b) => a!.name.localeCompare(b!.name)) as Group[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current preference for the given notification event type taking
|
||||
* into account the default system value.
|
||||
|
||||
@@ -5,7 +5,6 @@ import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterRemove } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class UserMembership extends Model {
|
||||
@@ -71,13 +70,6 @@ class UserMembership extends Model {
|
||||
const index = memberships.indexOf(this);
|
||||
return memberships[index + 1];
|
||||
}
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterRemove
|
||||
public static removeFromPolicies(model: UserMembership) {
|
||||
model.store.rootStore.policies.removeForMembership(model.id);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserMembership;
|
||||
|
||||
+11
-48
@@ -1,10 +1,9 @@
|
||||
import pick from "lodash/pick";
|
||||
import { observable, action } from "mobx";
|
||||
import { set, observable, action } from "mobx";
|
||||
import { JSONObject } from "@shared/types";
|
||||
import type Store from "~/stores/base/Store";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { getFieldsForModel } from "../decorators/Field";
|
||||
import { LifecycleManager } from "../decorators/Lifecycle";
|
||||
import { getRelationsForModelClass } from "../decorators/Relation";
|
||||
|
||||
export default abstract class Model {
|
||||
@@ -31,7 +30,6 @@ export default abstract class Model {
|
||||
this.store = store;
|
||||
this.updateData(fields);
|
||||
this.isNew = !this.id;
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,9 +37,7 @@ export default abstract class Model {
|
||||
*
|
||||
* @returns A promise that resolves when loading is complete.
|
||||
*/
|
||||
async loadRelations(
|
||||
options: { withoutPolicies?: boolean } = {}
|
||||
): Promise<any> {
|
||||
async loadRelations(): Promise<any> {
|
||||
const relations = getRelationsForModelClass(
|
||||
this.constructor as typeof Model
|
||||
);
|
||||
@@ -60,15 +56,12 @@ export default abstract class Model {
|
||||
properties.relationClassResolver().modelName
|
||||
);
|
||||
if ("fetch" in store) {
|
||||
const id = this[properties.idKey];
|
||||
if (id) {
|
||||
promises.push(store.fetch(id));
|
||||
}
|
||||
promises.push(store.fetch(this[properties.idKey]));
|
||||
}
|
||||
}
|
||||
|
||||
const policy = this.store.rootStore.policies.get(this.id);
|
||||
if (!policy && !options.withoutPolicies) {
|
||||
if (!policy) {
|
||||
promises.push(this.store.fetch(this.id, { force: true }));
|
||||
}
|
||||
|
||||
@@ -91,7 +84,6 @@ export default abstract class Model {
|
||||
params?: Record<string, any>,
|
||||
options?: Record<string, string | boolean | number | undefined>
|
||||
): Promise<Model> => {
|
||||
const isNew = this.isNew;
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
@@ -100,12 +92,6 @@ export default abstract class Model {
|
||||
params = this.toAPI();
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
LifecycleManager.executeHooks(this.constructor, "beforeCreate", this);
|
||||
} else {
|
||||
LifecycleManager.executeHooks(this.constructor, "beforeUpdate", this);
|
||||
}
|
||||
|
||||
const model = await this.store.save(
|
||||
{
|
||||
...params,
|
||||
@@ -113,18 +99,14 @@ export default abstract class Model {
|
||||
},
|
||||
{
|
||||
...options,
|
||||
isNew,
|
||||
isNew: this.isNew,
|
||||
}
|
||||
);
|
||||
|
||||
// if saving is successful set the new values on the model itself
|
||||
this.updateData({ ...params, ...model });
|
||||
set(this, { ...params, ...model, isNew: false });
|
||||
|
||||
if (isNew) {
|
||||
LifecycleManager.executeHooks(this.constructor, "afterCreate", this);
|
||||
} else {
|
||||
LifecycleManager.executeHooks(this.constructor, "afterUpdate", this);
|
||||
}
|
||||
this.persistedAttributes = this.toAPI();
|
||||
|
||||
return model;
|
||||
} finally {
|
||||
@@ -133,12 +115,6 @@ export default abstract class Model {
|
||||
};
|
||||
|
||||
updateData = action((data: Partial<Model>) => {
|
||||
if (this.initialized) {
|
||||
LifecycleManager.executeHooks(this.constructor, "beforeChange", this);
|
||||
}
|
||||
|
||||
const previousAttributes = this.toAPI();
|
||||
|
||||
for (const key in data) {
|
||||
try {
|
||||
this[key] = data[key];
|
||||
@@ -149,15 +125,6 @@ export default abstract class Model {
|
||||
|
||||
this.isNew = false;
|
||||
this.persistedAttributes = this.toAPI();
|
||||
|
||||
if (!this.initialized) {
|
||||
LifecycleManager.executeHooks(
|
||||
this.constructor,
|
||||
"afterChange",
|
||||
this,
|
||||
previousAttributes
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
fetch = (options?: JSONObject) => this.store.fetch(this.id, options);
|
||||
@@ -171,10 +138,7 @@ export default abstract class Model {
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
LifecycleManager.executeHooks(this.constructor, "beforeDelete", this);
|
||||
const response = await this.store.delete(this);
|
||||
LifecycleManager.executeHooks(this.constructor, "afterDelete", this);
|
||||
return response;
|
||||
return await this.store.delete(this);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
@@ -235,9 +199,8 @@ export default abstract class Model {
|
||||
|
||||
protected persistedAttributes: Partial<Model> = {};
|
||||
|
||||
/** A promise that resolves when all relations have been loaded. */
|
||||
/**
|
||||
* A promise that resolves when all relations have been loaded
|
||||
*/
|
||||
private loadingRelations: Promise<any[]> | undefined;
|
||||
|
||||
/** A boolean representing if the constructor has been called. */
|
||||
private initialized = false;
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
export class LifecycleManager {
|
||||
private static hooks = new Map();
|
||||
|
||||
public static getHooks(target: any, lifecycle: string) {
|
||||
const key = `lifecycle:${lifecycle}`;
|
||||
const modelHooks = this.hooks.get(target.name);
|
||||
return modelHooks?.get(key) || [];
|
||||
}
|
||||
|
||||
public static executeHooks(target: any, lifecycle: string, ...args: any[]) {
|
||||
const hooks = this.getHooks(target, lifecycle);
|
||||
hooks.forEach((hook: keyof typeof target) => {
|
||||
target[hook](...args);
|
||||
});
|
||||
}
|
||||
|
||||
public static registerHook(
|
||||
target: any,
|
||||
propertyKey: string,
|
||||
lifecycle: string
|
||||
) {
|
||||
const key = `lifecycle:${lifecycle}`;
|
||||
let modelHooks = this.hooks.get(target.name);
|
||||
|
||||
if (!modelHooks) {
|
||||
modelHooks = new Map();
|
||||
this.hooks.set(target.name, modelHooks);
|
||||
}
|
||||
|
||||
let lifecycleHooks = modelHooks.get(key);
|
||||
if (!lifecycleHooks) {
|
||||
lifecycleHooks = [];
|
||||
modelHooks.set(key, lifecycleHooks);
|
||||
}
|
||||
|
||||
lifecycleHooks.push(propertyKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function BeforeCreate(target: any, propertyKey: string) {
|
||||
LifecycleManager.registerHook(target, propertyKey, "beforeCreate");
|
||||
}
|
||||
|
||||
export function AfterCreate(target: any, propertyKey: string) {
|
||||
LifecycleManager.registerHook(target, propertyKey, "afterCreate");
|
||||
}
|
||||
|
||||
export function BeforeUpdate(target: any, propertyKey: string) {
|
||||
LifecycleManager.registerHook(target, propertyKey, "beforeUpdate");
|
||||
}
|
||||
|
||||
export function AfterUpdate(target: any, propertyKey: string) {
|
||||
LifecycleManager.registerHook(target, propertyKey, "afterUpdate");
|
||||
}
|
||||
|
||||
export function BeforeChange(target: any, propertyKey: string) {
|
||||
LifecycleManager.registerHook(target, propertyKey, "beforeChange");
|
||||
}
|
||||
|
||||
export function AfterChange(target: any, propertyKey: string) {
|
||||
LifecycleManager.registerHook(target, propertyKey, "afterChange");
|
||||
}
|
||||
|
||||
export function BeforeRemove(target: any, propertyKey: string) {
|
||||
LifecycleManager.registerHook(target, propertyKey, "beforeRemove");
|
||||
}
|
||||
|
||||
export function AfterRemove(target: any, propertyKey: string) {
|
||||
LifecycleManager.registerHook(target, propertyKey, "afterRemove");
|
||||
}
|
||||
|
||||
export function BeforeDelete(target: any, propertyKey: string) {
|
||||
LifecycleManager.registerHook(target, propertyKey, "beforeDelete");
|
||||
}
|
||||
|
||||
export function AfterDelete(target: any, propertyKey: string) {
|
||||
LifecycleManager.registerHook(target, propertyKey, "afterDelete");
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export const getInverseRelationsForModelClass = (targetClass: typeof Model) => {
|
||||
if (
|
||||
properties.relationClassResolver().modelName === targetClass.modelName
|
||||
) {
|
||||
inverseRelations.set(`${modelName}-${propertyName}`, {
|
||||
inverseRelations.set(propertyName, {
|
||||
...properties,
|
||||
modelName,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
|
||||
import Collection from "~/models/Collection";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
@@ -113,7 +113,10 @@ function CollectionScene() {
|
||||
void fetchData();
|
||||
}, [collections, isFetching, collection, error, id, can]);
|
||||
|
||||
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
|
||||
useCommandBarActions(
|
||||
[editCollection],
|
||||
ui.activeCollectionId ? [ui.activeCollectionId] : undefined
|
||||
);
|
||||
|
||||
if (!collection && error) {
|
||||
return <Search notFound />;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ProsemirrorData } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation, CommentValidation } from "@shared/validations";
|
||||
import Comment from "~/models/Comment";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
@@ -10,7 +10,7 @@ import { s } from "@shared/styles";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -67,7 +67,6 @@ function CommentThread({
|
||||
const { editor } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
const replyRef = React.useRef<HTMLDivElement>(null);
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
@@ -124,13 +123,13 @@ function CommentThread({
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
if (!replyRef.current) {
|
||||
if (!topRef.current) {
|
||||
return;
|
||||
}
|
||||
return scrollIntoView(replyRef.current, {
|
||||
return scrollIntoView(topRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
block: "end",
|
||||
boundary: (parent) =>
|
||||
// Prevents body and other parent elements from being scrolled
|
||||
parent.id !== "comments",
|
||||
@@ -203,7 +202,7 @@ function CommentThread({
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
|
||||
<ResizingHeightContainer hideOverflow={false}>
|
||||
{(focused || draft || commentsInThread.length === 0) && can.comment && (
|
||||
<Fade timing={100}>
|
||||
<CommentForm
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ProsemirrorData } from "@shared/types";
|
||||
import { dateToRelative } from "@shared/utils/date";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import Comment from "~/models/Comment";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
@@ -53,7 +53,7 @@ function useShowTime(
|
||||
|
||||
return (
|
||||
!msSincePreviousComment ||
|
||||
(msSincePreviousComment > 15 * Minute.ms &&
|
||||
(msSincePreviousComment > 15 * Minute &&
|
||||
previousTimeStamp !== currentTimeStamp)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
|
||||
import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
|
||||
const HEADING_OFFSET = 20;
|
||||
|
||||
@@ -31,7 +30,7 @@ export default function Contents({ headings }: Props) {
|
||||
for (let key = 0; key < headings.length; key++) {
|
||||
const heading = headings[key];
|
||||
const element = window.document.getElementById(
|
||||
decodeURIComponentSafe(heading.id)
|
||||
decodeURIComponent(heading.id)
|
||||
);
|
||||
|
||||
if (element) {
|
||||
|
||||
@@ -53,7 +53,8 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
|
||||
};
|
||||
|
||||
function DataLoader({ match, children }: Props) {
|
||||
const { ui, views, shares, comments, documents, revisions } = useStores();
|
||||
const { ui, views, shares, comments, documents, revisions, subscriptions } =
|
||||
useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const [error, setError] = React.useState<Error | null>(null);
|
||||
@@ -120,6 +121,22 @@ function DataLoader({ match, children }: Props) {
|
||||
void fetchRevision();
|
||||
}, [document, revisionId, revisions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchSubscription() {
|
||||
if (document?.id && !document?.isDeleted && !revisionId) {
|
||||
try {
|
||||
await subscriptions.fetchPage({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Failed to fetch subscriptions", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
void fetchSubscription();
|
||||
}, [document?.id, document?.isDeleted, subscriptions, revisionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchViews() {
|
||||
if (document?.id && !document?.isDeleted && !revisionId) {
|
||||
@@ -141,17 +158,12 @@ function DataLoader({ match, children }: Props) {
|
||||
throw new Error("Document not loaded yet");
|
||||
}
|
||||
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: nested ? undefined : document.collectionId,
|
||||
parentDocumentId: nested ? document.id : document.parentDocumentId,
|
||||
title,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{
|
||||
publish: document.isDraft ? undefined : true,
|
||||
}
|
||||
);
|
||||
const newDocument = await documents.create({
|
||||
collectionId: nested ? undefined : document.collectionId,
|
||||
parentDocumentId: nested ? document.id : document.parentDocumentId,
|
||||
title,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
});
|
||||
|
||||
return newDocument.url;
|
||||
},
|
||||
@@ -200,10 +212,6 @@ function DataLoader({ match, children }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (can.read === false) {
|
||||
return <Error404 />;
|
||||
}
|
||||
|
||||
if (!document || (revisionId && !revision)) {
|
||||
return (
|
||||
<>
|
||||
@@ -212,16 +220,14 @@ function DataLoader({ match, children }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const readOnly =
|
||||
!isEditing || !can.update || document.isArchived || !!revisionId;
|
||||
|
||||
return (
|
||||
<React.Fragment key={readOnly ? "readOnly" : ""}>
|
||||
<React.Fragment>
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
abilities: can,
|
||||
readOnly,
|
||||
readOnly:
|
||||
!isEditing || !can.update || document.isArchived || !!revisionId,
|
||||
onCreateLink,
|
||||
sharedTree,
|
||||
})}
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
documentPath,
|
||||
matchDocumentHistory,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||
import DocumentMeta from "./DocumentMeta";
|
||||
import DocumentTitle from "./DocumentTitle";
|
||||
@@ -179,16 +178,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
|
||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||
|
||||
const childOffsetHeight = childRef.current?.offsetHeight || 0;
|
||||
const editorStyle = React.useMemo(
|
||||
() => ({
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
||||
}),
|
||||
[childOffsetHeight]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
<DocumentTitle
|
||||
@@ -225,7 +214,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
ref={mergeRefs([ref, handleRefChanged])}
|
||||
autoFocus={!!document.title && !props.defaultValue}
|
||||
placeholder={t("Type '/' to insert, or start writing…")}
|
||||
scrollTo={decodeURIComponentSafe(window.location.hash)}
|
||||
scrollTo={decodeURIComponent(window.location.hash)}
|
||||
readOnly={readOnly}
|
||||
shareId={shareId}
|
||||
userId={user?.id}
|
||||
@@ -242,7 +231,13 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
: undefined
|
||||
}
|
||||
extensions={extensions}
|
||||
editorStyle={editorStyle}
|
||||
editorStyle={{
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(50vh - ${
|
||||
childRef.current?.offsetHeight || 0
|
||||
}px)`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
<div ref={childRef}>{children}</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import User from "~/models/User";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user