mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8cb10248e | |||
| 762a0c78c7 |
+3
-2
@@ -129,8 +129,9 @@ FORCE_HTTPS=true
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# Third party signin credentials, at least ONE OF these is required for a
|
||||
# working installation or you'll have no sign-in options.
|
||||
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
|
||||
# Discord, or Microsoft is required for a working installation or you'll
|
||||
# have no sign-in options.
|
||||
|
||||
# Slack sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 86400
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- outline-icons
|
||||
|
||||
@@ -70,7 +70,7 @@ yarn install
|
||||
### Exports
|
||||
|
||||
- Exported members must appear at the top of the file.
|
||||
- Always use named exports for new components & classes.
|
||||
- Prefer named exports for components & classes.
|
||||
- Document ALL public/exported functions with JSDoc.
|
||||
|
||||
## React Usage
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.6.1
|
||||
Licensed Work: Outline 1.5.0
|
||||
The Licensed Work is (c) 2026 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
|
||||
@@ -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: 2030-03-18
|
||||
Change Date: 2030-02-15
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ There is a short guide for [setting up a development environment](https://docs.g
|
||||
|
||||
## Contributing
|
||||
|
||||
Outline is built and maintained by a small team – your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
|
||||
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
CaseSensitiveIcon,
|
||||
RestoreIcon,
|
||||
EditIcon,
|
||||
EmbedIcon,
|
||||
OpenIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
@@ -75,7 +73,6 @@ import {
|
||||
searchPath,
|
||||
documentPath,
|
||||
urlify,
|
||||
desktopify,
|
||||
trashPath,
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
@@ -89,8 +86,6 @@ import type {
|
||||
} from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import env from "~/env";
|
||||
import { isMac, isWindows } from "@shared/utils/browser";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
@@ -340,15 +335,8 @@ export const createNewDocument = createActionWithChildren({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
|
||||
});
|
||||
|
||||
@@ -577,10 +565,7 @@ export const shareDocument = createAction({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const can = stores.policies.abilities(activeDocumentId);
|
||||
const can = stores.policies.abilities(activeDocumentId!);
|
||||
return can.manageUsers || can.share;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
@@ -959,49 +944,6 @@ export const printDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInDesktop = createAction({
|
||||
name: ({ t }) => t("Open in desktop app"),
|
||||
analyticsName: "Open in desktop",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <OpenIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
window.location.href = desktopify(documentPath(document));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const presentDocument = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")),
|
||||
analyticsName: "Present document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <EmbedIcon />,
|
||||
shortcut: ["Meta+Alt+p"],
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.setPresentingDocument(document);
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
@@ -1545,13 +1487,11 @@ export const rootDocumentActions = [
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
permanentlyDeleteDocumentsInTrash,
|
||||
presentDocument,
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocumentToHome,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
openDocumentInDesktop,
|
||||
shareDocument,
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
@@ -57,17 +57,15 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
history.push(newDocumentPath(activeCollectionId));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
history.replace(postLoginPath);
|
||||
}
|
||||
}, [spendPostLoginPath]);
|
||||
|
||||
if (auth.isSuspended) {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
return <Redirect to={postLoginPath} />;
|
||||
}
|
||||
|
||||
const sidebar = (
|
||||
<Fade>
|
||||
<Switch>
|
||||
|
||||
@@ -3,8 +3,6 @@ import { DisclosureIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import type { HapticInput } from "web-haptics";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { s } from "@shared/styles";
|
||||
import type { Props as ActionButtonProps } from "~/components/ActionButton";
|
||||
import ActionButton from "~/components/ActionButton";
|
||||
@@ -154,8 +152,6 @@ export type Props<T> = ActionButtonProps & {
|
||||
fullwidth?: boolean;
|
||||
as?: T;
|
||||
to?: LocationDescriptor;
|
||||
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
|
||||
haptic?: HapticInput;
|
||||
borderOnHover?: boolean;
|
||||
hideIcon?: boolean;
|
||||
href?: string;
|
||||
@@ -180,13 +176,11 @@ const Button = <T extends React.ElementType = "button">(
|
||||
hideIcon,
|
||||
fullwidth,
|
||||
danger,
|
||||
haptic,
|
||||
...rest
|
||||
} = props;
|
||||
const hasText = !!children || value !== undefined;
|
||||
const ic = hideIcon ? undefined : (action?.icon ?? icon);
|
||||
const hasIcon = ic !== undefined;
|
||||
const { trigger } = useWebHaptics();
|
||||
|
||||
return (
|
||||
<RealButton
|
||||
@@ -197,7 +191,6 @@ const Button = <T extends React.ElementType = "button">(
|
||||
$danger={danger}
|
||||
$fullwidth={fullwidth}
|
||||
$borderOnHover={borderOnHover}
|
||||
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
|
||||
{...rest}
|
||||
>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { CollectionPermission, TeamPreference } from "@shared/types";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import type { CollectionPermission } from "@shared/types";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
@@ -15,7 +15,6 @@ import type Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import { Collapsible } from "~/components/Collapsible";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import Switch from "~/components/Switch";
|
||||
@@ -35,7 +34,6 @@ export interface FormData {
|
||||
sharing: boolean;
|
||||
permission: CollectionPermission | undefined;
|
||||
commenting?: boolean | null;
|
||||
templateManagement: CollectionPermission;
|
||||
}
|
||||
|
||||
const useIconColor = (collection?: Collection) => {
|
||||
@@ -70,22 +68,6 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
const templateManagementOptions = useMemo<Option[]>(
|
||||
() => [
|
||||
{
|
||||
type: "item",
|
||||
label: t("Managers"),
|
||||
value: CollectionPermission.Admin,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Members"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const iconColor = useIconColor(collection);
|
||||
const fallbackIcon = (
|
||||
<Icon
|
||||
@@ -111,8 +93,6 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
sharing: collection?.sharing ?? true,
|
||||
permission: collection?.permission,
|
||||
commenting: collection?.commenting ?? true,
|
||||
templateManagement:
|
||||
collection?.templateManagement ?? CollectionPermission.Admin,
|
||||
color: iconColor,
|
||||
},
|
||||
});
|
||||
@@ -155,71 +135,6 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const initial = values.name.charAt(0).toUpperCase();
|
||||
|
||||
const options = (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateManagement"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<InputSelect
|
||||
value={field.value}
|
||||
onChange={(value: string) => {
|
||||
field.onChange(value as CollectionPermission);
|
||||
}}
|
||||
options={templateManagementOptions}
|
||||
label={t("Manage templates")}
|
||||
/>
|
||||
<Text
|
||||
type="secondary"
|
||||
size="small"
|
||||
as="p"
|
||||
style={{ paddingTop: 4 }}
|
||||
>
|
||||
{t(
|
||||
"Choose who can create and edit templates in this collection."
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t("Allow commenting on documents within this collection.")}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<Text as="p">
|
||||
@@ -275,10 +190,44 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{collection ? (
|
||||
options
|
||||
) : (
|
||||
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
|
||||
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
|
||||
<Collapsible label={t("Advanced options")}>
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t(
|
||||
"Allow commenting on documents within this collection."
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
|
||||
@@ -128,14 +128,7 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current && value !== contentRef.current.textContent) {
|
||||
if (document.activeElement === contentRef.current) {
|
||||
// Don't reset content while the user is actively editing. Update
|
||||
// lastValue so that the next input or blur event will push the
|
||||
// current DOM text back to the model via onChange.
|
||||
lastValue.current = value;
|
||||
} else {
|
||||
setInnerValue(value);
|
||||
}
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
@@ -41,28 +42,6 @@ type Props = {
|
||||
showDocuments?: boolean;
|
||||
};
|
||||
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(function innerElementType(
|
||||
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function DocumentExplorer({
|
||||
onSubmit,
|
||||
onSelect,
|
||||
@@ -88,6 +67,8 @@ function DocumentExplorer({
|
||||
return node || null;
|
||||
}
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] =
|
||||
React.useState<number>(0);
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
||||
if (defaultValue) {
|
||||
@@ -110,6 +91,9 @@ function DocumentExplorer({
|
||||
);
|
||||
const listRef = React.useRef<List<NavigationNode[]>>(null);
|
||||
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const searchIndex = React.useMemo(
|
||||
() =>
|
||||
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
|
||||
@@ -160,8 +144,7 @@ function DocumentExplorer({
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultValue]);
|
||||
}, [defaultValue, selectedNode, nodes]);
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
@@ -169,9 +152,17 @@ function DocumentExplorer({
|
||||
const normalizedBaseDepth =
|
||||
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback((node: number) => {
|
||||
listRef.current?.scrollToItem(node, "smart");
|
||||
}, []);
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
if (itemRefs[node] && itemRefs[node].current) {
|
||||
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
|
||||
behavior: "auto",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
},
|
||||
[itemRefs]
|
||||
);
|
||||
|
||||
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(ev.target.value);
|
||||
@@ -179,16 +170,16 @@ function DocumentExplorer({
|
||||
|
||||
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
|
||||
|
||||
const preserveScrollOffset = (itemCount: number) => {
|
||||
const calculateInitialScrollOffset = (itemCount: number) => {
|
||||
if (listRef.current) {
|
||||
const { height, itemSize } = listRef.current.props;
|
||||
const { scrollOffset } = listRef.current.state as {
|
||||
scrollOffset: number;
|
||||
};
|
||||
const itemsHeight = itemCount * itemSize;
|
||||
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
setTimeout(() => listRef.current?.scrollTo(offset), 0);
|
||||
return itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const collapse = (node: number) => {
|
||||
@@ -199,7 +190,8 @@ function DocumentExplorer({
|
||||
|
||||
// remove children
|
||||
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
|
||||
preserveScrollOffset(newNodes.length);
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
};
|
||||
|
||||
const expand = (node: number) => {
|
||||
@@ -208,7 +200,8 @@ function DocumentExplorer({
|
||||
// add children
|
||||
const newNodes = nodes.slice();
|
||||
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
|
||||
preserveScrollOffset(newNodes.length);
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -232,8 +225,7 @@ function DocumentExplorer({
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 ||
|
||||
(showDocuments !== false && nodes[node].type === "collection");
|
||||
nodes[node].children.length > 0 || showDocuments !== false;
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -395,6 +387,25 @@ function DocumentExplorer({
|
||||
}
|
||||
};
|
||||
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(function innerElementType(
|
||||
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
<ListSearch
|
||||
@@ -414,12 +425,14 @@ function DocumentExplorer({
|
||||
<Flex role="listbox" column>
|
||||
<List
|
||||
ref={listRef}
|
||||
key={nodes.length}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={nodes}
|
||||
itemCount={nodes.length}
|
||||
itemSize={isMobile ? 48 : 32}
|
||||
innerElementType={innerElementType}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemKey={(index, results) => results[index].id}
|
||||
>
|
||||
{ListItem}
|
||||
|
||||
@@ -40,8 +40,10 @@ function DocumentExplorerNode(
|
||||
ref: React.RefObject<HTMLSpanElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const DISCLOSURE = 24;
|
||||
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
|
||||
const OFFSET = 12;
|
||||
const DISCLOSURE = 20;
|
||||
|
||||
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
@@ -78,7 +80,7 @@ const Title = styled(Text)`
|
||||
const StyledDisclosure = styled(Disclosure)`
|
||||
position: relative;
|
||||
left: auto;
|
||||
margin: 2px 0;
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const Spacer = styled(Flex)<{ width: number }>`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { Node as SearchResult } from "./DocumentExplorerNode";
|
||||
@@ -31,8 +32,22 @@ function DocumentExplorerSearchResult({
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ref = React.useCallback(
|
||||
(node: HTMLSpanElement | null) => {
|
||||
if (active && node) {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
},
|
||||
[active]
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchResult
|
||||
ref={ref}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -88,7 +88,6 @@ function Header(
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
haptic="light"
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
|
||||
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Description = styled(StyledText)<{ $margin?: string }>`
|
||||
export const Description = styled(StyledText)`
|
||||
${sharedVars}
|
||||
margin-top: ${(props) => props.$margin ?? "0.5em"};
|
||||
margin-top: 0.5em;
|
||||
line-height: var(--line-height);
|
||||
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
|
||||
overflow: hidden;
|
||||
@@ -64,6 +64,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
width: fit-content;
|
||||
border-radius: 2em;
|
||||
padding: 1px 8px 1px 20px;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -73,8 +75,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) =>
|
||||
props.color || props.theme.backgroundSecondary};
|
||||
|
||||
@@ -17,7 +17,6 @@ import HoverPreviewGroup from "./HoverPreviewGroup";
|
||||
import HoverPreviewIssue from "./HoverPreviewIssue";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
import HoverPreviewProject from "./HoverPreviewProject";
|
||||
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
|
||||
|
||||
const DELAY_CLOSE = 500;
|
||||
@@ -193,18 +192,6 @@ const HoverPreviewDesktop = observer(
|
||||
createdAt={data.createdAt}
|
||||
state={data.state}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Project ? (
|
||||
<HoverPreviewProject
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
name={data.name}
|
||||
color={data.color}
|
||||
lead={data.lead}
|
||||
labels={data.labels}
|
||||
description={data.description}
|
||||
state={data.state}
|
||||
targetDate={data.targetDate}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
|
||||
@@ -75,7 +75,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
</Description>
|
||||
)}
|
||||
|
||||
<Flex wrap gap={6} style={{ marginTop: 8 }}>
|
||||
<Flex wrap>
|
||||
{labels.map((label, index) => (
|
||||
<Label key={index} color={label.color}>
|
||||
{label.name}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import Editor from "~/components/Editor";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Card,
|
||||
CardContent,
|
||||
Label,
|
||||
Description,
|
||||
} from "./Components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
|
||||
type Props = Pick<
|
||||
UnfurlResponse[UnfurlResourceType.Project],
|
||||
| "url"
|
||||
| "name"
|
||||
| "color"
|
||||
| "lead"
|
||||
| "labels"
|
||||
| "state"
|
||||
| "targetDate"
|
||||
| "description"
|
||||
>;
|
||||
|
||||
const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_(
|
||||
{ url, name, color, lead, labels, state, description, targetDate }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column ref={ref}>
|
||||
<Card fadeOut={false}>
|
||||
<CardContent>
|
||||
<Flex gap={4} column>
|
||||
<Title>
|
||||
<StyledSquircle color={color} size={16} />
|
||||
<span>
|
||||
<Backticks content={name} />
|
||||
</span>
|
||||
</Title>
|
||||
{description && (
|
||||
<Description as="div" $margin="0">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
<Text
|
||||
type="tertiary"
|
||||
size="small"
|
||||
style={{ textTransform: "capitalize" }}
|
||||
>
|
||||
{state.name}
|
||||
</Text>
|
||||
|
||||
{(lead || targetDate) && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
{lead && (
|
||||
<MetadataRow>
|
||||
<MetadataLabel>{t("Lead")}</MetadataLabel>
|
||||
<Flex align="center" gap={6}>
|
||||
<Avatar src={lead.avatarUrl} size={AvatarSize.Toast} />
|
||||
<Text size="small">{lead.name}</Text>
|
||||
</Flex>
|
||||
</MetadataRow>
|
||||
)}
|
||||
|
||||
{targetDate && (
|
||||
<MetadataRow>
|
||||
<MetadataLabel>{t("Target date")}</MetadataLabel>
|
||||
<Text size="small">
|
||||
<Time dateTime={targetDate} addSuffix />
|
||||
</Text>
|
||||
</MetadataRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{labels.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<MetadataRow>
|
||||
<MetadataLabel>{t("Labels")}</MetadataLabel>
|
||||
<Flex wrap gap={6}>
|
||||
{labels.map((label, index) => (
|
||||
<Label key={index} color={label.color}>
|
||||
{label.name}
|
||||
</Label>
|
||||
))}
|
||||
</Flex>
|
||||
</MetadataRow>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledSquircle = styled(Squircle)`
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const Divider = styled.div`
|
||||
height: 1px;
|
||||
background: ${s("divider")};
|
||||
margin: 4px 0;
|
||||
`;
|
||||
|
||||
const MetadataRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 28px;
|
||||
`;
|
||||
|
||||
const MetadataLabel = styled(Text).attrs({
|
||||
type: "tertiary",
|
||||
size: "small",
|
||||
})`
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
`;
|
||||
|
||||
export default HoverPreviewProject;
|
||||
+17
-22
@@ -9,44 +9,39 @@ export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
interface LazyLoadOptions {
|
||||
retries?: number;
|
||||
interval?: number;
|
||||
/** If provided, picks this named export from the module instead of `default`. */
|
||||
exportName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
|
||||
* Supports both default and named exports.
|
||||
*
|
||||
* @param factory A function that returns a promise of a module.
|
||||
* @param options Optional configuration for retry behavior and export name.
|
||||
* @returns An object containing the lazy Component and a preload function.
|
||||
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
|
||||
* @param options Optional configuration for retry behavior
|
||||
* @returns An object containing the lazy Component and a preload function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Default export
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
|
||||
*
|
||||
* // Named export
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'), {
|
||||
* exportName: 'MyComponent',
|
||||
* });
|
||||
* function App() {
|
||||
* return (
|
||||
* <Suspense fallback={<div>Loading...</div>}>
|
||||
* <MyComponent.Component />
|
||||
* </Suspense>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Preload when needed:
|
||||
* MyComponent.preload();
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
factory: () => Promise<Record<string, T>>,
|
||||
factory: () => Promise<{ default: T }>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
const { retries, interval, exportName } = options;
|
||||
|
||||
const wrappedFactory = exportName
|
||||
? () =>
|
||||
factory().then((m) => ({
|
||||
default: m[exportName],
|
||||
}))
|
||||
: (factory as () => Promise<{ default: T }>);
|
||||
const { retries, interval } = options;
|
||||
|
||||
return {
|
||||
Component: lazyWithRetry(wrappedFactory, retries, interval),
|
||||
preload: wrappedFactory,
|
||||
Component: lazyWithRetry(factory, retries, interval),
|
||||
preload: factory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Suspense } from "react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const PresentationMode = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/PresentationMode")
|
||||
);
|
||||
|
||||
function Presentation() {
|
||||
const { ui } = useStores();
|
||||
|
||||
if (!ui.presentationData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PresentationMode
|
||||
title={ui.presentationData.title}
|
||||
icon={ui.presentationData.icon}
|
||||
iconColor={ui.presentationData.color}
|
||||
data={ui.presentationData.data}
|
||||
onClose={() => {
|
||||
ui.setPresentingDocument(null);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Presentation);
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useKBar } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
|
||||
@@ -15,7 +14,7 @@ interface CacheEntry {
|
||||
// Cache configuration
|
||||
const cacheTTL = Minute.ms * 5;
|
||||
|
||||
function SearchActions() {
|
||||
export default function SearchActions() {
|
||||
const { searches, documents } = useStores();
|
||||
|
||||
// Cache structure: Map of search queries to timestamp of last search
|
||||
@@ -59,5 +58,3 @@ function SearchActions() {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(SearchActions);
|
||||
|
||||
@@ -76,8 +76,7 @@ function SettingsSidebar() {
|
||||
to={item.path}
|
||||
onClickIntent={item.preload}
|
||||
active={
|
||||
item.path.startsWith(settingsPath("templates")) ||
|
||||
item.path.startsWith(settingsPath("groups"))
|
||||
item.path.startsWith(settingsPath("templates"))
|
||||
? location.pathname.startsWith(item.path)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -54,7 +53,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
const collapsed = ui.sidebarIsClosed && canCollapse;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const { trigger } = useWebHaptics();
|
||||
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
@@ -226,11 +224,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
[width]
|
||||
);
|
||||
|
||||
const handleCloseSidebar = () => {
|
||||
trigger("light");
|
||||
ui.toggleMobileSidebar();
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Container
|
||||
@@ -282,7 +275,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
</Container>
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={handleCloseSidebar} />}
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ function SidebarLink(
|
||||
$isActiveDrop={isActiveDrop}
|
||||
$isDraft={isDraft}
|
||||
$disabled={disabled}
|
||||
style={active ? activeStyle : style}
|
||||
style={style}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
onClick={handleClick}
|
||||
onActiveClick={handleDisclosureClick}
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Toaster, useSonner } from "sonner";
|
||||
import { Toaster } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
function Toasts() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const { toasts } = useSonner();
|
||||
const { trigger } = useWebHaptics();
|
||||
const prevCountRef = React.useRef(toasts.length);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (toasts.length > prevCountRef.current) {
|
||||
const latest = toasts[toasts.length - 1];
|
||||
if (latest.type === "error") {
|
||||
void trigger("error");
|
||||
} else if (latest.type === "success") {
|
||||
void trigger("success");
|
||||
}
|
||||
}
|
||||
prevCountRef.current = toasts.length;
|
||||
}, [toasts, trigger]);
|
||||
|
||||
return (
|
||||
<StyledToaster
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { s, depths } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useTooltipContext } from "./TooltipContext";
|
||||
|
||||
@@ -267,7 +267,7 @@ const StyledContent = styled(TooltipPrimitive.Content)`
|
||||
white-space: normal;
|
||||
outline: 0;
|
||||
padding: 5px 9px;
|
||||
z-index: ${depths.tooltip};
|
||||
z-index: 9999;
|
||||
max-width: calc(100vw - 10px);
|
||||
|
||||
/* Animation */
|
||||
|
||||
@@ -1,126 +1,17 @@
|
||||
import { DocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useCallback } from "react";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import getMenuItems from "../menus/block";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
|
||||
import SuggestionsMenu from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
/**
|
||||
* Hook that returns a template menu item with children for inserting template
|
||||
* content into the editor, or undefined if no templates are available.
|
||||
*/
|
||||
function useTemplateMenuItem(): MenuItem | undefined {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { documents, templates: templatesStore } = useStores();
|
||||
const editor = useEditor();
|
||||
const documentId = editor.props.id;
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
const collectionId = document?.collectionId;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allTemplates = templatesStore.orderedData.filter(
|
||||
(template) => template.isActive
|
||||
);
|
||||
const hasTemplates = allTemplates.some(
|
||||
(template) =>
|
||||
template.isWorkspaceTemplate || template.collectionId === collectionId
|
||||
);
|
||||
|
||||
if (!hasTemplates) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const toMenuItem = (template: (typeof allTemplates)[0]): MenuItem => ({
|
||||
name: "noop",
|
||||
title: TextHelper.replaceTemplateVariables(
|
||||
template.titleWithDefault,
|
||||
user
|
||||
),
|
||||
icon: template.icon ? (
|
||||
<Icon
|
||||
value={template.icon}
|
||||
initial={template.initial}
|
||||
color={template.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
keywords: template.titleWithDefault,
|
||||
onClick: () => {
|
||||
const data = cloneDeep(template.data);
|
||||
ProsemirrorHelper.replaceTemplateVariables(data, user);
|
||||
editor.insertContent(data);
|
||||
},
|
||||
});
|
||||
|
||||
const children = (): MenuItem[] => {
|
||||
const collectionTemplates = allTemplates.filter(
|
||||
(template) =>
|
||||
!template.isWorkspaceTemplate &&
|
||||
template.collectionId === collectionId
|
||||
);
|
||||
const workspaceTemplates = allTemplates.filter(
|
||||
(tmpl) => tmpl.isWorkspaceTemplate
|
||||
);
|
||||
|
||||
const items: MenuItem[] = collectionTemplates.map(toMenuItem);
|
||||
|
||||
if (collectionTemplates.length && workspaceTemplates.length) {
|
||||
items.push({ name: "separator" });
|
||||
}
|
||||
|
||||
if (workspaceTemplates.length) {
|
||||
for (const template of workspaceTemplates) {
|
||||
items.push(toMenuItem(template));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
return {
|
||||
name: "noop",
|
||||
title: t("Templates"),
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "template",
|
||||
children,
|
||||
} satisfies MenuItem;
|
||||
}, [user, templatesStore.orderedData, collectionId, editor, t]);
|
||||
}
|
||||
|
||||
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
|
||||
Required<Pick<SuggestionsMenuProps, "embeds">>;
|
||||
|
||||
function BlockMenu(props: Props) {
|
||||
const dictionary = useDictionary();
|
||||
const { elementRef } = useEditor();
|
||||
const templateMenuItem = useTemplateMenuItem();
|
||||
|
||||
const items = useMemo(() => {
|
||||
const baseItems = getMenuItems(dictionary, elementRef);
|
||||
|
||||
if (!templateMenuItem) {
|
||||
return baseItems;
|
||||
}
|
||||
|
||||
return [...baseItems, { name: "separator" } as MenuItem, templateMenuItem];
|
||||
}, [dictionary, elementRef, templateMenuItem]);
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
@@ -141,9 +32,9 @@ function BlockMenu(props: Props) {
|
||||
filterable
|
||||
trigger="/"
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={items}
|
||||
items={getMenuItems(dictionary, elementRef)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(BlockMenu);
|
||||
export default BlockMenu;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
DocumentIcon,
|
||||
PlusIcon,
|
||||
NewDocumentIcon,
|
||||
CollectionIcon,
|
||||
} from "outline-icons";
|
||||
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -232,24 +227,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
label: search,
|
||||
},
|
||||
} as MentionItem,
|
||||
{
|
||||
name: "link",
|
||||
icon: <NewDocumentIcon />,
|
||||
title: search?.trim(),
|
||||
section: DocumentsSection,
|
||||
subtitle: t("Create a nested doc"),
|
||||
visible: !!search && !isEmail(search) && !!documentId,
|
||||
priority: -2,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: uuidv4(),
|
||||
actorId,
|
||||
label: search,
|
||||
nested: true,
|
||||
},
|
||||
} as MentionItem,
|
||||
])
|
||||
: [];
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { isInternalUrl, isUrl } from "@shared/utils/urls";
|
||||
import { isUrl } from "@shared/utils/urls";
|
||||
import type Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -67,7 +67,6 @@ function useItems({
|
||||
|
||||
const singleUrl =
|
||||
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
|
||||
const isInternal = singleUrl ? isInternalUrl(singleUrl) : false;
|
||||
const matchedEmbed = singleUrl
|
||||
? getMatchingEmbed(embeds, singleUrl)?.embed
|
||||
: null;
|
||||
@@ -75,7 +74,7 @@ function useItems({
|
||||
|
||||
// Check embeddability for single URL
|
||||
useEffect(() => {
|
||||
if (!singleUrl || !embed || isInternal) {
|
||||
if (!singleUrl || !embed) {
|
||||
setEmbedCheck({ loading: false });
|
||||
return;
|
||||
}
|
||||
@@ -102,7 +101,7 @@ function useItems({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [singleUrl, embed, isInternal]);
|
||||
}, [singleUrl, embed]);
|
||||
|
||||
// single item is pasted.
|
||||
if (typeof pastedText === "string") {
|
||||
@@ -144,10 +143,8 @@ function useItems({
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
subtitle:
|
||||
embedCheck.embeddable === false || isInternal
|
||||
? t("Not supported")
|
||||
: undefined,
|
||||
disabled: isInternal || embedCheck.loading || !embedCheck.embeddable,
|
||||
embedCheck.embeddable === false ? t("Not supported") : undefined,
|
||||
disabled: embedCheck.loading || !embedCheck.embeddable,
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
|
||||
@@ -125,11 +125,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.isActive) {
|
||||
// Save the selection position when the menu opens and as the user types.
|
||||
// On mobile, the editor may lose focus/selection when tapping on menu
|
||||
// items, so we restore it. The position must stay current as the search
|
||||
// text grows, otherwise the deletion range calculated in handleClearSearch
|
||||
// will be wrong.
|
||||
// Save the selection position when the menu opens. On mobile, the editor
|
||||
// may lose focus/selection when tapping on menu items, so we restore it.
|
||||
requestAnimationFrame(() => {
|
||||
const { from, to } = view.state.selection;
|
||||
selectionRef.current = { from, to };
|
||||
@@ -138,7 +135,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
selectionRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.isActive, props.search]);
|
||||
}, [props.isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSubmenu(null);
|
||||
@@ -213,9 +210,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
|
||||
|
||||
if (item.name === "noop") {
|
||||
if ("onClick" in item) {
|
||||
item.onClick?.();
|
||||
}
|
||||
// Do nothing
|
||||
} else if (command) {
|
||||
command(attrs);
|
||||
} else {
|
||||
@@ -245,13 +240,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
...item,
|
||||
name: "mention",
|
||||
});
|
||||
void editorProps.onCreateLink?.(
|
||||
{
|
||||
title: item.attrs.label,
|
||||
id: item.attrs.modelId,
|
||||
},
|
||||
!!item.attrs.nested
|
||||
);
|
||||
void editorProps.onCreateLink?.({
|
||||
title: item.attrs.label,
|
||||
id: item.attrs.modelId,
|
||||
});
|
||||
return;
|
||||
case "image":
|
||||
return triggerFilePick(
|
||||
@@ -734,16 +726,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [
|
||||
close,
|
||||
filtered,
|
||||
handleClickItem,
|
||||
insertItem,
|
||||
openSubmenu,
|
||||
props,
|
||||
selectedIndex,
|
||||
submenu,
|
||||
]);
|
||||
}, [close, filtered, handleClickItem, insertItem, openSubmenu, props, selectedIndex, submenu]);
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
@@ -760,7 +743,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const fileInput = uploadFile && (
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
<Trans>Upload file</Trans>
|
||||
<Trans>Import document</Trans>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
@@ -956,7 +939,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
if (submenuContentRef.current?.contains(e.target as Node)) {
|
||||
if (
|
||||
submenuContentRef.current?.contains(
|
||||
e.target as Node
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
@@ -980,16 +967,18 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
{fileInput}
|
||||
</BouncyPopoverContent>
|
||||
</Popover>
|
||||
{fileInput}
|
||||
{submenu && itemRefs.current.get(submenu.index) && (
|
||||
<Popover open modal={false}>
|
||||
<PopoverAnchor
|
||||
virtualRef={{
|
||||
current: {
|
||||
getBoundingClientRect: () =>
|
||||
itemRefs.current.get(submenu.index)!.getBoundingClientRect(),
|
||||
itemRefs.current
|
||||
.get(submenu.index)!
|
||||
.getBoundingClientRect(),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -12,10 +12,6 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
return "clipboardTextSerializer";
|
||||
}
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const mdSerializer = this.editor.extensions.serializer();
|
||||
|
||||
|
||||
@@ -33,10 +33,6 @@ export default class HoverPreviews extends Extension {
|
||||
return "hover-previews";
|
||||
}
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const isHoverTarget = (target: Element | null, view: EditorView) =>
|
||||
target instanceof HTMLElement &&
|
||||
|
||||
@@ -25,10 +25,6 @@ export default class Multiplayer extends Extension {
|
||||
return "multiplayer";
|
||||
}
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const { user, provider, document: doc } = this.options;
|
||||
const type = doc.get("default", Y.XmlFragment);
|
||||
|
||||
+14
-55
@@ -133,10 +133,7 @@ export type Props = {
|
||||
/** Callback when file upload progress changes */
|
||||
onFileUploadProgress?: (id: string, fractionComplete: number) => void;
|
||||
/** Callback when a link is created, should return url to created document */
|
||||
onCreateLink?: (
|
||||
params: Properties<Document>,
|
||||
nested?: boolean
|
||||
) => Promise<string>;
|
||||
onCreateLink?: (params: Properties<Document>) => Promise<string>;
|
||||
/** Callback when user clicks on any link in the document */
|
||||
onClickLink: (
|
||||
href: string,
|
||||
@@ -253,25 +250,17 @@ export class Editor extends React.PureComponent<
|
||||
this.view.updateState(newState);
|
||||
}
|
||||
|
||||
// When transitioning from readOnly to editable, reinitialize to create
|
||||
// editing extensions, keymaps, input rules, and commands that were skipped.
|
||||
if (prevProps.readOnly && !this.props.readOnly) {
|
||||
const docJSON = this.view.state.doc.toJSON();
|
||||
this.view.destroy();
|
||||
this.init();
|
||||
const newState = this.createState(docJSON);
|
||||
this.view.updateState(newState);
|
||||
} else if (!prevProps.readOnly && this.props.readOnly) {
|
||||
// pass readOnly changes through to underlying editor instance
|
||||
// pass readOnly changes through to underlying editor instance
|
||||
if (prevProps.readOnly !== this.props.readOnly) {
|
||||
this.view.update({
|
||||
...this.view.props,
|
||||
editable: () => false,
|
||||
editable: () => !this.props.readOnly,
|
||||
});
|
||||
|
||||
// NodeView will not automatically render when editable changes so we must trigger an update
|
||||
// manually, see: https://discuss.prosemirror.net/t/re-render-custom-nodeview-when-view-editable-changes/6441
|
||||
Array.from(this.renderers).forEach((view) =>
|
||||
view.setProp("isEditable", false)
|
||||
view.setProp("isEditable", !this.props.readOnly)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,24 +301,15 @@ export class Editor extends React.PureComponent<
|
||||
this.nodes = this.createNodes();
|
||||
this.marks = this.createMarks();
|
||||
this.schema = this.createSchema();
|
||||
this.widgets = this.createWidgets();
|
||||
this.plugins = this.createPlugins();
|
||||
this.rulePlugins = this.createRulePlugins();
|
||||
this.keymaps = this.createKeymaps();
|
||||
this.serializer = this.createSerializer();
|
||||
this.parser = this.createParser();
|
||||
this.pasteParser = this.createPasteParser();
|
||||
this.inputRules = this.createInputRules();
|
||||
this.nodeViews = this.createNodeViews();
|
||||
|
||||
this.widgets = this.createWidgets();
|
||||
|
||||
if (this.props.readOnly) {
|
||||
this.keymaps = [];
|
||||
this.inputRules = [];
|
||||
this.pasteParser = this.parser;
|
||||
} else {
|
||||
this.keymaps = this.createKeymaps();
|
||||
this.inputRules = this.createInputRules();
|
||||
this.pasteParser = this.createPasteParser();
|
||||
}
|
||||
|
||||
this.view = this.createView();
|
||||
this.commands = this.createCommands();
|
||||
}
|
||||
@@ -431,20 +411,12 @@ export class Editor extends React.PureComponent<
|
||||
private createState(value?: string | ProsemirrorData | ProsemirrorNode) {
|
||||
const doc = this.createDocument(value || this.props.defaultValue);
|
||||
|
||||
if (this.props.readOnly) {
|
||||
return EditorState.create({
|
||||
schema: this.schema,
|
||||
doc,
|
||||
plugins: [...this.plugins, anchorPlugin()],
|
||||
});
|
||||
}
|
||||
|
||||
return EditorState.create({
|
||||
schema: this.schema,
|
||||
doc,
|
||||
plugins: [
|
||||
...this.plugins,
|
||||
...this.keymaps,
|
||||
...this.plugins,
|
||||
anchorPlugin(),
|
||||
dropCursor({
|
||||
color: this.props.theme.cursor,
|
||||
@@ -648,25 +620,12 @@ export class Editor extends React.PureComponent<
|
||||
window?.getSelection()?.removeAllRanges();
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert content into the editor, replacing the block at the current selection.
|
||||
*
|
||||
* @param content The prosemirror data to insert.
|
||||
*/
|
||||
public insertContent = (content: ProsemirrorData) => {
|
||||
const doc = ProsemirrorNode.fromJSON(this.schema, content);
|
||||
const { $from } = this.view.state.selection;
|
||||
const start = $from.before($from.depth);
|
||||
const end = $from.after($from.depth);
|
||||
this.view.dispatch(this.view.state.tr.replaceWith(start, end, doc.content));
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert files at the current selection.
|
||||
*
|
||||
* @param event The source event.
|
||||
* @param files The files to insert.
|
||||
* @returns True if the files were inserted.
|
||||
* =
|
||||
* @param event The source event
|
||||
* @param files The files to insert
|
||||
* @returns True if the files were inserted
|
||||
*/
|
||||
public insertFiles = (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TrashIcon, DownloadIcon, ReplaceIcon, PDFIcon } from "outline-icons";
|
||||
import { TrashIcon, DownloadIcon, ReplaceIcon } from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
@@ -17,9 +17,6 @@ export default function attachmentMenuItems(
|
||||
const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, {
|
||||
preview: true,
|
||||
});
|
||||
const isPdfAttachment = isNodeActive(schema.nodes.attachment, {
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -32,13 +29,6 @@ export default function attachmentMenuItems(
|
||||
tooltip: dictionary.deleteAttachment,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
name: "toggleAttachmentPreview",
|
||||
tooltip: dictionary.previewAttachment,
|
||||
icon: <PDFIcon />,
|
||||
active: isAttachmentWithPreview,
|
||||
visible: isPdfAttachment(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
|
||||
@@ -126,7 +126,6 @@ export default function blockMenuItems(
|
||||
accept: "application/pdf",
|
||||
width: 300,
|
||||
height: 424,
|
||||
preview: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -165,12 +164,6 @@ export default function blockMenuItems(
|
||||
icon: <MathIcon />,
|
||||
keywords: "math katex latex",
|
||||
},
|
||||
{
|
||||
name: "container_toggle",
|
||||
title: dictionary.toggleBlock,
|
||||
icon: <CollapseIcon />,
|
||||
keywords: "toggle collapsible collapse fold",
|
||||
},
|
||||
{
|
||||
name: "hr",
|
||||
title: dictionary.hr,
|
||||
@@ -250,6 +243,12 @@ export default function blockMenuItems(
|
||||
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
|
||||
keywords: "diagram flowchart draw.io",
|
||||
},
|
||||
{
|
||||
name: "container_toggle",
|
||||
title: dictionary.toggleBlock,
|
||||
icon: <CollapseIcon />,
|
||||
keywords: "toggle collapsible collapse fold",
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out diagrams.net in desktop app
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { isMermaid } from "@shared/editor/lib/isCode";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
|
||||
export default function codeMenuItems(
|
||||
state: EditorState,
|
||||
@@ -61,11 +60,13 @@ export default function codeMenuItems(
|
||||
: undefined,
|
||||
tooltip: dictionary.copy,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "edit_mermaid",
|
||||
icon: <EditIcon />,
|
||||
tooltip: dictionary.editDiagram,
|
||||
shortcut: `${metaDisplay} Enter`,
|
||||
visible: isMermaid(node) && !isEditingMermaid && !readOnly,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,7 +32,6 @@ export default function useDictionary() {
|
||||
codeBlock: t("Code block"),
|
||||
codeCopied: t("Copied to clipboard"),
|
||||
codeInline: t("Code"),
|
||||
collapseCode: t("Collapse"),
|
||||
comment: t("Comment"),
|
||||
copy: t("Copy"),
|
||||
createLink: t("Create link"),
|
||||
@@ -45,7 +44,6 @@ export default function useDictionary() {
|
||||
deleteRow: t("Delete"),
|
||||
deleteTable: t("Delete table"),
|
||||
deleteAttachment: t("Delete file"),
|
||||
previewAttachment: t("Show preview"),
|
||||
dimensions: `${t("Width")} × ${t("Height")}`,
|
||||
download: t("Download"),
|
||||
downloadAttachment: t("Download file"),
|
||||
@@ -55,7 +53,6 @@ export default function useDictionary() {
|
||||
replaceImage: t("Replace image"),
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
expandCode: t("Expand"),
|
||||
file: t("File attachment"),
|
||||
pdf: t("Embed PDF"),
|
||||
enterLink: `${t("Enter a link")}…`,
|
||||
|
||||
@@ -24,10 +24,8 @@ import {
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
openDocumentInDesktop,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
presentDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
deleteDocument,
|
||||
@@ -108,8 +106,6 @@ export function useDocumentMenuAction({
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
openDocumentInDesktop,
|
||||
presentDocument,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import type Group from "~/models/Group";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -16,35 +16,27 @@ import {
|
||||
} from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
interface Options {
|
||||
/** Whether to hide the "Members" navigation action. */
|
||||
hideMembers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for group management operations.
|
||||
*
|
||||
*
|
||||
* @param targetGroup - the group to build actions for, or null to skip.
|
||||
* @param options - optional configuration for the menu.
|
||||
* @returns action with children for use in menus, or undefined if group is null.
|
||||
*/
|
||||
export function useGroupMenuActions(
|
||||
targetGroup: Group | null,
|
||||
options?: Options
|
||||
) {
|
||||
export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(targetGroup ?? ({} as Group));
|
||||
|
||||
const navigateToMembers = React.useCallback(() => {
|
||||
const openMembersDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
history.push(settingsPath("groups", targetGroup.id, "members"));
|
||||
}, [targetGroup, history]);
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={targetGroup} />,
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
|
||||
const openEditDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
@@ -53,10 +45,7 @@ export function useGroupMenuActions(
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog
|
||||
group={targetGroup}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
@@ -68,10 +57,7 @@ export function useGroupMenuActions(
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog
|
||||
group={targetGroup}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
@@ -81,30 +67,26 @@ export function useGroupMenuActions(
|
||||
!targetGroup
|
||||
? []
|
||||
: [
|
||||
...(options?.hideMembers
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: t("Members"),
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: can.read,
|
||||
perform: navigateToMembers,
|
||||
}),
|
||||
ActionSeparator,
|
||||
]),
|
||||
createAction({
|
||||
name: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.read),
|
||||
perform: openMembersDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: can.update,
|
||||
visible: !!(targetGroup && can.update),
|
||||
perform: openEditDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: GroupSection,
|
||||
visible: can.delete,
|
||||
visible: !!(targetGroup && can.delete),
|
||||
dangerous: true,
|
||||
perform: openDeleteDialog,
|
||||
}),
|
||||
@@ -116,13 +98,6 @@ export function useGroupMenuActions(
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
createExternalLinkAction({
|
||||
name: `External ID: ${targetGroup.externalGroup?.externalId ?? ""}`,
|
||||
section: GroupSection,
|
||||
visible: !!targetGroup.externalGroup?.externalId,
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
@@ -130,8 +105,7 @@ export function useGroupMenuActions(
|
||||
can.read,
|
||||
can.update,
|
||||
can.delete,
|
||||
options?.hideMembers,
|
||||
navigateToMembers,
|
||||
openMembersDialog,
|
||||
openEditDialog,
|
||||
openDeleteDialog,
|
||||
]
|
||||
|
||||
@@ -39,7 +39,6 @@ export const useLocaleTime = ({
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
de_DE: "d. MMMM yyyy 'um' H:mm",
|
||||
};
|
||||
const formatLocaleLong =
|
||||
(userLocale ? dateFormatLong[userLocale] : undefined) ??
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Router } from "react-router-dom";
|
||||
import stores from "~/stores";
|
||||
import Analytics from "~/components/Analytics";
|
||||
import Dialogs from "~/components/Dialogs";
|
||||
import Presentation from "~/components/Presentation";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import PageTheme from "~/components/PageTheme";
|
||||
import ScrollToTop from "~/components/ScrollToTop";
|
||||
@@ -73,7 +72,6 @@ if (element) {
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Presentation />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</LazyMotion>
|
||||
|
||||
@@ -8,13 +8,11 @@ import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
/** Whether to hide the "Members" navigation action. */
|
||||
hideMembers?: boolean;
|
||||
};
|
||||
|
||||
function GroupMenu({ group, hideMembers }: Props) {
|
||||
function GroupMenu({ group }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rootAction = useGroupMenuActions(group, { hideMembers });
|
||||
const rootAction = useGroupMenuActions(group);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import type { AuthenticationProviderSettings } from "@shared/types";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterDelete } from "./decorators/Lifecycle";
|
||||
@@ -14,10 +13,6 @@ class AuthenticationProvider extends Model {
|
||||
|
||||
providerId: string;
|
||||
|
||||
groupSyncSupported: boolean;
|
||||
|
||||
groupSyncUsesClaim: boolean;
|
||||
|
||||
@observable
|
||||
isConnected: boolean;
|
||||
|
||||
@@ -25,10 +20,6 @@ class AuthenticationProvider extends Model {
|
||||
@observable
|
||||
isEnabled: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
settings: AuthenticationProviderSettings | undefined;
|
||||
|
||||
@computed
|
||||
get isActive() {
|
||||
return this.isEnabled && this.isConnected;
|
||||
|
||||
@@ -67,11 +67,6 @@ export default class Collection extends ParanoidModel {
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
/** The minimum permission level required to manage templates in this collection. */
|
||||
@Field
|
||||
@observable
|
||||
templateManagement: CollectionPermission;
|
||||
|
||||
/**
|
||||
* Whether commenting is enabled for the collection.
|
||||
*/
|
||||
|
||||
@@ -5,22 +5,6 @@ import Field from "./decorators/Field";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import type { Searchable } from "./interfaces/Searchable";
|
||||
|
||||
/**
|
||||
* Information about a group that is managed by an external provider.
|
||||
*/
|
||||
interface ExternalGroupInfo {
|
||||
/** The unique identifier of the external group record in Outline. */
|
||||
id: string;
|
||||
/** The unique identifier of the group in the external provider. */
|
||||
externalId: string;
|
||||
/** The name of the external provider (e.g. google, slack, azure). */
|
||||
provider: string;
|
||||
/** The display name of the group in the external provider. */
|
||||
displayName: string;
|
||||
/** The date and time the group was last synced from the external provider. */
|
||||
lastSyncedAt: string | null;
|
||||
}
|
||||
|
||||
class Group extends Model implements Searchable {
|
||||
static modelName = "Group";
|
||||
|
||||
@@ -42,17 +26,6 @@ class Group extends Model implements Searchable {
|
||||
@observable
|
||||
disableMentions: boolean;
|
||||
|
||||
@observable
|
||||
externalGroup: ExternalGroupInfo | undefined;
|
||||
|
||||
/**
|
||||
* Whether this group's membership is managed by an external authentication provider.
|
||||
*/
|
||||
@computed
|
||||
get isExternallyManaged(): boolean {
|
||||
return !!this.externalGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the users that are members of this group.
|
||||
*/
|
||||
|
||||
+4
-12
@@ -1,15 +1,12 @@
|
||||
import { Switch } from "react-router-dom";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import { createLazyComponent as lazy } from "~/components/LazyLoad";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
const Application = lazy(() => import("~/scenes/Settings/Application"));
|
||||
const GroupMembers = lazy(() => import("~/scenes/Settings/GroupMembers"), {
|
||||
exportName: "GroupMembersScene",
|
||||
});
|
||||
const Template = lazy(() => import("~/scenes/Settings/Template"));
|
||||
const TemplateNew = lazy(() => import("~/scenes/Settings/TemplateNew"));
|
||||
|
||||
@@ -27,25 +24,20 @@ function SettingsRoutes() {
|
||||
/>
|
||||
))}
|
||||
{/* TODO: Refactor these exceptions into config? */}
|
||||
<Route
|
||||
exact
|
||||
path={settingsPath("groups", ":id", "members")}
|
||||
component={GroupMembers.Component}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={settingsPath("applications", ":id")}
|
||||
component={Application.Component}
|
||||
component={Application}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={settingsPath("templates", "new")}
|
||||
component={TemplateNew.Component}
|
||||
component={TemplateNew}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={settingsPath("templates", ":id")}
|
||||
component={Template.Component}
|
||||
component={Template}
|
||||
/>
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
|
||||
@@ -66,12 +66,7 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
|
||||
shortcut="e"
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
icon={<EditIcon />}
|
||||
onClick={goToEdit}
|
||||
haptic="light"
|
||||
neutral
|
||||
>
|
||||
<Button icon={<EditIcon />} onClick={goToEdit} neutral>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -80,9 +75,7 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
|
||||
{isEditing && user?.separateEditMode && (
|
||||
<Action>
|
||||
<RegisterKeyDown trigger="Escape" handler={goBack} />
|
||||
<Button onClick={goBack} haptic="medium">
|
||||
{t("Done editing")}
|
||||
</Button>
|
||||
<Button onClick={goBack}>{t("Done editing")}</Button>
|
||||
</Action>
|
||||
)}
|
||||
{can.createDocument && (
|
||||
|
||||
@@ -61,7 +61,7 @@ function Overview({ collection, readOnly }: Props) {
|
||||
() => ({
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
|
||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
||||
}),
|
||||
[childOffsetHeight]
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Properties } from "~/types";
|
||||
import Logger from "~/utils/Logger";
|
||||
@@ -89,7 +88,6 @@ function DataLoader({ match, children }: Props) {
|
||||
const isEditing = isEditRoute || !user?.separateEditMode;
|
||||
const can = usePolicy(document);
|
||||
const location = useLocation<LocationState>();
|
||||
const query = useQuery();
|
||||
const missingPolicy = !can || Object.keys(can).length === 0;
|
||||
|
||||
useDocumentSidebar();
|
||||
@@ -207,13 +205,6 @@ function DataLoader({ match, children }: Props) {
|
||||
revisionId,
|
||||
]);
|
||||
|
||||
// Auto-enter presentation mode when ?present=true query param is set
|
||||
React.useEffect(() => {
|
||||
if (document && query.has("present") && !ui.presentationData) {
|
||||
ui.setPresentingDocument(document);
|
||||
}
|
||||
}, [document, query, ui]);
|
||||
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? (
|
||||
<ErrorOffline />
|
||||
|
||||
@@ -669,11 +669,9 @@ const Main = styled.div<MainProps>`
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
max-width: ${({ fullWidth }: MainProps) =>
|
||||
fullWidth
|
||||
? `100%`
|
||||
: `calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`
|
||||
};
|
||||
max-width: calc(
|
||||
${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter}
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -722,10 +720,10 @@ const EditorContainer = styled.div<EditorContainerProps>`
|
||||
|
||||
// Decides the editor column position & span
|
||||
grid-column: ${({
|
||||
docFullWidth,
|
||||
showContents,
|
||||
tocPosition,
|
||||
}: EditorContainerProps) =>
|
||||
docFullWidth,
|
||||
showContents,
|
||||
tocPosition,
|
||||
}: EditorContainerProps) =>
|
||||
docFullWidth
|
||||
? showContents
|
||||
? tocPosition === TOCPosition.Left
|
||||
|
||||
@@ -172,7 +172,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
() => ({
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
|
||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
||||
}),
|
||||
[childOffsetHeight]
|
||||
);
|
||||
|
||||
@@ -158,7 +158,6 @@ function DocumentHeader({
|
||||
pathname: documentEditPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
haptic="light"
|
||||
neutral
|
||||
>
|
||||
{isMobile ? null : t("Edit")}
|
||||
@@ -284,7 +283,6 @@ function DocumentHeader({
|
||||
onClick={handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
neutral={isDraft}
|
||||
haptic="medium"
|
||||
hideIcon
|
||||
>
|
||||
{isDraft ? t("Save draft") : t("Done editing")}
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShrinkIcon, GrowIcon, CloseIcon } from "outline-icons";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { canUseElementFullscreen } from "@shared/utils/browser";
|
||||
import { s, depths, hover } from "@shared/styles";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Editor from "~/components/Editor";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import Flex from "~/components/Flex";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useIdle from "~/hooks/useIdle";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
/** Activity events that reset the idle timer — excludes keyboard to stay idle during navigation. */
|
||||
const idleEvents = [
|
||||
"click",
|
||||
"mousemove",
|
||||
"mousedown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
];
|
||||
|
||||
type Slide =
|
||||
| {
|
||||
type: "title";
|
||||
title: string;
|
||||
icon?: string | null;
|
||||
iconColor?: string | null;
|
||||
}
|
||||
| { type: "content"; content: ProsemirrorData[] }
|
||||
| { type: "instructions" };
|
||||
|
||||
interface Props {
|
||||
/** The document title. */
|
||||
title: string;
|
||||
/** The document icon. */
|
||||
icon?: string | null;
|
||||
/** The document icon color. */
|
||||
iconColor?: string | null;
|
||||
/** The prosemirror data for the document. */
|
||||
data: ProsemirrorData;
|
||||
/** Callback when presentation mode is closed. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given content nodes contain no meaningful text or elements.
|
||||
*
|
||||
* @param nodes the prosemirror content nodes.
|
||||
* @returns true when every node is an empty paragraph.
|
||||
*/
|
||||
function isContentEmpty(nodes: ProsemirrorData[]): boolean {
|
||||
return nodes.every(
|
||||
(node) =>
|
||||
node.type === "paragraph" && (!node.content || node.content.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a ProseMirror document into slides based on heading and divider nodes.
|
||||
* A dedicated title slide is prepended. Each h1/h2 heading or horizontal rule
|
||||
* starts a new content slide. Divider nodes are consumed as separators and not
|
||||
* rendered on slides.
|
||||
*
|
||||
* @param data the prosemirror document data.
|
||||
* @param title the document title.
|
||||
* @param icon the document icon.
|
||||
* @param iconColor the document icon color.
|
||||
* @returns an array of slides.
|
||||
*/
|
||||
function splitIntoSlides(
|
||||
data: ProsemirrorData,
|
||||
title: string,
|
||||
icon?: string | null,
|
||||
iconColor?: string | null
|
||||
): Slide[] {
|
||||
const content = data.content ?? [];
|
||||
const slides: Slide[] = [{ type: "title", title, icon, iconColor }];
|
||||
let currentNodes: ProsemirrorData[] = [];
|
||||
|
||||
for (const node of content) {
|
||||
const isDivider = node.type === "horizontal_rule" || node.type === "hr";
|
||||
const isHeadingBreak =
|
||||
node.type === "heading" &&
|
||||
node.attrs &&
|
||||
typeof node.attrs.level === "number" &&
|
||||
node.attrs.level <= 2;
|
||||
|
||||
if (isDivider) {
|
||||
if (currentNodes.length > 0) {
|
||||
slides.push({ type: "content", content: currentNodes });
|
||||
currentNodes = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isHeadingBreak && currentNodes.length > 0) {
|
||||
slides.push({ type: "content", content: currentNodes });
|
||||
currentNodes = [];
|
||||
}
|
||||
|
||||
currentNodes.push(node);
|
||||
}
|
||||
|
||||
if (currentNodes.length > 0) {
|
||||
slides.push({ type: "content", content: currentNodes });
|
||||
}
|
||||
|
||||
return slides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen presentation mode that splits a document into slides by headings
|
||||
* and dividers, and allows navigating through them with keyboard controls.
|
||||
*/
|
||||
function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [currentSlide, setCurrentSlide] = React.useState(0);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const slideContentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||
const supportsFullscreen = React.useMemo(() => canUseElementFullscreen(), []);
|
||||
const isIdle = useIdle(3000, idleEvents);
|
||||
|
||||
const slides = React.useMemo(() => {
|
||||
const result = splitIntoSlides(data, title, icon, iconColor);
|
||||
const contentSlides = result.filter((s) => s.type === "content");
|
||||
const hasContent =
|
||||
contentSlides.length > 0 &&
|
||||
contentSlides.some(
|
||||
(s) => s.type === "content" && !isContentEmpty(s.content)
|
||||
);
|
||||
|
||||
if (!hasContent) {
|
||||
return [result[0], { type: "instructions" as const }];
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, title, icon, iconColor]);
|
||||
|
||||
const totalSlides = slides.length;
|
||||
|
||||
const goNext = React.useCallback(() => {
|
||||
setCurrentSlide((prev) => Math.min(prev + 1, totalSlides - 1));
|
||||
}, [totalSlides]);
|
||||
|
||||
const goPrev = React.useCallback(() => {
|
||||
setCurrentSlide((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const goFirst = React.useCallback(() => {
|
||||
setCurrentSlide(0);
|
||||
}, []);
|
||||
|
||||
const goLast = React.useCallback(() => {
|
||||
setCurrentSlide(totalSlides - 1);
|
||||
}, [totalSlides]);
|
||||
|
||||
const toggleFullscreen = React.useCallback(() => {
|
||||
if (!supportsFullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = containerRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
} else {
|
||||
el.requestFullscreen().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
}, [supportsFullscreen]);
|
||||
|
||||
useKeyDown("Escape", onClose);
|
||||
useKeyDown("ArrowRight", goNext);
|
||||
useKeyDown("ArrowDown", goNext);
|
||||
useKeyDown("PageDown", goNext);
|
||||
useKeyDown("ArrowLeft", goPrev);
|
||||
useKeyDown("ArrowUp", goPrev);
|
||||
useKeyDown("PageUp", goPrev);
|
||||
useKeyDown("Home", goFirst);
|
||||
useKeyDown("End", goLast);
|
||||
useKeyDown(" ", goNext);
|
||||
useKeyDown("f", toggleFullscreen);
|
||||
|
||||
// Prevent body scrolling while presentation is open
|
||||
React.useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track fullscreen state changes
|
||||
React.useEffect(() => {
|
||||
if (!supportsFullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [supportsFullscreen]);
|
||||
|
||||
// Measure natural size once per slide, then apply scale directly to the DOM
|
||||
// to avoid React re-render loops during window resize.
|
||||
const naturalSize = React.useRef({ width: 0, height: 0 });
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = slideContentRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!el || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyScale = () => {
|
||||
const { width, height } = naturalSize.current;
|
||||
if (width === 0 || height === 0) {
|
||||
el.style.transform = "scale(1)";
|
||||
return;
|
||||
}
|
||||
|
||||
const availableWidth = container.clientWidth - 160;
|
||||
const availableHeight = container.clientHeight - 48 - 160;
|
||||
const scaleX = availableWidth / width;
|
||||
const scaleY = availableHeight / height;
|
||||
const newScale = Math.min(scaleX, scaleY, 1.5);
|
||||
el.style.transform = `scale(${Math.max(newScale, 0.5)})`;
|
||||
};
|
||||
|
||||
// Measure natural size with scale removed, then apply
|
||||
el.style.transform = "none";
|
||||
requestAnimationFrame(() => {
|
||||
naturalSize.current = {
|
||||
width: el.scrollWidth,
|
||||
height: el.scrollHeight,
|
||||
};
|
||||
applyScale();
|
||||
window.addEventListener("resize", applyScale);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", applyScale);
|
||||
};
|
||||
}, [currentSlide]);
|
||||
|
||||
const slide = slides[currentSlide];
|
||||
|
||||
const slideData: ProsemirrorData | undefined = React.useMemo(
|
||||
() =>
|
||||
slide.type === "content"
|
||||
? { type: "doc", content: slide.content }
|
||||
: undefined,
|
||||
[slide]
|
||||
);
|
||||
|
||||
const extensions = React.useMemo(() => richExtensions, []);
|
||||
|
||||
return createPortal(
|
||||
<Container ref={containerRef} $background={theme.background} $idle={isIdle}>
|
||||
<TopBar $idle={isIdle}>
|
||||
<Flex align="center" gap={12}>
|
||||
<Tooltip content={t("Previous slide")} delay={500}>
|
||||
<Button onClick={goPrev} disabled={currentSlide === 0}>
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<SlideCounter>
|
||||
{currentSlide + 1} / {totalSlides}
|
||||
</SlideCounter>
|
||||
<Tooltip content={t("Next slide")} delay={500}>
|
||||
<Button
|
||||
onClick={goNext}
|
||||
disabled={currentSlide === totalSlides - 1}
|
||||
>
|
||||
<ArrowRightIcon color="currentColor" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<RightButtons>
|
||||
{supportsFullscreen && (
|
||||
<Tooltip content={t("Toggle fullscreen")} delay={500}>
|
||||
<Button onClick={toggleFullscreen}>
|
||||
{isFullscreen ? (
|
||||
<ShrinkIcon color="currentColor" />
|
||||
) : (
|
||||
<GrowIcon color="currentColor" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={t("Close")} delay={500}>
|
||||
<Button onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</RightButtons>
|
||||
</TopBar>
|
||||
<SlideArea onClick={goNext}>
|
||||
<SlideContent ref={slideContentRef}>
|
||||
{slide.type === "title" ? (
|
||||
<TitleSlide>
|
||||
{slide.icon && (
|
||||
<TitleIcon>
|
||||
<Icon
|
||||
value={slide.icon}
|
||||
color={slide.iconColor ?? colorPalette[0]}
|
||||
size={64}
|
||||
initial={slide.title[0]}
|
||||
/>
|
||||
</TitleIcon>
|
||||
)}
|
||||
<TitleText>{slide.title}</TitleText>
|
||||
</TitleSlide>
|
||||
) : slide.type === "instructions" ? (
|
||||
<InstructionSlide>
|
||||
<InstructionHeading>
|
||||
{t("Create your presentation")}
|
||||
</InstructionHeading>
|
||||
<InstructionBody>
|
||||
{t(
|
||||
"Add content to your document, then use headings or dividers to separate it into slides."
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://docs.getoutline.com/s/guide/doc/present-mode-yMGzaY7A9L"
|
||||
target="_blank"
|
||||
>
|
||||
{t("Learn more")}
|
||||
</a>
|
||||
.
|
||||
</InstructionBody>
|
||||
</InstructionSlide>
|
||||
) : slideData ? (
|
||||
<Editor
|
||||
key={currentSlide}
|
||||
defaultValue={slideData}
|
||||
extensions={extensions}
|
||||
readOnly
|
||||
grow={false}
|
||||
placeholder=""
|
||||
/>
|
||||
) : null}
|
||||
</SlideContent>
|
||||
</SlideArea>
|
||||
</Container>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $background: string; $idle: boolean }>`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: ${depths.presentation};
|
||||
background: ${(props) => props.$background};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
cursor: ${(props) => (props.$idle ? "none" : "default")};
|
||||
|
||||
* {
|
||||
cursor: inherit;
|
||||
}
|
||||
`;
|
||||
|
||||
const SlideArea = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 80px;
|
||||
`;
|
||||
|
||||
const SlideContent = styled.div`
|
||||
max-width: 960px;
|
||||
width: 100%;
|
||||
transform-origin: center center;
|
||||
|
||||
.ProseMirror {
|
||||
padding: 0;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.4em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
`;
|
||||
|
||||
const TitleSlide = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 24px;
|
||||
min-height: 200px;
|
||||
`;
|
||||
|
||||
const TitleIcon = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const TitleText = styled.h1`
|
||||
font-size: 3em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const TopBar = styled.div<{ $idle: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
opacity: ${(props) => (props.$idle ? 0 : 1)};
|
||||
transition: opacity 300ms ease;
|
||||
`;
|
||||
|
||||
const SlideCounter = styled(Text)`
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const RightButtons = styled(Flex).attrs({ align: "center", gap: 16 })`
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
`;
|
||||
|
||||
const Button = styled(NudeButton).attrs({ size: 32 })`
|
||||
&:not(:disabled) {
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:${hover},
|
||||
&:active {
|
||||
color: ${s("text")};
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${s("textTertiary")};
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const InstructionSlide = styled(TitleSlide)`
|
||||
gap: 16px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const InstructionHeading = styled.h2`
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const InstructionBody = styled.p`
|
||||
font-size: 1.2em;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default PresentationMode;
|
||||
@@ -30,7 +30,7 @@ function References({ document }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShare) {
|
||||
void documents.fetchRelationships(document.id);
|
||||
void documents.fetchBacklinks(document.id);
|
||||
}
|
||||
}, [isShare, documents, document.id]);
|
||||
|
||||
|
||||
@@ -108,15 +108,6 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
|
||||
),
|
||||
label: t("Go to link"),
|
||||
},
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
<Key symbol>{metaDisplay}</Key> + <Key symbol>{altDisplay}</Key>{" "}
|
||||
+ <Key>p</Key>
|
||||
</>
|
||||
),
|
||||
label: t("Present document"),
|
||||
},
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
|
||||
@@ -6,8 +6,6 @@ import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import type AuthenticationProvider from "~/models/AuthenticationProvider";
|
||||
import PluginIcon from "~/components/PluginIcon";
|
||||
import Scene from "~/components/Scene";
|
||||
@@ -23,7 +21,6 @@ import { settingsPath } from "~/utils/routeHelpers";
|
||||
import DomainManagement from "./components/DomainManagement";
|
||||
import Button from "~/components/Button";
|
||||
import { ConnectedIcon } from "~/components/Icons/ConnectedIcon";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { useTheme } from "styled-components";
|
||||
import { VStack } from "~/components/primitives/VStack";
|
||||
|
||||
@@ -100,54 +97,6 @@ function Authentication() {
|
||||
window.location.href = `/auth/${name}?host=${window.location.host}`;
|
||||
}, []);
|
||||
|
||||
const handleToggleGroupSync = React.useCallback(
|
||||
(provider: AuthenticationProvider, checked: boolean) => {
|
||||
if (checked) {
|
||||
void (async () => {
|
||||
try {
|
||||
await provider.save({
|
||||
settings: {
|
||||
...provider.settings,
|
||||
groupSyncEnabled: true,
|
||||
},
|
||||
});
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
dialogs.openModal({
|
||||
title: t("Disable group sync"),
|
||||
content: (
|
||||
<DisableGroupSyncDialog
|
||||
provider={provider}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
[t, dialogs]
|
||||
);
|
||||
|
||||
const handleGroupClaimChange = React.useCallback(
|
||||
async (provider: AuthenticationProvider, groupClaim: string) => {
|
||||
try {
|
||||
await provider.save({
|
||||
settings: {
|
||||
...provider.settings,
|
||||
groupClaim,
|
||||
},
|
||||
});
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const showSuccessMessage = React.useMemo(
|
||||
() => () => toast.success(t("Settings saved")),
|
||||
[t]
|
||||
@@ -166,107 +115,58 @@ function Authentication() {
|
||||
<Heading as="h2">{t("Sign In")}</Heading>
|
||||
|
||||
{authenticationProviders.orderedData.map((provider) => (
|
||||
<React.Fragment key={provider.name}>
|
||||
<SettingRow
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<PluginIcon id={provider.name} /> {provider.displayName}
|
||||
</Flex>
|
||||
}
|
||||
name={provider.name}
|
||||
description={
|
||||
provider.isConnected
|
||||
? t("Allow members to sign-in with {{ authProvider }}", {
|
||||
authProvider: provider.displayName,
|
||||
})
|
||||
: t("Connect {{ authProvider }} to allow members to sign-in", {
|
||||
authProvider: provider.displayName,
|
||||
})
|
||||
}
|
||||
border={!(provider.isActive && provider.groupSyncSupported)}
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
{provider.isConnected ? (
|
||||
<VStack align="start">
|
||||
<Button
|
||||
icon={
|
||||
provider.isEnabled ? (
|
||||
<ConnectedIcon />
|
||||
) : (
|
||||
<ConnectedIcon color={theme.textSecondary} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
!provider.isEnabled
|
||||
? handleToggleProvider(provider, true)
|
||||
: handleRemoveProvider(provider)
|
||||
}
|
||||
neutral
|
||||
>
|
||||
{provider.isEnabled ? t("Connected") : t("Disabled")}
|
||||
</Button>
|
||||
<Text type="tertiary" size="small">
|
||||
{provider.providerId}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<SettingRow
|
||||
key={provider.name}
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<PluginIcon id={provider.name} /> {provider.displayName}
|
||||
</Flex>
|
||||
}
|
||||
name={provider.name}
|
||||
description={
|
||||
provider.isConnected
|
||||
? t("Allow members to sign-in with {{ authProvider }}", {
|
||||
authProvider: provider.displayName,
|
||||
})
|
||||
: t("Connect {{ authProvider }} to allow members to sign-in", {
|
||||
authProvider: provider.displayName,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
{provider.isConnected ? (
|
||||
<VStack align="start">
|
||||
<Button
|
||||
onClick={() => handleConnectProvider(provider.name)}
|
||||
icon={
|
||||
provider.isEnabled ? (
|
||||
<ConnectedIcon />
|
||||
) : (
|
||||
<ConnectedIcon color={theme.textSecondary} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
!provider.isEnabled
|
||||
? handleToggleProvider(provider, true)
|
||||
: handleRemoveProvider(provider)
|
||||
}
|
||||
neutral
|
||||
>
|
||||
{t("Connect")}
|
||||
{provider.isEnabled ? t("Connected") : t("Disabled")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
{provider.isActive && provider.groupSyncSupported && (
|
||||
<SettingRow
|
||||
label={t("Group sync")}
|
||||
name={`groupSync-${provider.name}`}
|
||||
description={t(
|
||||
"Sync group memberships from {{ authProvider }} on each sign-in",
|
||||
{ authProvider: provider.displayName }
|
||||
)}
|
||||
border={
|
||||
!(
|
||||
provider.settings?.groupSyncEnabled &&
|
||||
provider.groupSyncUsesClaim
|
||||
)
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
id={`groupSync-${provider.name}`}
|
||||
checked={provider.settings?.groupSyncEnabled ?? false}
|
||||
onChange={(checked) => handleToggleGroupSync(provider, checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{provider.isActive &&
|
||||
provider.groupSyncSupported &&
|
||||
provider.groupSyncUsesClaim &&
|
||||
provider.settings?.groupSyncEnabled && (
|
||||
<SettingRow
|
||||
label={t("Group claim")}
|
||||
name={`groupClaim-${provider.name}`}
|
||||
description={t(
|
||||
"The claim in the provider response that contains group names (e.g. groups, roles)"
|
||||
)}
|
||||
border={false}
|
||||
<Text type="tertiary" size="small">
|
||||
{provider.providerId}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleConnectProvider(provider.name)}
|
||||
neutral
|
||||
>
|
||||
<Input
|
||||
id={`groupClaim-${provider.name}`}
|
||||
defaultValue={provider.settings?.groupClaim ?? "groups"}
|
||||
placeholder="groups"
|
||||
onBlur={(ev: React.FocusEvent<HTMLInputElement>) => {
|
||||
const value = ev.target.value.trim();
|
||||
if (value !== (provider.settings?.groupClaim ?? "")) {
|
||||
void handleGroupClaimChange(provider, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
))}
|
||||
<SettingRow
|
||||
label={
|
||||
@@ -319,87 +219,4 @@ function Authentication() {
|
||||
);
|
||||
}
|
||||
|
||||
const DisableGroupSyncDialog = observer(function DisableGroupSyncDialog({
|
||||
provider,
|
||||
onSubmit,
|
||||
}: {
|
||||
provider: AuthenticationProvider;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [action, setAction] = React.useState("keep");
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const options = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "item" as const,
|
||||
label: t("Keep synced groups"),
|
||||
description: t("Groups will remain but no longer update"),
|
||||
value: "keep",
|
||||
},
|
||||
{
|
||||
type: "item" as const,
|
||||
label: t("Delete synced groups"),
|
||||
description: t("Remove all groups created by sync"),
|
||||
value: "delete",
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await provider.save({
|
||||
settings: {
|
||||
...provider.settings,
|
||||
groupSyncEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (action === "delete") {
|
||||
await client.post("/groups.deleteAll", {
|
||||
authenticationProviderId: provider.id,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(t("Settings saved"));
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[provider, action, onSubmit, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Flex gap={12} column>
|
||||
<Text type="secondary">
|
||||
{t(
|
||||
"Group memberships will no longer be synced from {{ authProvider }} when members sign in.",
|
||||
{ authProvider: provider.displayName }
|
||||
)}
|
||||
</Text>
|
||||
<InputSelect
|
||||
label={t("Existing groups")}
|
||||
options={options}
|
||||
value={action}
|
||||
onChange={setAction}
|
||||
/>
|
||||
<Flex justify="flex-end">
|
||||
<Button type="submit" disabled={isSaving} danger>
|
||||
{isSaving ? `${t("Disabling")}…` : t("Disable")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(Authentication);
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
import type { ColumnSort } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon, HiddenIcon, PlusIcon } from "outline-icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { toast } from "sonner";
|
||||
import type User from "~/models/User";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Button from "~/components/Button";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import type { FetchPageParams, PaginatedResponse } from "~/stores/base/Store";
|
||||
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
|
||||
import GroupMenu from "~/menus/GroupMenu";
|
||||
import { AddPeopleToGroupDialog } from "./components/GroupDialogs";
|
||||
import GroupPermissionFilter from "./components/GroupPermissionFilter";
|
||||
import { GroupMembersTable } from "./components/GroupMembersTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
/**
|
||||
* Settings page that lists members of a specific group.
|
||||
*/
|
||||
function GroupMembers() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { groups } = useStores();
|
||||
const group = groups.get(id);
|
||||
const { request, error } = useRequest(() => groups.fetch(id));
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) {
|
||||
void request();
|
||||
}
|
||||
}, [group, request]);
|
||||
|
||||
if (error) {
|
||||
return <Error404 />;
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <GroupMembersPage groupId={group.id} />;
|
||||
}
|
||||
|
||||
const GroupMembersPage = observer(function GroupMembersPage({
|
||||
groupId,
|
||||
}: {
|
||||
groupId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { dialogs, groups, users, groupUsers } = useStores();
|
||||
const group = groups.get(groupId)!;
|
||||
const can = usePolicy(group);
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const params = useQuery();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const reqParams = useMemo(
|
||||
() => ({
|
||||
id: group.id,
|
||||
query: params.get("query") || undefined,
|
||||
permission: params.get("permission") || undefined,
|
||||
sort: params.get("sort") || "name",
|
||||
direction: (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC",
|
||||
}),
|
||||
[params, group.id]
|
||||
);
|
||||
|
||||
const sort: ColumnSort = useMemo(
|
||||
() => ({
|
||||
id: reqParams.sort,
|
||||
desc: reqParams.direction === "DESC",
|
||||
}),
|
||||
[reqParams.sort, reqParams.direction]
|
||||
);
|
||||
|
||||
const fetchMembers = useCallback(
|
||||
async (fetchParams: FetchPageParams): Promise<PaginatedResponse<User>> => {
|
||||
const response = await groupUsers.fetchPage(fetchParams);
|
||||
const result = response.map((gu) => gu.user) as PaginatedResponse<User>;
|
||||
result[PAGINATION_SYMBOL] = response[PAGINATION_SYMBOL];
|
||||
return result;
|
||||
},
|
||||
[groupUsers]
|
||||
);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
let result = users.inGroup(group.id, reqParams.query);
|
||||
if (reqParams.permission) {
|
||||
const memberIds = new Set(
|
||||
groupUsers.orderedData
|
||||
.filter(
|
||||
(gu) =>
|
||||
gu.groupId === group.id && gu.permission === reqParams.permission
|
||||
)
|
||||
.map((gu) => gu.userId)
|
||||
);
|
||||
result = result.filter((user) => memberIds.has(user.id));
|
||||
}
|
||||
return result;
|
||||
}, [
|
||||
users,
|
||||
groupUsers.orderedData,
|
||||
group.id,
|
||||
reqParams.query,
|
||||
reqParams.permission,
|
||||
]);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: filteredUsers,
|
||||
sort,
|
||||
reqFn: fetchMembers,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
const updateParams = useCallback(
|
||||
(name: string, value: string) => {
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete(name);
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const updateQuery = useCallback(
|
||||
(value: string) => updateParams("query", value),
|
||||
[updateParams]
|
||||
);
|
||||
|
||||
const handlePermissionFilter = useCallback(
|
||||
(permission: string | null | undefined) =>
|
||||
updateParams("permission", permission ?? ""),
|
||||
[updateParams]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddPeople = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t(`Add people to {{groupName}}`, {
|
||||
groupName: group.name,
|
||||
}),
|
||||
content: <AddPeopleToGroupDialog group={group} />,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load group members"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => updateQuery(query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateQuery]);
|
||||
|
||||
const breadcrumbActions = useMemo(
|
||||
() => [
|
||||
createInternalLinkAction({
|
||||
name: t("Groups"),
|
||||
section: NavigationSection,
|
||||
icon: <GroupIcon />,
|
||||
to: settingsPath("groups"),
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={group.name}
|
||||
left={<Breadcrumb actions={breadcrumbActions} />}
|
||||
actions={
|
||||
<>
|
||||
{can.update && (
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddPeople}
|
||||
disabled={group.isExternallyManaged}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{`${t("Add people")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
<Action>
|
||||
<GroupMenu group={group} hideMembers />
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
wide
|
||||
>
|
||||
<Heading>
|
||||
{group.name}
|
||||
{group.disableMentions && (
|
||||
<>
|
||||
|
||||
<Tooltip content={t("This group is hidden")}>
|
||||
<HiddenIcon size={32} color={theme.textSecondary} />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
{group.externalGroup && (
|
||||
<>
|
||||
{t("Synced to {{ provider }}", {
|
||||
provider: group.externalGroup.displayName,
|
||||
})}
|
||||
{group.description && <> · </>}
|
||||
</>
|
||||
)}
|
||||
{group.description || (!group.externalGroup && t("No description"))}
|
||||
</Text>
|
||||
<StickyFilters>
|
||||
<InputSearch
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<LargeGroupPermissionFilter
|
||||
activeKey={reqParams.permission ?? ""}
|
||||
onSelect={handlePermissionFilter}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<GroupMembersTable
|
||||
group={group}
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</ConditionalFade>
|
||||
</Scene>
|
||||
);
|
||||
});
|
||||
|
||||
const LargeGroupPermissionFilter = styled(GroupPermissionFilter)`
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
export const GroupMembersScene = observer(GroupMembers);
|
||||
@@ -2,7 +2,6 @@ import type { ColumnSort } from "@tanstack/react-table";
|
||||
import deburr from "lodash/deburr";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon, GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
@@ -13,7 +12,6 @@ import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import styled from "styled-components";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
@@ -23,30 +21,18 @@ import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { CreateGroupDialog } from "./components/GroupDialogs";
|
||||
import GroupSourceFilter from "./components/GroupSourceFilter";
|
||||
import { GroupsTable } from "./components/GroupsTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
function getFilteredGroups(groups: Group[], query?: string, source?: string) {
|
||||
let filtered = groups;
|
||||
|
||||
if (query?.length) {
|
||||
const normalizedQuery = deburr(query.toLocaleLowerCase());
|
||||
filtered = filtered.filter((group) =>
|
||||
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
function getFilteredGroups(groups: Group[], query?: string) {
|
||||
if (!query?.length) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
if (source === "manual") {
|
||||
filtered = filtered.filter((group) => !group.externalGroup);
|
||||
} else if (source) {
|
||||
filtered = filtered.filter(
|
||||
(group) => group.externalGroup?.provider === source
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
const normalizedQuery = deburr(query.toLocaleLowerCase());
|
||||
return groups.filter((group) =>
|
||||
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
}
|
||||
|
||||
function Groups() {
|
||||
@@ -62,7 +48,6 @@ function Groups() {
|
||||
const reqParams = useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
source: params.get("source") || undefined,
|
||||
sort: params.get("sort") || "name",
|
||||
direction: (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
@@ -80,11 +65,7 @@ function Groups() {
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: getFilteredGroups(
|
||||
groups.orderedData,
|
||||
reqParams.query,
|
||||
reqParams.source
|
||||
),
|
||||
data: getFilteredGroups(groups.orderedData, reqParams.query),
|
||||
sort,
|
||||
reqFn: groups.fetchPage,
|
||||
reqParams,
|
||||
@@ -92,12 +73,12 @@ function Groups() {
|
||||
|
||||
const isEmpty = !loading && !groups.orderedData.length;
|
||||
|
||||
const updateParams = useCallback(
|
||||
(name: string, value: string) => {
|
||||
const updateQuery = useCallback(
|
||||
(value: string) => {
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
params.set("query", value);
|
||||
} else {
|
||||
params.delete(name);
|
||||
params.delete("query");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
@@ -108,18 +89,10 @@ function Groups() {
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSourceFilter = useCallback(
|
||||
(source: string | null | undefined) => updateParams("source", source ?? ""),
|
||||
[updateParams]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleSearch = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
}, []);
|
||||
|
||||
const handleNewGroup = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
@@ -135,9 +108,9 @@ function Groups() {
|
||||
}, [t, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => updateParams("query", query), 250);
|
||||
const timeout = setTimeout(() => updateQuery(query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateParams]);
|
||||
}, [query, updateQuery]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -171,18 +144,11 @@ function Groups() {
|
||||
) : (
|
||||
<>
|
||||
<StickyFilters>
|
||||
<HStack>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<LargeGroupSourceFilter
|
||||
activeKey={reqParams.source ?? ""}
|
||||
onSelect={handleSourceFilter}
|
||||
/>
|
||||
</HStack>
|
||||
<InputSearch
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<GroupsTable
|
||||
@@ -201,8 +167,4 @@ function Groups() {
|
||||
);
|
||||
}
|
||||
|
||||
const LargeGroupSourceFilter = styled(GroupSourceFilter)`
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
export default observer(Groups);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { runInAction } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
@@ -25,14 +24,19 @@ import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Notifications() {
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
const options = [
|
||||
{
|
||||
@@ -147,8 +151,6 @@ function Notifications() {
|
||||
},
|
||||
];
|
||||
|
||||
const visibleOptions = options.filter((o) => o.visible !== false);
|
||||
|
||||
const showSuccessMessage = debounce(() => {
|
||||
toast.success(t("Notifications saved"));
|
||||
}, 500);
|
||||
@@ -161,29 +163,6 @@ function Notifications() {
|
||||
[user, showSuccessMessage]
|
||||
);
|
||||
|
||||
const handleToggleAll = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
runInAction(() => {
|
||||
const updated = { ...user.notificationSettings };
|
||||
for (const option of visibleOptions) {
|
||||
updated[option.event] = checked;
|
||||
}
|
||||
user.notificationSettings = updated;
|
||||
});
|
||||
await client.post(
|
||||
checked
|
||||
? `/users.notificationsSubscribe`
|
||||
: `/users.notificationsUnsubscribe`
|
||||
);
|
||||
showSuccessMessage();
|
||||
},
|
||||
[user, visibleOptions, showSuccessMessage]
|
||||
);
|
||||
|
||||
const allEnabled = visibleOptions.every((o) =>
|
||||
user.subscribedToEventType(o.event)
|
||||
);
|
||||
|
||||
const showSuccessNotice = window.location.search === "?success";
|
||||
|
||||
return (
|
||||
@@ -201,18 +180,17 @@ function Notifications() {
|
||||
<Trans>Manage when and where you receive email notifications.</Trans>
|
||||
</Text>
|
||||
|
||||
<SettingRow
|
||||
name="allNotifications"
|
||||
label={t("All notifications")}
|
||||
compact
|
||||
border={false}
|
||||
>
|
||||
<Switch
|
||||
id="allNotifications"
|
||||
checked={allEnabled}
|
||||
onChange={handleToggleAll}
|
||||
/>
|
||||
</SettingRow>
|
||||
{env.EMAIL_ENABLED && can.manage && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The email integration is currently disabled. Please set the
|
||||
associated environment variables and restart the server to enable
|
||||
notifications.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<h2>{t("Notifications")}</h2>
|
||||
|
||||
{options.map((option) => {
|
||||
const setting = user.subscribedToEventType(option.event);
|
||||
@@ -221,14 +199,13 @@ function Notifications() {
|
||||
<SettingRow
|
||||
key={option.event}
|
||||
visible={option.visible}
|
||||
label={option.title}
|
||||
name={option.event}
|
||||
description={
|
||||
<Text size="small" type="secondary">
|
||||
{option.description}
|
||||
</Text>
|
||||
label={
|
||||
<HStack spacing={4}>
|
||||
{option.icon} {option.title}
|
||||
</HStack>
|
||||
}
|
||||
compact
|
||||
name={option.event}
|
||||
description={option.description}
|
||||
>
|
||||
<Switch
|
||||
key={option.event}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -15,6 +16,8 @@ import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import type { Item } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { ListItem } from "~/components/Sharing/components/ListItem";
|
||||
@@ -31,8 +34,6 @@ import type { Permission } from "~/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import type GroupUser from "~/models/GroupUser";
|
||||
import Switch from "~/components/Switch";
|
||||
import history from "~/utils/history";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -62,14 +63,17 @@ export function CreateGroupDialog() {
|
||||
try {
|
||||
await group.save();
|
||||
dialogs.closeAllModals();
|
||||
history.push(settingsPath("groups", group.id, "members"));
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dialogs, groups, name, description]
|
||||
[t, dialogs, groups, name, description]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -151,17 +155,10 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
{group.isExternallyManaged ? (
|
||||
<Trans>
|
||||
This group is managed by an external authentication provider. The
|
||||
name is synced automatically and cannot be changed.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You can edit the name of this group at any time, however doing so
|
||||
too often might confuse your team mates.
|
||||
</Trans>
|
||||
)}
|
||||
<Trans>
|
||||
You can edit the name of this group at any time, however doing so too
|
||||
often might confuse your team mates.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex column>
|
||||
<Input
|
||||
@@ -169,7 +166,6 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
disabled={group.isExternallyManaged}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
@@ -185,7 +181,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
/>
|
||||
<Switch
|
||||
id="mentions"
|
||||
label={t("Hidden")}
|
||||
label={t("Disable mentions")}
|
||||
note={t(
|
||||
"Prevent this group from being mentionable in documents or comments"
|
||||
)}
|
||||
@@ -229,7 +225,195 @@ export function DeleteGroupDialog({ group, onSubmit }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export const AddPeopleToGroupDialog = observer(function ({
|
||||
export const ViewGroupMembersDialog = observer(function ({
|
||||
group,
|
||||
}: Pick<Props, "group">) {
|
||||
const { dialogs, users, groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(group);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [permissionFilter, setPermissionFilter] = React.useState<
|
||||
GroupPermission | "all"
|
||||
>("all");
|
||||
|
||||
const handleAddPeople = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t(`Add people to {{groupName}}`, {
|
||||
groupName: group.name,
|
||||
}),
|
||||
content: <AddPeopleToGroupDialog group={group} />,
|
||||
replace: true,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleRemoveUser = React.useCallback(
|
||||
async (user: User) => {
|
||||
try {
|
||||
await groupUsers.delete({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
toast.success(
|
||||
t(`{{userName}} was removed from the group`, {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} catch (_err) {
|
||||
toast.error(t("Could not remove user"));
|
||||
}
|
||||
},
|
||||
[t, groupUsers, group.id]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePermissionFilterChange = React.useCallback((value: string) => {
|
||||
setPermissionFilter(value as GroupPermission | "all");
|
||||
}, []);
|
||||
|
||||
const permissionOptions: Item[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "item",
|
||||
label: t("All permissions"),
|
||||
value: "all",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Group admin"),
|
||||
value: GroupPermission.Admin,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Member"),
|
||||
value: GroupPermission.Member,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
let result = users.inGroup(group.id, query);
|
||||
|
||||
if (permissionFilter !== "all") {
|
||||
const groupUserMap = new Map(
|
||||
groupUsers.orderedData
|
||||
.filter((gu) => gu.groupId === group.id)
|
||||
.map((gu) => [gu.userId, gu])
|
||||
);
|
||||
|
||||
result = result.filter((user) => {
|
||||
const groupUser = groupUserMap.get(user.id);
|
||||
return groupUser?.permission === permissionFilter;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [users, group.id, query, permissionFilter, groupUsers.orderedData]);
|
||||
|
||||
const hasActiveFilters = query || permissionFilter !== "all";
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{can.update ? (
|
||||
<>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{can.update && (
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddPeople}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Add people")}…
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
<br />
|
||||
</>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Listing members of the <em>{{groupName}}</em> group."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{(filteredUsers.length || hasActiveFilters) && (
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search members")}
|
||||
labelHidden
|
||||
flex
|
||||
/>
|
||||
<InputSelect
|
||||
options={permissionOptions}
|
||||
value={permissionFilter}
|
||||
onChange={handlePermissionFilterChange}
|
||||
label={t("Filter by permissions")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<PaginatedList<User>
|
||||
items={filteredUsers}
|
||||
fetch={groupUsers.fetchPage}
|
||||
options={{
|
||||
id: group.id,
|
||||
}}
|
||||
empty={
|
||||
hasActiveFilters ? (
|
||||
<Empty>{t("No members matching your filters")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("This group has no members.")}</Empty>
|
||||
)
|
||||
}
|
||||
renderItem={(user) => (
|
||||
<GroupMemberListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
group={group}
|
||||
groupUser={groupUsers.orderedData.find(
|
||||
(gu) => gu.userId === user.id && gu.groupId === group.id
|
||||
)}
|
||||
onRemove={can.update ? () => handleRemoveUser(user) : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
const AddPeopleToGroupDialog = observer(function ({
|
||||
group,
|
||||
}: Pick<Props, "group">) {
|
||||
const { dialogs, users, groupUsers } = useStores();
|
||||
@@ -397,7 +581,7 @@ const GroupMemberListItem = observer(function ({
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}{" "}
|
||||
)}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
@@ -435,7 +619,7 @@ const GroupMemberListItem = observer(function ({
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update || group.isExternallyManaged}
|
||||
disabled={!can.update}
|
||||
value={groupUser?.permission}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import type Group from "~/models/Group";
|
||||
import type User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import {
|
||||
type Props as TableProps,
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type { Permission } from "~/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import { VStack } from "~/components/primitives/VStack";
|
||||
|
||||
const ROW_HEIGHT = 50;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
|
||||
group: Group;
|
||||
};
|
||||
|
||||
/**
|
||||
* Table component for displaying group members with permission management.
|
||||
*/
|
||||
export const GroupMembersTable = observer(function GroupMembersTable({
|
||||
group,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { groupUsers } = useStores();
|
||||
const can = usePolicy(group);
|
||||
|
||||
const permissions = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("Group admin"),
|
||||
value: GroupPermission.Admin,
|
||||
},
|
||||
{
|
||||
label: t("Member"),
|
||||
value: GroupPermission.Member,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
label: t("Remove"),
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
] as Permission[],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handlePermissionChange = useCallback(
|
||||
async (
|
||||
user: User,
|
||||
permission: GroupPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
try {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupUsers.delete({
|
||||
userId: user.id,
|
||||
groupId: group.id,
|
||||
});
|
||||
toast.success(
|
||||
t(`{{userName}} was removed from the group`, {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await groupUsers.update({
|
||||
userId: user.id,
|
||||
groupId: group.id,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[t, groupUsers, group.id]
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumn<User>[]>(
|
||||
() =>
|
||||
compact<TableColumn<User>>([
|
||||
{
|
||||
type: "data",
|
||||
id: "name",
|
||||
header: t("Name"),
|
||||
accessor: (user) => user.name,
|
||||
component: (user) => (
|
||||
<HStack>
|
||||
<Avatar model={user} size={AvatarSize.Large} />
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<Text selectable>{user.name}</Text>
|
||||
<Text type="tertiary" size="small">
|
||||
{user.email}
|
||||
</Text>
|
||||
</VStack>
|
||||
{user.isAdmin && <Badge primary>{t("Admin")}</Badge>}
|
||||
</HStack>
|
||||
),
|
||||
width: "3fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "lastActiveAt",
|
||||
header: t("Last active"),
|
||||
accessor: (user) => user.lastActiveAt,
|
||||
component: (user) => (
|
||||
<HStack spacing={4} wrap>
|
||||
{user.lastActiveAt ? (
|
||||
<Time dateTime={user.lastActiveAt} addSuffix />
|
||||
) : (
|
||||
<Text type="tertiary">{t("Never signed in")}</Text>
|
||||
)}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
</HStack>
|
||||
),
|
||||
width: "1fr",
|
||||
},
|
||||
can.update
|
||||
? {
|
||||
type: "data",
|
||||
id: "permission",
|
||||
header: t("Permission"),
|
||||
accessor: (user) => {
|
||||
const gu = groupUsers.orderedData.find(
|
||||
(m) => m.userId === user.id && m.groupId === group.id
|
||||
);
|
||||
return gu?.permission ?? "";
|
||||
},
|
||||
component: (user: User) => (
|
||||
<InputMemberPermissionSelect
|
||||
permissions={permissions}
|
||||
disabled={group.isExternallyManaged}
|
||||
onChange={(permission) =>
|
||||
handlePermissionChange(
|
||||
user,
|
||||
permission as GroupPermission | typeof EmptySelectValue
|
||||
)
|
||||
}
|
||||
value={
|
||||
groupUsers.orderedData.find(
|
||||
(m) => m.userId === user.id && m.groupId === group.id
|
||||
)?.permission
|
||||
}
|
||||
/>
|
||||
),
|
||||
width: "130px",
|
||||
}
|
||||
: undefined,
|
||||
]),
|
||||
[
|
||||
t,
|
||||
can.update,
|
||||
group.id,
|
||||
groupUsers.orderedData,
|
||||
permissions,
|
||||
handlePermissionChange,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import FilterOptions from "~/components/FilterOptions";
|
||||
|
||||
type Props = {
|
||||
activeKey: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter component for group member permissions.
|
||||
*/
|
||||
const GroupPermissionFilter = ({ activeKey, onSelect, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "",
|
||||
label: t("All permissions"),
|
||||
},
|
||||
{
|
||||
key: GroupPermission.Admin,
|
||||
label: t("Group admin"),
|
||||
},
|
||||
{
|
||||
key: GroupPermission.Member,
|
||||
label: t("Member"),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
selectedKeys={[activeKey]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("All permissions")}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(GroupPermissionFilter);
|
||||
@@ -1,52 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "~/components/FilterOptions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
activeKey: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
};
|
||||
|
||||
const GroupSourceFilter = ({ activeKey, onSelect, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { authenticationProviders } = useStores();
|
||||
|
||||
useEffect(() => {
|
||||
void authenticationProviders.fetchPage({});
|
||||
}, [authenticationProviders]);
|
||||
|
||||
const syncProviders = useMemo(
|
||||
() =>
|
||||
authenticationProviders.orderedData.filter(
|
||||
(p) => p.settings?.groupSyncEnabled
|
||||
),
|
||||
[authenticationProviders.orderedData]
|
||||
);
|
||||
|
||||
if (!syncProviders.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ key: "", label: t("All sources") },
|
||||
{ key: "manual", label: t("Manual") },
|
||||
...syncProviders.map((p) => ({
|
||||
key: p.name,
|
||||
label: p.displayName,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
selectedKeys={[activeKey]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("All sources")}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(GroupSourceFilter);
|
||||
@@ -1,11 +1,10 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon, HiddenIcon } from "outline-icons";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import type Group from "~/models/Group";
|
||||
@@ -21,13 +20,13 @@ import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMenu from "~/menus/GroupMenu";
|
||||
import { ViewGroupMembersDialog } from "./GroupDialogs";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
@@ -53,14 +52,16 @@ const GroupRowContextMenu = observer(function GroupRowContextMenu({
|
||||
|
||||
export function GroupsTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
const handleViewMembers = useCallback(
|
||||
(group: Group) => {
|
||||
history.push(settingsPath("groups", group.id, "members"));
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
});
|
||||
},
|
||||
[history]
|
||||
[t, dialogs]
|
||||
);
|
||||
|
||||
const applyContextMenu = useCallback(
|
||||
@@ -88,14 +89,6 @@ export function GroupsTable(props: Props) {
|
||||
<Flex column>
|
||||
<Title onClick={() => handleViewMembers(group)}>
|
||||
{group.name}
|
||||
{group.disableMentions && (
|
||||
<>
|
||||
{" "}
|
||||
<Tooltip content={t("This group is hidden")}>
|
||||
<HiddenIcon size={16} color={theme.textSecondary} />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Title>
|
||||
<Text type="tertiary" size="small" weight="normal">
|
||||
<Trans
|
||||
@@ -147,33 +140,6 @@ export function GroupsTable(props: Props) {
|
||||
width: "1.5fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "source",
|
||||
header: t("Source"),
|
||||
accessor: (group) => group.externalGroup?.displayName ?? "manual",
|
||||
component: (group) =>
|
||||
group.externalGroup ? (
|
||||
<Flex column>
|
||||
<Text type="secondary" size="small" weight="normal">
|
||||
{group.externalGroup.displayName}
|
||||
</Text>
|
||||
{group.externalGroup.lastSyncedAt && (
|
||||
<Text type="tertiary" size="xsmall" weight="normal">
|
||||
<Trans>
|
||||
Synced{" "}
|
||||
<Time
|
||||
dateTime={group.externalGroup.lastSyncedAt}
|
||||
addSuffix
|
||||
shorten
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
) : null,
|
||||
width: "1fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
@@ -192,7 +158,7 @@ export function GroupsTable(props: Props) {
|
||||
width: "50px",
|
||||
},
|
||||
]),
|
||||
[t, handleViewMembers, theme.textSecondary]
|
||||
[t, handleViewMembers]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -56,7 +56,7 @@ const TemplateRowContextMenu = observer(function TemplateRowContextMenu({
|
||||
export function TemplatesTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOpen = (template: Template) => {
|
||||
const handleOpen = (template: Template) => () => {
|
||||
history.push(template.path);
|
||||
};
|
||||
|
||||
@@ -122,10 +122,7 @@ export function TemplatesTable(props: Props) {
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (template) => (
|
||||
<TemplateMenu
|
||||
template={template}
|
||||
onEdit={() => handleOpen(template)}
|
||||
/>
|
||||
<TemplateMenu template={template} onEdit={handleOpen(template)} />
|
||||
),
|
||||
width: "50px",
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import styled, { ThemeProvider } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -19,11 +18,9 @@ import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useBuildTheme from "~/hooks/useBuildTheme";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, OfflineError } from "~/utils/errors";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
@@ -154,18 +151,6 @@ function SharedScene() {
|
||||
)
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
useCallback(
|
||||
(ev: KeyboardEvent) => isModKey(ev) && ev.shiftKey && ev.code === "KeyL",
|
||||
[]
|
||||
),
|
||||
useCallback(() => {
|
||||
if (!ui.themeOverride) {
|
||||
ui.setTheme(ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light);
|
||||
}
|
||||
}, [ui])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
void changeLanguage(detectLanguage(), i18n);
|
||||
|
||||
@@ -13,10 +13,10 @@ type DialogDefinition = {
|
||||
};
|
||||
|
||||
export default class DialogsStore {
|
||||
@observable.shallow
|
||||
@observable
|
||||
guide: DialogDefinition;
|
||||
|
||||
@observable.shallow
|
||||
@observable
|
||||
modalStack = new Map<string, DialogDefinition>();
|
||||
|
||||
openGuide = ({
|
||||
|
||||
@@ -53,9 +53,6 @@ export default class DocumentsStore extends Store<Document> {
|
||||
@observable
|
||||
backlinks: Map<string, string[]> = new Map();
|
||||
|
||||
@observable
|
||||
similar: Map<string, string[]> = new Map();
|
||||
|
||||
@observable
|
||||
movingDocumentId: string | null | undefined;
|
||||
|
||||
@@ -257,27 +254,16 @@ export default class DocumentsStore extends Store<Document> {
|
||||
}
|
||||
|
||||
@action
|
||||
fetchRelationships = async (documentId: string): Promise<void> => {
|
||||
const res = await client.post("/relationships.list", { documentId });
|
||||
invariant(res?.data, "Relationships not available");
|
||||
fetchBacklinks = async (documentId: string): Promise<void> => {
|
||||
const documents = await this.fetchAll({
|
||||
backlinkDocumentId: documentId,
|
||||
});
|
||||
|
||||
runInAction("DocumentsStore#fetchRelationships", () => {
|
||||
res.data.documents.forEach(this.add);
|
||||
this.addPolicies(res.policies);
|
||||
|
||||
const backlinkIds: string[] = [];
|
||||
const similarIds: string[] = [];
|
||||
|
||||
for (const relationship of res.data.relationships) {
|
||||
if (relationship.type === "backlink") {
|
||||
backlinkIds.push(relationship.reverseDocumentId);
|
||||
} else if (relationship.type === "similar") {
|
||||
similarIds.push(relationship.reverseDocumentId);
|
||||
}
|
||||
}
|
||||
|
||||
this.backlinks.set(documentId, backlinkIds);
|
||||
this.similar.set(documentId, similarIds);
|
||||
runInAction("DocumentsStore#fetchBacklinks", () => {
|
||||
this.backlinks.set(
|
||||
documentId,
|
||||
documents.map((doc) => doc.id)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -290,15 +276,6 @@ export default class DocumentsStore extends Store<Document> {
|
||||
);
|
||||
}
|
||||
|
||||
getSimilarDocuments(documentId: string): Document[] {
|
||||
const documentIds = this.similar.get(documentId) || [];
|
||||
return orderBy(
|
||||
compact(documentIds.map((id) => this.data.get(id))),
|
||||
"title",
|
||||
"asc"
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
fetchChildDocuments = async (documentId: string): Promise<void> => {
|
||||
const res = await client.post(`/documents.list`, {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { flushSync } from "react-dom";
|
||||
import { light as defaultTheme } from "@shared/styles/theme";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import Document from "~/models/Document";
|
||||
import type Model from "~/models/base/Model";
|
||||
@@ -93,32 +92,6 @@ class UiStore {
|
||||
@observable
|
||||
debugSafeArea = false;
|
||||
|
||||
/** Data for the currently active presentation, if any. */
|
||||
@observable
|
||||
presentationData: {
|
||||
title: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
data: ProsemirrorData;
|
||||
} | null = null;
|
||||
|
||||
/**
|
||||
* Enter presentation mode for the given document.
|
||||
*
|
||||
* @param document the document to present, or null to exit.
|
||||
*/
|
||||
@action
|
||||
setPresentingDocument = (document: Document | null): void => {
|
||||
this.presentationData = document
|
||||
? {
|
||||
title: document.title,
|
||||
icon: document.icon,
|
||||
color: document.color,
|
||||
data: document.data,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
/** Tracks active export toasts for in-place updates when export completes */
|
||||
exportToasts = observable.map<
|
||||
string,
|
||||
|
||||
Vendored
-52
@@ -1,52 +0,0 @@
|
||||
declare module "web-haptics" {
|
||||
interface Vibration {
|
||||
duration: number;
|
||||
intensity?: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
type HapticPattern = number[] | Vibration[];
|
||||
|
||||
interface HapticPreset {
|
||||
pattern: Vibration[];
|
||||
}
|
||||
|
||||
type HapticInput = number | string | HapticPattern | HapticPreset;
|
||||
|
||||
interface TriggerOptions {
|
||||
intensity?: number;
|
||||
}
|
||||
|
||||
interface WebHapticsOptions {
|
||||
debug?: boolean;
|
||||
showSwitch?: boolean;
|
||||
}
|
||||
|
||||
export {
|
||||
HapticInput,
|
||||
HapticPattern,
|
||||
HapticPreset,
|
||||
TriggerOptions,
|
||||
Vibration,
|
||||
WebHapticsOptions,
|
||||
};
|
||||
}
|
||||
|
||||
declare module "web-haptics/react" {
|
||||
import type {
|
||||
HapticInput,
|
||||
TriggerOptions,
|
||||
WebHapticsOptions,
|
||||
} from "web-haptics";
|
||||
|
||||
function useWebHaptics(options?: WebHapticsOptions): {
|
||||
trigger: (
|
||||
input?: HapticInput,
|
||||
options?: TriggerOptions
|
||||
) => Promise<void> | undefined;
|
||||
cancel: () => void | undefined;
|
||||
isSupported: boolean;
|
||||
};
|
||||
|
||||
export { useWebHaptics };
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import queryString from "query-string";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import type { JSONObject } from "@shared/types";
|
||||
import { Scope } from "@shared/types";
|
||||
import { version } from "../../package.json";
|
||||
import env from "~/env";
|
||||
import stores from "~/stores";
|
||||
import Logger from "./Logger";
|
||||
import download from "./download";
|
||||
@@ -111,7 +109,6 @@ class ApiClient {
|
||||
"cache-control": "no-cache",
|
||||
"x-editor-version": EDITOR_VERSION,
|
||||
"x-api-version": "4",
|
||||
"x-client-version": env.VERSION ? `${version}-${env.VERSION}` : version,
|
||||
pragma: "no-cache",
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ export class PluginManager {
|
||||
}
|
||||
|
||||
if (!this.plugins.has(plugin.type)) {
|
||||
this.plugins.set(plugin.type, observable.array([], { deep: false }));
|
||||
this.plugins.set(plugin.type, observable.array([]));
|
||||
}
|
||||
|
||||
this.plugins
|
||||
|
||||
+10
-51
@@ -2,19 +2,6 @@ import type { IntegrationSettings, IntegrationType } from "@shared/types";
|
||||
import { IntegrationService, MentionType } from "@shared/types";
|
||||
import type Integration from "~/models/Integration";
|
||||
|
||||
const gitlabSystemPaths = new Set([
|
||||
"explore",
|
||||
"help",
|
||||
"admin",
|
||||
"dashboard",
|
||||
"users",
|
||||
"groups",
|
||||
"projects",
|
||||
"snippets",
|
||||
"search",
|
||||
"-",
|
||||
]);
|
||||
|
||||
export const isURLMentionable = ({
|
||||
url,
|
||||
integration,
|
||||
@@ -43,15 +30,9 @@ export const isURLMentionable = ({
|
||||
case IntegrationService.GitLab: {
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
let gitlabHostname: string | undefined;
|
||||
try {
|
||||
gitlabHostname = settings.gitlab?.url
|
||||
? new URL(settings.gitlab.url).hostname
|
||||
: undefined;
|
||||
} catch {
|
||||
// Invalid URL stored in settings
|
||||
return false;
|
||||
}
|
||||
const gitlabHostname = settings.gitlab?.url
|
||||
? new URL(settings.gitlab?.url).hostname
|
||||
: undefined;
|
||||
|
||||
return hostname === "gitlab.com" || hostname === gitlabHostname;
|
||||
}
|
||||
@@ -78,42 +59,20 @@ export const determineMentionType = ({
|
||||
? MentionType.PullRequest
|
||||
: type === "issues"
|
||||
? MentionType.Issue
|
||||
: type === "projects"
|
||||
? MentionType.Project
|
||||
: undefined;
|
||||
: undefined;
|
||||
}
|
||||
|
||||
case IntegrationService.Linear: {
|
||||
const type = pathParts[2];
|
||||
return type === "issue"
|
||||
? MentionType.Issue
|
||||
: type === "project"
|
||||
? MentionType.Project
|
||||
: undefined;
|
||||
return type === "issue" ? MentionType.Issue : undefined;
|
||||
}
|
||||
|
||||
case IntegrationService.GitLab: {
|
||||
const hasShowParam = url.searchParams.has("show");
|
||||
|
||||
if (
|
||||
/\/-\/merge_requests\/\d+/.test(pathname) ||
|
||||
(/\/-\/merge_requests\/?$/.test(pathname) && hasShowParam)
|
||||
) {
|
||||
return MentionType.PullRequest;
|
||||
}
|
||||
if (
|
||||
/\/-\/(issues|work_items)\/\d+/.test(pathname) ||
|
||||
(/\/-\/(issues|work_items)\/?$/.test(pathname) && hasShowParam)
|
||||
) {
|
||||
return MentionType.Issue;
|
||||
}
|
||||
if (!pathname.includes("/-/")) {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
if (parts.length >= 2 && !gitlabSystemPaths.has(parts[0])) {
|
||||
return MentionType.Project;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return pathname.includes("merge_requests")
|
||||
? MentionType.PullRequest
|
||||
: pathname.includes("issues")
|
||||
? MentionType.Issue
|
||||
: undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sharedModelPath, desktopify } from "./routeHelpers";
|
||||
import { sharedModelPath } from "./routeHelpers";
|
||||
|
||||
describe("#sharedDocumentPath", () => {
|
||||
test("should return share path for a document", () => {
|
||||
@@ -12,25 +12,3 @@ describe("#sharedDocumentPath", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#desktopify", () => {
|
||||
test("should replace https protocol with outline://", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { origin: "https://app.getoutline.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(desktopify("/doc/test-DjDlkBi77t")).toBe(
|
||||
"outline://app.getoutline.com/doc/test-DjDlkBi77t"
|
||||
);
|
||||
});
|
||||
|
||||
test("should replace http protocol with outline://", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { origin: "http://localhost:3000" },
|
||||
writable: true,
|
||||
});
|
||||
expect(desktopify("/doc/test-DjDlkBi77t")).toBe(
|
||||
"outline://localhost:3000/doc/test-DjDlkBi77t"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,16 +179,6 @@ export function urlify(path: string): string {
|
||||
return `${window.location.origin}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a path to a desktop app URL using the outline:// protocol.
|
||||
*
|
||||
* @param path The path to convert.
|
||||
* @returns The desktop app URL.
|
||||
*/
|
||||
export function desktopify(path: string): string {
|
||||
return urlify(path).replace(/^https?:\/\//, "outline://");
|
||||
}
|
||||
|
||||
export const matchCollectionSlug =
|
||||
":collectionSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
|
||||
|
||||
|
||||
+3
-5
@@ -106,7 +106,6 @@
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@types/form-data": "^2.5.2",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/sanitize-filename": "^1.6.3",
|
||||
"@vitejs/plugin-react-oxc": "^0.2.3",
|
||||
"addressparser": "^1.0.1",
|
||||
@@ -122,6 +121,7 @@
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"core-js": "^3.45.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.82.0",
|
||||
"diff": "^5.2.0",
|
||||
@@ -138,7 +138,6 @@
|
||||
"fs-extra": "^11.3.2",
|
||||
"fuzzy-search": "^3.2.1",
|
||||
"glob": "^8.1.0",
|
||||
"hot-shots": "^12.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"i18next": "^22.5.1",
|
||||
@@ -180,7 +179,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^4.3.0",
|
||||
"outline-icons": "^4.1.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"pako": "^2.1.0",
|
||||
"passport": "^0.7.0",
|
||||
@@ -266,7 +265,6 @@
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "npm:rolldown-vite@7.3.0",
|
||||
"vite-plugin-pwa": "1.0.3",
|
||||
"web-haptics": "^0.0.6",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
@@ -392,6 +390,6 @@
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"version": "1.6.1",
|
||||
"version": "1.5.0",
|
||||
"packageManager": "yarn@4.11.0"
|
||||
}
|
||||
|
||||
@@ -51,8 +51,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
store: new StateStore(),
|
||||
state: true,
|
||||
callbackURL: `${env.URL}/auth/${config.id}.callback`,
|
||||
authorizationURL:
|
||||
"https://discord.com/api/oauth2/authorize?prompt=none",
|
||||
authorizationURL: "https://discord.com/api/oauth2/authorize",
|
||||
tokenURL: "https://discord.com/api/oauth2/token",
|
||||
pkce: false,
|
||||
},
|
||||
@@ -228,6 +227,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
config.id,
|
||||
passport.authenticate(config.id, {
|
||||
scope,
|
||||
prompt: "consent",
|
||||
})
|
||||
);
|
||||
router.get(`${config.id}.callback`, passportMiddleware(config.id));
|
||||
|
||||
@@ -80,7 +80,6 @@ router.post(
|
||||
// send email to users email address with a short-lived token and code
|
||||
await new SigninEmail({
|
||||
to: user.email,
|
||||
language: user.language,
|
||||
token,
|
||||
teamUrl: team.url,
|
||||
client,
|
||||
@@ -172,7 +171,6 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
if (user.isInvited) {
|
||||
await new WelcomeEmail({
|
||||
to: user.email,
|
||||
language: user.language,
|
||||
role: user.role,
|
||||
teamUrl: user.team.url,
|
||||
}).schedule();
|
||||
@@ -181,7 +179,6 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
if (inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)) {
|
||||
await new InviteAcceptedEmail({
|
||||
to: inviter.email,
|
||||
language: inviter.language,
|
||||
inviterId: inviter.id,
|
||||
invitedName: user.name,
|
||||
teamUrl: user.team.url,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import env from "./env";
|
||||
import { FigmaUtils } from "../shared/FigmaUtils";
|
||||
import type { UnfurlSignature } from "@server/types";
|
||||
|
||||
+11
-171
@@ -13,11 +13,7 @@ import { IntegrationService, UnfurlResourceType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import type { User } from "@server/models";
|
||||
import { Integration } from "@server/models";
|
||||
import type {
|
||||
UnfurlIssueOrPR,
|
||||
UnfurlProject,
|
||||
UnfurlSignature,
|
||||
} from "@server/types";
|
||||
import type { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import { GitHubUtils } from "../shared/GitHubUtils";
|
||||
import env from "./env";
|
||||
|
||||
@@ -28,33 +24,6 @@ type Issue =
|
||||
type Installation =
|
||||
Endpoints["GET /app/installations/{installation_id}"]["response"]["data"];
|
||||
|
||||
type ParsedIssueOrPR = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
type: UnfurlResourceType.Issue | UnfurlResourceType.PR;
|
||||
id: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ParsedProject = {
|
||||
owner: string;
|
||||
ownerType: "orgs" | "users";
|
||||
type: UnfurlResourceType.Project;
|
||||
projectNumber: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type GitHubResource = ParsedIssueOrPR | ParsedProject;
|
||||
|
||||
type GitHubProject = {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
closed: boolean;
|
||||
};
|
||||
|
||||
const requestPlugin = (octokit: Octokit) => ({
|
||||
requestRepos: () =>
|
||||
octokit.paginate.iterator(
|
||||
@@ -67,7 +36,7 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
}
|
||||
),
|
||||
|
||||
requestPR: async (params: ParsedIssueOrPR) =>
|
||||
requestPR: async (params: NonNullable<ReturnType<typeof GitHub.parseUrl>>) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, {
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
@@ -78,7 +47,9 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
},
|
||||
}),
|
||||
|
||||
requestIssue: async (params: ParsedIssueOrPR) =>
|
||||
requestIssue: async (
|
||||
params: NonNullable<ReturnType<typeof GitHub.parseUrl>>
|
||||
) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, {
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
@@ -89,61 +60,6 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
},
|
||||
}),
|
||||
|
||||
/**
|
||||
* Fetches details of a GitHub ProjectV2 using the GraphQL API.
|
||||
*
|
||||
* @param params Parsed project URL identifiers.
|
||||
* @returns Project data or undefined if not found.
|
||||
*/
|
||||
requestProject: async (
|
||||
params: ParsedProject
|
||||
): Promise<GitHubProject | undefined> => {
|
||||
const ownerField = params.ownerType === "orgs" ? "organization" : "user";
|
||||
|
||||
const query = `query($login: String!, $number: Int!) {
|
||||
${ownerField}(login: $login) {
|
||||
projectV2(number: $number) {
|
||||
number
|
||||
title
|
||||
shortDescription
|
||||
url
|
||||
createdAt
|
||||
closed
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = await octokit.graphql<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
projectV2: {
|
||||
number: number;
|
||||
title: string;
|
||||
shortDescription: string | null;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
closed: boolean;
|
||||
} | null;
|
||||
}
|
||||
>
|
||||
>(query, { login: params.owner, number: params.projectNumber });
|
||||
|
||||
const project = result[ownerField]?.projectV2;
|
||||
if (!project) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
number: project.number,
|
||||
title: project.title,
|
||||
description: project.shortDescription,
|
||||
url: project.url,
|
||||
createdAt: project.createdAt,
|
||||
closed: project.closed,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches app installations accessible to the user
|
||||
*
|
||||
@@ -159,7 +75,7 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
* @returns Response containing resource details
|
||||
*/
|
||||
requestResource: async function requestResource(
|
||||
resource: GitHubResource | undefined
|
||||
resource: ReturnType<typeof GitHub.parseUrl>
|
||||
): Promise<OctokitResponse<Issue | PR> | undefined> {
|
||||
switch (resource?.type) {
|
||||
case UnfurlResourceType.PR:
|
||||
@@ -223,7 +139,7 @@ export class GitHub {
|
||||
* @param url URL to parse
|
||||
* @returns {object} Containing resource identifiers - `owner`, `repo`, `type` and `id`.
|
||||
*/
|
||||
public static parseUrl(url: string): GitHubResource | undefined {
|
||||
public static parseUrl(url: string) {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== "github.com") {
|
||||
@@ -231,29 +147,6 @@ export class GitHub {
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
|
||||
// Handle project URLs: /orgs/{org}/projects/{number} or /users/{user}/projects/{number}
|
||||
if (
|
||||
(parts[1] === "orgs" || parts[1] === "users") &&
|
||||
parts[3] === "projects"
|
||||
) {
|
||||
const ownerType = parts[1] as "orgs" | "users";
|
||||
const owner = parts[2];
|
||||
const projectNumber = Number(parts[4]);
|
||||
|
||||
if (!owner || isNaN(projectNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
ownerType,
|
||||
type: UnfurlResourceType.Project,
|
||||
projectNumber,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
const owner = parts[1];
|
||||
const repo = parts[2];
|
||||
const type = parts[3]
|
||||
@@ -261,17 +154,11 @@ export class GitHub {
|
||||
: undefined;
|
||||
const id = Number(parts[4]);
|
||||
|
||||
if (!type || !GitHub.supportedResources.includes(type) || isNaN(id)) {
|
||||
if (!type || !GitHub.supportedResources.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
type: type as UnfurlResourceType.Issue | UnfurlResourceType.PR,
|
||||
id,
|
||||
url,
|
||||
};
|
||||
return { owner, repo, type, id, url };
|
||||
} catch (_err) {
|
||||
// Invalid URL format
|
||||
return;
|
||||
@@ -374,10 +261,6 @@ export class GitHub {
|
||||
integration.settings.github!.installation.id
|
||||
);
|
||||
|
||||
if (resource.type === UnfurlResourceType.Project) {
|
||||
return GitHub.unfurlProject(client, resource);
|
||||
}
|
||||
|
||||
const res = await client.requestResource(resource);
|
||||
if (!res) {
|
||||
return { error: "Resource not found" };
|
||||
@@ -390,52 +273,9 @@ export class GitHub {
|
||||
}
|
||||
};
|
||||
|
||||
private static async unfurlProject(
|
||||
client: InstanceType<typeof CustomOctokit>,
|
||||
resource: ParsedProject
|
||||
) {
|
||||
let project: GitHubProject | undefined;
|
||||
try {
|
||||
project = await client.requestProject(resource);
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch project from GitHub", err);
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
const state = project.closed ? "completed" : "open";
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Project,
|
||||
url: project.url,
|
||||
id: `#${project.number}`,
|
||||
name: project.title,
|
||||
color: GitHubUtils.getColorForStatus(state),
|
||||
description: project.description,
|
||||
lead: null,
|
||||
state: {
|
||||
type: state,
|
||||
name: state,
|
||||
color: GitHubUtils.getColorForStatus(state),
|
||||
},
|
||||
labels: [],
|
||||
createdAt: project.createdAt,
|
||||
targetDate: null,
|
||||
} satisfies UnfurlProject;
|
||||
}
|
||||
|
||||
private static transformData(data: Issue | PR, type: UnfurlResourceType) {
|
||||
if (type === UnfurlResourceType.Issue) {
|
||||
const issue = data as Issue;
|
||||
const issueState =
|
||||
issue.state === "closed"
|
||||
? issue.state_reason === "completed"
|
||||
? "completed"
|
||||
: "canceled"
|
||||
: issue.state;
|
||||
return {
|
||||
type: UnfurlResourceType.Issue,
|
||||
url: issue.html_url,
|
||||
@@ -451,8 +291,8 @@ export class GitHub {
|
||||
color: `#${label.color}`,
|
||||
})),
|
||||
state: {
|
||||
name: issueState,
|
||||
color: GitHubUtils.getColorForStatus(issueState),
|
||||
name: issue.state,
|
||||
color: GitHubUtils.getColorForStatus(issue.state),
|
||||
},
|
||||
createdAt: issue.created_at,
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
|
||||
@@ -53,7 +53,6 @@ export class GitHubUtils {
|
||||
return "#a371f7";
|
||||
case "closed":
|
||||
return "#f85149";
|
||||
case "completed":
|
||||
case "merged":
|
||||
return "#8250df";
|
||||
case "canceled":
|
||||
|
||||
@@ -2,8 +2,6 @@ import { Gitlab } from "@gitbeaker/rest";
|
||||
import type {
|
||||
IssueSchemaWithExpandedLabels,
|
||||
MergeRequestSchema,
|
||||
ProjectSchema,
|
||||
StatisticsSchema,
|
||||
} from "@gitbeaker/rest";
|
||||
import z from "zod";
|
||||
import {
|
||||
@@ -14,11 +12,7 @@ import {
|
||||
import Logger from "@server/logging/Logger";
|
||||
import type { User } from "@server/models";
|
||||
import { Integration, IntegrationAuthentication } from "@server/models";
|
||||
import type {
|
||||
UnfurlIssueOrPR,
|
||||
UnfurlProject,
|
||||
UnfurlSignature,
|
||||
} from "@server/types";
|
||||
import type { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import { validateUrlNotPrivate } from "@server/utils/url";
|
||||
import { GitLabUtils } from "../shared/GitLabUtils";
|
||||
@@ -214,6 +208,10 @@ export class GitLab {
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
Logger.debug(
|
||||
"plugins",
|
||||
`Could not parse GitLab resource from URL: ${url}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,13 +254,6 @@ export class GitLab {
|
||||
customUrl
|
||||
);
|
||||
return this.transformMR(mr);
|
||||
} else if (resource.type === UnfurlResourceType.Project) {
|
||||
const client = await this.createClient(token, customUrl);
|
||||
const [project, issueStats] = await Promise.all([
|
||||
client.Projects.show(projectPath),
|
||||
client.IssuesStatistics.all({ projectId: projectPath }),
|
||||
]);
|
||||
return this.transformProject(project, issueStats);
|
||||
}
|
||||
|
||||
return { error: "Resource not found" };
|
||||
@@ -399,45 +390,4 @@ export class GitLab {
|
||||
createdAt: mr.created_at,
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
}
|
||||
|
||||
private static transformProject(
|
||||
project: ProjectSchema,
|
||||
issueStats: StatisticsSchema
|
||||
) {
|
||||
const visibility = project.visibility ?? "private";
|
||||
const owner = project.owner as
|
||||
| { name: string; avatar_url?: string }
|
||||
| undefined;
|
||||
const { opened, closed } = issueStats.statistics.counts;
|
||||
const total = opened + closed;
|
||||
const progress = total > 0 ? closed / total : 0;
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Project,
|
||||
url: project.web_url,
|
||||
id: String(project.id),
|
||||
name: project.name,
|
||||
color: GitLabUtils.getColorForProject(project.id),
|
||||
avatarUrl: project.avatar_url || undefined,
|
||||
description: project.description ?? null,
|
||||
lead: owner
|
||||
? {
|
||||
name: owner.name,
|
||||
avatarUrl: owner.avatar_url ?? "",
|
||||
}
|
||||
: null,
|
||||
state: {
|
||||
type: visibility,
|
||||
name: visibility.charAt(0).toUpperCase() + visibility.slice(1),
|
||||
color: GitLabUtils.getColorForVisibility(visibility),
|
||||
},
|
||||
labels: (project.topics ?? []).map((topic: string) => ({
|
||||
name: topic,
|
||||
color: "#6B7280",
|
||||
})),
|
||||
progress,
|
||||
createdAt: project.created_at,
|
||||
targetDate: null,
|
||||
} satisfies UnfurlProject;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,32 +42,6 @@ describe("GitLabUtils.parseUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a work_items URL", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/speak/purser/-/work_items/39"
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "speak",
|
||||
repo: "purser",
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: 39,
|
||||
url: "https://gitlab.com/speak/purser/-/work_items/39",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a nested group work_items URL", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/group/subgroup/repo/-/work_items/5"
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "group/subgroup",
|
||||
repo: "repo",
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: 5,
|
||||
url: "https://gitlab.com/group/subgroup/repo/-/work_items/5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined for unsupported resource type", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/speak/purser/-/pipelines/100"
|
||||
@@ -75,18 +49,8 @@ describe("GitLabUtils.parseUrl", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should parse a project URL", () => {
|
||||
const result = GitLabUtils.parseUrl("https://gitlab.com/speak/purser");
|
||||
expect(result).toEqual({
|
||||
owner: "speak",
|
||||
repo: "purser",
|
||||
type: UnfurlResourceType.Project,
|
||||
url: "https://gitlab.com/speak/purser",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined for a URL with too few path segments", () => {
|
||||
const result = GitLabUtils.parseUrl("https://gitlab.com/speak");
|
||||
const result = GitLabUtils.parseUrl("https://gitlab.com/speak/purser");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -96,54 +60,6 @@ describe("GitLabUtils.parseUrl", () => {
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for an issues list URL without an ID", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/speak/purser/-/issues"
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should parse a nested group project URL", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/group/subgroup/repo"
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "group/subgroup",
|
||||
repo: "repo",
|
||||
type: UnfurlResourceType.Project,
|
||||
url: "https://gitlab.com/group/subgroup/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined for an invalid custom URL", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.example.com/team/project/-/issues/10",
|
||||
"not-a-valid-url"
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for system paths", () => {
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/explore/projects")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/help/topics")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/admin/users")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/dashboard/projects")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/users/someone")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
GitLabUtils.parseUrl("https://gitlab.com/groups/mygroup")
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("base64 show parameter URLs", () => {
|
||||
@@ -208,22 +124,6 @@ describe("GitLabUtils.parseUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a work_items URL with show parameter", () => {
|
||||
const show = btoa(
|
||||
JSON.stringify({ iid: "39", full_path: "speak/purser", id: 1215135 })
|
||||
);
|
||||
const result = GitLabUtils.parseUrl(
|
||||
`https://gitlab.com/speak/purser/-/work_items?show=${show}`
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "speak",
|
||||
repo: "purser",
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: 39,
|
||||
url: `https://gitlab.com/speak/purser/-/work_items?show=${show}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined for invalid base64 in show parameter", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/speak/purser/-/issues?show=not-valid-base64!!!"
|
||||
@@ -255,19 +155,6 @@ describe("GitLabUtils.parseUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a project URL with a custom URL", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://git.example.com/team/project",
|
||||
"https://git.example.com"
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "team",
|
||||
repo: "project",
|
||||
type: UnfurlResourceType.Project,
|
||||
url: "https://git.example.com/team/project",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not match default gitlab.com when custom URL is set", () => {
|
||||
const result = GitLabUtils.parseUrl(
|
||||
"https://gitlab.com/speak/purser/-/issues/1",
|
||||
|
||||
@@ -8,7 +8,6 @@ export class GitLabUtils {
|
||||
private static supportedResources = [
|
||||
UnfurlResourceType.Issue,
|
||||
UnfurlResourceType.PR,
|
||||
UnfurlResourceType.Project,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -104,95 +103,27 @@ export class GitLabUtils {
|
||||
* @param customUrl - Optional custom GitLab URL from integration settings.
|
||||
* @returns An object containing resource identifiers or undefined if the URL is invalid.
|
||||
*/
|
||||
public static parseUrl(
|
||||
url: string,
|
||||
customUrl?: string
|
||||
):
|
||||
| {
|
||||
owner: string;
|
||||
repo: string | undefined;
|
||||
type: UnfurlResourceType.Issue | UnfurlResourceType.PR;
|
||||
id: number;
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
owner: string;
|
||||
repo: string;
|
||||
type: UnfurlResourceType.Project;
|
||||
url: string;
|
||||
}
|
||||
| undefined {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const urlHostname = new URL(this.getGitlabUrl(customUrl)).hostname;
|
||||
public static parseUrl(url: string, customUrl?: string) {
|
||||
const parsed = new URL(url);
|
||||
const urlHostname = new URL(this.getGitlabUrl(customUrl)).hostname;
|
||||
|
||||
if (parsed.hostname !== urlHostname) {
|
||||
return;
|
||||
}
|
||||
if (parsed.hostname !== urlHostname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
|
||||
// Try base64-encoded `show` query parameter first
|
||||
// e.g. /owner/repo/-/issues?show=eyJ...
|
||||
const showParam = parsed.searchParams.get("show");
|
||||
if (showParam && parts.length >= 4) {
|
||||
const resourceType = parts.pop();
|
||||
parts.pop(); // separator ("-")
|
||||
const repo = parts.pop();
|
||||
const owner = parts.join("/");
|
||||
|
||||
const type =
|
||||
resourceType === "issues" || resourceType === "work_items"
|
||||
? UnfurlResourceType.Issue
|
||||
: resourceType === "merge_requests"
|
||||
? UnfurlResourceType.PR
|
||||
: undefined;
|
||||
|
||||
if (!type || !this.supportedResources.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = JSON.parse(atob(decodeURIComponent(showParam)));
|
||||
const iid = Number(decoded.iid);
|
||||
if (!iid) {
|
||||
return;
|
||||
}
|
||||
return { owner, repo, type, id: iid, url };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a project URL (no -/ separator pattern in path)
|
||||
if (!parsed.pathname.includes("/-/")) {
|
||||
if (parts.length >= 2 && !this.isSystemPath(parts[0])) {
|
||||
const repo = parts[parts.length - 1];
|
||||
const owner = parts.slice(0, -1).join("/");
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
type: UnfurlResourceType.Project,
|
||||
url,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.length < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct URL: /owner/repo/-/issues/123 or /owner/repo/-/merge_requests/123
|
||||
const resourceId = parts.pop();
|
||||
// Try base64-encoded `show` query parameter first
|
||||
// e.g. /owner/repo/-/issues?show=eyJ...
|
||||
const showParam = parsed.searchParams.get("show");
|
||||
if (showParam && parts.length >= 4) {
|
||||
const resourceType = parts.pop();
|
||||
parts.pop(); // separator ("-")
|
||||
|
||||
const repo = parts.pop();
|
||||
const owner = parts.join("/");
|
||||
|
||||
const type =
|
||||
resourceType === "issues" || resourceType === "work_items"
|
||||
resourceType === "issues"
|
||||
? UnfurlResourceType.Issue
|
||||
: resourceType === "merge_requests"
|
||||
? UnfurlResourceType.PR
|
||||
@@ -202,38 +133,48 @@ export class GitLabUtils {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
type,
|
||||
id: Number(resourceId),
|
||||
url,
|
||||
};
|
||||
} catch {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(decodeURIComponent(showParam)));
|
||||
const iid = Number(decoded.iid);
|
||||
if (!iid) {
|
||||
return;
|
||||
}
|
||||
return { owner, repo, type, id: iid, url };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length < 5) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the first path segment is a known GitLab system path.
|
||||
*
|
||||
* @param segment - the first path segment of the URL.
|
||||
* @returns true if the segment is a known system path.
|
||||
*/
|
||||
private static isSystemPath(segment: string): boolean {
|
||||
const systemPaths = new Set([
|
||||
"explore",
|
||||
"help",
|
||||
"admin",
|
||||
"dashboard",
|
||||
"users",
|
||||
"groups",
|
||||
"projects",
|
||||
"snippets",
|
||||
"search",
|
||||
"-",
|
||||
]);
|
||||
return systemPaths.has(segment);
|
||||
// Direct URL: /owner/repo/-/issues/123 or /owner/repo/-/merge_requests/123
|
||||
const resourceId = parts.pop();
|
||||
const resourceType = parts.pop();
|
||||
parts.pop(); // separator ("-")
|
||||
|
||||
const repo = parts.pop();
|
||||
const owner = parts.join("/");
|
||||
|
||||
const type =
|
||||
resourceType === "issues"
|
||||
? UnfurlResourceType.Issue
|
||||
: resourceType === "merge_requests"
|
||||
? UnfurlResourceType.PR
|
||||
: undefined;
|
||||
|
||||
if (!type || !this.supportedResources.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
type,
|
||||
id: Number(resourceId),
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,42 +195,7 @@ export class GitLabUtils {
|
||||
merged: "#8250df",
|
||||
canceled: "#848d97",
|
||||
};
|
||||
|
||||
return statusColors[status] ?? "#848d97";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deterministic color for a GitLab project based on its ID.
|
||||
* Mirrors GitLab's identicon algorithm: (id % 7) mapped to a palette.
|
||||
*
|
||||
* @param projectId - the numeric project ID.
|
||||
* @returns a hex color string.
|
||||
*/
|
||||
public static getColorForProject(projectId: number): string {
|
||||
const palette = [
|
||||
"#e05842", // red
|
||||
"#a972cc", // purple
|
||||
"#5b6abf", // indigo
|
||||
"#3e8fda", // blue
|
||||
"#42a68c", // teal
|
||||
"#e67e3c", // orange
|
||||
"#7e7e7e", // neutral
|
||||
];
|
||||
return palette[projectId % 7];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the color associated with a given visibility level.
|
||||
*
|
||||
* @param visibility - The visibility level of the resource.
|
||||
* @returns The color associated with the visibility level.
|
||||
*/
|
||||
public static getColorForVisibility(visibility: string): string {
|
||||
const visibilityColors: Record<string, string> = {
|
||||
public: "#1f75cb",
|
||||
internal: "#f8ae1a",
|
||||
private: "#848d97",
|
||||
};
|
||||
|
||||
return visibilityColors[visibility] ?? "#848d97";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TeamDomainRequiredError,
|
||||
} from "@server/errors";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import { AuthenticationProvider, User } from "@server/models";
|
||||
import { User } from "@server/models";
|
||||
import type { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
StateStore,
|
||||
@@ -56,7 +56,7 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
context: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number; scope?: string },
|
||||
params: { expires_in: number },
|
||||
profile: GoogleProfile,
|
||||
done: (
|
||||
err: Error | null,
|
||||
@@ -139,7 +139,7 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes: params.scope ? params.scope.split(" ") : scopes,
|
||||
scopes,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,30 +151,13 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
)
|
||||
);
|
||||
|
||||
router.get(config.id, async (ctx, next) => {
|
||||
const team = await getTeamFromContext(ctx, {
|
||||
includeHostQueryParam: true,
|
||||
});
|
||||
let extraScopes: string[] = [];
|
||||
|
||||
if (team) {
|
||||
const authProvider = await AuthenticationProvider.findOne({
|
||||
where: { name: config.id, teamId: team.id },
|
||||
});
|
||||
|
||||
if (authProvider?.settings?.groupSyncEnabled) {
|
||||
extraScopes = authProvider.settings.groupSyncScopes ?? [
|
||||
"https://www.googleapis.com/auth/admin.directory.group.readonly",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return passport.authenticate(config.id, {
|
||||
router.get(
|
||||
config.id,
|
||||
passport.authenticate(config.id, {
|
||||
accessType: "offline",
|
||||
prompt: "select_account consent",
|
||||
scope: [...scopes, ...extraScopes],
|
||||
})(ctx, next);
|
||||
});
|
||||
})
|
||||
);
|
||||
router.get(`${config.id}.callback`, passportMiddleware(config.id));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ import env from "./env";
|
||||
const enabled = !!env.GOOGLE_CLIENT_ID && !!env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
if (enabled) {
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.AuthProvider,
|
||||
value: { router, id: config.id },
|
||||
},
|
||||
]);
|
||||
PluginManager.add({
|
||||
...config,
|
||||
type: Hook.AuthProvider,
|
||||
value: { router, id: config.id },
|
||||
});
|
||||
}
|
||||
|
||||
+49
-122
@@ -1,6 +1,5 @@
|
||||
import type { Issue, WorkflowState } from "@linear/sdk";
|
||||
import { LinearClient } from "@linear/sdk";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { z } from "zod";
|
||||
import type { IntegrationType } from "@shared/types";
|
||||
@@ -8,11 +7,7 @@ import { IntegrationService, UnfurlResourceType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
import type User from "@server/models/User";
|
||||
import type {
|
||||
UnfurlIssueOrPR,
|
||||
UnfurlProject,
|
||||
UnfurlSignature,
|
||||
} from "@server/types";
|
||||
import type { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import { LinearUtils } from "../shared/LinearUtils";
|
||||
import env from "./env";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
@@ -30,10 +25,7 @@ const AccessTokenResponseSchema = z.object({
|
||||
});
|
||||
|
||||
export class Linear {
|
||||
private static supportedUnfurls = [
|
||||
UnfurlResourceType.Issue,
|
||||
UnfurlResourceType.Project,
|
||||
];
|
||||
private static supportedUnfurls = [UnfurlResourceType.Issue];
|
||||
|
||||
static async oauthAccess(code: string) {
|
||||
const headers = {
|
||||
@@ -110,7 +102,7 @@ export class Linear {
|
||||
*
|
||||
* @param url Linear resource url
|
||||
* @param actor User attempting to unfurl resource url
|
||||
* @returns An object containing resource details e.g, a Linear issue or project details
|
||||
* @returns An object containing resource details e.g, a Linear issue details
|
||||
*/
|
||||
static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
|
||||
const resource = Linear.parseUrl(url);
|
||||
@@ -145,125 +137,60 @@ export class Linear {
|
||||
);
|
||||
|
||||
const client = new LinearClient({ accessToken });
|
||||
const issue = await client.issue(resource.id);
|
||||
|
||||
switch (resource.type) {
|
||||
case UnfurlResourceType.Issue:
|
||||
return Linear.unfurlIssue(client, resource.id, actor);
|
||||
case UnfurlResourceType.Project:
|
||||
return Linear.unfurlProject(client, resource.id, actor);
|
||||
default:
|
||||
return;
|
||||
if (!issue) {
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
const [author, state, labels] = await Promise.all([
|
||||
issue.creator,
|
||||
issue.state,
|
||||
issue.paginate(issue.labels, {}),
|
||||
]);
|
||||
|
||||
if (!state || !labels) {
|
||||
return { error: "Failed to fetch auxiliary data from Linear" };
|
||||
}
|
||||
|
||||
const completionPercentage = await Linear.completionPercentage(
|
||||
client,
|
||||
issue,
|
||||
state
|
||||
);
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Issue,
|
||||
url: issue.url,
|
||||
id: issue.identifier,
|
||||
title: issue.title,
|
||||
description: issue.description ?? null,
|
||||
author: {
|
||||
name:
|
||||
author?.name ??
|
||||
issue.botActor?.userDisplayName ??
|
||||
issue.botActor?.name ??
|
||||
t("Unknown", opts(actor)),
|
||||
avatarUrl: author?.avatarUrl ?? "",
|
||||
},
|
||||
labels: labels.map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
})),
|
||||
state: {
|
||||
type: state.type,
|
||||
name: state.name,
|
||||
color: state.color,
|
||||
completionPercentage,
|
||||
},
|
||||
createdAt: issue.createdAt.toISOString(),
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch resource from Linear", err);
|
||||
return { error: err.message || "Unknown error" };
|
||||
}
|
||||
};
|
||||
|
||||
private static async unfurlIssue(
|
||||
client: LinearClient,
|
||||
id: string,
|
||||
actor: User | undefined
|
||||
) {
|
||||
const issue = await client.issue(id);
|
||||
|
||||
if (!issue) {
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
const [author, state, labels] = await Promise.all([
|
||||
issue.creator,
|
||||
issue.state,
|
||||
issue.paginate(issue.labels, {}),
|
||||
]);
|
||||
|
||||
if (!state || !labels) {
|
||||
return { error: "Failed to fetch auxiliary data from Linear" };
|
||||
}
|
||||
|
||||
const completionPercentage = await Linear.completionPercentage(
|
||||
client,
|
||||
issue,
|
||||
state
|
||||
);
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Issue,
|
||||
url: issue.url,
|
||||
id: issue.identifier,
|
||||
title: issue.title,
|
||||
description: issue.description ?? null,
|
||||
author: {
|
||||
name:
|
||||
author?.name ??
|
||||
issue.botActor?.userDisplayName ??
|
||||
issue.botActor?.name ??
|
||||
t("Unknown", opts(actor)),
|
||||
avatarUrl: author?.avatarUrl ?? "",
|
||||
},
|
||||
labels: labels.map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
})),
|
||||
state: {
|
||||
type: state.type,
|
||||
name: state.name,
|
||||
color: state.color,
|
||||
completionPercentage,
|
||||
},
|
||||
createdAt: issue.createdAt.toISOString(),
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
}
|
||||
|
||||
private static async unfurlProject(
|
||||
client: LinearClient,
|
||||
id: string,
|
||||
_actor: User | undefined
|
||||
) {
|
||||
const project = await client.project(id);
|
||||
|
||||
if (!project) {
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
const [lead, status, labels] = await Promise.all([
|
||||
project.lead,
|
||||
project.status,
|
||||
project.paginate(project.labels, {}),
|
||||
]);
|
||||
|
||||
if (!status || !labels) {
|
||||
return { error: "Failed to fetch auxiliary data from Linear" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Project,
|
||||
url: project.url,
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
color: project.color ?? status.color,
|
||||
description: project.description ?? null,
|
||||
lead: lead
|
||||
? {
|
||||
name: lead.name,
|
||||
avatarUrl: lead.avatarUrl ?? "",
|
||||
}
|
||||
: null,
|
||||
state: {
|
||||
type: status.type,
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
},
|
||||
labels: labels.map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
})),
|
||||
progress: project.progress,
|
||||
createdAt: project.createdAt.toISOString(),
|
||||
targetDate: project.targetDate ?? null,
|
||||
} satisfies UnfurlProject;
|
||||
}
|
||||
|
||||
private static async completionPercentage(
|
||||
client: LinearClient,
|
||||
issue: Issue,
|
||||
|
||||
@@ -71,7 +71,7 @@ export function createOIDCRouter(
|
||||
context: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number; id_token: string; scope?: string },
|
||||
params: { expires_in: number; id_token: string },
|
||||
_profile: unknown,
|
||||
done: (
|
||||
err: Error | null,
|
||||
@@ -216,7 +216,7 @@ export function createOIDCRouter(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes: params.scope ? params.scope.split(" ") : scopes,
|
||||
scopes,
|
||||
},
|
||||
});
|
||||
return done(null, result.user, { ...result, client });
|
||||
|
||||
@@ -23,13 +23,11 @@ const enabled = hasManualConfig || hasIssuerConfig;
|
||||
|
||||
if (enabled) {
|
||||
// Register plugin with the router (which handles both manual and discovery config)
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.AuthProvider,
|
||||
value: { router, id: config.id },
|
||||
name: env.OIDC_DISPLAY_NAME || config.name,
|
||||
},
|
||||
]);
|
||||
PluginManager.add({
|
||||
...config,
|
||||
type: Hook.AuthProvider,
|
||||
value: { router, id: config.id },
|
||||
name: env.OIDC_DISPLAY_NAME || config.name,
|
||||
});
|
||||
Logger.info("plugins", "OIDC plugin registered");
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { User, UserPasskey, Team } from "@server/models";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import env from "@server/env";
|
||||
import { AuthorizationError, ValidationError } from "@server/errors";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import type { APIContext } from "@server/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import Redis from "@server/storage/redis";
|
||||
@@ -56,10 +56,7 @@ export const getExpectedOrigin = (ctx: APIContext): string => {
|
||||
if (forwardedPort) {
|
||||
const port = parseInt(forwardedPort, 10);
|
||||
// Only add port if it's not the default for the protocol
|
||||
if (
|
||||
(protocol === "https" && port !== 443) ||
|
||||
(protocol === "http" && port !== 80)
|
||||
) {
|
||||
if ((protocol === "https" && port !== 443) || (protocol === "http" && port !== 80)) {
|
||||
origin = `${protocol}://${hostname}:${port}`;
|
||||
}
|
||||
} else if (hostWithPort !== hostname) {
|
||||
@@ -265,12 +262,6 @@ router.post(
|
||||
const user = passkey.user;
|
||||
const team = user.team;
|
||||
|
||||
if (!team.passkeysEnabled) {
|
||||
throw AuthorizationError(
|
||||
"Passkey authentication is not enabled for this team"
|
||||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
|
||||
@@ -29,31 +29,29 @@ export class PasskeyCreatedEmail extends BaseEmail<InputProps> {
|
||||
}
|
||||
|
||||
protected subject() {
|
||||
return this.t("New passkey added to your {{ appName }} account", {
|
||||
appName: env.APP_NAME,
|
||||
});
|
||||
return `New passkey added to your ${env.APP_NAME} account`;
|
||||
}
|
||||
|
||||
protected preview() {
|
||||
return this.t("A new passkey was created for your account.");
|
||||
return "A new passkey was created for your account.";
|
||||
}
|
||||
|
||||
protected renderAsText({ passkeyName, teamUrl }: Props) {
|
||||
return `
|
||||
${this.t("New Passkey Created")}
|
||||
New Passkey Created
|
||||
|
||||
${this.t("A new passkey has been added to your {{ appName }} account", { appName: env.APP_NAME }) + ":"}
|
||||
A new passkey has been added to your ${env.APP_NAME} account:
|
||||
|
||||
${passkeyName}
|
||||
|
||||
${this.t("Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately.")}
|
||||
Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately.
|
||||
|
||||
${this.t("You can manage your passkeys at any time")}:
|
||||
You can manage your passkeys at any time:
|
||||
${teamUrl}/settings/passkeys
|
||||
|
||||
---
|
||||
|
||||
${this.t("If you have any concerns about your account security, please contact a workspace admin.")}
|
||||
If you have any concerns about your account security, please contact a workspace admin.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -65,30 +63,24 @@ ${this.t("If you have any concerns about your account security, please contact a
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{this.t("New Passkey Created")}</Heading>
|
||||
<p>
|
||||
{this.t(
|
||||
"A new passkey has been added to your {{ appName }} account",
|
||||
{ appName: env.APP_NAME }
|
||||
) + ":"}
|
||||
</p>
|
||||
<Heading>New Passkey Created</Heading>
|
||||
<p>A new passkey has been added to your {env.APP_NAME} account:</p>
|
||||
<p>
|
||||
<strong>{passkeyName}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{this.t(
|
||||
"Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately."
|
||||
)}
|
||||
Passkeys provide a secure, passwordless way to sign in to your
|
||||
account. If you did not create this passkey, please review your
|
||||
account security settings immediately.
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={securityUrl}>{this.t("Manage Passkeys")}</Button>
|
||||
<Button href={securityUrl}>Manage Passkeys</Button>
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p style={{ fontSize: "14px", color: "#666" }}>
|
||||
{this.t(
|
||||
"If you have any concerns about your account security, please contact a workspace admin."
|
||||
)}
|
||||
If you have any concerns about your account security, please contact
|
||||
a workspace admin.
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export class PasskeyCreatedProcessor extends BaseProcessor {
|
||||
|
||||
await new PasskeyCreatedEmail({
|
||||
to: user.email,
|
||||
language: user.language,
|
||||
userId: user.id,
|
||||
passkeyId: userPasskey.id,
|
||||
passkeyName: userPasskey.name,
|
||||
|
||||
@@ -886,7 +886,6 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
if (createdBy && team) {
|
||||
await new WebhookDisabledEmail({
|
||||
to: createdBy.email,
|
||||
language: createdBy.language,
|
||||
teamUrl: team.url,
|
||||
webhookName: subscription.name,
|
||||
}).schedule();
|
||||
|
||||
|
Before Width: | Height: | Size: 965 B After Width: | Height: | Size: 965 B |
Binary file not shown.
|
Before Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -8,7 +8,6 @@ import {
|
||||
InvalidAuthenticationError,
|
||||
AuthenticationProviderDisabledError,
|
||||
} from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import type { User } from "@server/models";
|
||||
import {
|
||||
@@ -19,8 +18,6 @@ import {
|
||||
} from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { PluginManager } from "@server/utils/PluginManager";
|
||||
import groupsSyncer from "./groupsSyncer";
|
||||
import teamProvisioner from "./teamProvisioner";
|
||||
import userProvisioner from "./userProvisioner";
|
||||
import type { APIContext } from "@server/types";
|
||||
@@ -198,7 +195,6 @@ async function accountProvisioner(
|
||||
if (isNewUser) {
|
||||
await new WelcomeEmail({
|
||||
to: user.email,
|
||||
language: user.language,
|
||||
role: user.role,
|
||||
teamUrl: team.url,
|
||||
}).schedule();
|
||||
@@ -224,47 +220,6 @@ async function accountProvisioner(
|
||||
}
|
||||
}
|
||||
|
||||
// Sync group memberships from the authentication provider if enabled
|
||||
if (authenticationParams.accessToken) {
|
||||
const settings = authenticationProvider.settings;
|
||||
|
||||
if (settings?.groupSyncEnabled) {
|
||||
const syncProvider = PluginManager.getGroupSyncProvider(
|
||||
authenticationProviderParams.name
|
||||
);
|
||||
|
||||
if (syncProvider) {
|
||||
try {
|
||||
const externalGroups = await syncProvider.fetchUserGroups(
|
||||
authenticationParams.accessToken,
|
||||
settings
|
||||
);
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const groupSyncCtx = createContext({
|
||||
user,
|
||||
ip: ctx.context?.ip,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await groupsSyncer(groupSyncCtx, {
|
||||
user,
|
||||
team,
|
||||
authenticationProvider,
|
||||
externalGroups,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// Group sync failure should never block login
|
||||
Logger.error("Group sync failed during login", err, {
|
||||
userId: user.id,
|
||||
provider: authenticationProviderParams.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
team,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user