mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f67927367a | |||
| d18592a235 | |||
| 21e756c357 | |||
| 2cc5846f1b | |||
| de6c1735d9 | |||
| b7c13f092b | |||
| 298298223b | |||
| 21f37c0d14 | |||
| 18bc93c9c2 | |||
| 6a12822829 | |||
| adcab68b59 | |||
| 943fd7e2e1 | |||
| 01db19a0b1 | |||
| 51cb5bffce | |||
| d37b7fa31e | |||
| f86225c332 | |||
| e53c90f25f | |||
| d84d5a4b09 | |||
| 0031fc1562 | |||
| 9b73635727 | |||
| 5cefb534cc | |||
| 8fb6f7f8c6 | |||
| 6b497cf1ec | |||
| 05a61927af | |||
| 2b07f412e2 | |||
| 65bb3b11f3 | |||
| e1e334dd5f | |||
| 6e9092bcaf | |||
| 09a4b76aae | |||
| 5789d65bf5 | |||
| 03a0f54236 | |||
| 1e7244c737 | |||
| 96c41ce823 | |||
| 0702570b0d | |||
| 4b209a7913 | |||
| 6393bd02f4 | |||
| 1776aad833 | |||
| 0c6b37cb60 | |||
| d664044579 | |||
| b3ca434c51 | |||
| 631b75def4 | |||
| d183dab063 | |||
| f082da6456 | |||
| ad72210714 | |||
| 9c85b26d43 | |||
| bf6a56849e | |||
| 68e8b2791a | |||
| 89db519b72 | |||
| 31c412b4a6 | |||
| 199584428a | |||
| f22780e944 | |||
| a71381785c | |||
| a61b53aa74 | |||
| 45f0885533 |
@@ -127,6 +127,10 @@ GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# Linear
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
# To configure Discord auth, you'll need to create a Discord Application at
|
||||
# => https://discord.com/developers/applications/
|
||||
#
|
||||
|
||||
@@ -63,6 +63,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
app: ${{ steps.filter.outputs.app }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
@@ -81,7 +82,7 @@ jobs:
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: build
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -143,8 +144,8 @@ jobs:
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [build, types]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
needs: [build, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -161,3 +162,4 @@ jobs:
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
webpackStatsFile: ./build/app/webpack-stats.json
|
||||
|
||||
@@ -69,7 +69,6 @@ function CollectionDescription({ collection }: Props) {
|
||||
readOnly={!can.update}
|
||||
userId={user.id}
|
||||
editorStyle={editorStyle}
|
||||
embedsDisabled
|
||||
/>
|
||||
<div ref={childRef} />
|
||||
</React.Suspense>
|
||||
|
||||
@@ -18,6 +18,13 @@ type Props = {
|
||||
children?: React.ReactNode;
|
||||
document: Document;
|
||||
onlyText?: boolean;
|
||||
reverse?: boolean;
|
||||
/**
|
||||
* Maximum number of items to show in the breadcrumb.
|
||||
* If value is less than or equals to 0, no items will be shown.
|
||||
* If value is undefined, all items will be shown.
|
||||
*/
|
||||
maxDepth?: number;
|
||||
};
|
||||
|
||||
function useCategory(document: Document): MenuInternalLink | null {
|
||||
@@ -54,7 +61,7 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
}
|
||||
|
||||
function DocumentBreadcrumb(
|
||||
{ document, children, onlyText }: Props,
|
||||
{ document, children, onlyText, reverse = false, maxDepth }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const { collections } = useStores();
|
||||
@@ -65,6 +72,7 @@ function DocumentBreadcrumb(
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = usePolicy(collection);
|
||||
const depth = maxDepth === undefined ? undefined : Math.max(0, maxDepth);
|
||||
|
||||
React.useEffect(() => {
|
||||
void document.loadRelations({ withoutPolicies: true });
|
||||
@@ -91,20 +99,23 @@ function DocumentBreadcrumb(
|
||||
};
|
||||
}
|
||||
|
||||
const path = document.pathTo;
|
||||
const path = document.pathTo.slice(0, -1);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const output = [];
|
||||
const output: MenuInternalLink[] = [];
|
||||
|
||||
if (depth === 0) {
|
||||
return output;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
if (collectionNode) {
|
||||
output.push(collectionNode);
|
||||
}
|
||||
|
||||
path.slice(0, -1).forEach((node: NavigationNode) => {
|
||||
path.forEach((node: NavigationNode) => {
|
||||
const title = node.title || t("Untitled");
|
||||
output.push({
|
||||
type: "route",
|
||||
@@ -121,21 +132,43 @@ function DocumentBreadcrumb(
|
||||
},
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [t, path, category, sidebarContext, collectionNode]);
|
||||
|
||||
return reverse
|
||||
? depth !== undefined
|
||||
? output.slice(-depth)
|
||||
: output
|
||||
: depth !== undefined
|
||||
? output.slice(0, depth)
|
||||
: output;
|
||||
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onlyText === true) {
|
||||
if (onlyText) {
|
||||
if (depth === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const slicedPath = reverse
|
||||
? path.slice(depth && -depth)
|
||||
: path.slice(0, depth);
|
||||
|
||||
const showCollection =
|
||||
collection &&
|
||||
(!reverse || depth === undefined || slicedPath.length < depth);
|
||||
|
||||
return (
|
||||
<>
|
||||
{collection?.name}
|
||||
{path.slice(0, -1).map((node: NavigationNode) => (
|
||||
{showCollection && collection.name}
|
||||
{slicedPath.map((node: NavigationNode, index: number) => (
|
||||
<React.Fragment key={node.id}>
|
||||
<SmallSlash />
|
||||
{showCollection && <SmallSlash />}
|
||||
{node.title || t("Untitled")}
|
||||
{!showCollection && index !== slicedPath.length - 1 && (
|
||||
<SmallSlash />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -46,10 +46,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<PaginatedList
|
||||
<PaginatedList<User>
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={(model: User) => {
|
||||
renderItem={(model) => {
|
||||
const view = documentViews.find((v) => v.userId === model.id);
|
||||
const isPresent = presentIds.includes(model.id);
|
||||
const isEditing = editingIds.includes(model.id);
|
||||
|
||||
@@ -56,7 +56,7 @@ const FilterOptions = ({
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option: TFilterOption) => (
|
||||
(option) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
@@ -174,7 +174,7 @@ const FilterOptions = ({
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
|
||||
<PaginatedList
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import {
|
||||
IntegrationService,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
@@ -23,6 +27,11 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const authorName = author.name;
|
||||
const urlObj = new URL(url);
|
||||
const service =
|
||||
urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -31,7 +40,7 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
<CardContent>
|
||||
<Flex gap={2} column>
|
||||
<Title>
|
||||
<IssueStatusIcon status={state.name} color={state.color} />
|
||||
<IssueStatusIcon service={service} state={state} />
|
||||
<span>
|
||||
{title} <Text type="tertiary">{id}</Text>
|
||||
</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { PullRequestIcon } from "../Icons/PullRequestIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
|
||||
@@ -114,6 +114,8 @@ const Modal: React.FC<Props> = ({
|
||||
<Small {...props}>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
style={{ maxHeight: "65vh" }}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
@@ -259,6 +261,7 @@ const Small = styled.div`
|
||||
width: 75vw;
|
||||
min-width: 350px;
|
||||
max-width: 450px;
|
||||
max-height: 65vh;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -79,11 +79,11 @@ function Notifications(
|
||||
</Header>
|
||||
<React.Suspense fallback={null}>
|
||||
<Scrollable ref={ref} flex topShadow>
|
||||
<PaginatedList
|
||||
<PaginatedList<Notification>
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={isOpen ? notifications.orderedData : undefined}
|
||||
renderItem={(item: Notification) => (
|
||||
renderItem={(item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
notification={item}
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
fetch: (options: any) => Promise<Document[] | undefined>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
empty?: JSX.Element;
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
@@ -34,7 +34,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PaginatedList
|
||||
<PaginatedList<Document>
|
||||
aria-label={t("Documents")}
|
||||
items={documents}
|
||||
empty={empty}
|
||||
@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderError={(props) => <Error {...props} />}
|
||||
renderItem={(item: Document, _index) => (
|
||||
renderItem={(item, _index) => (
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
empty?: JSX.Element;
|
||||
};
|
||||
|
||||
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import "../stores";
|
||||
import { render } from "@testing-library/react";
|
||||
import { TFunction } from "i18next";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { Component as PaginatedList } from "./PaginatedList";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
|
||||
describe("PaginatedList", () => {
|
||||
const i18n = getI18n();
|
||||
const authStore = {};
|
||||
|
||||
const props = {
|
||||
i18n,
|
||||
@@ -17,19 +19,23 @@ describe("PaginatedList", () => {
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const result = render(
|
||||
<PaginatedList items={[]} renderItem={render} {...props} />
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList items={[]} renderItem={render} {...props} />
|
||||
</Provider>
|
||||
);
|
||||
expect(result.container.innerHTML).toEqual("");
|
||||
});
|
||||
|
||||
it("with no items renders empty prop", async () => {
|
||||
const result = render(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
await expect(
|
||||
result.findAllByText("Sorry, no results")
|
||||
@@ -42,13 +48,15 @@ describe("PaginatedList", () => {
|
||||
id: "one",
|
||||
};
|
||||
render(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
...options,
|
||||
|
||||
+238
-194
@@ -1,265 +1,309 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observable, action, computed } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import withStores from "~/components/withStores";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { dateToHeading } from "~/utils/date";
|
||||
|
||||
/**
|
||||
* Base interface for items that can be paginated
|
||||
* @interface PaginatedItem
|
||||
*/
|
||||
export interface PaginatedItem {
|
||||
/** Unique identifier for the item */
|
||||
id?: string;
|
||||
/** Last update timestamp of the item */
|
||||
updatedAt?: string;
|
||||
/** Creation timestamp of the item */
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
type Props<T> = WithTranslation &
|
||||
RootStore &
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<T[] | undefined> | undefined;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
loading?: React.ReactElement;
|
||||
items?: T[];
|
||||
className?: string;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
renderError?: (options: {
|
||||
error: Error;
|
||||
retry: () => void;
|
||||
}) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
listRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
/**
|
||||
* Props for the PaginatedList component
|
||||
* @template T Type of items in the list, must extend PaginatedItem
|
||||
*/
|
||||
interface Props<T extends PaginatedItem>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Function to fetch paginated data. Should return a promise resolving to an array of items
|
||||
* @param options Pagination and other query options
|
||||
*/
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<unknown[] | undefined> | undefined;
|
||||
|
||||
@observer
|
||||
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
Props<T>
|
||||
> {
|
||||
@observable
|
||||
error?: Error;
|
||||
/** Additional options to pass to the fetch function */
|
||||
options?: Record<string, any>;
|
||||
|
||||
@observable
|
||||
isFetchingMore = false;
|
||||
/** Optional header content to display above the list */
|
||||
heading?: React.ReactNode;
|
||||
|
||||
@observable
|
||||
isFetching = false;
|
||||
/** Content to display when the list is empty */
|
||||
empty?: JSX.Element | null;
|
||||
|
||||
@observable
|
||||
isFetchingInitial = !this.props.items?.length;
|
||||
/** Optional loading state content */
|
||||
loading?: JSX.Element | null;
|
||||
|
||||
@observable
|
||||
fetchCounter = 0;
|
||||
/** Array of items to display in the list */
|
||||
items?: T[];
|
||||
|
||||
@observable
|
||||
renderCount = Pagination.defaultLimit;
|
||||
/** CSS class name to apply to the list container */
|
||||
className?: string;
|
||||
|
||||
@observable
|
||||
offset = 0;
|
||||
/**
|
||||
* Function to render each individual item in the list
|
||||
* @param item The item to render
|
||||
* @param index The index of the item in the list
|
||||
*/
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
|
||||
@observable
|
||||
allowLoadMore = true;
|
||||
/**
|
||||
* Function to render error state
|
||||
* @param options Object containing error details and retry function
|
||||
*/
|
||||
renderError?: (options: {
|
||||
/** Details of the error */
|
||||
error: Error;
|
||||
/** Function to retry the fetch operation */
|
||||
retry: () => void;
|
||||
}) => JSX.Element;
|
||||
|
||||
componentDidMount() {
|
||||
void this.fetchResults();
|
||||
}
|
||||
/**
|
||||
* Function to render section headings (typically date-based)
|
||||
* @param name The heading text or element to render
|
||||
*/
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
|
||||
componentDidUpdate(prevProps: Props<T>) {
|
||||
if (
|
||||
prevProps.fetch !== this.props.fetch ||
|
||||
!isEqual(prevProps.options, this.props.options)
|
||||
) {
|
||||
this.reset();
|
||||
void this.fetchResults();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Handler for escape key press
|
||||
* @param ev Keyboard event object
|
||||
*/
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
|
||||
reset = () => {
|
||||
this.offset = 0;
|
||||
this.allowLoadMore = true;
|
||||
this.renderCount = Pagination.defaultLimit;
|
||||
this.isFetching = false;
|
||||
this.isFetchingInitial = false;
|
||||
this.isFetchingMore = false;
|
||||
};
|
||||
/** Reference to the list container element */
|
||||
listRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@action
|
||||
fetchResults = async () => {
|
||||
if (!this.props.fetch) {
|
||||
/**
|
||||
* A reusable component that renders a paginated list with infinite scrolling
|
||||
* and optional date-based section headings.
|
||||
*
|
||||
* @template T Type of the list items, must extend PaginatedItem
|
||||
*/
|
||||
const PaginatedList = <T extends PaginatedItem>({
|
||||
fetch,
|
||||
options,
|
||||
heading,
|
||||
empty = null,
|
||||
loading = null,
|
||||
items = [],
|
||||
className,
|
||||
renderItem,
|
||||
renderError,
|
||||
renderHeading,
|
||||
onEscape,
|
||||
listRef,
|
||||
...rest
|
||||
}: Props<T>): JSX.Element | null => {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const [isFetchingMore, setIsFetchingMore] = React.useState(false);
|
||||
const [isFetching, setIsFetching] = React.useState(false);
|
||||
const [isFetchingInitial, setIsFetchingInitial] = React.useState(
|
||||
!items?.length
|
||||
);
|
||||
const [fetchCounter, setFetchCounter] = React.useState(0);
|
||||
const [renderCount, setRenderCount] = React.useState(Pagination.defaultLimit);
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [allowLoadMore, setAllowLoadMore] = React.useState(true);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setOffset(0);
|
||||
setAllowLoadMore(true);
|
||||
setRenderCount(Pagination.defaultLimit);
|
||||
setIsFetching(false);
|
||||
setIsFetchingInitial(false);
|
||||
setIsFetchingMore(false);
|
||||
}, []);
|
||||
|
||||
const fetchResults = React.useCallback(async () => {
|
||||
if (!fetch) {
|
||||
return;
|
||||
}
|
||||
this.isFetching = true;
|
||||
const counter = ++this.fetchCounter;
|
||||
const limit = this.props.options?.limit ?? Pagination.defaultLimit;
|
||||
this.error = undefined;
|
||||
|
||||
setIsFetching(true);
|
||||
const counter = fetchCounter + 1;
|
||||
setFetchCounter(counter);
|
||||
const limit = options?.limit ?? Pagination.defaultLimit;
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const results = await this.props.fetch({
|
||||
const results = await fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
...this.props.options,
|
||||
offset,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (this.offset !== 0) {
|
||||
this.renderCount += limit;
|
||||
if (offset !== 0) {
|
||||
setRenderCount((prevCount) => prevCount + limit);
|
||||
}
|
||||
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
setAllowLoadMore(false);
|
||||
} else {
|
||||
this.offset += limit;
|
||||
setOffset((prevOffset) => prevOffset + limit);
|
||||
}
|
||||
|
||||
this.isFetchingInitial = false;
|
||||
setIsFetchingInitial(false);
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
setError(err);
|
||||
} finally {
|
||||
// only the most recent fetch should end the loading state
|
||||
if (counter >= this.fetchCounter) {
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
if (counter >= fetchCounter) {
|
||||
setIsFetching(false);
|
||||
setIsFetchingMore(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [fetch, fetchCounter, offset, options]);
|
||||
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re currently fetching
|
||||
if (!this.allowLoadMore || this.isFetching) {
|
||||
const loadMoreResults = React.useCallback(async () => {
|
||||
// Don't paginate if there aren't more results or we're currently fetching
|
||||
if (!allowLoadMore || isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are already cached results that we haven't yet rendered because
|
||||
// of lazy rendering then show another page.
|
||||
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
|
||||
const leftToRender = (items?.length ?? 0) - renderCount;
|
||||
|
||||
if (leftToRender > 0) {
|
||||
this.renderCount += Pagination.defaultLimit;
|
||||
setRenderCount((prevCount) => prevCount + Pagination.defaultLimit);
|
||||
}
|
||||
|
||||
// If there are less than a pages results in the cache go ahead and fetch
|
||||
// another page from the server
|
||||
if (leftToRender <= Pagination.defaultLimit) {
|
||||
this.isFetchingMore = true;
|
||||
await this.fetchResults();
|
||||
setIsFetchingMore(true);
|
||||
await fetchResults();
|
||||
}
|
||||
};
|
||||
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
|
||||
|
||||
@computed
|
||||
get itemsToRender() {
|
||||
return this.props.items?.slice(0, this.renderCount) ?? [];
|
||||
}
|
||||
React.useEffect(() => {
|
||||
void fetchResults();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
render() {
|
||||
const {
|
||||
items = [],
|
||||
heading,
|
||||
auth,
|
||||
empty = null,
|
||||
renderHeading,
|
||||
renderError,
|
||||
onEscape,
|
||||
} = this.props;
|
||||
const prevFetch = usePrevious(fetch);
|
||||
const prevOptions = usePrevious(options);
|
||||
|
||||
const showLoading =
|
||||
this.isFetching &&
|
||||
!this.isFetchingMore &&
|
||||
(!items?.length || (this.fetchCounter <= 1 && this.isFetchingInitial));
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
this.props.loading || (
|
||||
<DelayedMount>
|
||||
<div className={this.props.className}>
|
||||
<PlaceholderList count={5} />
|
||||
</div>
|
||||
</DelayedMount>
|
||||
)
|
||||
);
|
||||
// Equivalent to componentDidUpdate
|
||||
React.useEffect(() => {
|
||||
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
|
||||
reset();
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch, options, reset, prevFetch, prevOptions, fetchResults]);
|
||||
|
||||
if (items?.length === 0) {
|
||||
if (this.error && renderError) {
|
||||
return renderError({ error: this.error, retry: this.fetchResults });
|
||||
}
|
||||
// Computed property equivalent
|
||||
const itemsToRender = React.useMemo(
|
||||
() => items?.slice(0, renderCount) ?? [],
|
||||
[items, renderCount]
|
||||
);
|
||||
|
||||
return empty;
|
||||
}
|
||||
const showLoading =
|
||||
isFetching &&
|
||||
!isFetchingMore &&
|
||||
(!items?.length || (fetchCounter <= 1 && isFetchingInitial));
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
<>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
aria-label={this.props["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={this.props.className}
|
||||
items={this.itemsToRender}
|
||||
ref={this.props.listRef}
|
||||
>
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
return this.itemsToRender.map((item, index) => {
|
||||
const children = this.props.renderItem(item, index);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
// headings are rendered
|
||||
if (!renderHeading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
"updatedAt" in item && item.updatedAt
|
||||
? item.updatedAt
|
||||
: "createdAt" in item && item.createdAt
|
||||
? item.createdAt
|
||||
: previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
this.props.t,
|
||||
auth.user?.language
|
||||
);
|
||||
|
||||
// If the heading is different to any previous heading then we
|
||||
// should render it, otherwise the item can go under the previous
|
||||
// heading
|
||||
if (
|
||||
children &&
|
||||
(!previousHeading || currentHeading !== previousHeading)
|
||||
) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment
|
||||
key={"id" in item && item.id ? item.id : index}
|
||||
>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
});
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<div style={{ height: "1px" }}>
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
loading || (
|
||||
<DelayedMount>
|
||||
<div className={className}>
|
||||
<PlaceholderList count={5} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</DelayedMount>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Component = PaginatedList;
|
||||
if (items?.length === 0) {
|
||||
if (error && renderError) {
|
||||
return renderError({ error, retry: fetchResults });
|
||||
}
|
||||
|
||||
export default withTranslation()(withStores(PaginatedList));
|
||||
return empty;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
aria-label={rest["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={className}
|
||||
items={itemsToRender}
|
||||
ref={listRef}
|
||||
>
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
return itemsToRender.map((item, index) => {
|
||||
const children = renderItem(item, index);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
// headings are rendered
|
||||
if (!renderHeading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
"updatedAt" in item && item.updatedAt
|
||||
? item.updatedAt
|
||||
: "createdAt" in item && item.createdAt
|
||||
? item.createdAt
|
||||
: previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
t,
|
||||
user?.language
|
||||
);
|
||||
|
||||
// If the heading is different to any previous heading then we
|
||||
// should render it, otherwise the item can go under the previous
|
||||
// heading
|
||||
if (
|
||||
children &&
|
||||
(!previousHeading || currentHeading !== previousHeading)
|
||||
) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment key={"id" in item && item.id ? item.id : index}>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
});
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{allowLoadMore && (
|
||||
<div style={{ height: "1px" }}>
|
||||
<Waypoint key={renderCount} onEnter={loadMoreResults} />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginatedList;
|
||||
|
||||
@@ -200,7 +200,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
style={{ zIndex: depths.sidebar + 1 }}
|
||||
shrink
|
||||
>
|
||||
<PaginatedList
|
||||
<PaginatedList<SearchResult>
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
@@ -209,7 +209,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
|
||||
}
|
||||
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
|
||||
renderItem={(item: SearchResult, index) => (
|
||||
renderItem={(item, index) => (
|
||||
<SearchListItem
|
||||
key={item.document.id}
|
||||
shareId={shareId}
|
||||
|
||||
@@ -82,12 +82,12 @@ function ArchiveLink() {
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
<PaginatedList<Collection>
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
renderItem={(item) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
|
||||
@@ -54,7 +54,7 @@ function Collections() {
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
<PaginatedList<Collection>
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
@@ -69,7 +69,7 @@ function Collections() {
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
renderItem={(item, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
|
||||
@@ -148,7 +148,12 @@ function InnerDocumentLink(
|
||||
const color = document?.color || node.color;
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
|
||||
const [{ isDragging }, drag] = useDragDocument(
|
||||
node,
|
||||
depth,
|
||||
document,
|
||||
isEditing
|
||||
);
|
||||
|
||||
// Drop to re-parent
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -270,6 +275,8 @@ function InnerDocumentLink(
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
// @ts-expect-error react-router type is wrong, string component is fine.
|
||||
component={isEditing ? "div" : undefined}
|
||||
expanded={hasChildren ? isExpanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
@@ -285,6 +292,7 @@ function InnerDocumentLink(
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
location?: Location;
|
||||
strict?: boolean;
|
||||
to: LocationDescriptor;
|
||||
component?: React.ComponentType;
|
||||
onBeforeClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -146,17 +147,22 @@ const NavLink = ({
|
||||
setPreActive(undefined);
|
||||
}, [currentLocation]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
},
|
||||
[navigateTo]
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={isActive ? "active" : "inactive"}
|
||||
ref={linkRef}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-current={(isActive && ariaCurrent) || undefined}
|
||||
className={className}
|
||||
style={style}
|
||||
|
||||
@@ -166,11 +166,13 @@ export function useDropToReorderStar(getIndex?: () => string) {
|
||||
* @param node The NavigationNode model to drag.
|
||||
* @param depth The depth of the node in the sidebar.
|
||||
* @param document The related Document model.
|
||||
* @param isEditing Whether the sidebar item is currently being edited.
|
||||
*/
|
||||
export function useDragDocument(
|
||||
node: NavigationNode,
|
||||
depth: number,
|
||||
document?: Document
|
||||
document?: Document,
|
||||
isEditing?: boolean
|
||||
) {
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
@@ -188,7 +190,7 @@ export function useDragDocument(
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
} as DragObject),
|
||||
canDrag: () => !!document?.isActive,
|
||||
canDrag: () => !!document?.isActive && !isEditing,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
|
||||
@@ -335,6 +335,7 @@ const TR = styled.div<{ $columns: string }>`
|
||||
grid-template-columns: ${({ $columns }) => `${$columns}`};
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
@@ -357,7 +358,8 @@ const TD = styled.span`
|
||||
padding: 10px 6px;
|
||||
font-size: 14px;
|
||||
text-wrap: wrap;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:first-child {
|
||||
font-size: 15px;
|
||||
|
||||
+1
-71
@@ -1,73 +1,3 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** The type of text to render */
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
/** The size of the text */
|
||||
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
|
||||
/** The direction of the text (defaults to ltr) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the text should be selectable (defaults to false) */
|
||||
selectable?: boolean;
|
||||
/** The font weight of the text */
|
||||
weight?: "xbold" | "bold" | "normal";
|
||||
/** Whether the text should be italic */
|
||||
italic?: boolean;
|
||||
/** Whether the text should be truncated with an ellipsis */
|
||||
ellipsis?: boolean;
|
||||
/** Whether the text should be monospaced */
|
||||
monospace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this component for all interface text that should not be selectable
|
||||
* by the user, this is the majority of UI text explainers, notes, headings.
|
||||
*/
|
||||
const Text = styled.span<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
: props.type === "tertiary"
|
||||
? props.theme.textTertiary
|
||||
: props.type === "danger"
|
||||
? props.theme.brand.red
|
||||
: props.theme.text};
|
||||
font-size: ${(props) =>
|
||||
props.size === "xlarge"
|
||||
? "26px"
|
||||
: props.size === "large"
|
||||
? "18px"
|
||||
: props.size === "medium"
|
||||
? "16px"
|
||||
: props.size === "small"
|
||||
? "14px"
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
: "inherit"};
|
||||
|
||||
${(props) =>
|
||||
props.weight &&
|
||||
css`
|
||||
font-weight: ${props.weight === "xbold"
|
||||
? 600
|
||||
: props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? 400
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
font-style: ${(props) => (props.italic ? "italic" : "normal")};
|
||||
font-family: ${(props) =>
|
||||
props.monospace ? props.theme.fontFamilyMono : "inherit"};
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
|
||||
${(props) => props.ellipsis && ellipsis()}
|
||||
`;
|
||||
import Text from "@shared/components/Text";
|
||||
|
||||
export default Text;
|
||||
|
||||
@@ -10,6 +10,7 @@ import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { hideScrollbars, s } from "@shared/styles";
|
||||
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
@@ -253,7 +254,14 @@ const LinkEditor: React.FC<Props> = ({
|
||||
onPointerMove={() => setSelectedIndex(index)}
|
||||
selected={index === selectedIndex}
|
||||
key={doc.id}
|
||||
subtitle={doc.collection?.name}
|
||||
subtitle={
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
}
|
||||
title={doc.title}
|
||||
icon={
|
||||
doc.icon ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DocumentsSection,
|
||||
@@ -57,7 +58,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
res.data.documents.map(documents.add);
|
||||
res.data.users.map(users.add);
|
||||
res.data.collections.map(collections.add);
|
||||
}, [search, documents, users])
|
||||
}, [search, documents, users, collections])
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -68,7 +69,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (actorId && !loading) {
|
||||
const items = users
|
||||
const items: MentionItem[] = users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
@@ -112,7 +113,14 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: doc.collection?.name,
|
||||
subtitle: (
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
),
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { EmailIcon, LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 } from "uuid";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { determineMentionType, isURLMentionable } from "~/utils/mention";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
@@ -15,34 +23,65 @@ type Props = Omit<
|
||||
embeds: EmbedDescriptor[];
|
||||
};
|
||||
|
||||
const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { integrations } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
|
||||
let mentionType: MentionType | undefined;
|
||||
const url = pastedText ? new URL(pastedText) : undefined;
|
||||
|
||||
if (url) {
|
||||
const integration = integrations.find((intg: Integration) =>
|
||||
isURLMentionable({ url, integration: intg })
|
||||
);
|
||||
|
||||
mentionType = integration
|
||||
? determineMentionType({ url, integration })
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const embed = React.useMemo(() => {
|
||||
for (const e of embeds) {
|
||||
const matches = e.matcher(props.pastedText);
|
||||
const matches = e.matcher(pastedText);
|
||||
if (matches) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [embeds, props.pastedText]);
|
||||
}, [embeds, pastedText]);
|
||||
|
||||
const items = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
],
|
||||
[embed, t]
|
||||
() =>
|
||||
[
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "mention",
|
||||
title: t("Mention"),
|
||||
icon: <EmailIcon />,
|
||||
visible: !!mentionType,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: mentionType,
|
||||
label: pastedText,
|
||||
href: pastedText,
|
||||
modelId: v4(),
|
||||
actorId: user?.id,
|
||||
},
|
||||
appendSpace: true,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
] satisfies MenuItem[],
|
||||
[t, embed, mentionType, pastedText, user]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -52,9 +91,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={() => {
|
||||
props.onSelect?.(item);
|
||||
}}
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
@@ -63,6 +100,4 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasteMenu;
|
||||
});
|
||||
|
||||
@@ -174,12 +174,12 @@ export default function SelectionToolbar(props: Props) {
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
|
||||
if ((readOnly && !canComment) || isDragging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isList } from "@shared/editor/queries/isList";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
/**
|
||||
@@ -18,17 +19,25 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
clipboardTextSerializer: (slice, view) => {
|
||||
const isMultiline = slice.content.childCount > 1;
|
||||
|
||||
// This is a cheap way to determine if the content is "complex",
|
||||
// aka it has multiple marks or formatting. In which case we'll use
|
||||
// markdown formatting
|
||||
const hasMultipleListItems = slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.some((node) => isList(node, view.state.schema));
|
||||
const hasMultipleBlockTypes =
|
||||
[
|
||||
...new Set(
|
||||
slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.map((node) => node.type.name)
|
||||
),
|
||||
].length > 1;
|
||||
const copyAsMarkdown =
|
||||
isMultiline ||
|
||||
slice.content.content.some(
|
||||
(node) => node.content.content.length > 1
|
||||
);
|
||||
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
|
||||
|
||||
return copyAsMarkdown
|
||||
? mdSerializer.serialize(slice.content, {
|
||||
|
||||
@@ -24,7 +24,7 @@ import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
import { PasteMenu } from "../components/PasteMenu";
|
||||
|
||||
export default class PasteHandler extends Extension {
|
||||
state: {
|
||||
@@ -415,6 +415,21 @@ export default class PasteHandler extends Extension {
|
||||
});
|
||||
};
|
||||
|
||||
private insertMention = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
// Remove just the placeholder here.
|
||||
// Mention node will be created by SuggestionsMenu.
|
||||
if (result) {
|
||||
const tr = state.tr.deleteRange(result[0], result[1]);
|
||||
view.dispatch(
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private removePlaceholder = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
@@ -450,6 +465,11 @@ export default class PasteHandler extends Extension {
|
||||
this.insertEmbed();
|
||||
break;
|
||||
}
|
||||
case "mention": {
|
||||
this.hidePasteMenu();
|
||||
this.insertMention();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
import * as React from "react";
|
||||
import useIsMounted from "@shared/hooks/useIsMounted";
|
||||
|
||||
/**
|
||||
* Hook to check if component is still mounted
|
||||
*
|
||||
* @returns {boolean} true if the component is mounted, false otherwise
|
||||
*/
|
||||
export default function useIsMounted() {
|
||||
const isMounted = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return React.useCallback(() => isMounted.current, []);
|
||||
}
|
||||
export default useIsMounted;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
GlobeIcon,
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
SettingsIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
@@ -40,7 +39,6 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
|
||||
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
|
||||
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
|
||||
const Security = lazy(() => import("~/scenes/Settings/Security"));
|
||||
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
|
||||
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
|
||||
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
|
||||
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
|
||||
@@ -177,14 +175,6 @@ const useSettingsConfig = () => {
|
||||
icon: ExportIcon,
|
||||
},
|
||||
// Integrations
|
||||
{
|
||||
name: t("Self Hosted"),
|
||||
path: integrationSettingsPath("self-hosted"),
|
||||
component: SelfHosted,
|
||||
enabled: can.update && !isCloudHosted,
|
||||
group: t("Integrations"),
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
{
|
||||
name: "Zapier",
|
||||
path: integrationSettingsPath("zapier"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { observable } from "mobx";
|
||||
import type {
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationSettings,
|
||||
IntegrationType,
|
||||
type IntegrationSettings,
|
||||
type IntegrationType,
|
||||
} from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import Model from "~/models/base/Model";
|
||||
|
||||
@@ -144,10 +144,10 @@ function Insights() {
|
||||
small
|
||||
/>
|
||||
)}
|
||||
<PaginatedList
|
||||
<PaginatedList<User>
|
||||
aria-label={t("Contributors")}
|
||||
items={document.collaborators}
|
||||
renderItem={(model: User) => (
|
||||
renderItem={(model) => (
|
||||
<ListItem
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
|
||||
@@ -209,7 +209,9 @@ function Invite({ onSubmit }: Props) {
|
||||
placeholder={`name@${predictedDomain}`}
|
||||
value={invite.email}
|
||||
required={index === 0}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
data-1p-ignore
|
||||
flex
|
||||
/>
|
||||
<StyledInput
|
||||
|
||||
@@ -39,7 +39,10 @@ export function LoginDialog() {
|
||||
maxLength={255}
|
||||
autoComplete="off"
|
||||
placeholder={t("subdomain")}
|
||||
{...register("subdomain", { required: true, pattern: /^[a-z\d-]+$/ })}
|
||||
{...register("subdomain", {
|
||||
required: true,
|
||||
pattern: /^[a-z\d-]{1,63}$/,
|
||||
})}
|
||||
>
|
||||
<Domain>.getoutline.com</Domain>
|
||||
</Input>
|
||||
|
||||
@@ -3,6 +3,15 @@ import { parseDomain } from "@shared/utils/domains";
|
||||
import env from "~/env";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
function validateAndEncodeSubdomain(subdomain: string): string {
|
||||
const encodedSubdomain = encodeURIComponent(subdomain);
|
||||
const urlPattern = /^[a-z\d-]{1,63}$/;
|
||||
if (!urlPattern.test(encodedSubdomain)) {
|
||||
throw new Error("Invalid subdomain");
|
||||
}
|
||||
return `https://${encodedSubdomain}.getoutline.com`;
|
||||
}
|
||||
|
||||
/**
|
||||
* If we're on a custom domain or a subdomain then the auth must point to the
|
||||
* apex (env.URL) for authentication so that the state cookie can be set and read.
|
||||
@@ -36,7 +45,7 @@ export async function navigateToSubdomain(subdomain: string) {
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "");
|
||||
const host = `https://${normalizedSubdomain}.getoutline.com`;
|
||||
const host = validateAndEncodeSubdomain(normalizedSubdomain);
|
||||
await Desktop.bridge?.addCustomHost(host);
|
||||
window.location.href = host;
|
||||
}
|
||||
|
||||
@@ -58,11 +58,11 @@ function ApiKeys() {
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
<PaginatedList<ApiKey>
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.orderedData}
|
||||
heading={<h2>{t("All")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
renderItem={(apiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -48,7 +48,7 @@ function Export() {
|
||||
{t("Export data")}…
|
||||
</Button>
|
||||
<br />
|
||||
<PaginatedList
|
||||
<PaginatedList<FileOperation>
|
||||
items={fileOperations.exports}
|
||||
fetch={fileOperations.fetchPage}
|
||||
options={{
|
||||
@@ -59,7 +59,7 @@ function Export() {
|
||||
<Trans>Recent exports</Trans>
|
||||
</h2>
|
||||
}
|
||||
renderItem={(item: FileOperation) => (
|
||||
renderItem={(item) => (
|
||||
<FileOperationListItem key={item.id} fileOperation={item} />
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -183,7 +183,7 @@ function Import() {
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
<PaginatedList
|
||||
<PaginatedList<ImportModel | FileOperation>
|
||||
items={allImports}
|
||||
fetch={fetchImports}
|
||||
heading={
|
||||
@@ -191,7 +191,7 @@ function Import() {
|
||||
<Trans>Recent imports</Trans>
|
||||
</h2>
|
||||
}
|
||||
renderItem={(item: ImportModel | FileOperation) =>
|
||||
renderItem={(item) =>
|
||||
item instanceof ImportModel ? (
|
||||
<ImportListItem key={item.id} importModel={item} />
|
||||
) : (
|
||||
|
||||
@@ -61,12 +61,12 @@ function PersonalApiKeys() {
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
<PaginatedList<ApiKey>
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
renderItem={(apiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import find from "lodash/find";
|
||||
import { observer } from "mobx-react";
|
||||
import { BuildingBlocksIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
type FormData = {
|
||||
drawIoUrl: string;
|
||||
gristUrl: string;
|
||||
};
|
||||
|
||||
function SelfHosted() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const integrationDiagrams = find(integrations.orderedData, {
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Diagrams,
|
||||
}) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
const integrationGrist = find(integrations.orderedData, {
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Grist,
|
||||
}) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
drawIoUrl: integrationDiagrams?.settings.url,
|
||||
gristUrl: integrationGrist?.settings.url,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchPage({
|
||||
type: IntegrationType.Embed,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
React.useEffect(() => {
|
||||
reset({
|
||||
drawIoUrl: integrationDiagrams?.settings.url,
|
||||
gristUrl: integrationGrist?.settings.url,
|
||||
});
|
||||
}, [integrationDiagrams, integrationGrist, reset]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
if (data.drawIoUrl) {
|
||||
await integrations.save({
|
||||
id: integrationDiagrams?.id,
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Diagrams,
|
||||
settings: {
|
||||
url: data.drawIoUrl,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await integrationDiagrams?.delete();
|
||||
}
|
||||
|
||||
if (data.gristUrl) {
|
||||
await integrations.save({
|
||||
id: integrationGrist?.id,
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Grist,
|
||||
settings: {
|
||||
url: data.gristUrl,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await integrationGrist?.delete();
|
||||
}
|
||||
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[integrations, integrationDiagrams, integrationGrist, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Self Hosted")} icon={<BuildingBlocksIcon />}>
|
||||
<Heading>{t("Self Hosted")}</Heading>
|
||||
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<SettingRow
|
||||
label={t("Draw.io deployment")}
|
||||
name="drawIoUrl"
|
||||
description={t(
|
||||
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://app.diagrams.net/"
|
||||
pattern="https?://.*"
|
||||
{...register("drawIoUrl")}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("Grist deployment")}
|
||||
name="gristUrl"
|
||||
description={t("Add your self-hosted grist installation URL here.")}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://docs.getgrist.com/"
|
||||
pattern="https?://.*"
|
||||
{...register("gristUrl")}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(SelfHosted);
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Spinner from "@shared/components/Spinner";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
FileOperationState,
|
||||
@@ -13,7 +14,6 @@ import FileOperation from "~/models/FileOperation";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -268,14 +268,14 @@ export const ViewGroupMembersDialog = observer(function ({
|
||||
<Subheading>
|
||||
<Trans>Members</Trans>
|
||||
</Subheading>
|
||||
<PaginatedList
|
||||
<PaginatedList<User>
|
||||
items={users.inGroup(group.id)}
|
||||
fetch={groupUsers.fetchPage}
|
||||
options={{
|
||||
id: group.id,
|
||||
}}
|
||||
empty={<Empty>{t("This group has no members.")}</Empty>}
|
||||
renderItem={(user: User) => (
|
||||
renderItem={(user) => (
|
||||
<GroupMemberListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
@@ -382,7 +382,7 @@ const AddPeopleToGroupDialog = observer(function ({
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
) : (
|
||||
<PaginatedList
|
||||
<PaginatedList<User>
|
||||
empty={
|
||||
query ? (
|
||||
<Empty>{t("No people matching your search")}</Empty>
|
||||
@@ -392,7 +392,7 @@ const AddPeopleToGroupDialog = observer(function ({
|
||||
}
|
||||
items={users.notInGroup(group.id, query)}
|
||||
fetch={query ? undefined : users.fetchPage}
|
||||
renderItem={(item: User) => (
|
||||
renderItem={(item) => (
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import AvatarEditor from "react-avatar-editor";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import Flex from "~/components/Flex";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Modal from "~/components/Modal";
|
||||
import withStores from "~/components/withStores";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { uploadFile, dataUrlToBlob } from "~/utils/files";
|
||||
|
||||
@@ -24,143 +23,167 @@ export type Props = {
|
||||
borderRadius?: number;
|
||||
};
|
||||
|
||||
@observer
|
||||
class ImageUpload extends React.Component<RootStore & Props> {
|
||||
@observable
|
||||
isUploading = false;
|
||||
const ImageUpload: React.FC<Props> = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
submitText,
|
||||
borderRadius,
|
||||
children,
|
||||
}) => {
|
||||
const { dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@observable
|
||||
isCropping = false;
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isCropping, setIsCropping] = useState(false);
|
||||
|
||||
@observable
|
||||
zoom = 1;
|
||||
const uploadImage = React.useCallback(
|
||||
async (blob: Blob, file: File) => {
|
||||
try {
|
||||
const compressed = await compressImage(blob, {
|
||||
maxHeight: 512,
|
||||
maxWidth: 512,
|
||||
});
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Avatar,
|
||||
});
|
||||
void onSuccess(attachment.url);
|
||||
} catch (err) {
|
||||
onError(err.message);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setIsCropping(false);
|
||||
dialogs.closeAllModals();
|
||||
}
|
||||
},
|
||||
[dialogs, onSuccess, onError]
|
||||
);
|
||||
|
||||
@observable
|
||||
file: File;
|
||||
const handleUpload = React.useCallback(
|
||||
(blob: Blob, file: File) => {
|
||||
setIsUploading(true);
|
||||
// allow the UI to update before converting the canvas to a Blob
|
||||
// for large images this can cause the page rendering to hang.
|
||||
setTimeout(() => uploadImage(blob, file), 0);
|
||||
},
|
||||
[uploadImage]
|
||||
);
|
||||
|
||||
avatarEditorRef = React.createRef<AvatarEditor>();
|
||||
const handleClose = React.useCallback(() => {
|
||||
setIsUploading(false);
|
||||
setIsCropping(false);
|
||||
}, []);
|
||||
|
||||
static defaultProps = {
|
||||
submitText: "Crop Image",
|
||||
borderRadius: 150,
|
||||
};
|
||||
|
||||
onDropAccepted = async (files: File[]) => {
|
||||
this.isCropping = true;
|
||||
this.file = files[0];
|
||||
};
|
||||
|
||||
handleCrop = () => {
|
||||
this.isUploading = true;
|
||||
// allow the UI to update before converting the canvas to a Blob
|
||||
// for large images this can cause the page rendering to hang.
|
||||
setTimeout(this.uploadImage, 0);
|
||||
};
|
||||
|
||||
uploadImage = async () => {
|
||||
const canvas = this.avatarEditorRef.current?.getImage();
|
||||
invariant(canvas, "canvas is not defined");
|
||||
const imageBlob = dataUrlToBlob(canvas.toDataURL());
|
||||
|
||||
try {
|
||||
const compressed = await compressImage(imageBlob, {
|
||||
maxHeight: 512,
|
||||
maxWidth: 512,
|
||||
});
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: this.file.name,
|
||||
preset: AttachmentPreset.Avatar,
|
||||
});
|
||||
void this.props.onSuccess(attachment.url);
|
||||
} catch (err) {
|
||||
this.props.onError(err.message);
|
||||
} finally {
|
||||
this.isUploading = false;
|
||||
this.isCropping = false;
|
||||
}
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.isUploading = false;
|
||||
this.isCropping = false;
|
||||
};
|
||||
|
||||
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
this.zoom = parseFloat(target.value);
|
||||
}
|
||||
};
|
||||
|
||||
renderCropping() {
|
||||
const { ui, submitText } = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onRequestClose={this.handleClose}
|
||||
fullscreen={false}
|
||||
title={<> </>}
|
||||
isOpen
|
||||
>
|
||||
<Flex auto column align="center" justify="center">
|
||||
{this.isUploading && <LoadingIndicator />}
|
||||
<AvatarEditorContainer>
|
||||
<AvatarEditor
|
||||
ref={this.avatarEditorRef}
|
||||
image={this.file}
|
||||
width={250}
|
||||
height={250}
|
||||
border={25}
|
||||
borderRadius={this.props.borderRadius}
|
||||
color={
|
||||
ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]
|
||||
} // RGBA
|
||||
scale={this.zoom}
|
||||
rotate={0}
|
||||
/>
|
||||
</AvatarEditorContainer>
|
||||
<RangeInput
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.01"
|
||||
defaultValue="1"
|
||||
onChange={this.handleZoom}
|
||||
const onDropAccepted = React.useCallback(
|
||||
async (files: File[]) => {
|
||||
setIsCropping(true);
|
||||
dialogs.openModal({
|
||||
title: "",
|
||||
content: (
|
||||
<AvatarEditorDialog
|
||||
file={files[0]}
|
||||
onUpload={handleUpload}
|
||||
isUploading={isUploading}
|
||||
borderRadius={borderRadius ?? 150}
|
||||
submitText={submitText || t("Crop image")}
|
||||
/>
|
||||
<br />
|
||||
<ButtonLarge
|
||||
fullwidth
|
||||
onClick={this.handleCrop}
|
||||
disabled={this.isUploading}
|
||||
>
|
||||
{this.isUploading ? "Uploading…" : submitText}
|
||||
</ButtonLarge>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
),
|
||||
onClose: handleClose,
|
||||
});
|
||||
},
|
||||
[
|
||||
t,
|
||||
dialogs,
|
||||
handleUpload,
|
||||
handleClose,
|
||||
isUploading,
|
||||
borderRadius,
|
||||
submitText,
|
||||
]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: AttachmentValidation.avatarContentTypes.join(", "),
|
||||
onDropAccepted,
|
||||
});
|
||||
|
||||
if (isCropping) {
|
||||
return null; // onDropAccepted would have opened a modal for cropping the image.
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.isCropping) {
|
||||
return this.renderCropping();
|
||||
}
|
||||
return (
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type AvatarEditorDialogProps = {
|
||||
file: File;
|
||||
onUpload: (blob: Blob, file: File) => void;
|
||||
isUploading: boolean;
|
||||
borderRadius: number;
|
||||
submitText: string;
|
||||
};
|
||||
|
||||
const AvatarEditorDialog: React.FC<AvatarEditorDialogProps> = observer(
|
||||
({ file, onUpload, isUploading, borderRadius, submitText }) => {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const avatarEditorRef = useRef<AvatarEditor>(null);
|
||||
|
||||
const handleUpload = React.useCallback(() => {
|
||||
const canvas = avatarEditorRef.current?.getImage();
|
||||
invariant(canvas, "canvas is not defined");
|
||||
const blob = dataUrlToBlob(canvas.toDataURL());
|
||||
onUpload(blob, file);
|
||||
}, [file, onUpload]);
|
||||
|
||||
const handleZoom = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
setZoom(parseFloat(target.value));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept={AttachmentValidation.avatarContentTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
{this.props.children}
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
<Flex auto column align="center" justify="center">
|
||||
{isUploading && <LoadingIndicator />}
|
||||
<AvatarEditorContainer>
|
||||
<AvatarEditor
|
||||
ref={avatarEditorRef}
|
||||
image={file}
|
||||
width={250}
|
||||
height={250}
|
||||
border={25}
|
||||
borderRadius={borderRadius}
|
||||
color={ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]} // RGBA
|
||||
scale={zoom}
|
||||
rotate={0}
|
||||
/>
|
||||
</AvatarEditorContainer>
|
||||
<RangeInput
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.01"
|
||||
defaultValue="1"
|
||||
onChange={handleZoom}
|
||||
/>
|
||||
<br />
|
||||
<ButtonLarge fullwidth onClick={handleUpload} disabled={isUploading}>
|
||||
{isUploading ? `${t(`Uploading`)}…` : submitText}
|
||||
</ButtonLarge>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const AvatarEditorContainer = styled(Flex)`
|
||||
margin-bottom: 30px;
|
||||
@@ -191,4 +214,4 @@ const RangeInput = styled.input`
|
||||
}
|
||||
`;
|
||||
|
||||
export default withStores(ImageUpload);
|
||||
export default observer(ImageUpload);
|
||||
|
||||
@@ -5,12 +5,12 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Spinner from "@shared/components/Spinner";
|
||||
import { ImportState } from "@shared/types";
|
||||
import Import from "~/models/Import";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -2,6 +2,7 @@ import compact from "lodash/compact";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Text from "@shared/components/Text";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import UserMenu from "~/menus/UserMenu";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
|
||||
@@ -27,6 +29,7 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
|
||||
export function MembersTable({ canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const columns = React.useMemo<TableColumn<User>[]>(
|
||||
() =>
|
||||
@@ -38,13 +41,20 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
accessor: (user) => user.name,
|
||||
component: (user) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
|
||||
{currentUser.id === user.id && `(${t("You")})`}
|
||||
<Avatar model={user} size={AvatarSize.Large} />{" "}
|
||||
<Flex column>
|
||||
<Text>
|
||||
{user.name} {currentUser.id === user.id && `(${t("You")})`}
|
||||
</Text>
|
||||
{isMobile && canManage && (
|
||||
<Text type="tertiary">{user.email}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
),
|
||||
width: "4fr",
|
||||
},
|
||||
canManage
|
||||
canManage && !isMobile
|
||||
? {
|
||||
type: "data",
|
||||
id: "email",
|
||||
@@ -54,17 +64,19 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
width: "4fr",
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
type: "data",
|
||||
id: "lastActiveAt",
|
||||
header: t("Last active"),
|
||||
accessor: (user) => user.lastActiveAt,
|
||||
component: (user) =>
|
||||
user.lastActiveAt ? (
|
||||
<Time dateTime={user.lastActiveAt} addSuffix />
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
isMobile
|
||||
? undefined
|
||||
: {
|
||||
type: "data",
|
||||
id: "lastActiveAt",
|
||||
header: t("Last active"),
|
||||
accessor: (user) => user.lastActiveAt,
|
||||
component: (user) =>
|
||||
user.lastActiveAt ? (
|
||||
<Time dateTime={user.lastActiveAt} addSuffix />
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "role",
|
||||
@@ -85,7 +97,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
|
||||
</Badges>
|
||||
),
|
||||
width: "2fr",
|
||||
width: "80px",
|
||||
},
|
||||
canManage
|
||||
? {
|
||||
@@ -97,7 +109,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
}
|
||||
: undefined,
|
||||
]),
|
||||
[t, currentUser, canManage]
|
||||
[t, currentUser, canManage, isMobile]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,13 @@ class IntegrationsStore extends Store<Integration> {
|
||||
(integration) => integration.service === IntegrationService.GitHub
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get linear(): Integration<IntegrationType.Embed>[] {
|
||||
return this.orderedData.filter(
|
||||
(integration) => integration.service === IntegrationService.Linear
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default IntegrationsStore;
|
||||
|
||||
@@ -99,7 +99,14 @@ export default abstract class Store<T extends Model> {
|
||||
const normalized = deburr((query ?? "").trim().toLocaleLowerCase());
|
||||
|
||||
if (!normalized) {
|
||||
return this.orderedData.slice(0, options?.maxResults);
|
||||
return this.orderedData
|
||||
.filter((item) => {
|
||||
if ("deletedAt" in item && item.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, options?.maxResults);
|
||||
}
|
||||
|
||||
return this.orderedData
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationSettings,
|
||||
IntegrationType,
|
||||
MentionType,
|
||||
} from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
|
||||
export const isURLMentionable = ({
|
||||
url,
|
||||
integration,
|
||||
}: {
|
||||
url: URL;
|
||||
integration: Integration;
|
||||
}): boolean => {
|
||||
const { hostname, pathname } = url;
|
||||
const pathParts = pathname.split("/");
|
||||
|
||||
switch (integration.service) {
|
||||
case IntegrationService.GitHub: {
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
|
||||
return (
|
||||
hostname === "github.com" &&
|
||||
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
|
||||
);
|
||||
}
|
||||
|
||||
case IntegrationService.Linear: {
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
|
||||
return (
|
||||
hostname === "linear.app" &&
|
||||
settings.linear?.workspace.key === pathParts[1] // ensure installed workspace key matches with the provided url.
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const determineMentionType = ({
|
||||
url,
|
||||
integration,
|
||||
}: {
|
||||
url: URL;
|
||||
integration: Integration;
|
||||
}): MentionType | undefined => {
|
||||
const { pathname } = url;
|
||||
const pathParts = pathname.split("/");
|
||||
|
||||
switch (integration.service) {
|
||||
case IntegrationService.GitHub: {
|
||||
const type = pathParts[3];
|
||||
return type === "pull"
|
||||
? MentionType.PullRequest
|
||||
: type === "issues"
|
||||
? MentionType.Issue
|
||||
: undefined;
|
||||
}
|
||||
|
||||
case IntegrationService.Linear: {
|
||||
const type = pathParts[2];
|
||||
return type === "issue" ? MentionType.Issue : undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
+19
-17
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.782.0",
|
||||
"@aws-sdk/lib-storage": "3.782.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.782.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.782.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.782.0",
|
||||
"@aws-sdk/client-s3": "3.787.0",
|
||||
"@aws-sdk/lib-storage": "3.787.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.787.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.787.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.787.0",
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
@@ -79,12 +79,13 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@notionhq/client": "^2.2.16",
|
||||
"@linear/sdk": "^39.0.0",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.2",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.0",
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.120.3",
|
||||
"@sentry/react": "^7.120.3",
|
||||
@@ -114,7 +115,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.40.0",
|
||||
"diff": "^5.2.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"dotenv": "^16.5.0",
|
||||
"email-providers": "^1.14.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
@@ -179,7 +180,7 @@
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"polished": "^4.3.1",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-commands": "^1.7.0",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
@@ -187,11 +188,11 @@
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.2",
|
||||
"prosemirror-model": "^1.25.0",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.38.1",
|
||||
"prosemirror-view": "^1.39.1",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.1",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
@@ -210,7 +211,7 @@
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.2.2",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.21",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-waypoint": "^10.3.0",
|
||||
"react-window": "^1.8.11",
|
||||
"reakit": "^1.3.11",
|
||||
@@ -219,7 +220,7 @@
|
||||
"refractor": "^3.6.0",
|
||||
"request-filtering-agent": "^1.1.2",
|
||||
"resolve-path": "^1.4.0",
|
||||
"rfc6902": "^5.1.1",
|
||||
"rfc6902": "^5.1.2",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"semver": "^7.7.1",
|
||||
@@ -248,8 +249,8 @@
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^5.4.17",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"vite": "^5.4.18",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
@@ -362,7 +363,7 @@
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.0.3",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
@@ -377,5 +378,6 @@
|
||||
"rollup": "^4.5.1",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.83.0"
|
||||
"version": "0.83.0",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Endpoints } from "@octokit/types";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { Integration } from "@server/models";
|
||||
import { BaseIssueProvider } from "@server/utils/BaseIssueProvider";
|
||||
import { GitHub } from "./github";
|
||||
|
||||
// This is needed to handle Octokit paginate response type mismatch.
|
||||
type ReposForInstallation =
|
||||
Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"];
|
||||
|
||||
export class GitHubIssueProvider extends BaseIssueProvider {
|
||||
constructor() {
|
||||
super(IntegrationService.GitHub);
|
||||
}
|
||||
|
||||
async fetchSources(
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
): Promise<IssueSource[]> {
|
||||
const client = await GitHub.authenticateAsInstallation(
|
||||
integration.settings.github!.installation.id
|
||||
);
|
||||
|
||||
const sources: IssueSource[] = [];
|
||||
|
||||
for await (const response of client.requestRepos()) {
|
||||
const repos = response.data as unknown as ReposForInstallation;
|
||||
sources.push(
|
||||
...repos.map<IssueSource>((repo) => ({
|
||||
id: String(repo.id),
|
||||
name: repo.name,
|
||||
owner: { id: String(repo.owner.id), name: repo.owner.login },
|
||||
service: IntegrationService.GitHub,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Router from "koa-router";
|
||||
import find from "lodash/find";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
@@ -88,30 +89,27 @@ router.get(
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Integration.create(
|
||||
{
|
||||
service: IntegrationService.GitHub,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
github: {
|
||||
installation: {
|
||||
id: installationId!,
|
||||
account: {
|
||||
id: installation.account?.id,
|
||||
name:
|
||||
// @ts-expect-error Property 'login' does not exist on type
|
||||
installation.account?.login,
|
||||
avatarUrl: installation.account?.avatar_url,
|
||||
},
|
||||
await Integration.createWithCtx(createContext({ user, transaction }), {
|
||||
service: IntegrationService.GitHub,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
github: {
|
||||
installation: {
|
||||
id: installationId!,
|
||||
account: {
|
||||
id: installation.account?.id,
|
||||
name:
|
||||
// @ts-expect-error Property 'login' does not exist on type
|
||||
installation.account?.login,
|
||||
avatarUrl: installation.account?.avatar_url,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
ctx.redirect(GitHubUtils.url);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,36 +4,55 @@ import {
|
||||
type OAuthWebFlowAuthOptions,
|
||||
type InstallationAuthOptions,
|
||||
} from "@octokit/auth-app";
|
||||
import { Endpoints, OctokitResponse } from "@octokit/types";
|
||||
import { Octokit } from "octokit";
|
||||
import pluralize from "pluralize";
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationType,
|
||||
JSONObject,
|
||||
UnfurlResourceType,
|
||||
} from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration, User } from "@server/models";
|
||||
import { UnfurlSignature } from "@server/types";
|
||||
import { UnfurlIssueAndPR, UnfurlSignature } from "@server/types";
|
||||
import { GitHubUtils } from "../shared/GitHubUtils";
|
||||
import env from "./env";
|
||||
|
||||
type PR =
|
||||
Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"];
|
||||
type Issue =
|
||||
Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"];
|
||||
|
||||
const requestPlugin = (octokit: Octokit) => ({
|
||||
requestPR: async (params: ReturnType<typeof GitHub.parseUrl>) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/pulls/{id}`, {
|
||||
owner: params?.owner,
|
||||
repo: params?.repo,
|
||||
id: params?.id,
|
||||
requestRepos: () =>
|
||||
octokit.paginate.iterator(
|
||||
octokit.rest.apps.listReposAccessibleToInstallation,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}
|
||||
),
|
||||
|
||||
requestPR: async (params: NonNullable<ReturnType<typeof GitHub.parseUrl>>) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, {
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
pull_number: params.id,
|
||||
headers: {
|
||||
Accept: "application/vnd.github.text+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}),
|
||||
|
||||
requestIssue: async (params: ReturnType<typeof GitHub.parseUrl>) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/issues/{id}`, {
|
||||
owner: params?.owner,
|
||||
repo: params?.repo,
|
||||
id: params?.id,
|
||||
requestIssue: async (
|
||||
params: NonNullable<ReturnType<typeof GitHub.parseUrl>>
|
||||
) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, {
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
issue_number: params.id,
|
||||
headers: {
|
||||
Accept: "application/vnd.github.text+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
@@ -56,14 +75,14 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
*/
|
||||
requestResource: async function requestResource(
|
||||
resource: ReturnType<typeof GitHub.parseUrl>
|
||||
): Promise<{ data?: JSONObject }> {
|
||||
): Promise<OctokitResponse<Issue | PR> | undefined> {
|
||||
switch (resource?.type) {
|
||||
case UnfurlResourceType.PR:
|
||||
return this.requestPR(resource);
|
||||
return this.requestPR(resource) as Promise<OctokitResponse<PR>>;
|
||||
case UnfurlResourceType.Issue:
|
||||
return this.requestIssue(resource);
|
||||
return this.requestIssue(resource) as Promise<OctokitResponse<Issue>>;
|
||||
default:
|
||||
return { data: undefined };
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -91,7 +110,10 @@ export class GitHub {
|
||||
|
||||
private static appOctokit: Octokit;
|
||||
|
||||
private static supportedResources = Object.values(UnfurlResourceType);
|
||||
private static supportedResources = [
|
||||
UnfurlResourceType.Issue,
|
||||
UnfurlResourceType.PR,
|
||||
];
|
||||
|
||||
/**
|
||||
* Parses a given URL and returns resource identifiers for GitHub specific URLs
|
||||
@@ -111,7 +133,7 @@ export class GitHub {
|
||||
const type = parts[3]
|
||||
? (pluralize.singular(parts[3]) as UnfurlResourceType)
|
||||
: undefined;
|
||||
const id = parts[4];
|
||||
const id = Number(parts[4]);
|
||||
|
||||
if (!type || !GitHub.supportedResources.includes(type)) {
|
||||
return;
|
||||
@@ -204,14 +226,63 @@ export class GitHub {
|
||||
const client = await GitHub.authenticateAsInstallation(
|
||||
integration.settings.github!.installation.id
|
||||
);
|
||||
const { data } = await client.requestResource(resource);
|
||||
if (!data) {
|
||||
return;
|
||||
|
||||
const res = await client.requestResource(resource);
|
||||
if (!res) {
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
return { ...data, type: resource.type };
|
||||
|
||||
return GitHub.transformData(res.data, resource.type);
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch resource from GitHub", err);
|
||||
return;
|
||||
return { error: err.message || "Unknown error" };
|
||||
}
|
||||
};
|
||||
|
||||
private static transformData(data: Issue | PR, type: UnfurlResourceType) {
|
||||
if (type === UnfurlResourceType.Issue) {
|
||||
const issue = data as Issue;
|
||||
return {
|
||||
type: UnfurlResourceType.Issue,
|
||||
url: issue.html_url,
|
||||
id: `#${issue.number}`,
|
||||
title: issue.title,
|
||||
description: issue.body_text ?? null,
|
||||
author: {
|
||||
name: issue.user?.login ?? "",
|
||||
avatarUrl: issue.user?.avatar_url ?? "",
|
||||
},
|
||||
labels: issue.labels.map((label: { name: string; color: string }) => ({
|
||||
name: label.name,
|
||||
color: `#${label.color}`,
|
||||
})),
|
||||
state: {
|
||||
name: issue.state,
|
||||
color: GitHubUtils.getColorForStatus(issue.state),
|
||||
},
|
||||
createdAt: issue.created_at,
|
||||
transformed_unfurl: true,
|
||||
} satisfies UnfurlIssueAndPR;
|
||||
}
|
||||
|
||||
const pr = data as PR;
|
||||
const prState = pr.merged ? "merged" : pr.state;
|
||||
return {
|
||||
type: UnfurlResourceType.PR,
|
||||
url: pr.html_url,
|
||||
id: `#${pr.number}`,
|
||||
title: pr.title,
|
||||
description: pr.body,
|
||||
author: {
|
||||
name: pr.user.login,
|
||||
avatarUrl: pr.user.avatar_url,
|
||||
},
|
||||
state: {
|
||||
name: prState,
|
||||
color: GitHubUtils.getColorForStatus(prState),
|
||||
},
|
||||
createdAt: pr.created_at,
|
||||
transformed_unfurl: true,
|
||||
} satisfies UnfurlIssueAndPR;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { PluginManager, Hook } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import { GitHubIssueProvider } from "./GitHubIssueProvider";
|
||||
import router from "./api/github";
|
||||
import env from "./env";
|
||||
import { GitHub } from "./github";
|
||||
@@ -20,6 +21,10 @@ if (enabled) {
|
||||
type: Hook.API,
|
||||
value: router,
|
||||
},
|
||||
{
|
||||
type: Hook.IssueProvider,
|
||||
value: new GitHubIssueProvider(),
|
||||
},
|
||||
{
|
||||
type: Hook.UnfurlProvider,
|
||||
value: { unfurl: GitHub.unfurl, cacheExpiry: Minute.seconds },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JSONObject, UnfurlResourceType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { UnfurlSignature } from "@server/types";
|
||||
import { UnfurlError, UnfurlSignature } from "@server/types";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import env from "./env";
|
||||
|
||||
@@ -10,7 +10,7 @@ class Iframely {
|
||||
public static async requestResource(
|
||||
url: string,
|
||||
type = "oembed"
|
||||
): Promise<JSONObject | undefined> {
|
||||
): Promise<JSONObject | UnfurlError> {
|
||||
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
|
||||
|
||||
// Cloud Iframely requires /api path, while self-hosted does not.
|
||||
@@ -25,7 +25,7 @@ class Iframely {
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
|
||||
return;
|
||||
return { error: err.message || "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ class Iframely {
|
||||
*/
|
||||
public static unfurl: UnfurlSignature = async (url: string) => {
|
||||
const data = await Iframely.requestResource(url);
|
||||
return { ...data, type: UnfurlResourceType.OEmbed };
|
||||
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
|
||||
? ({ error: data.error } as UnfurlError)
|
||||
: { ...data, type: UnfurlResourceType.OEmbed };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
fill?: string;
|
||||
};
|
||||
|
||||
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
<path
|
||||
d="M3.93091 12.8481C4.11753 14.6298 4.89358 16.3615 6.25902 17.727C7.62446 19.0923 9.35612 19.8684 11.1378 20.0551L3.93091 12.8481Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M3.89929 11.5437L12.4422 20.0865C13.1671 20.0459 13.8876 19.9084 14.5827 19.6738L4.31194 9.4032C4.07743 10.0982 3.93988 10.8187 3.89929 11.5437Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M4.67981 8.49828L15.4875 19.306C16.0482 19.0374 16.5845 18.7005 17.0837 18.2953L5.6905 6.90222C5.28537 7.40142 4.94847 7.93759 4.67981 8.49828Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M6.29602 6.23494C9.46213 3.10852 14.5632 3.12079 17.7141 6.27173C20.865 9.42266 20.8774 14.5237 17.7509 17.6898L6.29602 6.23494Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import List from "~/components/List";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Notice from "~/components/Notice";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import env from "~/env";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import LinearIcon from "./Icon";
|
||||
import { LinearConnectButton } from "./components/LinearButton";
|
||||
|
||||
function Linear() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const query = useQuery();
|
||||
const error = query.get("error");
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchAll({
|
||||
service: IntegrationService.Linear,
|
||||
withRelations: true,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
return (
|
||||
<Scene title="Linear" icon={<LinearIcon />}>
|
||||
<Heading>Linear</Heading>
|
||||
|
||||
{error === "access_denied" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Whoops, you need to accept the permissions in Linear to connect{" "}
|
||||
{{ appName }} to your workspace. Try again?
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{error === "unauthenticated" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Something went wrong while authenticating your request. Please try
|
||||
logging in again.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{env.LINEAR_CLIENT_ID ? (
|
||||
<>
|
||||
<Text as="p">
|
||||
<Trans>
|
||||
Enable previews of Linear issues in documents by connecting a
|
||||
Linear workspace to {appName}.
|
||||
</Trans>
|
||||
</Text>
|
||||
{integrations.linear.length ? (
|
||||
<>
|
||||
<Heading as="h2">
|
||||
<Flex justify="space-between" auto>
|
||||
{t("Connected")}
|
||||
<LinearConnectButton icon={<PlusIcon />} />
|
||||
</Flex>
|
||||
</Heading>
|
||||
<List>
|
||||
{integrations.linear.map((integration) => {
|
||||
const linearWorkspace =
|
||||
integration.settings?.linear?.workspace;
|
||||
const integrationCreatedBy = integration.user
|
||||
? integration.user.name
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={linearWorkspace?.id}
|
||||
small
|
||||
title={linearWorkspace?.name}
|
||||
subtitle={
|
||||
integrationCreatedBy ? (
|
||||
<>
|
||||
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
|
||||
·{" "}
|
||||
<Time
|
||||
dateTime={integration.createdAt}
|
||||
relative={false}
|
||||
format={{ en_US: "MMMM d, y" }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<PlaceholderText />
|
||||
)
|
||||
}
|
||||
image={
|
||||
<TeamLogo
|
||||
src={linearWorkspace?.logoUrl}
|
||||
size={AvatarSize.Large}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<ConnectedButton
|
||||
onClick={integration.delete}
|
||||
confirmationMessage={t(
|
||||
"Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
<LinearConnectButton icon={<LinearIcon />} />
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The Linear integration is currently disabled. Please set the
|
||||
associated environment variables and restart the server to enable
|
||||
the integration.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Linear);
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Button, { type Props } from "~/components/Button";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { redirectTo } from "~/utils/urls";
|
||||
import { LinearUtils } from "../../shared/LinearUtils";
|
||||
|
||||
export function LinearConnectButton(props: Props<HTMLButtonElement>) {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
redirectTo(LinearUtils.authUrl({ state: { teamId: team.id } }))
|
||||
}
|
||||
neutral
|
||||
{...props}
|
||||
>
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "linear",
|
||||
"name": "Linear",
|
||||
"priority": 15,
|
||||
"description": "Adds a Linear integration for link unfurling and converting links to mentions.",
|
||||
"after": "github"
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Router from "koa-router";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { IntegrationAuthentication, Integration, Team } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { Linear } from "../linear";
|
||||
import * as T from "./schema";
|
||||
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get(
|
||||
"linear.callback",
|
||||
auth({
|
||||
optional: true,
|
||||
}),
|
||||
validate(T.LinearCallbackSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.LinearCallbackReq>) => {
|
||||
const { code, state, error } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let parsedState;
|
||||
try {
|
||||
parsedState = LinearUtils.parseState(state);
|
||||
} catch {
|
||||
ctx.redirect(LinearUtils.errorUrl("invalid_state"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { teamId } = parsedState;
|
||||
|
||||
// this code block accounts for the root domain being unable to
|
||||
// access authentication for subdomains. We must forward to the appropriate
|
||||
// subdomain to complete the oauth flow
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
LinearUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
|
||||
return ctx.redirect(LinearUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(LinearUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(LinearUtils.errorUrl(error));
|
||||
return;
|
||||
}
|
||||
|
||||
// validation middleware ensures that code is non-null at this point.
|
||||
const oauth = await Linear.oauthAccess(code!);
|
||||
const workspace = await Linear.getInstalledWorkspace(oauth.access_token);
|
||||
|
||||
const authentication = await IntegrationAuthentication.create(
|
||||
{
|
||||
service: IntegrationService.Linear,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: oauth.access_token,
|
||||
scopes: oauth.scope.split(" "),
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Integration.create<Integration<IntegrationType.Embed>>(
|
||||
{
|
||||
service: IntegrationService.Linear,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
linear: {
|
||||
workspace: {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
key: workspace.urlKey,
|
||||
logoUrl: workspace.logoUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.redirect(LinearUtils.successUrl());
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,17 @@
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
export const LinearCallbackSchema = BaseSchema.extend({
|
||||
query: z
|
||||
.object({
|
||||
code: z.string().nullish(),
|
||||
state: z.string(),
|
||||
error: z.string().nullish(),
|
||||
})
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export type LinearCallbackReq = z.infer<typeof LinearCallbackSchema>;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsOptional } from "class-validator";
|
||||
import { Environment } from "@server/env";
|
||||
import { Public } from "@server/utils/decorators/Public";
|
||||
import environment from "@server/utils/environment";
|
||||
import { CannotUseWithout } from "@server/utils/validators";
|
||||
|
||||
class LinearPluginEnvironment extends Environment {
|
||||
/**
|
||||
* Linear OAuth2 app client id. To enable integration with Linear.
|
||||
*/
|
||||
@Public
|
||||
@IsOptional()
|
||||
public LINEAR_CLIENT_ID = this.toOptionalString(environment.LINEAR_CLIENT_ID);
|
||||
|
||||
/**
|
||||
* Linear OAuth2 app client secret. To enable integration with Linear.
|
||||
*/
|
||||
@IsOptional()
|
||||
@CannotUseWithout("LINEAR_CLIENT_ID")
|
||||
public LINEAR_CLIENT_SECRET = this.toOptionalString(
|
||||
environment.LINEAR_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
export default new LinearPluginEnvironment();
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import router from "./api/linear";
|
||||
import env from "./env";
|
||||
import { Linear } from "./linear";
|
||||
import { uninstall } from "./uninstall";
|
||||
|
||||
const enabled = !!env.LINEAR_CLIENT_ID && !!env.LINEAR_CLIENT_SECRET;
|
||||
|
||||
if (enabled) {
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.API,
|
||||
value: router,
|
||||
},
|
||||
{
|
||||
type: Hook.UnfurlProvider,
|
||||
value: { unfurl: Linear.unfurl, cacheExpiry: Minute.seconds },
|
||||
},
|
||||
{
|
||||
type: Hook.Uninstall,
|
||||
value: uninstall,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Issue, LinearClient, WorkflowState } from "@linear/sdk";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationType,
|
||||
UnfurlResourceType,
|
||||
} from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
import User from "@server/models/User";
|
||||
import { UnfurlIssueAndPR, UnfurlSignature } from "@server/types";
|
||||
import { LinearUtils } from "../shared/LinearUtils";
|
||||
import env from "./env";
|
||||
|
||||
const AccessTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string(),
|
||||
expires_in: z.number(),
|
||||
scope: z.string(),
|
||||
});
|
||||
|
||||
export class Linear {
|
||||
private static supportedUnfurls = [UnfurlResourceType.Issue];
|
||||
|
||||
static async oauthAccess(code: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set("code", code);
|
||||
body.set("client_id", env.LINEAR_CLIENT_ID!);
|
||||
body.set("client_secret", env.LINEAR_CLIENT_SECRET!);
|
||||
body.set("redirect_uri", LinearUtils.callbackUrl());
|
||||
body.set("grant_type", "authorization_code");
|
||||
|
||||
const res = await fetch(LinearUtils.tokenUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
return AccessTokenResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
static async revokeAccess(accessToken: string) {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
};
|
||||
|
||||
await fetch(LinearUtils.revokeUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
static async getInstalledWorkspace(accessToken: string) {
|
||||
const client = new LinearClient({ accessToken });
|
||||
return client.organization;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param url Linear resource url
|
||||
* @param actor User attempting to unfurl resource url
|
||||
* @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);
|
||||
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integration = (await Integration.scope("withAuthentication").findOne({
|
||||
where: {
|
||||
service: IntegrationService.Linear,
|
||||
teamId: actor.teamId,
|
||||
"settings.linear.workspace.key": resource.workspaceKey,
|
||||
},
|
||||
})) as Integration<IntegrationType.Embed>;
|
||||
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new LinearClient({
|
||||
accessToken: integration.authentication.token,
|
||||
});
|
||||
const issue = await client.issue(resource.id);
|
||||
|
||||
if (!issue) {
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
const [author, state, labels] = await Promise.all([
|
||||
issue.creator,
|
||||
issue.state,
|
||||
issue.paginate(issue.labels, {}),
|
||||
]);
|
||||
|
||||
if (!author || !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,
|
||||
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(),
|
||||
transformed_unfurl: true,
|
||||
} satisfies UnfurlIssueAndPR;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch resource from Linear", err);
|
||||
return { error: err.message || "Unknown error" };
|
||||
}
|
||||
};
|
||||
|
||||
private static async completionPercentage(
|
||||
client: LinearClient,
|
||||
issue: Issue,
|
||||
state: WorkflowState
|
||||
) {
|
||||
const defaultCompletionPercentage = 0.5; // fallback when we cannot determine actual value.
|
||||
|
||||
if (state.type !== "started") {
|
||||
return defaultCompletionPercentage;
|
||||
}
|
||||
|
||||
const team = await issue.team;
|
||||
if (!team) {
|
||||
return defaultCompletionPercentage;
|
||||
}
|
||||
|
||||
const allStates = await client.paginate(client.workflowStates, {
|
||||
filter: {
|
||||
team: { id: { eq: team.id } },
|
||||
type: { eq: "started" },
|
||||
},
|
||||
});
|
||||
const states = sortBy(
|
||||
allStates.map((s) => ({
|
||||
name: s.name,
|
||||
position: s.position,
|
||||
})),
|
||||
(s) => s.position
|
||||
);
|
||||
|
||||
const idx = states.findIndex((s) => s.name === state.name);
|
||||
return idx !== -1
|
||||
? (idx + 1) / (states.length + 1) // add 1 to states for the "done" state.
|
||||
: defaultCompletionPercentage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a given URL and returns resource identifiers for Linear specific URLs
|
||||
*
|
||||
* @param url URL to parse
|
||||
* @returns {object} Containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
|
||||
*/
|
||||
private static parseUrl(url: string) {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== "linear.app") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
const workspaceKey = parts[1];
|
||||
const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined;
|
||||
const id = parts[3];
|
||||
const name = parts[4];
|
||||
|
||||
if (!type || !Linear.supportedUnfurls.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { workspaceKey, type, id, name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
import { Linear } from "./linear";
|
||||
|
||||
export async function uninstall(
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
) {
|
||||
if (integration.service !== IntegrationService.Linear) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authentication = await integration.$get("authentication");
|
||||
|
||||
if (!authentication) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Linear.revokeAccess(authentication.token);
|
||||
} catch (err) {
|
||||
// suppress error since this is a best-effort operation.
|
||||
Logger.error("Failed to revoke Linear access token", err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import queryString from "query-string";
|
||||
import env from "@shared/env";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
|
||||
export type OAuthState = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export class LinearUtils {
|
||||
private static oauthScopes = "read,issues:create";
|
||||
|
||||
public static tokenUrl = "https://api.linear.app/oauth/token";
|
||||
public static revokeUrl = "https://api.linear.app/oauth/revoke";
|
||||
private static authBaseUrl = "https://linear.app/oauth/authorize";
|
||||
|
||||
private static settingsUrl = integrationSettingsPath("linear");
|
||||
|
||||
static parseState(state: string): OAuthState {
|
||||
return JSON.parse(state);
|
||||
}
|
||||
|
||||
static successUrl() {
|
||||
return this.settingsUrl;
|
||||
}
|
||||
|
||||
static errorUrl(error: string) {
|
||||
return `${this.settingsUrl}?error=${error}`;
|
||||
}
|
||||
|
||||
static callbackUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: `${env.URL}`,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
return params
|
||||
? `${baseUrl}/api/linear.callback?${params}`
|
||||
: `${baseUrl}/api/linear.callback`;
|
||||
}
|
||||
|
||||
static authUrl({ state }: { state: OAuthState }) {
|
||||
const params = {
|
||||
client_id: env.LINEAR_CLIENT_ID,
|
||||
redirect_uri: this.callbackUrl(),
|
||||
state: JSON.stringify(state),
|
||||
scope: this.oauthScopes,
|
||||
response_type: "code",
|
||||
prompt: "consent",
|
||||
actor: "application",
|
||||
};
|
||||
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,13 @@ import {
|
||||
RichTextItemResponse,
|
||||
} from "@notionhq/client/build/src/api-endpoints";
|
||||
import { RateLimit } from "async-sema";
|
||||
import emojiRegex from "emoji-regex";
|
||||
import compact from "lodash/compact";
|
||||
import truncate from "lodash/truncate";
|
||||
import { z } from "zod";
|
||||
import { Second } from "@shared/utils/time";
|
||||
import { isUrl } from "@shared/utils/urls";
|
||||
import { CollectionValidation, DocumentValidation } from "@shared/validations";
|
||||
import { NotionUtils } from "../shared/NotionUtils";
|
||||
import { Block, Page, PageType } from "../shared/types";
|
||||
import env from "./env";
|
||||
@@ -37,7 +41,16 @@ const AccessTokenResponseSchema = z.object({
|
||||
bot_id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
workspace_name: z.string().nullish(),
|
||||
workspace_icon: z.string().url().nullish(),
|
||||
workspace_icon: z
|
||||
.string()
|
||||
.nullish()
|
||||
.transform((val) => {
|
||||
const emojiRegexp = emojiRegex();
|
||||
if (val && (isUrl(val) || emojiRegexp.test(val))) {
|
||||
return val;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
});
|
||||
|
||||
export class NotionClient {
|
||||
@@ -105,7 +118,9 @@ export class NotionClient {
|
||||
pages.push({
|
||||
type: item.object === "page" ? PageType.Page : PageType.Database,
|
||||
id: item.id,
|
||||
name: this.parseTitle(item),
|
||||
name: this.parseTitle(item, {
|
||||
maxLength: CollectionValidation.maxNameLength,
|
||||
}),
|
||||
emoji: this.parseEmoji(item),
|
||||
});
|
||||
}
|
||||
@@ -118,14 +133,22 @@ export class NotionClient {
|
||||
return pages;
|
||||
}
|
||||
|
||||
async fetchPage(pageId: string) {
|
||||
const pageInfo = await this.fetchPageInfo(pageId);
|
||||
async fetchPage(
|
||||
pageId: string,
|
||||
{ titleMaxLength }: { titleMaxLength: number }
|
||||
) {
|
||||
const pageInfo = await this.fetchPageInfo(pageId, { titleMaxLength });
|
||||
const blocks = await this.fetchBlockChildren(pageId);
|
||||
return { ...pageInfo, blocks };
|
||||
}
|
||||
|
||||
async fetchDatabase(databaseId: string) {
|
||||
const databaseInfo = await this.fetchDatabaseInfo(databaseId);
|
||||
async fetchDatabase(
|
||||
databaseId: string,
|
||||
{ titleMaxLength }: { titleMaxLength: number }
|
||||
) {
|
||||
const databaseInfo = await this.fetchDatabaseInfo(databaseId, {
|
||||
titleMaxLength,
|
||||
});
|
||||
const pages = await this.queryDatabase(databaseId);
|
||||
return { ...databaseInfo, pages };
|
||||
}
|
||||
@@ -151,7 +174,6 @@ export class NotionClient {
|
||||
cursor = response.next_cursor ?? undefined;
|
||||
}
|
||||
|
||||
// Recursive fetch when direct children have their own children.
|
||||
await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
if (
|
||||
@@ -192,7 +214,9 @@ export class NotionClient {
|
||||
return {
|
||||
type: PageType.Page,
|
||||
id: item.id,
|
||||
name: this.parseTitle(item),
|
||||
name: this.parseTitle(item, {
|
||||
maxLength: DocumentValidation.maxTitleLength,
|
||||
}),
|
||||
emoji: this.parseEmoji(item),
|
||||
};
|
||||
})
|
||||
@@ -207,7 +231,10 @@ export class NotionClient {
|
||||
return pages;
|
||||
}
|
||||
|
||||
private async fetchPageInfo(pageId: string): Promise<PageInfo> {
|
||||
private async fetchPageInfo(
|
||||
pageId: string,
|
||||
{ titleMaxLength }: { titleMaxLength: number }
|
||||
): Promise<PageInfo> {
|
||||
await this.limiter();
|
||||
const page = (await this.client.pages.retrieve({
|
||||
page_id: pageId,
|
||||
@@ -216,7 +243,9 @@ export class NotionClient {
|
||||
const author = await this.fetchUsername(page.created_by.id);
|
||||
|
||||
return {
|
||||
title: this.parseTitle(page),
|
||||
title: this.parseTitle(page, {
|
||||
maxLength: titleMaxLength,
|
||||
}),
|
||||
emoji: this.parseEmoji(page),
|
||||
author: author ?? undefined,
|
||||
createdAt: !page.created_time ? undefined : new Date(page.created_time),
|
||||
@@ -226,7 +255,10 @@ export class NotionClient {
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchDatabaseInfo(databaseId: string): Promise<PageInfo> {
|
||||
private async fetchDatabaseInfo(
|
||||
databaseId: string,
|
||||
{ titleMaxLength }: { titleMaxLength: number }
|
||||
): Promise<PageInfo> {
|
||||
await this.limiter();
|
||||
const database = (await this.client.databases.retrieve({
|
||||
database_id: databaseId,
|
||||
@@ -235,7 +267,9 @@ export class NotionClient {
|
||||
const author = await this.fetchUsername(database.created_by.id);
|
||||
|
||||
return {
|
||||
title: this.parseTitle(database),
|
||||
title: this.parseTitle(database, {
|
||||
maxLength: titleMaxLength,
|
||||
}),
|
||||
emoji: this.parseEmoji(database),
|
||||
author: author ?? undefined,
|
||||
createdAt: !database.created_time
|
||||
@@ -256,12 +290,12 @@ export class NotionClient {
|
||||
return user.name;
|
||||
}
|
||||
|
||||
// bot belongs to a user, get the user's name.
|
||||
// bot belongs to a user, get the user's name
|
||||
if (user.bot.owner.type === "user" && isFullUser(user.bot.owner.user)) {
|
||||
return user.bot.owner.user.name;
|
||||
}
|
||||
|
||||
// bot belongs to a workspace, fallback to bot's name.
|
||||
// bot belongs to a workspace, fallback to bot's name
|
||||
return user.name;
|
||||
} catch (error) {
|
||||
// Handle the case where a user can't be found
|
||||
@@ -275,7 +309,12 @@ export class NotionClient {
|
||||
}
|
||||
}
|
||||
|
||||
private parseTitle(item: PageObjectResponse | DatabaseObjectResponse) {
|
||||
private parseTitle(
|
||||
item: PageObjectResponse | DatabaseObjectResponse,
|
||||
{
|
||||
maxLength = DocumentValidation.maxTitleLength,
|
||||
}: { maxLength?: number } = {}
|
||||
) {
|
||||
let richTexts: RichTextItemResponse[];
|
||||
|
||||
if (item.object === "page") {
|
||||
@@ -287,7 +326,10 @@ export class NotionClient {
|
||||
richTexts = item.title;
|
||||
}
|
||||
|
||||
return richTexts.map((richText) => richText.plain_text).join("");
|
||||
const title = richTexts.map((richText) => richText.plain_text).join("");
|
||||
|
||||
// Truncate title to fit within validation limits
|
||||
return truncate(title, { length: maxLength });
|
||||
}
|
||||
|
||||
private parseEmoji(item: PageObjectResponse | DatabaseObjectResponse) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { APIResponseError, APIErrorCode } from "@notionhq/client";
|
||||
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
|
||||
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { CollectionValidation, DocumentValidation } from "@shared/validations";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
import ImportTask from "@server/models/ImportTask";
|
||||
@@ -95,12 +96,17 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
client: NotionClient;
|
||||
}): Promise<ParsePageOutput | null> {
|
||||
const collectionExternalId = item.collectionExternalId ?? item.externalId;
|
||||
const titleMaxLength =
|
||||
item.externalId === collectionExternalId // This means it's a root page which will be imported as a collection
|
||||
? CollectionValidation.maxNameLength
|
||||
: DocumentValidation.maxTitleLength;
|
||||
|
||||
try {
|
||||
// Convert Notion database to an empty page with "pages in database" as its children.
|
||||
if (item.type === PageType.Database) {
|
||||
const { pages, ...databaseInfo } = await client.fetchDatabase(
|
||||
item.externalId
|
||||
item.externalId,
|
||||
{ titleMaxLength }
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -115,7 +121,9 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
};
|
||||
}
|
||||
|
||||
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
|
||||
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId, {
|
||||
titleMaxLength,
|
||||
});
|
||||
|
||||
return {
|
||||
...pageInfo,
|
||||
|
||||
@@ -277,6 +277,35 @@ describe("#files.get", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok when avatar is requested using key", async () => {
|
||||
const user = await buildUser();
|
||||
const key = path.join("avatars", user.id, uuidV4());
|
||||
const attachment = await buildAttachment({
|
||||
key,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
contentType: "image/jpg",
|
||||
acl: "public-read",
|
||||
});
|
||||
|
||||
await attachment.destroy({
|
||||
hooks: false,
|
||||
});
|
||||
|
||||
ensureDirSync(
|
||||
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
|
||||
);
|
||||
|
||||
copyFileSync(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
|
||||
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
|
||||
);
|
||||
|
||||
const res = await server.get(`/api/files.get?key=${key}`);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok when avatar is requested using key", async () => {
|
||||
const user = await buildUser();
|
||||
const key = path.join("avatars", user.id, uuidV4());
|
||||
|
||||
@@ -78,17 +78,13 @@ router.get(
|
||||
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
|
||||
const skipAuthorize = isPublicBucket || isSignedRequest;
|
||||
const cacheHeader = "max-age=604800, immutable";
|
||||
|
||||
const attachment = await Attachment.findOne({
|
||||
where: { key },
|
||||
});
|
||||
|
||||
// Attachment is requested with a key, but it was not found
|
||||
if (!attachment && !!ctx.input.query.key) {
|
||||
throw NotFoundError();
|
||||
}
|
||||
const attachment = await Attachment.findByKey(key);
|
||||
|
||||
if (!skipAuthorize) {
|
||||
if (!attachment && !!ctx.input.query.key) {
|
||||
throw NotFoundError();
|
||||
}
|
||||
|
||||
authorize(actor, "read", attachment);
|
||||
}
|
||||
|
||||
@@ -100,6 +96,7 @@ router.get(
|
||||
ctx.set("Accept-Ranges", "bytes");
|
||||
ctx.set("Cache-Control", cacheHeader);
|
||||
ctx.set("Content-Type", contentType);
|
||||
ctx.set("Content-Security-Policy", "sandbox");
|
||||
ctx.attachment(fileName, {
|
||||
type: forceDownload
|
||||
? "attachment"
|
||||
|
||||
@@ -52,18 +52,18 @@ function Webhooks() {
|
||||
in near real-time.
|
||||
</Trans>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
<PaginatedList<WebhookSubscription>
|
||||
fetch={webhookSubscriptions.fetchPage}
|
||||
items={webhookSubscriptions.enabled}
|
||||
heading={<h2>{t("Active")}</h2>}
|
||||
renderItem={(webhook: WebhookSubscription) => (
|
||||
renderItem={(webhook) => (
|
||||
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
|
||||
)}
|
||||
/>
|
||||
<PaginatedList
|
||||
<PaginatedList<WebhookSubscription>
|
||||
items={webhookSubscriptions.disabled}
|
||||
heading={<h2>{t("Inactive")}</h2>}
|
||||
renderItem={(webhook: WebhookSubscription) => (
|
||||
renderItem={(webhook) => (
|
||||
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
|
||||
)}
|
||||
/>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
queryInterface.addColumn("integrations", "issueSources", {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
queryInterface.removeColumn("integrations", "issueSources");
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
QueryTypes,
|
||||
FindOptions,
|
||||
} from "sequelize";
|
||||
import {
|
||||
BeforeDestroy,
|
||||
@@ -164,6 +165,20 @@ class Attachment extends IdModel<
|
||||
|
||||
// static methods
|
||||
|
||||
/**
|
||||
* Find an attachment by its key.
|
||||
*
|
||||
* @param key - The key of the attachment to find.
|
||||
* @param options - Additional options for the query.
|
||||
* @returns A promise resolving to the attachment with the given key, or null if not found.
|
||||
*/
|
||||
static async findByKey(
|
||||
key: string,
|
||||
options?: FindOptions<Attachment>
|
||||
): Promise<Attachment | null> {
|
||||
return this.findOne({ where: { key }, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total size of all attachments for a given team.
|
||||
*
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IsIn,
|
||||
AfterDestroy,
|
||||
} from "sequelize-typescript";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import type { IntegrationSettings } from "@shared/types";
|
||||
import Collection from "@server/models/Collection";
|
||||
@@ -53,6 +54,9 @@ class Integration<T = unknown> extends ParanoidModel<
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
events: string[];
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
issueSources: IssueSource[] | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
|
||||
@@ -251,6 +251,10 @@ describe("NotificationHelper", () => {
|
||||
userId: subscribedUser.id,
|
||||
collectionId: document.collectionId!,
|
||||
});
|
||||
await buildSubscription({
|
||||
userId: subscribedUser.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uniq from "lodash/uniq";
|
||||
import uniqBy from "lodash/uniqBy";
|
||||
import { Op } from "sequelize";
|
||||
import {
|
||||
NotificationEventType,
|
||||
@@ -187,7 +188,6 @@ export default class NotificationHelper {
|
||||
});
|
||||
} else {
|
||||
const subscriptions = await Subscription.findAll({
|
||||
attributes: ["userId"],
|
||||
where: {
|
||||
userId: {
|
||||
[Op.ne]: actorId,
|
||||
@@ -206,17 +206,19 @@ export default class NotificationHelper {
|
||||
],
|
||||
});
|
||||
|
||||
recipients = subscriptions.map((s) => s.user);
|
||||
recipients = uniqBy(
|
||||
subscriptions.map((s) => s.user),
|
||||
(user) => user.id
|
||||
);
|
||||
}
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(notificationType)
|
||||
);
|
||||
|
||||
const filtered = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (recipient.isSuspended) {
|
||||
if (
|
||||
recipient.isSuspended ||
|
||||
!recipient.subscribedToEventType(notificationType)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import compact from "lodash/compact";
|
||||
import flatten from "lodash/flatten";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import isMatch from "lodash/isMatch";
|
||||
import uniq from "lodash/uniq";
|
||||
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
@@ -12,7 +12,7 @@ import * as Y from "yjs";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import GlobalStyles from "@shared/styles/globals";
|
||||
import light from "@shared/styles/theme";
|
||||
import { MentionType, ProsemirrorData } from "@shared/types";
|
||||
import { MentionType, ProsemirrorData, UnfurlResponse } from "@shared/types";
|
||||
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
@@ -42,6 +42,8 @@ export type MentionAttrs = {
|
||||
modelId: string;
|
||||
actorId: string | undefined;
|
||||
id: string;
|
||||
href?: string;
|
||||
unfurl?: UnfurlResponse[keyof UnfurlResponse];
|
||||
};
|
||||
|
||||
@trace()
|
||||
@@ -194,7 +196,7 @@ export class ProsemirrorHelper {
|
||||
node.descendants((childNode: Node) => {
|
||||
if (
|
||||
childNode.type.name === "mention" &&
|
||||
isEqual(childNode.attrs, mention)
|
||||
isMatch(childNode.attrs, mention)
|
||||
) {
|
||||
foundMention = true;
|
||||
return false;
|
||||
|
||||
@@ -2,7 +2,7 @@ import invariant from "invariant";
|
||||
import filter from "lodash/filter";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { Collection, User, Team } from "@server/models";
|
||||
import { allow, can } from "./cancan";
|
||||
import { allow } from "./cancan";
|
||||
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
|
||||
|
||||
allow(User, "createCollection", Team, (actor, team) =>
|
||||
@@ -67,15 +67,6 @@ allow(
|
||||
}
|
||||
);
|
||||
|
||||
allow(User, "export", Collection, (actor, collection) =>
|
||||
and(
|
||||
//
|
||||
can(actor, "read", collection),
|
||||
!actor.isViewer,
|
||||
!actor.isGuest
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "share", Collection, (user, collection) => {
|
||||
if (
|
||||
!collection ||
|
||||
@@ -161,7 +152,7 @@ allow(
|
||||
}
|
||||
);
|
||||
|
||||
allow(User, ["update", "archive"], Collection, (user, collection) =>
|
||||
allow(User, ["update", "export", "archive"], Collection, (user, collection) =>
|
||||
and(
|
||||
!!collection,
|
||||
!!collection?.isActive,
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("policies/team", () => {
|
||||
const permissions = new Map<UserRole, boolean>([
|
||||
[UserRole.Admin, true],
|
||||
[UserRole.Member, true],
|
||||
[UserRole.Viewer, false],
|
||||
[UserRole.Viewer, true],
|
||||
[UserRole.Guest, true],
|
||||
]);
|
||||
for (const [role, permission] of permissions.entries()) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
or,
|
||||
} from "./utils";
|
||||
|
||||
allow(User, "read", Team, isTeamModel);
|
||||
allow(User, ["read", "readTemplate"], Team, isTeamModel);
|
||||
|
||||
allow(User, "share", Team, (actor, team) =>
|
||||
and(
|
||||
@@ -50,10 +50,6 @@ allow(User, "createTemplate", Team, (actor, team) =>
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "readTemplate", Team, (actor, team) =>
|
||||
and(!actor.isViewer, isTeamModel(actor, team))
|
||||
);
|
||||
|
||||
allow(User, "updateTemplate", Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
|
||||
+54
-38
@@ -71,47 +71,63 @@ const presentDocument = (
|
||||
|
||||
const presentPR = (
|
||||
data: Record<string, any>
|
||||
): UnfurlResponse[UnfurlResourceType.PR] => ({
|
||||
url: data.html_url,
|
||||
type: UnfurlResourceType.PR,
|
||||
id: `#${data.number}`,
|
||||
title: data.title,
|
||||
description: data.body,
|
||||
author: {
|
||||
name: data.user.login,
|
||||
avatarUrl: data.user.avatar_url,
|
||||
},
|
||||
state: {
|
||||
name: data.merged ? "merged" : data.state,
|
||||
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
});
|
||||
): UnfurlResponse[UnfurlResourceType.PR] => {
|
||||
// TODO: For backwards compatibility, remove once cache has expired in next release.
|
||||
if (data.transformed_unfurl) {
|
||||
delete data.transformed_unfurl;
|
||||
return data as UnfurlResponse[UnfurlResourceType.PR]; // this would have been transformed by the unfurl plugin.
|
||||
}
|
||||
|
||||
return {
|
||||
url: data.html_url,
|
||||
type: UnfurlResourceType.PR,
|
||||
id: `#${data.number}`,
|
||||
title: data.title,
|
||||
description: data.body,
|
||||
author: {
|
||||
name: data.user.login,
|
||||
avatarUrl: data.user.avatar_url,
|
||||
},
|
||||
state: {
|
||||
name: data.merged ? "merged" : data.state,
|
||||
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
};
|
||||
|
||||
const presentIssue = (
|
||||
data: Record<string, any>
|
||||
): UnfurlResponse[UnfurlResourceType.Issue] => ({
|
||||
url: data.html_url,
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: `#${data.number}`,
|
||||
title: data.title,
|
||||
description: data.body_text,
|
||||
author: {
|
||||
name: data.user.login,
|
||||
avatarUrl: data.user.avatar_url,
|
||||
},
|
||||
labels: data.labels.map((label: { name: string; color: string }) => ({
|
||||
name: label.name,
|
||||
color: `#${label.color}`,
|
||||
})),
|
||||
state: {
|
||||
name: data.state,
|
||||
color: GitHubUtils.getColorForStatus(
|
||||
data.state === "closed" ? "done" : data.state
|
||||
),
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
});
|
||||
): UnfurlResponse[UnfurlResourceType.Issue] => {
|
||||
// TODO: For backwards compatibility, remove once cache has expired in next release.
|
||||
if (data.transformed_unfurl) {
|
||||
delete data.transformed_unfurl;
|
||||
return data as UnfurlResponse[UnfurlResourceType.Issue]; // this would have been transformed by the unfurl plugin.
|
||||
}
|
||||
|
||||
return {
|
||||
url: data.html_url,
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: `#${data.number}`,
|
||||
title: data.title,
|
||||
description: data.body_text,
|
||||
author: {
|
||||
name: data.user.login,
|
||||
avatarUrl: data.user.avatar_url,
|
||||
},
|
||||
labels: data.labels.map((label: { name: string; color: string }) => ({
|
||||
name: label.name,
|
||||
color: `#${label.color}`,
|
||||
})),
|
||||
state: {
|
||||
name: data.state,
|
||||
color: GitHubUtils.getColorForStatus(
|
||||
data.state === "closed" ? "done" : data.state
|
||||
),
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
};
|
||||
|
||||
const presentLastOnlineInfoFor = (user: User) => {
|
||||
const locale = dateLocale(user.language);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Integration } from "@server/models";
|
||||
import BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import { IntegrationEvent, Event } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import CacheIssueSourcesTask from "../tasks/CacheIssueSourcesTask";
|
||||
|
||||
export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = ["integrations.create"];
|
||||
@@ -18,6 +19,11 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the available issue sources in the integration record.
|
||||
await CacheIssueSourcesTask.schedule({
|
||||
integrationId: integration.id,
|
||||
});
|
||||
|
||||
// Clear the cache of unfurled data for the team as it may be stale now.
|
||||
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Integration } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
const plugins = PluginManager.getHooks(Hook.IssueProvider);
|
||||
|
||||
type Props = {
|
||||
integrationId: string;
|
||||
};
|
||||
|
||||
export default class CacheIssueSourcesTask extends BaseTask<Props> {
|
||||
async perform({ integrationId }: Props) {
|
||||
const integration = await Integration.findByPk(integrationId);
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = plugins.find((p) => p.value.service === integration.service);
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = await plugin.value.fetchSources(integration);
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await integration.reload({ transaction, lock: transaction.LOCK.UPDATE });
|
||||
integration.issueSources = sources;
|
||||
await integration.save({ transaction });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,16 +85,21 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
|
||||
)
|
||||
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.CreateComment,
|
||||
userId: recipient.id,
|
||||
actorId: comment.createdById,
|
||||
teamId: document.teamId,
|
||||
commentId: comment.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
}
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
for (const recipient of recipients) {
|
||||
await Notification.create(
|
||||
{
|
||||
event: NotificationEventType.CreateComment,
|
||||
userId: recipient.id,
|
||||
actorId: comment.createdById,
|
||||
teamId: document.teamId,
|
||||
commentId: comment.id,
|
||||
documentId: document.id,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public get options() {
|
||||
|
||||
@@ -20,7 +20,7 @@ jest.mock("dns", () => ({
|
||||
|
||||
jest
|
||||
.spyOn(Iframely, "requestResource")
|
||||
.mockImplementation(() => Promise.resolve(undefined));
|
||||
.mockImplementation(() => Promise.resolve({}));
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
|
||||
@@ -98,11 +98,12 @@ router.post(
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const data = await plugin.value.unfurl(url, actor);
|
||||
if (data) {
|
||||
if ("error" in data) {
|
||||
const unfurl = await plugin.value.unfurl(url, actor);
|
||||
if (unfurl) {
|
||||
if ("error" in unfurl) {
|
||||
return (ctx.response.status = 204);
|
||||
} else {
|
||||
const data = unfurl as Unfurl;
|
||||
await CacheHelper.setData(
|
||||
CacheHelper.getUnfurlKey(actor.teamId, url),
|
||||
data,
|
||||
|
||||
+18
-2
@@ -10,6 +10,7 @@ import {
|
||||
JSONValue,
|
||||
UnfurlResourceType,
|
||||
ProsemirrorData,
|
||||
UnfurlResponse,
|
||||
} from "@shared/types";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
import { AccountProvisionerResult } from "./commands/accountProvisioner";
|
||||
@@ -576,12 +577,27 @@ export type CollectionJSONExport = {
|
||||
};
|
||||
};
|
||||
|
||||
export type Unfurl = { [x: string]: JSONValue; type: UnfurlResourceType };
|
||||
export type UnfurlIssueAndPR = (
|
||||
| UnfurlResponse[UnfurlResourceType.Issue]
|
||||
| UnfurlResponse[UnfurlResourceType.PR]
|
||||
) & { transformed_unfurl: true };
|
||||
|
||||
export type Unfurl =
|
||||
| UnfurlIssueAndPR
|
||||
| {
|
||||
type: Exclude<
|
||||
UnfurlResourceType,
|
||||
UnfurlResourceType.Issue | UnfurlResourceType.PR
|
||||
>;
|
||||
[x: string]: JSONValue;
|
||||
};
|
||||
|
||||
export type UnfurlError = { error: string };
|
||||
|
||||
export type UnfurlSignature = (
|
||||
url: string,
|
||||
actor?: User
|
||||
) => Promise<Unfurl | void>;
|
||||
) => Promise<Unfurl | UnfurlError | undefined>;
|
||||
|
||||
export type UninstallSignature = (integration: Integration) => Promise<void>;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { IntegrationType, IssueTrackerIntegrationService } from "@shared/types";
|
||||
import { Integration } from "@server/models";
|
||||
|
||||
export abstract class BaseIssueProvider {
|
||||
service: IssueTrackerIntegrationService;
|
||||
|
||||
constructor(service: IssueTrackerIntegrationService) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
abstract fetchSources(
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
): Promise<IssueSource[]>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import Logger from "@server/logging/Logger";
|
||||
import type BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import type BaseTask from "@server/queues/tasks/BaseTask";
|
||||
import { UnfurlSignature, UninstallSignature } from "@server/types";
|
||||
import { BaseIssueProvider } from "./BaseIssueProvider";
|
||||
|
||||
export enum PluginPriority {
|
||||
VeryHigh = 0,
|
||||
@@ -25,6 +26,7 @@ export enum Hook {
|
||||
API = "api",
|
||||
AuthProvider = "authProvider",
|
||||
EmailTemplate = "emailTemplate",
|
||||
IssueProvider = "issueProvider",
|
||||
Processor = "processor",
|
||||
Task = "task",
|
||||
UnfurlProvider = "unfurl",
|
||||
@@ -39,6 +41,7 @@ type PluginValueMap = {
|
||||
[Hook.API]: Router;
|
||||
[Hook.AuthProvider]: { router: Router; id: string };
|
||||
[Hook.EmailTemplate]: typeof BaseEmail;
|
||||
[Hook.IssueProvider]: BaseIssueProvider;
|
||||
[Hook.Processor]: typeof BaseProcessor;
|
||||
[Hook.Task]: typeof BaseTask<any>;
|
||||
[Hook.Uninstall]: UninstallSignature;
|
||||
|
||||
+11
-36
@@ -1,43 +1,18 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import React from "react";
|
||||
import { BaseIconProps } from ".";
|
||||
|
||||
type Props = {
|
||||
status: string;
|
||||
color: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
export function GitHubIssueStatusIcon(props: BaseIconProps) {
|
||||
const { state, className } = props;
|
||||
|
||||
/**
|
||||
* Issue status icon based on GitHub issue status, but can be used for any git-style integration.
|
||||
*/
|
||||
export function IssueStatusIcon({ size, ...rest }: Props) {
|
||||
return (
|
||||
<Icon size={size}>
|
||||
<BaseIcon {...rest} />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = styled.span<{ size?: number }>`
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: ${(props) => props.size ?? 24}px;
|
||||
height: ${(props) => props.size ?? 24}px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
function BaseIcon(props: Props) {
|
||||
switch (props.status) {
|
||||
switch (state.name) {
|
||||
case "open":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
fill={state.color}
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
|
||||
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
|
||||
@@ -49,8 +24,8 @@ function BaseIcon(props: Props) {
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
fill={state.color}
|
||||
className={className}
|
||||
>
|
||||
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
|
||||
@@ -62,8 +37,8 @@ function BaseIcon(props: Props) {
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
fill={state.color}
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />
|
||||
</svg>
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from "react";
|
||||
import { useTheme } from "styled-components";
|
||||
import { isSafari } from "../../utils/browser";
|
||||
import { BaseIconProps } from ".";
|
||||
|
||||
enum StateType {
|
||||
Triage = "triage",
|
||||
Backlog = "backlog",
|
||||
Unstarted = "unstarted",
|
||||
Started = "started",
|
||||
Completed = "completed",
|
||||
Canceled = "canceled",
|
||||
}
|
||||
|
||||
export function LinearIssueStatusIcon(props: BaseIconProps) {
|
||||
const theme = useTheme();
|
||||
const { state } = props;
|
||||
const percentage =
|
||||
state.type === StateType.Triage ||
|
||||
state.type === StateType.Backlog ||
|
||||
state.type === StateType.Unstarted
|
||||
? 0
|
||||
: state.type === StateType.Started
|
||||
? state.completionPercentage ?? 0.5
|
||||
: 1;
|
||||
const isTriage = state.type === StateType.Triage;
|
||||
const isBacklog = state.type === StateType.Backlog;
|
||||
const isCompleted = state.type === StateType.Completed;
|
||||
// Due to some rendering issues and differences between browsers, the logical constant 4 in the rendering below
|
||||
// needs to be a bit less to make 50% look like half a circle.
|
||||
const magicFour = isSafari() ? 3.895 : 3.98;
|
||||
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<circle
|
||||
cx={7}
|
||||
cy={7}
|
||||
r={isTriage ? 3.5 : 6}
|
||||
fill="none"
|
||||
stroke={state.color}
|
||||
strokeWidth={isTriage ? 7 : 1.5}
|
||||
strokeDasharray={isTriage ? "2 0" : isBacklog ? "1.4 1.74" : "3.14 0"}
|
||||
strokeDashoffset={isTriage ? 3.2 : isBacklog ? 0.65 : -0.7}
|
||||
/>
|
||||
<circle
|
||||
cx={7}
|
||||
cy={7}
|
||||
r={percentage === 1 ? 3 : 2}
|
||||
fill="none"
|
||||
stroke={state.color}
|
||||
strokeWidth={percentage === 1 ? 6 : 4}
|
||||
strokeDasharray={`${
|
||||
percentage * Math.PI * (percentage === 1 ? 6 : magicFour)
|
||||
} 100`}
|
||||
strokeDashoffset={0}
|
||||
transform={`rotate(-90 7 7)`}
|
||||
/>
|
||||
{(isTriage || percentage === 1) && (
|
||||
<path
|
||||
className="icon"
|
||||
stroke="none"
|
||||
d={isTriage ? triageIcon : isCompleted ? checkMarkIcon : closeIcon}
|
||||
fill={theme.background}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const checkMarkIcon =
|
||||
"M10.951 4.24896C11.283 4.58091 11.283 5.11909 10.951 5.45104L5.95104 10.451C5.61909 10.783 5.0809 10.783 4.74896 10.451L2.74896 8.45104C2.41701 8.11909 2.41701 7.5809 2.74896 7.24896C3.0809 6.91701 3.61909 6.91701 3.95104 7.24896L5.35 8.64792L9.74896 4.24896C10.0809 3.91701 10.6191 3.91701 10.951 4.24896Z";
|
||||
const triageIcon =
|
||||
"M8.0126 7.98223V9.50781C8.0126 9.92901 8.52329 10.1548 8.85102 9.87854L11.8258 7.37066C12.0581 7.17486 12.0581 6.82507 11.8258 6.62927L8.85102 4.12139C8.52329 3.84509 8.0126 4.07092 8.0126 4.49212V6.01763H5.98739V4.49218C5.98739 4.07098 5.4767 3.84515 5.14897 4.12146L2.17419 6.62933C1.94194 6.82513 1.94194 7.17492 2.17419 7.37072L5.14897 9.8786C5.4767 10.1549 5.98739 9.92907 5.98739 9.50787V7.98223H8.0126Z";
|
||||
const closeIcon =
|
||||
"M3.73657 3.73657C4.05199 3.42114 4.56339 3.42114 4.87881 3.73657L5.93941 4.79716L7 5.85775L9.12117 3.73657C9.4366 3.42114 9.94801 3.42114 10.2634 3.73657C10.5789 4.05199 10.5789 4.56339 10.2634 4.87881L8.14225 7L10.2634 9.12118C10.5789 9.4366 10.5789 9.94801 10.2634 10.2634C9.94801 10.5789 9.4366 10.5789 9.12117 10.2634L7 8.14225L4.87881 10.2634C4.56339 10.5789 4.05199 10.5789 3.73657 10.2634C3.42114 9.94801 3.42114 9.4366 3.73657 9.12118L4.79716 8.06059L5.85775 7L3.73657 4.87881C3.42114 4.56339 3.42114 4.05199 3.73657 3.73657Z";
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
IntegrationService,
|
||||
IssueTrackerIntegrationService,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "../../types";
|
||||
import { GitHubIssueStatusIcon } from "./GitHubIssueStatusIcon";
|
||||
import { LinearIssueStatusIcon } from "./LinearIssueStatusIcon";
|
||||
|
||||
export type BaseIconProps = {
|
||||
state: UnfurlResponse[UnfurlResourceType.Issue]["state"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type Props = BaseIconProps & {
|
||||
size?: number;
|
||||
service: IssueTrackerIntegrationService;
|
||||
};
|
||||
|
||||
export function IssueStatusIcon(props: Props) {
|
||||
return <Icon size={props.size}>{getIcon(props)}</Icon>;
|
||||
}
|
||||
|
||||
function getIcon({ service, ...rest }: Props) {
|
||||
switch (service) {
|
||||
case IntegrationService.GitHub:
|
||||
return <GitHubIssueStatusIcon {...rest} />;
|
||||
case IntegrationService.Linear:
|
||||
return <LinearIssueStatusIcon {...rest} />;
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = styled.span<{ size?: number }>`
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: ${(props) => props.size ?? 24}px;
|
||||
height: ${(props) => props.size ?? 24}px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
@@ -0,0 +1,73 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { ellipsis } from "../styles";
|
||||
|
||||
type Props = {
|
||||
/** The type of text to render */
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
/** The size of the text */
|
||||
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
|
||||
/** The direction of the text (defaults to ltr) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the text should be selectable (defaults to false) */
|
||||
selectable?: boolean;
|
||||
/** The font weight of the text */
|
||||
weight?: "xbold" | "bold" | "normal";
|
||||
/** Whether the text should be italic */
|
||||
italic?: boolean;
|
||||
/** Whether the text should be truncated with an ellipsis */
|
||||
ellipsis?: boolean;
|
||||
/** Whether the text should be monospaced */
|
||||
monospace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this component for all interface text that should not be selectable
|
||||
* by the user, this is the majority of UI text explainers, notes, headings.
|
||||
*/
|
||||
const Text = styled.span<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
: props.type === "tertiary"
|
||||
? props.theme.textTertiary
|
||||
: props.type === "danger"
|
||||
? props.theme.brand.red
|
||||
: props.theme.text};
|
||||
font-size: ${(props) =>
|
||||
props.size === "xlarge"
|
||||
? "26px"
|
||||
: props.size === "large"
|
||||
? "18px"
|
||||
: props.size === "medium"
|
||||
? "16px"
|
||||
: props.size === "small"
|
||||
? "14px"
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
: "inherit"};
|
||||
|
||||
${(props) =>
|
||||
props.weight &&
|
||||
css`
|
||||
font-weight: ${props.weight === "xbold"
|
||||
? 600
|
||||
: props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? 400
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
font-style: ${(props) => (props.italic ? "italic" : "normal")};
|
||||
font-family: ${(props) =>
|
||||
props.monospace ? props.theme.fontFamilyMono : "inherit"};
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
|
||||
${(props) => props.ellipsis && ellipsis()}
|
||||
`;
|
||||
|
||||
export default Text;
|
||||
@@ -17,10 +17,10 @@ type Props = ComponentProps & {
|
||||
|
||||
const Embed = (props: Props) => {
|
||||
const ref = React.useRef<HTMLIFrameElement>(null);
|
||||
const { node, isEditable, onChangeSize } = props;
|
||||
const { node, isEditable, embedsDisabled, onChangeSize } = props;
|
||||
const naturalWidth = 0;
|
||||
const naturalHeight = 400;
|
||||
const isResizable = !!onChangeSize;
|
||||
const isResizable = !!onChangeSize && !embedsDisabled;
|
||||
|
||||
const { width, height, setSize, handlePointerDown, dragging } = useDragResize(
|
||||
{
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { OpenIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Optional } from "utility-types";
|
||||
import { s } from "../../styles";
|
||||
import { sanitizeUrl } from "../../utils/urls";
|
||||
|
||||
type Props = Omit<Optional<HTMLIFrameElement>, "children" | "style"> & {
|
||||
type Props = Omit<
|
||||
Optional<React.ComponentProps<typeof Iframe>>,
|
||||
"children" | "style"
|
||||
> & {
|
||||
/** The URL to load in the iframe */
|
||||
src?: string;
|
||||
/** Whether to display a border, defaults to true */
|
||||
@@ -30,85 +32,79 @@ type PropsWithRef = Props & {
|
||||
forwardedRef: React.Ref<HTMLIFrameElement>;
|
||||
};
|
||||
|
||||
@observer
|
||||
class Frame extends React.Component<PropsWithRef> {
|
||||
mounted: boolean;
|
||||
const Frame = ({
|
||||
border,
|
||||
style = {},
|
||||
forwardedRef,
|
||||
icon,
|
||||
title,
|
||||
canonicalUrl,
|
||||
isSelected,
|
||||
referrerPolicy,
|
||||
className = "",
|
||||
src,
|
||||
...rest
|
||||
}: PropsWithRef) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
@observable
|
||||
isLoaded = false;
|
||||
useEffect(() => {
|
||||
// Set mounted flag
|
||||
mountedRef.current = true;
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
setTimeout(this.loadIframe, 0);
|
||||
}
|
||||
// Load iframe after a small delay
|
||||
const timer = setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
// Cleanup function
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
loadIframe = () => {
|
||||
if (!this.mounted) {
|
||||
return;
|
||||
}
|
||||
this.isLoaded = true;
|
||||
};
|
||||
const showBottomBar = !!(icon || canonicalUrl);
|
||||
|
||||
render() {
|
||||
const {
|
||||
border,
|
||||
style = {},
|
||||
forwardedRef,
|
||||
icon,
|
||||
title,
|
||||
canonicalUrl,
|
||||
isSelected,
|
||||
referrerPolicy,
|
||||
className = "",
|
||||
src,
|
||||
} = this.props;
|
||||
const showBottomBar = !!(icon || canonicalUrl);
|
||||
|
||||
return (
|
||||
<Rounded
|
||||
style={style}
|
||||
$showBottomBar={showBottomBar}
|
||||
$border={border}
|
||||
className={
|
||||
isSelected ? `ProseMirror-selectednode ${className}` : className
|
||||
}
|
||||
>
|
||||
{this.isLoaded && (
|
||||
<Iframe
|
||||
ref={forwardedRef}
|
||||
$showBottomBar={showBottomBar}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-downloads allow-storage-access-by-user-activation"
|
||||
style={style}
|
||||
frameBorder="0"
|
||||
title="embed"
|
||||
loading="lazy"
|
||||
src={sanitizeUrl(src)}
|
||||
referrerPolicy={referrerPolicy}
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
{showBottomBar && (
|
||||
<Bar>
|
||||
{icon} <Title>{title}</Title>
|
||||
{canonicalUrl && (
|
||||
<Open
|
||||
href={canonicalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<OpenIcon size={18} /> Open
|
||||
</Open>
|
||||
)}
|
||||
</Bar>
|
||||
)}
|
||||
</Rounded>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Rounded
|
||||
style={style}
|
||||
$showBottomBar={showBottomBar}
|
||||
$border={border}
|
||||
className={
|
||||
isSelected ? `ProseMirror-selectednode ${className}` : className
|
||||
}
|
||||
>
|
||||
{isLoaded && (
|
||||
<Iframe
|
||||
ref={forwardedRef}
|
||||
$showBottomBar={showBottomBar}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-downloads allow-storage-access-by-user-activation"
|
||||
style={style}
|
||||
frameBorder="0"
|
||||
title="embed"
|
||||
loading="lazy"
|
||||
src={sanitizeUrl(src)}
|
||||
referrerPolicy={referrerPolicy}
|
||||
allowFullScreen
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
{showBottomBar && (
|
||||
<Bar>
|
||||
{icon} <Title>{title}</Title>
|
||||
{canonicalUrl && (
|
||||
<Open href={canonicalUrl} target="_blank" rel="noopener noreferrer">
|
||||
<OpenIcon size={18} /> Open
|
||||
</Open>
|
||||
)}
|
||||
</Bar>
|
||||
)}
|
||||
</Rounded>
|
||||
);
|
||||
};
|
||||
|
||||
const Iframe = styled.iframe<{ $showBottomBar: boolean }>`
|
||||
border-radius: ${(props) => (props.$showBottomBar ? "3px 3px 0 0" : "3px")};
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, EmailIcon, CollectionIcon } from "outline-icons";
|
||||
import {
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
CollectionIcon,
|
||||
WarningIcon,
|
||||
} from "outline-icons";
|
||||
import { Node } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Flex from "../../components/Flex";
|
||||
import Icon from "../../components/Icon";
|
||||
import { IssueStatusIcon } from "../../components/IssueStatusIcon";
|
||||
import { PullRequestIcon } from "../../components/PullRequestIcon";
|
||||
import Spinner from "../../components/Spinner";
|
||||
import Text from "../../components/Text";
|
||||
import useIsMounted from "../../hooks/useIsMounted";
|
||||
import useStores from "../../hooks/useStores";
|
||||
import theme from "../../styles/theme";
|
||||
import {
|
||||
IntegrationService,
|
||||
type JSONValue,
|
||||
type UnfurlResourceType,
|
||||
type UnfurlResponse,
|
||||
} from "../../types";
|
||||
import { cn } from "../styles/utils";
|
||||
import { ComponentProps } from "../types";
|
||||
|
||||
const getAttributesFromNode = (node: Node) => {
|
||||
const spec = node.type.spec.toDOM?.(node) as any as Record<string, string>[];
|
||||
const { class: className, ...attrs } = spec[1];
|
||||
return { className, ...attrs };
|
||||
type Attrs = {
|
||||
className: string;
|
||||
unfurl?: UnfurlResponse[keyof UnfurlResponse];
|
||||
} & Record<string, JSONValue>;
|
||||
|
||||
const getAttributesFromNode = (node: Node): Attrs => {
|
||||
const spec = node.type.spec.toDOM?.(node) as any as Record<
|
||||
string,
|
||||
JSONValue
|
||||
>[];
|
||||
const { class: className, "data-unfurl": unfurl, ...attrs } = spec[1];
|
||||
|
||||
return {
|
||||
className: className as Attrs["className"],
|
||||
unfurl: unfurl ? (JSON.parse(unfurl as any) as Attrs["unfurl"]) : undefined,
|
||||
...attrs,
|
||||
};
|
||||
};
|
||||
|
||||
export const MentionUser = observer(function MentionUser_(
|
||||
@@ -20,7 +53,7 @@ export const MentionUser = observer(function MentionUser_(
|
||||
const { isSelected, node } = props;
|
||||
const { users } = useStores();
|
||||
const user = users.get(node.attrs.modelId);
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -42,7 +75,7 @@ export const MentionDocument = observer(function MentionDocument_(
|
||||
const { documents } = useStores();
|
||||
const doc = documents.get(node.attrs.modelId);
|
||||
const modelId = node.attrs.modelId;
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modelId) {
|
||||
@@ -75,7 +108,7 @@ export const MentionCollection = observer(function MentionCollection_(
|
||||
const { collections } = useStores();
|
||||
const collection = collections.get(node.attrs.modelId);
|
||||
const modelId = node.attrs.modelId;
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modelId) {
|
||||
@@ -100,3 +133,185 @@ export const MentionCollection = observer(function MentionCollection_(
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
type IssuePrProps = ComponentProps & {
|
||||
onChangeUnfurl: (
|
||||
unfurl:
|
||||
| UnfurlResponse[UnfurlResourceType.Issue]
|
||||
| UnfurlResponse[UnfurlResourceType.PR]
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const MentionIssue = observer((props: IssuePrProps) => {
|
||||
const { unfurls } = useStores();
|
||||
const isMounted = useIsMounted();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
||||
|
||||
const { isSelected, node } = props;
|
||||
const {
|
||||
className,
|
||||
unfurl: unfurlAttr,
|
||||
...attrs
|
||||
} = getAttributesFromNode(node);
|
||||
|
||||
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchIssue = async () => {
|
||||
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unfurlModel) {
|
||||
onChangeUnfurl({
|
||||
...unfurlModel.data,
|
||||
description: null,
|
||||
} satisfies UnfurlResponse[UnfurlResourceType.Issue]);
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
void fetchIssue();
|
||||
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
||||
|
||||
if (!unfurl) {
|
||||
return !loaded ? (
|
||||
<MentionLoading className={className} />
|
||||
) : (
|
||||
<MentionError className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
const issue = unfurl as UnfurlResponse[UnfurlResourceType.Issue];
|
||||
|
||||
const url = new URL(issue.url);
|
||||
const service =
|
||||
url.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
|
||||
return (
|
||||
<a
|
||||
{...attrs}
|
||||
className={cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
})}
|
||||
href={attrs.href as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
<IssueStatusIcon size={14} service={service} state={issue.state} />
|
||||
<Flex align="center" gap={4}>
|
||||
<Text>{issue.title}</Text>
|
||||
<Text type="tertiary">{issue.id}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
export const MentionPullRequest = observer((props: IssuePrProps) => {
|
||||
const { unfurls } = useStores();
|
||||
const isMounted = useIsMounted();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
||||
|
||||
const { isSelected, node } = props;
|
||||
const {
|
||||
className,
|
||||
unfurl: unfurlAttr,
|
||||
...attrs
|
||||
} = getAttributesFromNode(node);
|
||||
|
||||
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchPR = async () => {
|
||||
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unfurlModel) {
|
||||
onChangeUnfurl({
|
||||
...unfurlModel.data,
|
||||
description: null,
|
||||
} satisfies UnfurlResponse[UnfurlResourceType.PR]);
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
void fetchPR();
|
||||
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
||||
|
||||
const sharedProps = {
|
||||
className: cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
}),
|
||||
};
|
||||
|
||||
if (!unfurl) {
|
||||
return !loaded ? (
|
||||
<MentionLoading {...sharedProps} />
|
||||
) : (
|
||||
<MentionError {...sharedProps} />
|
||||
);
|
||||
}
|
||||
|
||||
const pullRequest = unfurl as UnfurlResponse[UnfurlResourceType.PR];
|
||||
|
||||
return (
|
||||
<a
|
||||
{...attrs}
|
||||
{...sharedProps}
|
||||
href={attrs.href as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
<PullRequestIcon
|
||||
size={14}
|
||||
status={pullRequest.state.name}
|
||||
color={pullRequest.state.color}
|
||||
/>
|
||||
<Flex align="center" gap={4}>
|
||||
<Text>{pullRequest.title}</Text>
|
||||
<Text type="tertiary">{pullRequest.id}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
const MentionLoading = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<Spinner />
|
||||
<Text type="tertiary">{`${t("Loading")}…`}</Text>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const MentionError = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<StyledWarningIcon size={20} color={theme.danger} />
|
||||
<Text type="secondary">{`${t("Error loading data")}`}</Text>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledWarningIcon = styled(WarningIcon)`
|
||||
margin: 0 -2px;
|
||||
`;
|
||||
|
||||
@@ -128,6 +128,7 @@ const mathStyle = (props: Props) => css`
|
||||
math-block .math-src .ProseMirror {
|
||||
width: 100%;
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
math-block .katex-display {
|
||||
@@ -334,7 +335,7 @@ width: 100%;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
& > .ProseMirror {
|
||||
position: relative;
|
||||
outline: none;
|
||||
word-wrap: break-word;
|
||||
@@ -707,6 +708,7 @@ img.ProseMirror-separator {
|
||||
resize: none;
|
||||
user-select: text;
|
||||
margin: 0 auto !important;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.ProseMirror[contenteditable="false"] {
|
||||
@@ -1073,6 +1075,21 @@ p {
|
||||
min-height: 1.6em;
|
||||
}
|
||||
|
||||
h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {
|
||||
color: ${props.theme.text};
|
||||
text-decoration: underline;
|
||||
text-decoration-color: ${lighten(0.5, props.theme.text)};
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: .15em;
|
||||
font-weight: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: ${props.theme.text};
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.heading-content a,
|
||||
p a {
|
||||
color: ${props.theme.text};
|
||||
@@ -1173,7 +1190,7 @@ ul.checkbox_list > li.checked > div > p {
|
||||
ul li,
|
||||
ol li {
|
||||
&::before {
|
||||
background: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iOCIgeT0iNyIgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIGZpbGw9IiM0RTVDNkUiLz4KPHJlY3QgeD0iOCIgeT0iMTEiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiBmaWxsPSIjNEU1QzZFIi8+CjxyZWN0IHg9IjgiIHk9IjE1IiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgZmlsbD0iIzRFNUM2RSIvPgo8cmVjdCB4PSIxMyIgeT0iNyIgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIGZpbGw9IiM0RTVDNkUiLz4KPHJlY3QgeD0iMTMiIHk9IjExIiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgZmlsbD0iIzRFNUM2RSIvPgo8cmVjdCB4PSIxMyIgeT0iMTUiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiBmaWxsPSIjNEU1QzZFIi8+Cjwvc3ZnPgo=") no-repeat;
|
||||
background: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iOCIgeT0iNyIgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIGZpbGw9IiM0RTVDNkUiLz4KPHJlY3QgeD0iMTMiIHk9IjExIiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgZmlsbD0iIzRFNUM2RSIvPgo8cmVjdCB4PSIxMyIgeT0iNyIgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIGZpbGw9IiM0RTVDNkUiLz4KPHJlY3QgeD0iMTUiIHk9IjE1IiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgZmlsbD0iIzRFNUM2RSIvPgo8cmVjdCB4PSIxMyIgeT0iMTMiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiBmaWxsPSIjNEU1QzZFIi8+CjxyZWN0IHg9IjgiIHk9IjE1IiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgZmlsbD0iIzRFNUM2RSIvPgo8cmVjdCB4PSIxMyIgeT0iMTUiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiBmaWxsPSIjNEU1QzZFIi8+Cjwvc3ZnPgo=") no-repeat;
|
||||
background-position: 0 2px;
|
||||
content: "";
|
||||
display: ${props.readOnly ? "none" : "inline-block"};
|
||||
@@ -1335,6 +1352,13 @@ mark {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-block[data-language=none],
|
||||
.code-block[data-language=markdown] {
|
||||
pre code {
|
||||
color: ${props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.code-block[data-language=mermaidjs] {
|
||||
margin: 0.75em 0;
|
||||
|
||||
@@ -1532,7 +1556,7 @@ table {
|
||||
background-position: 50% 50%;
|
||||
background-image: url("data:image/svg+xml;base64,${btoa(
|
||||
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 5C11.4477 5 11 5.44772 11 6V11H6C5.44772 11 5 11.4477 5 12C5 12.5523 5.44772 13 6 13H11V18C11 18.5523 11.4477 19 12 19C12.5523 19 13 18.5523 13 18V13H18C18.5523 13 19 12.5523 19 12C19 11.4477 18.5523 11 18 11H13V6C13 5.44772 12.5523 5 12 5Z" fill="white"/></svg>'
|
||||
)}")
|
||||
)}`)
|
||||
}
|
||||
|
||||
// extra clickable area
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user