Compare commits

..

22 Commits

Author SHA1 Message Date
Tom Moor fe2c0d75ef Switch icon 2024-07-24 21:56:16 -04:00
Tom Moor 2e9c451979 fix: Hover on DD
fix: Guard editing state
2024-07-24 21:49:34 -04:00
Tom Moor 3a97361654 Fixed attribute order 2024-07-24 21:49:34 -04:00
Tom Moor a8bdc14ca2 Remove save button 2024-07-24 21:49:34 -04:00
Tom Moor 563b41e34a fix: Correct updatedAt on document data attributes 2024-07-24 21:49:34 -04:00
Tom Moor 0621706a95 Add realtime events 2024-07-24 21:49:33 -04:00
Tom Moor ec1cb61807 fix initial attribute 2024-07-24 21:49:09 -04:00
Tom Moor 5850181c29 Do not render properties div if none 2024-07-24 21:49:09 -04:00
Tom Moor d29bc22676 Property editing 2024-07-24 21:49:09 -04:00
Tom Moor a9dd771598 tsc 2024-07-24 21:49:09 -04:00
Tom Moor 35d185a971 wip 2024-07-24 21:49:09 -04:00
Tom Moor d6c85f0aac wip 2024-07-24 21:49:09 -04:00
Tom Moor b721287d19 Settings UI 2024-07-24 21:49:09 -04:00
Tom Moor 57296e3139 dataAttributes.delete endpoint 2024-07-24 21:49:09 -04:00
Tom Moor 4210f877b4 stash 2024-07-24 21:49:09 -04:00
Tom Moor 4db8682423 wip 2024-07-24 21:49:09 -04:00
Tom Moor 74229813d2 stash 2024-07-24 21:49:09 -04:00
Tom Moor 75e457feee stash 2024-07-24 21:49:07 -04:00
Tom Moor 36682574c6 stash 2024-07-24 21:48:54 -04:00
Tom Moor c636c4e5df stash 2024-07-24 21:48:52 -04:00
Tom Moor c23b5d1ef4 Model, endpoints 2024-07-24 21:48:28 -04:00
Tom Moor 9f8298d012 Migration 2024-07-24 21:47:27 -04:00
449 changed files with 9090 additions and 12779 deletions
+1 -1
View File
@@ -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
-4
View File
@@ -189,10 +189,6 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
-7
View File
@@ -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"]
+5
View File
@@ -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 -3
View File
@@ -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
+2 -8
View File
@@ -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
}
}
}
}
+10 -10
View File
@@ -72,7 +72,7 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: CollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
@@ -98,10 +98,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: CollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
perform: ({ t, stores, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -143,7 +143,7 @@ export const starCollection = createAction({
section: CollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -153,7 +153,7 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId }) => {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -170,7 +170,7 @@ export const unstarCollection = createAction({
section: CollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -180,7 +180,7 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId }) => {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -196,13 +196,13 @@ export const deleteCollection = createAction({
section: CollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t }) => {
perform: ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
@@ -230,7 +230,7 @@ export const createTemplate = createAction({
section: CollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
@@ -0,0 +1,25 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { DataAttributeNew } from "~/components/DataAttribute/DataAttributeNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createDataAttribute = createAction({
name: ({ t }) => t("New attribute"),
analyticsName: "New attribute",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createDataAttribute,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New attribute"),
content: <DataAttributeNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+31 -136
View File
@@ -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,11 +37,10 @@ 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 DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import DuplicateDialog from "~/components/DuplicateDialog";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection, TrashSection } from "~/actions/sections";
import env from "~/env";
@@ -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,
}),
});
@@ -224,7 +223,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId || document?.template) {
if (document?.collectionId) {
await document.save(undefined, {
publish: true,
});
@@ -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();
@@ -699,7 +688,7 @@ export const createTemplateFromDocument = createAction({
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).updateDocument
stores.policies.abilities(activeCollectionId).update
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -725,11 +714,11 @@ export const openRandomDocument = createAction({
const documentPaths = stores.collections.pathsToDocuments.filter(
(path) => path.type === "document" && path.id !== activeDocumentId
);
const randomPath =
const documentPath =
documentPaths[Math.round(Math.random() * documentPaths.length)];
if (randomPath) {
history.push(randomPath.url);
if (documentPath) {
history.push(documentPath.url);
}
},
});
@@ -746,50 +735,11 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -813,46 +763,8 @@ export const moveDocumentToCollection = createAction({
},
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
name: ({ t }) => t("Archive"),
analyticsName: "Archive document",
section: DocumentSection,
icon: <ArchiveIcon />,
@@ -863,30 +775,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"));
}
},
});
@@ -1101,8 +997,7 @@ export const rootDocumentActions = [
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
moveDocument,
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
+1 -3
View File
@@ -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);
}
},
});
+2 -2
View File
@@ -17,7 +17,7 @@ export const restoreRevision = createAction({
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId }) =>
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId }) => {
event?.preventDefault();
@@ -47,7 +47,7 @@ export const copyLinkToRevision = createAction({
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, t }) => {
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
+3 -3
View File
@@ -18,7 +18,7 @@ export const inviteUser = createAction({
icon: <PlusIcon />,
keywords: "team member workspace user",
section: UserSection,
visible: () =>
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => {
stores.dialogs.openModal({
@@ -40,7 +40,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
})}…`,
analyticsName: "Update user role",
section: UserSection,
visible: () => {
visible: ({ stores }) => {
const can = stores.policies.abilities(user.id);
return UserRoleHelper.isRoleHigher(role, user.role)
@@ -70,7 +70,7 @@ export const deleteUserActionFactory = (userId: string) =>
keywords: "leave",
dangerous: true,
section: UserSection,
visible: () => stores.policies.abilities(userId).delete,
visible: ({ stores }) => stores.policies.abilities(userId).delete,
perform: ({ t }) => {
const user = stores.users.get(userId);
if (!user) {
-18
View File
@@ -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}</>;
};
+1 -1
View File
@@ -19,7 +19,7 @@ function ArrowKeyNavigation(
return;
}
if (ev.key === "Escape" || ev.key === "Backspace") {
if (ev.key === "Escape") {
ev.preventDefault();
onEscape(ev);
}
+1 -1
View File
@@ -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;
-35
View File
@@ -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 -2
View File
@@ -1,5 +1,4 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
const Initials = styled(Flex)<{
@@ -12,7 +11,7 @@ const Initials = styled(Flex)<{
border-radius: 50%;
width: 100%;
height: 100%;
color: ${s("white75")};
color: #fff;
background-color: ${(props) => props.color};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
+3 -4
View File
@@ -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;
+1 -1
View File
@@ -105,7 +105,7 @@ const RealButton = styled(ActionButton)<RealProps>`
background: ${lighten(0.05, props.theme.danger)};
}
&:focus-visible {
&.focus-visible {
outline-color: ${darken(0.2, props.theme.danger)} !important;
}
`};
+10 -23
View File
@@ -1,13 +1,13 @@
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import sortBy from "lodash/sortBy";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
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";
@@ -16,14 +16,9 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
type Props = {
/** The document to display live collaborators for */
document: Document;
};
/**
* Displays a list of live collaborators for a document, including their avatars
* and presence status.
*/
function Collaborators(props: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
@@ -44,16 +39,15 @@ function Collaborators(props: Props) {
// ensure currently present via websocket are always ordered first
const collaborators = React.useMemo(
() =>
orderBy(
sortBy(
filter(
users.orderedData,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
!u.isSuspended
(user) =>
(presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)) &&
!user.isSuspended
),
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
(user) => presentIds.includes(user.id)
),
[document.collaboratorIds, users.orderedData, presentIds]
);
@@ -75,19 +69,12 @@ function Collaborators(props: Props) {
placement: "bottom-end",
});
const limit = 8;
return (
<>
<PopoverDisclosure {...popover}>
{(popoverProps) => (
<NudeButton
width={Math.min(collaborators.length, limit) * 32}
height={32}
{...popoverProps}
>
{(props) => (
<NudeButton width={collaborators.length * 32} height={32} {...props}>
<Facepile
limit={limit}
users={collaborators}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
+2 -2
View File
@@ -69,8 +69,8 @@ function CommandBarItem(
) : (
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{key}</Key>
{sc.split("+").map((s) => (
<Key key={s}>{s}</Key>
))}
</React.Fragment>
))}
+4 -3
View File
@@ -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")`
+4 -14
View File
@@ -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";
@@ -23,7 +22,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactElement;
icon?: React.ReactElement | null;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
@@ -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>
@@ -153,7 +152,7 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
&.focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
@@ -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);
+3 -54
View File
@@ -6,7 +6,6 @@ import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
@@ -51,8 +50,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 +134,6 @@ type InnerContextMenuProps = MenuStateReturn & {
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
@@ -175,32 +171,6 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
};
}, [props.isSubMenu, props.visible]);
useEventListener(
"animationstart",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "none";
}
}
},
backgroundRef.current
);
useEventListener(
"animationend",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "auto";
}
}
},
backgroundRef.current
);
const style =
topAnchor && !isMobile
? {
@@ -223,7 +193,6 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
@@ -254,30 +223,10 @@ export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
// Note: pointer events are re-enabled after the animation ends, see event listeners above
pointer-events: none;
&:focus-visible {
&.focus-visible {
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 +247,6 @@ type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
@@ -310,8 +258,9 @@ 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;
pointer-events: all;
font-weight: normal;
@media print {
@@ -0,0 +1,34 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import { DataAttributeForm, FormData } from "./DataAttributeForm";
type Props = {
dataAttribute: DataAttribute;
onSubmit: () => void;
};
export const DataAttributeEdit = observer(function DataAttributeEdit_({
dataAttribute,
onSubmit,
}: Props) {
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await dataAttribute.save(data);
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[dataAttribute, onSubmit]
);
return (
<DataAttributeForm
dataAttribute={dataAttribute}
handleSubmit={handleSubmit}
/>
);
});
@@ -0,0 +1,212 @@
import { observer } from "mobx-react";
import { CloseIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import {
DataAttributeDataType,
type DataAttributeOptions,
} from "@shared/models/types";
import { DataAttributeValidation } from "@shared/validations";
import type DataAttribute from "~/models/DataAttribute";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
import InputSelect from "../InputSelect";
import NudeButton from "../NudeButton";
type Props = {
handleSubmit: (data: FormData) => void;
dataAttribute?: DataAttribute;
};
export interface FormData {
name: string;
description?: string;
dataType: DataAttributeDataType;
options?: DataAttributeOptions;
}
export const DataAttributeForm = observer(function DataAttributeForm_({
handleSubmit,
dataAttribute,
}: Props) {
const theme = useTheme();
const { t } = useTranslation();
const {
register,
handleSubmit: formHandleSubmit,
formState,
watch,
control,
setFocus,
setValue,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: dataAttribute?.name,
description: dataAttribute?.description ?? undefined,
dataType: dataAttribute?.dataType ?? DataAttributeDataType.String,
options: dataAttribute?.options ?? undefined,
},
});
const values = watch();
const isEditing = !!dataAttribute;
React.useEffect(() => {
if (isEditing) {
return;
}
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [isEditing, setFocus]);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<div>
<Controller
control={control}
name="dataType"
render={({ field }) => (
<InputSelect
ref={field.ref}
value={field.value}
disabled={isEditing}
onChange={(value: DataAttributeDataType) => {
field.onChange(value);
if (value === DataAttributeDataType.List) {
setValue("options", {
options: [
{
value: "",
},
{
value: "",
},
],
});
}
}}
ariaLabel={t("Format")}
label={t("Format")}
options={Object.values(DataAttributeDataType).map((dataType) => ({
value: dataType,
label: DataAttributesHelper.getName(dataType, t),
}))}
style={{ width: "auto" }}
/>
)}
/>
</div>
{values.dataType === DataAttributeDataType.List && (
<Options gap={8} column>
{values.options?.options?.map((option, index) => (
<Flex gap={4} align="center" key={index}>
<Input
value={option.value}
onChange={(event) => {
const newOptions = [...(values.options?.options ?? [])];
newOptions[index] = { value: event.target.value };
setValue("options", { options: newOptions });
}}
type="text"
autoComplete="off"
autoFocus={index !== 1}
minLength={DataAttributeValidation.minOptionLength}
maxLength={DataAttributeValidation.maxOptionLength}
margin={0}
required
flex
/>
<NudeButton
disabled={
(values.options?.options?.length ?? 0) <=
DataAttributeValidation.minOptions
}
onClick={() => {
const newOptions = [...(values.options?.options ?? [])];
newOptions.splice(index, 1);
setValue("options", { options: newOptions });
}}
>
<CloseIcon color={theme.textSecondary} />
</NudeButton>
</Flex>
))}
<div>
<Controller
control={control}
name="options"
render={({ field }) => (
<Button
neutral
borderOnHover
icon={<PlusIcon size={20} />}
disabled={
(values.options?.options?.length ?? 0) >=
DataAttributeValidation.maxOptions
}
onClick={() => {
field.onChange({
options: [
...(field.value?.options ?? []),
{
value: "",
},
],
});
}}
>
{t("Add option")}
</Button>
)}
/>
</div>
</Options>
)}
<Input
type="text"
label={t("Name")}
{...register("name", {
required: true,
minLength: DataAttributeValidation.minNameLength,
maxLength: DataAttributeValidation.maxNameLength,
})}
autoComplete="off"
autoFocus
flex
/>
<Input
type="text"
label={t("Description")}
placeholder={t("Optional")}
{...register("description", {
maxLength: DataAttributeValidation.maxDescriptionLength,
})}
autoComplete="off"
flex
/>
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{dataAttribute
? formState.isSubmitting
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
);
});
const Options = styled(Flex)`
margin-left: 16px;
margin-bottom: 16px;
`;
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import useStores from "~/hooks/useStores";
import { DataAttributeForm, FormData } from "./DataAttributeForm";
type Props = {
onSubmit: () => void;
};
export const DataAttributeNew = observer(function DataAttributeNew_({
onSubmit,
}: Props) {
const { dataAttributes } = useStores();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const dataAttribute = new DataAttribute(data, dataAttributes);
await dataAttribute.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[dataAttributes, onSubmit]
);
return <DataAttributeForm handleSubmit={handleSubmit} />;
});
+2 -4
View File
@@ -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,
+7 -6
View File
@@ -76,7 +76,8 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
return (
<DocumentLink
@@ -110,6 +111,11 @@ function DocumentListItem(
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isDraft && showDraft && (
<Tooltip
content={t("Only visible to you")}
@@ -119,11 +125,6 @@ function DocumentListItem(
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
@@ -0,0 +1,49 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize();
if (template) {
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
}
}, [document, history, t]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
+1 -1
View File
@@ -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";
+2 -2
View File
@@ -70,14 +70,14 @@ function DuplicateDialog({ document, onSubmit }: Props) {
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
label={t("Published")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
{document.publishedAt && (
<Text size="small">
<Switch
name="recursive"
+2 -9
View File
@@ -12,11 +12,10 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
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";
@@ -159,9 +158,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
}
actions={
isRevision && isActive && event.modelId && !latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.modelId} />
</StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
onMouseEnter={prefetchRevision}
@@ -178,10 +175,6 @@ const BaseItem = React.forwardRef(function _BaseItem(
return <ListItem to={to} ref={ref} {...rest} />;
});
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const Subtitle = styled.span`
svg {
margin: -3px;
+2 -1
View File
@@ -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[];
+24 -182
View File
@@ -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;
+6 -1
View File
@@ -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";
+2 -1
View File
@@ -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")};
}
`;
@@ -44,7 +44,6 @@ const Row = ({ index, style, data }: ListChildComponentProps<RowProps>) => {
const Container = styled(FixedSizeList<RowProps>)`
padding: 0px 12px;
overflow-x: hidden !important;
// Needed for the absolutely positioned children
// to respect the VirtualList's padding
+1 -1
View File
@@ -309,4 +309,4 @@ const StyledTabPanel = styled(TabPanel)`
overflow-y: auto;
`;
export default React.memo(IconPicker);
export default IconPicker;
+2 -6
View File
@@ -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}
@@ -60,7 +57,6 @@ function ResolvedCollectionIcon({
size={size}
initial={collection.initial}
className={className}
forceColor={inputColor ? true : false}
/>
);
}
+12 -4
View File
@@ -143,8 +143,12 @@ export interface Props
onRequestSubmit?: (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
onFocus?: (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onBlur?: (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
}
function Input(
@@ -154,7 +158,9 @@ function Input(
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
const [focused, setFocused] = React.useState(false);
const handleBlur = (ev: React.SyntheticEvent) => {
const handleBlur = (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFocused(false);
if (props.onBlur) {
@@ -162,7 +168,9 @@ function Input(
}
};
const handleFocus = (ev: React.SyntheticEvent) => {
const handleFocus = (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFocused(true);
if (props.onFocus) {
+9 -4
View File
@@ -55,6 +55,8 @@ export type Props = {
* The Modal will take care of preventing body scroll behaviour.
*/
skipBodyScroll?: boolean;
autoFocus?: boolean;
placeholder?: string;
};
export interface InputSelectRef {
@@ -85,6 +87,8 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
icon,
nude,
skipBodyScroll,
autoFocus,
placeholder,
...rest
} = props;
@@ -214,6 +218,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
neutral
disclosure
className={className}
autoFocus={autoFocus}
icon={icon}
$nude={nude}
{...buttonProps}
@@ -221,7 +226,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
{option ? (
labelForOption(option)
) : (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
<Placeholder>
{placeholder ?? `Select a ${ariaLabel.toLowerCase()}`}
</Placeholder>
)}
</StyledButton>
)}
@@ -352,9 +359,7 @@ const Wrapper = styled.label<{ short?: boolean }>`
`;
export const Positioner = styled(Position)`
pointer-events: all;
&:focus-visible {
&.focus-visible {
${StyledSelectOption} {
&[aria-selected="true"] {
color: ${(props) => props.theme.white};
+1
View File
@@ -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 />
+4 -8
View File
@@ -142,14 +142,10 @@ const ListItem = (
$hover={!!rest.onClick}
{...rest}
{...rovingTabIndex}
onClick={
rest.onClick
? (ev) => {
rest.onClick?.(ev);
rovingTabIndex.onClick(ev);
}
: undefined
}
onClick={(ev) => {
rest.onClick?.(ev);
rovingTabIndex.onClick(ev);
}}
onKeyDown={(ev) => {
rest.onKeyDown?.(ev);
rovingTabIndex.onKeyDown(ev);
+1 -1
View File
@@ -271,7 +271,7 @@ const Small = styled.div`
outline: none;
${NudeButton} {
&:hover,
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { LocationDescriptor, LocationDescriptorObject } from "history";
import * as React from "react";
import { type match, NavLink, Route } from "react-router-dom";
import { match, NavLink, Route } from "react-router-dom";
type Props = React.ComponentProps<typeof NavLink> & {
children?: (
@@ -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";
+1
View File
@@ -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>
);
};
+4 -12
View File
@@ -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>
+27 -45
View File
@@ -30,24 +30,13 @@ type Props = {
pins: Pin[];
/** Maximum number of pins to display */
limit?: number;
/** Number of placeholder pins to display */
placeholderCount?: number;
/** Whether the user has permission to update pins */
canUpdate?: boolean;
};
function PinnedDocuments({
limit,
pins,
placeholderCount,
canUpdate,
...rest
}: Props) {
const { documents } = useStores();
function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
const { documents, collections } = useStores();
const [items, setItems] = React.useState(pins.map((pin) => pin.documentId));
const showPlaceholderRef = React.useRef(true);
const showPlaceholder =
placeholderCount && !items.length && showPlaceholderRef.current;
React.useEffect(() => {
setItems(pins.map((pin) => pin.documentId));
@@ -70,9 +59,9 @@ function PinnedDocuments({
const { active, over } = event;
if (over && active.id !== over.id) {
setItems((existing) => {
const activePos = existing.indexOf(active.id as string);
const overPos = existing.indexOf(over.id as string);
setItems((items) => {
const activePos = items.indexOf(active.id as string);
const overPos = items.indexOf(over.id as string);
const overIndex = pins[overPos]?.index || null;
const nextIndex = pins[overPos + 1]?.index || null;
@@ -89,16 +78,20 @@ function PinnedDocuments({
? fractionalIndex(prevIndex, overIndex)
: fractionalIndex(overIndex, nextIndex),
})
.catch(() => setItems(existing));
.catch(() => setItems(items));
// Update the order in state immediately
return arrayMove(existing, activePos, overPos);
return arrayMove(items, activePos, overPos);
});
}
},
[pins]
);
if (collections.orderedData.length === 0) {
return null;
}
return (
<DndContext
sensors={sensors}
@@ -116,34 +109,23 @@ function PinnedDocuments({
>
<SortableContext items={items} strategy={rectSortingStrategy}>
<List>
{showPlaceholder ? (
Array(placeholderCount)
.fill(undefined)
.map((_, index) => (
<div key={index} style={{ width: 170, height: 180 }} />
))
) : (
<AnimatePresence initial={false}>
{items.map((documentId) => {
const document = documents.get(documentId);
const pin = pins.find((p) => p.documentId === documentId);
<AnimatePresence initial={false}>
{items.map((documentId) => {
const document = documents.get(documentId);
const pin = pins.find((pin) => pin.documentId === documentId);
// Once any document is loaded, never render the placeholder again
showPlaceholderRef.current = false;
return document ? (
<DocumentCard
key={documentId}
document={document}
canUpdatePin={canUpdate}
isDraggable={items.length > 1}
pin={pin}
{...rest}
/>
) : null;
})}
</AnimatePresence>
)}
return document ? (
<DocumentCard
key={documentId}
document={document}
canUpdatePin={canUpdate}
isDraggable={items.length > 1}
pin={pin}
{...rest}
/>
) : null;
})}
</AnimatePresence>
</List>
</SortableContext>
</ResizingHeightContainer>
+4 -11
View File
@@ -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;
+29 -32
View File
@@ -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>
);
}
+4 -4
View File
@@ -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,254 +0,0 @@
import { observer } from "mobx-react";
import { 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 InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import InputSelectPermission from "~/components/InputSelectPermission";
import Scrollable from "~/components/Scrollable";
import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy";
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 */
collection: Collection;
/** Children to be rendered before the list of members */
children?: React.ReactNode;
/** List of users and groups that have been invited during the current editing session */
invitedInSession: string[];
};
export const AccessControlList = observer(
({ collection, invitedInSession }: Props) => {
const { memberships, groupMemberships } = useStores();
const can = usePolicy(collection);
const { t } = useTranslation();
const theme = useTheme();
const collectionId = collection.id;
const { request: fetchMemberships, loading: membershipLoading } =
useRequest(
React.useCallback(
() => memberships.fetchAll({ id: collectionId }),
[memberships, collectionId]
)
);
const { request: fetchGroupMemberships, loading: groupMembershipLoading } =
useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ 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();
}, [fetchMemberships, fetchGroupMemberships]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const { maxHeight, calcMaxHeight } = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
});
React.useEffect(() => {
calcMaxHeight();
});
const permissions = React.useMemo(
() =>
[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("Manage"),
value: CollectionPermission.Admin,
},
{
divider: true,
label: t("Remove"),
value: EmptySelectValue,
},
] as Permission[],
[t]
);
return (
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{ maxHeight }}
>
{showLoading ? (
<Placeholder count={2} />
) : (
<>
<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>
}
/>
{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>
}
/>
))}
{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>
}
/>
))}
</>
)}
</ScrollableContainer>
);
}
);
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
`;
@@ -0,0 +1,180 @@
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { EmptySelectValue, Permission } from "~/types";
import { ListItem } from "../components/ListItem";
type Props = {
/** Collection to which team members are supposed to be invited */
collection: Collection;
/** Children to be rendered before the list of members */
children?: React.ReactNode;
/** List of users and groups that have been invited during the current editing session */
invitedInSession: string[];
};
function CollectionMemberList({ collection, invitedInSession }: Props) {
const { memberships, groupMemberships } = useStores();
const can = usePolicy(collection);
const { t } = useTranslation();
const theme = useTheme();
const collectionId = collection.id;
const { request: fetchMemberships } = useRequest(
React.useCallback(
() => memberships.fetchAll({ id: collectionId }),
[memberships, collectionId]
)
);
const { request: fetchGroupMemberships } = useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ id: collectionId }),
[groupMemberships, collectionId]
)
);
React.useEffect(() => {
void fetchMemberships();
void fetchGroupMemberships();
}, [fetchMemberships, fetchGroupMemberships]);
const permissions = React.useMemo(
() =>
[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("Manage"),
value: CollectionPermission.Admin,
},
{
divider: true,
label: t("Remove"),
value: EmptySelectValue,
},
] as Permission[],
[t]
);
return (
<>
{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.text} size={AvatarSize.Medium}>
<GroupIcon color={theme.background} size={16} />
</Squircle>
}
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}
/>
}
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>
}
/>
))}
</>
);
}
export default observer(CollectionMemberList);
@@ -1,15 +1,18 @@
import { isEmail } from "class-validator";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { BackIcon } from "outline-icons";
import { BackIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
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 InputSelectPermission from "~/components/InputSelectPermission";
import NudeButton from "~/components/NudeButton";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
@@ -19,14 +22,15 @@ import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import { Permission } from "~/types";
import { EmptySelectValue, Permission } from "~/types";
import { collectionPath, urlify } from "~/utils/routeHelpers";
import { Wrapper, presence } from "../components";
import { CopyLinkButton } from "../components/CopyLinkButton";
import { ListItem } from "../components/ListItem";
import { PermissionAction } from "../components/PermissionAction";
import { SearchInput } from "../components/SearchInput";
import { Suggestions } from "../components/Suggestions";
import { AccessControlList } from "./AccessControlList";
import CollectionMemberList from "./CollectionMemberList";
type Props = {
/** The collection to share. */
@@ -38,6 +42,7 @@ type Props = {
};
function SharePopover({ collection, visible, onRequestClose }: Props) {
const theme = useTheme();
const team = useCurrentTeam();
const { groupMemberships, users, groups, memberships } = useStores();
const { t } = useTranslation();
@@ -357,11 +362,40 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
onEscape={handleEscape}
showGroups
/>
)}
<div style={{ display: picker ? "none" : "block" }}>
<AccessControlList
<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>
}
/>
<CollectionMemberList
collection={collection}
invitedInSession={invitedInSession}
/>
@@ -1,280 +0,0 @@
import { observer } from "mobx-react";
import { MoreIcon, QuestionMarkIcon, 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 { Pagination } from "@shared/constants";
import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import Share from "~/models/Share";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
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 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";
type Props = {
/** The document being shared. */
document: Document;
/** List of users that have been invited during the current editing session */
invitedInSession: string[];
/** The existing share model, if any. */
share: Share | null | undefined;
/** The existing share parent model, if any. */
sharedParent: Share | null | undefined;
/** Callback fired when the popover requests to be closed. */
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
};
export const AccessControlList = observer(
({
document,
invitedInSession,
share,
sharedParent,
onRequestClose,
visible,
}: Props) => {
const { t } = useTranslation();
const theme = useTheme();
const collection = document.collection;
const usersInCollection = useUsersInCollection(collection);
const user = useCurrentUser();
const { userMemberships, groupMemberships } = 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({
elementRef: containerRef,
maxViewportPercentage: 70,
margin: 24,
});
const { loading: userMembershipLoading, request: fetchUserMemberships } =
useRequest(
React.useCallback(
() =>
userMemberships.fetchDocumentMemberships({
id: documentId,
limit: Pagination.defaultLimit,
}),
[userMemberships, documentId]
)
);
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]);
React.useEffect(() => {
calcMaxHeight();
});
return (
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{ maxHeight }}
>
{showLoading ? (
<Placeholder />
) : (
<>
{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}
/>
</>
) : 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 && (
<>
{document.members.length ? <Separator /> : null}
<PublicAccess
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={onRequestClose}
/>
</>
)}
</ScrollableContainer>
);
}
);
const AccessTooltip = ({
children,
content,
}: {
children?: React.ReactNode;
content?: string;
}) => {
const { t } = useTranslation();
return (
<Flex align="center" gap={2}>
<Text type="secondary" size="small">
{children}
</Text>
<Tooltip content={content ?? t("Access inherited from collection")}>
<QuestionMarkIcon size={18} />
</Tooltip>
</Flex>
);
};
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
const theme = useTheme();
const iconType = determineIconType(collection.icon)!;
const squircleColor =
iconType === IconType.SVG ? collection.color! : theme.slateLight;
const iconSize = iconType === IconType.SVG ? 16 : 22;
return (
<Squircle color={squircleColor} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={iconSize}
/>
</Squircle>
);
};
function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores();
const { request } = useRequest(() =>
memberships.fetchPage({ limit: 1, id: collection!.id })
);
React.useEffect(() => {
if (collection && !collection.permission) {
void request();
}
}, [collection]);
return collection
? collection.permission
? true
: users.inCollection(collection.id).length > 1
: false;
}
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
`;
@@ -1,23 +1,19 @@
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 { Pagination } from "@shared/constants";
import Document from "~/models/Document";
import UserMembership from "~/models/UserMembership";
import { GroupAvatar } from "~/components/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import LoadingIndicator from "~/components/LoadingIndicator";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
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 +25,27 @@ 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 { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
useRequest(
React.useCallback(
() =>
userMemberships.fetchDocumentMemberships({
id: document.id,
limit: Pagination.defaultLimit,
}),
[userMemberships, document.id]
)
);
React.useEffect(() => {
void fetchDocumentMembers();
}, [fetchDocumentMembers]);
const handleRemoveUser = React.useCallback(
async (item) => {
@@ -58,7 +68,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 +88,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 +105,14 @@ 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]
);
if (loadingDocumentMembers) {
return <LoadingIndicator />;
}
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 +131,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>
)
}
/>
);
@@ -0,0 +1,167 @@
import { observer } from "mobx-react";
import { MoreIcon, QuestionMarkIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useCurrentUser from "~/hooks/useCurrentUser";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Avatar from "../../Avatar";
import { AvatarSize } from "../../Avatar/Avatar";
import CollectionIcon from "../../Icons/CollectionIcon";
import Tooltip from "../../Tooltip";
import { ListItem } from "../components/ListItem";
type Props = {
/** The document being shared. */
document: Document;
children: React.ReactNode;
};
export const OtherAccess = observer(({ document, children }: Props) => {
const { t } = useTranslation();
const theme = useTheme();
const collection = document.collection;
const usersInCollection = useUsersInCollection(collection);
const user = useCurrentUser();
return (
<>
{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>}
/>
)}
{children}
</>
) : 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>
}
/>
{children}
</>
) : (
<>
{children}
<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"
)}
/>
}
/>
</>
)}
</>
);
});
const AccessTooltip = ({
children,
content,
}: {
children?: React.ReactNode;
content?: string;
}) => {
const { t } = useTranslation();
return (
<Flex align="center" gap={2}>
<Text type="secondary" size="small">
{children}
</Text>
<Tooltip content={content ?? t("Access inherited from collection")}>
<QuestionMarkIcon size={18} />
</Tooltip>
</Flex>
);
};
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
const theme = useTheme();
const iconType = determineIconType(collection.icon)!;
const squircleColor =
iconType === IconType.SVG ? collection.color! : theme.slateLight;
const iconSize = iconType === IconType.SVG ? 16 : 22;
return (
<Squircle color={squircleColor} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={iconSize}
/>
</Squircle>
);
};
function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores();
const { request } = useRequest(() =>
memberships.fetchPage({ limit: 1, id: collection!.id })
);
React.useEffect(() => {
if (collection && !collection.permission) {
void request();
}
}, [collection]);
return collection
? collection.permission
? true
: users.inCollection(collection.id).length > 1
: false;
}
@@ -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";
@@ -22,12 +22,14 @@ import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import { Permission } from "~/types";
import { documentPath, urlify } from "~/utils/routeHelpers";
import { Wrapper, presence } from "../components";
import { Separator, Wrapper, presence } from "../components";
import { CopyLinkButton } from "../components/CopyLinkButton";
import { PermissionAction } from "../components/PermissionAction";
import { SearchInput } from "../components/SearchInput";
import { Suggestions } from "../components/Suggestions";
import { AccessControlList } from "./AccessControlList";
import DocumentMembersList from "./DocumentMemberList";
import { OtherAccess } from "./OtherAccess";
import PublicAccess from "./PublicAccess";
type Props = {
/** The document to share. */
@@ -53,11 +55,12 @@ 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[]>([]);
const [pendingIds, setPendingIds] = React.useState<string[]>([]);
const collectionSharingDisabled = document.collection?.sharing === false;
const [permission, setPermission] = React.useState<DocumentPermission>(
DocumentPermission.Read
);
@@ -129,9 +132,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 +148,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 +188,14 @@ function SharePopover({
},
}),
[
document.id,
groupMemberships,
groups,
t,
pendingIds,
hidePicker,
userMemberships,
pendingIds,
document.id,
permission,
t,
team.defaultUserRole,
users,
team.defaultUserRole,
]
);
@@ -379,14 +341,24 @@ function SharePopover({
)}
<div style={{ display: picker ? "none" : "block" }}>
<AccessControlList
document={document}
invitedInSession={invitedInSession}
share={share}
sharedParent={sharedParent}
visible={visible}
onRequestClose={onRequestClose}
/>
<OtherAccess document={document}>
<DocumentMembersList
document={document}
invitedInSession={invitedInSession}
/>
</OtherAccess>
{team.sharing && can.share && !collectionSharingDisabled && visible && (
<>
{document.members.length ? <Separator /> : null}
<PublicAccess
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={onRequestClose}
/>
</>
)}
</div>
</Wrapper>
);
@@ -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,8 +66,9 @@ 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({
const maxHeight = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
});
@@ -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 {
+33 -35
View File
@@ -34,18 +34,16 @@ import TrashLink from "./components/TrashLink";
function AppSidebar() {
const { t } = useTranslation();
const { documents, ui, collections } = useStores();
const { documents, ui } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team);
React.useEffect(() => {
void collections.fetchAll();
if (!user.isViewer) {
void documents.fetchDrafts();
}
}, [documents, collections, user.isViewer]);
}, [documents, user.isViewer]);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
@@ -94,43 +92,43 @@ 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>
<Starred />
<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>
<SharedWithMe />
</Section>
<Section>
<Starred />
</Section>
<Section auto>
<Collections />
</Section>
+5 -5
View File
@@ -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";
@@ -274,12 +274,14 @@ const hoverStyles = (props: ContainerProps) => `
`;
const Container = styled(Flex)<ContainerProps>`
opacity: ${(props) => (props.$hidden ? 0 : 1)};
position: fixed;
top: 0;
bottom: 0;
width: 100%;
background: ${s("sidebarBackground")};
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
transition: box-shadow 150ms ease-in-out, opacity 150ms ease-in-out,
transform 150ms ease-out,
${s("backgroundTransition")}
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
@@ -297,9 +299,7 @@ const Container = styled(Flex)<ContainerProps>`
}
& > div {
transition: opacity 150ms ease-in-out;
opacity: ${(props) =>
props.$hidden || (props.$collapsed && !props.$isHovering) ? "0" : "1"};
opacity: ${(props) => (props.$collapsed && !props.$isHovering ? "0" : "1")};
}
${breakpoint("tablet")`
@@ -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: (
@@ -94,7 +100,7 @@ const CollectionLink: React.FC<Props> = ({
),
});
} else {
await documents.move({ documentId: id, collectionId: collection.id });
await documents.move(id, collection.id);
if (!expanded) {
onDisclosureClick();
@@ -110,69 +116,78 @@ const CollectionLink: React.FC<Props> = ({
}),
});
const handleTitleEditing = React.useCallback((isEditing: boolean) => {
setIsEditing(isEditing);
}, []);
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>
</>
);
};
@@ -52,11 +52,7 @@ function CollectionLinkChildren({
if (!collection) {
return;
}
void documents.move({
documentId: item.id,
collectionId: collection.id,
index: 0,
});
void documents.move(item.id, collection.id, undefined, 0);
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
@@ -53,6 +53,7 @@ function Collections() {
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
fetch={collections.fetchPage}
options={params}
aria-label={t("Collections")}
items={collections.orderedData}
@@ -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(() => {
@@ -126,52 +128,138 @@ function InnerDocumentLink(
}, [prefetchDocument, node]);
const handleTitleChange = React.useCallback(
async (value: string) => {
async (title: string) => {
if (!document) {
return;
}
await documents.update({
id: document.id,
title: value,
title,
});
},
[documents, document]
);
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;
}
if (expanded) {
return {
documentId: item.id,
collectionId: collection.id,
parentDocumentId: node.id,
index: 0,
};
await documents.move(item.id, collection.id, 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);
}
}
return {
documentId: item.id,
collectionId: collection.id,
parentDocumentId: parentId,
index: index + 1,
};
});
},
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) {
void documents.move(item.id, collection.id, node.id, 0);
return;
}
void documents.move(item.id, collection.id, parentId, index + 1);
},
collect: (monitor) => ({
isOverReorder: monitor.isOver(),
isDraggingAnyDocument: monitor.canDrop(),
}),
});
const nodeChildren = React.useMemo(() => {
const insertDraftDocument =
@@ -207,18 +295,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 +324,7 @@ function InnerDocumentLink(
pathname: node.url,
state: {
title: node.title,
sidebarContext,
starred: inStarredSection,
},
}}
icon={icon && <Icon value={icon} color={color} />}
@@ -250,25 +338,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 +383,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>
</>
);
}
+37 -28
View File
@@ -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 -1
View File
@@ -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;
-22
View File
@@ -1,22 +0,0 @@
import React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
const Label = ({ icon, value }: { icon: React.ReactNode; value: string }) => (
<Flex align="center" gap={4}>
<IconWrapper>{icon}</IconWrapper>
{value}
</Flex>
);
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
height: 24px;
width: 24px;
overflow: hidden;
flex-shrink: 0;
`;
export default Label;
@@ -1,113 +0,0 @@
import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AvatarSize } from "~/components/Avatar";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect, { Option } from "~/components/InputSelect";
import TeamLogo from "~/components/TeamLogo";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Label from "./Label";
type Props = {
/** Collection ID to select by default. */
defaultCollectionId?: string | null;
/** Callback to be called when a collection is selected. */
onSelect: (collectionId: string | null) => void;
};
const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
const { loading, error } = useRequest(
React.useCallback(async () => {
if (!collections.isLoaded) {
await collections.fetchAll({
limit: 100,
});
}
}, [collections])
);
const workspaceOption: Option | null = can.createTemplate
? {
label: (
<Label
icon={<TeamLogo model={team} size={AvatarSize.Toast} />}
value={t("Workspace")}
/>
),
value: "workspace",
}
: null;
const collectionOptions: Option[] = React.useMemo(
() =>
collections.orderedData.reduce<Option[]>((memo, collection) => {
const canCollection = policies.abilities(collection.id);
if (canCollection.createDocument) {
memo.push({
label: (
<Label
icon={<CollectionIcon collection={collection} />}
value={collection.name}
/>
),
value: collection.id,
});
}
return memo;
}, []),
[collections.orderedData, policies]
);
const options: Option[] = workspaceOption
? collectionOptions.length
? [
workspaceOption,
...collectionOptions.map((opt, idx) => {
if (idx !== 0) {
return opt;
}
opt.divider = true;
return opt;
}),
]
: [workspaceOption]
: collectionOptions;
const handleSelection = React.useCallback(
(value: string | null) => {
onSelect(value === "workspace" ? null : value);
},
[onSelect]
);
if (error) {
toast.error(t("Collections could not be loaded, please reload the app"));
}
if (loading || !options.length) {
return null;
}
return (
<InputSelect
value={defaultCollectionId ?? "workspace"}
options={options}
onChange={handleSelection}
ariaLabel={t("Location")}
label={t("Location")}
/>
);
};
export default observer(SelectLocation);
-82
View File
@@ -1,82 +0,0 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Switch from "~/components/Switch";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import SelectLocation from "./SelectLocation";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const [publish, setPublish] = React.useState(true);
const [collectionId, setCollectionId] = React.useState(
document.collectionId ?? null
);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
collectionId,
publish,
});
if (template) {
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
}
}, [t, document, history, collectionId, publish]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Flex column gap={12}>
<div>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</div>
<SelectLocation
defaultCollectionId={collectionId}
onSelect={setCollectionId}
/>
<Switch
name="publish"
label={t("Published")}
note={t("Enable other members to use the template immediately")}
checked={publish}
onChange={handlePublishChange}
/>
</Flex>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
+100 -99
View File
@@ -10,12 +10,10 @@ import { FileOperationState, FileOperationType } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import DataAttribute from "~/models/DataAttribute";
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 +25,7 @@ import withStores from "~/components/withStores";
import {
PartialWithId,
WebsocketCollectionUpdateIndexEvent,
WebsocketCollectionUserEvent,
WebsocketEntitiesEvent,
WebsocketEntityDeletedEvent,
} from "~/types";
@@ -84,11 +83,10 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.authenticated = false;
const {
auth,
dataAttributes,
documents,
collections,
groups,
groupUsers,
groupMemberships,
pins,
stars,
memberships,
@@ -101,8 +99,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", () => {
@@ -141,9 +137,6 @@ class WebsocketProvider extends React.Component<Props> {
if (document?.updatedAt === documentDescriptor.updatedAt) {
continue;
}
if (!document && !event.fetchIfMissing) {
continue;
}
// otherwise, grab the latest version of the document
try {
@@ -189,9 +182,6 @@ class WebsocketProvider extends React.Component<Props> {
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
if (!collection?.documents?.length && !event.fetchIfMissing) {
continue;
}
try {
await collection?.fetchDocuments({
@@ -202,6 +192,7 @@ class WebsocketProvider extends React.Component<Props> {
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.removeCollectionDocuments(collectionId);
memberships.removeAll({ collectionId });
collections.remove(collectionId);
return;
@@ -263,75 +254,62 @@ 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);
});
}
}
"dataAttributes.create",
(event: PartialWithId<DataAttribute>) => {
dataAttributes.add(event);
}
);
this.socket.on(
"documents.remove_group",
(event: PartialWithId<GroupMembership>) => {
groupMemberships.remove(event.id);
"dataAttributes.update",
(event: PartialWithId<DataAttribute>) => {
dataAttributes.add(event);
}
);
this.socket.on(
"dataAttributes.delete",
(event: WebsocketEntityDeletedEvent) => {
dataAttributes.remove(event.modelId);
}
);
@@ -359,22 +337,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 +369,7 @@ class WebsocketProvider extends React.Component<Props> {
}
policies.remove(doc.id);
});
documents.removeCollectionDocuments(collectionId);
memberships.removeAll({ collectionId });
collections.remove(collectionId);
})
@@ -453,35 +430,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 +488,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);
}
})
);
@@ -304,10 +304,6 @@ const MobileWrapper = styled.div`
height: 100px;
background-color: ${s("menuBackground")};
}
@media print {
display: none;
}
`;
const Wrapper = styled.div<WrapperProps>`
-14
View File
@@ -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
+3 -32
View File
@@ -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}
+1 -10
View File
@@ -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(
@@ -438,7 +433,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
return (
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
(item.keywords || "").toLocaleLowerCase().includes(searchInput)
);
@@ -535,7 +529,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
if (event.key === "Escape") {
event.preventDefault();
close();
}
};
@@ -566,9 +559,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
<LinkInput
type="text"
placeholder={
"placeholder" in insertItem
? insertItem.placeholder
: insertItem.title
insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
+1 -1
View File
@@ -9,7 +9,7 @@ export default class MentionMenuExtension extends Suggestion {
return {
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s{2})|(\s+[\p{L}\p{M}\d]+))$/u,
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
};
}
-3
View File
@@ -157,9 +157,6 @@ export default class PasteHandler extends Extension {
!isInList(state)
) {
for (const embed of embeds) {
if (!embed.matchOnInput) {
continue;
}
const matches = embed.matcher(text);
if (matches) {
this.editor.commands.embed({

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