mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69f171c1c3 | |||
| 547ee17c5c | |||
| 48cb456a4f | |||
| 42f92fa289 | |||
| c9a16ce395 | |||
| 9858d64fee | |||
| 237253afdb | |||
| 82cdebfb66 | |||
| bed0bf9ec8 | |||
| 4573b3fea2 | |||
| 110e489c30 | |||
| b34dd138cd | |||
| 3b1ce063bf | |||
| b1d8acbad1 | |||
| ae05520a25 | |||
| 6e30bf3c64 | |||
| 775b038359 |
@@ -683,6 +683,7 @@ export const searchInDocument = createAction({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: ActiveDocumentSection,
|
||||
shortcut: [`Meta+/`],
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -1210,6 +1211,7 @@ export const rootDocumentActions = [
|
||||
unpublishDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
searchInDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "~/components/Text";
|
||||
@@ -70,7 +71,7 @@ function CommandBarItem(
|
||||
""
|
||||
)}
|
||||
{sc.split("+").map((key) => (
|
||||
<Key key={key}>{key}</Key>
|
||||
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -161,6 +161,9 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
const Fade = styled.span<{ timing?: number | string }>`
|
||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||
`;
|
||||
|
||||
export default Fade;
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
const Fade = styled.span<{ timing?: number | string }>`
|
||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children?: JSX.Element | null;
|
||||
/** If true, children will be animated. */
|
||||
animate: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps children in a <Fade> if loading is true on mount.
|
||||
*/
|
||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||
const [isAnimated] = React.useState(animate);
|
||||
|
||||
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
|
||||
};
|
||||
|
||||
export default Fade;
|
||||
@@ -19,6 +19,9 @@ class Revision extends Model {
|
||||
/** The document title when the revision was created */
|
||||
title: string;
|
||||
|
||||
/** An optional name for the revision */
|
||||
name: string | null;
|
||||
|
||||
/** Prosemirror data of the content when revision was created */
|
||||
data: ProsemirrorData;
|
||||
|
||||
|
||||
+8
-2
@@ -1,12 +1,13 @@
|
||||
import { observable } from "mobx";
|
||||
import { computed, observable } from "mobx";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
import { Searchable } from "./interfaces/Searchable";
|
||||
|
||||
class Share extends Model {
|
||||
class Share extends Model implements Searchable {
|
||||
static modelName = "Share";
|
||||
|
||||
@Field
|
||||
@@ -65,6 +66,11 @@ class Share extends Model {
|
||||
/** The user that shared the document. */
|
||||
@Relation(() => User, { onDelete: "null" })
|
||||
createdBy: User;
|
||||
|
||||
@computed
|
||||
get searchContent(): string[] {
|
||||
return [this.document?.title ?? this.documentTitle];
|
||||
}
|
||||
}
|
||||
|
||||
export default Share;
|
||||
|
||||
@@ -551,6 +551,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
>
|
||||
<Notices document={document} readOnly={readOnly} />
|
||||
|
||||
{showContents && (
|
||||
<PrintContentsContainer>
|
||||
<Contents />
|
||||
</PrintContentsContainer>
|
||||
)}
|
||||
<Editor
|
||||
id={document.id}
|
||||
key={embedsDisabled ? "disabled" : "enabled"}
|
||||
@@ -665,6 +670,19 @@ const ContentsContainer = styled.div<ContentsContainerProps>`
|
||||
justify-self: ${({ position }: ContentsContainerProps) =>
|
||||
position === TOCPosition.Left ? "end" : "start"};
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const PrintContentsContainer = styled.div`
|
||||
display: none;
|
||||
margin: 0 -12px;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
type EditorContainerProps = {
|
||||
|
||||
@@ -99,7 +99,11 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
});
|
||||
|
||||
provider.on("awarenessChange", (event: AwarenessChangeEvent) => {
|
||||
presence.updateFromAwarenessChangeEvent(documentId, event);
|
||||
presence.updateFromAwarenessChangeEvent(
|
||||
documentId,
|
||||
provider.awareness.clientID,
|
||||
event
|
||||
);
|
||||
|
||||
event.states.forEach(({ user, scrollY }) => {
|
||||
if (user) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RecentSearchListItem from "./RecentSearchListItem";
|
||||
|
||||
@@ -19,7 +19,6 @@ function RecentSearches(
|
||||
) {
|
||||
const { searches } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [isPreloaded] = React.useState(searches.recent.length > 0);
|
||||
|
||||
React.useEffect(() => {
|
||||
void searches.fetchPage({
|
||||
@@ -48,7 +47,11 @@ function RecentSearches(
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return isPreloaded ? content : <Fade>{content}</Fade>;
|
||||
return (
|
||||
<ConditionalFade animate={!searches.recent.length}>
|
||||
{content}
|
||||
</ConditionalFade>
|
||||
);
|
||||
}
|
||||
|
||||
const Heading = styled.h2`
|
||||
|
||||
@@ -10,6 +10,7 @@ import Group from "~/models/Group";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
@@ -149,15 +150,17 @@ function Groups() {
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<GroupsTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
<ConditionalFade animate={!data}>
|
||||
<GroupsTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</ConditionalFade>
|
||||
</>
|
||||
)}
|
||||
</Scene>
|
||||
|
||||
@@ -9,7 +9,7 @@ import styled from "styled-components";
|
||||
import UsersStore, { queriedUsers } from "~/stores/UsersStore";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
@@ -22,7 +22,7 @@ import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { PeopleTable } from "./components/PeopleTable";
|
||||
import { MembersTable } from "./components/MembersTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import UserRoleFilter from "./components/UserRoleFilter";
|
||||
import UserStatusFilter from "./components/UserStatusFilter";
|
||||
@@ -163,8 +163,8 @@ function Members() {
|
||||
onSelect={handleRoleFilter}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<Fade>
|
||||
<PeopleTable
|
||||
<ConditionalFade animate={!data}>
|
||||
<MembersTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
canManage={can.update}
|
||||
@@ -174,7 +174,7 @@ function Members() {
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
</ConditionalFade>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { observer } from "mobx-react";
|
||||
import { GlobeIcon, WarningIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
@@ -16,17 +17,22 @@ import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { SharesTable } from "./components/SharesTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
|
||||
function Shares() {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { shares, auth } = useStores();
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
const can = usePolicy(team);
|
||||
const params = useQuery();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const reqParams = React.useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
sort: params.get("sort") || "createdAt",
|
||||
direction: (params.get("direction") || "desc").toUpperCase() as
|
||||
| "ASC"
|
||||
@@ -44,18 +50,44 @@ function Shares() {
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: shares.orderedData,
|
||||
data: shares.findByQuery(reqParams.query ?? ""),
|
||||
sort,
|
||||
reqFn: shares.fetchPage,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
const updateParams = React.useCallback(
|
||||
(name: string, value: string) => {
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete(name);
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSearch = React.useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load shares"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => updateParams("query", query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateParams]);
|
||||
|
||||
return (
|
||||
<Scene title={t("Shared Links")} icon={<GlobeIcon />} wide>
|
||||
<Heading>{t("Shared Links")}</Heading>
|
||||
@@ -83,20 +115,26 @@ function Shares() {
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{data?.length ? (
|
||||
<Fade>
|
||||
<SharesTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
canManage={can.update}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
) : null}
|
||||
<StickyFilters gap={8}>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<SharesTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
canManage={can.update}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</ConditionalFade>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
export function PeopleTable({ canManage, ...rest }: Props) {
|
||||
export function MembersTable({ canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
|
||||
import Share from "~/models/Share";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import {
|
||||
@@ -46,10 +46,10 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
|
||||
accessor: (share) => share.createdBy,
|
||||
sortable: false,
|
||||
component: (share) => (
|
||||
<Flex align="center" gap={4}>
|
||||
<Flex align="center" gap={8}>
|
||||
{share.createdBy && (
|
||||
<>
|
||||
<Avatar model={share.createdBy} />
|
||||
<Avatar model={share.createdBy} size={AvatarSize.Small} />
|
||||
{share.createdBy.name}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -14,17 +14,16 @@ export default class PresenceStore {
|
||||
@observable
|
||||
data: Map<string, DocumentPresence> = new Map();
|
||||
|
||||
timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
offlineTimeout = 30000;
|
||||
|
||||
private rootStore: RootStore;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
this.rootStore = rootStore;
|
||||
}
|
||||
|
||||
// called when a user leaves the document
|
||||
/**
|
||||
* Removes a user from the presence store
|
||||
*
|
||||
* @param documentId ID of the document to remove the user from
|
||||
* @param userId ID of the user to remove
|
||||
*/
|
||||
@action
|
||||
public leave(documentId: string, userId: string) {
|
||||
const existing = this.data.get(documentId);
|
||||
@@ -34,8 +33,16 @@ export default class PresenceStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the presence store based on an awareness event from YJS
|
||||
*
|
||||
* @param documentId ID of the document the event is for
|
||||
* @param clientId ID of the client the event is for
|
||||
* @param event The awareness event
|
||||
*/
|
||||
public updateFromAwarenessChangeEvent(
|
||||
documentId: string,
|
||||
clientId: number,
|
||||
event: AwarenessChangeEvent
|
||||
) {
|
||||
const presence = this.data.get(documentId);
|
||||
@@ -45,7 +52,13 @@ export default class PresenceStore {
|
||||
|
||||
event.states.forEach((state) => {
|
||||
const { user, cursor } = state;
|
||||
if (user && this.rootStore.auth.currentUserId !== user.id) {
|
||||
|
||||
// To avoid loops we only want to update the presence for the current user
|
||||
// if it is also the current client.
|
||||
const isCurrentUser = this.rootStore.auth.currentUserId === user?.id;
|
||||
const isCurrentClient = clientId === state.clientId;
|
||||
|
||||
if (user && (!isCurrentUser || !isCurrentClient)) {
|
||||
this.update(documentId, user.id, !!cursor);
|
||||
existingUserIds = existingUserIds.filter((id) => id !== user.id);
|
||||
}
|
||||
@@ -56,6 +69,14 @@ export default class PresenceStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the presence store to indicate that a user is present in a document
|
||||
* and then removes the user after a timeout of inactivity.
|
||||
*
|
||||
* @param documentId ID of the document to update
|
||||
* @param userId ID of the user to update
|
||||
* @param isEditing Whether the user is "editing" the document
|
||||
*/
|
||||
public touch(documentId: string, userId: string, isEditing: boolean) {
|
||||
const id = `${documentId}-${userId}`;
|
||||
let timeout = this.timeouts.get(id);
|
||||
@@ -73,6 +94,13 @@ export default class PresenceStore {
|
||||
this.timeouts.set(id, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the presence store to indicate that a user is present in a document.
|
||||
*
|
||||
* @param documentId ID of the document to update
|
||||
* @param userId ID of the user to update
|
||||
* @param isEditing Whether the user is "editing" the document
|
||||
*/
|
||||
@action
|
||||
private update(documentId: string, userId: string, isEditing: boolean) {
|
||||
const presence = this.data.get(documentId) || new Map();
|
||||
@@ -95,4 +123,10 @@ export default class PresenceStore {
|
||||
public clear() {
|
||||
this.data.clear();
|
||||
}
|
||||
|
||||
private timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
private offlineTimeout = 30000;
|
||||
|
||||
private rootStore: RootStore;
|
||||
}
|
||||
|
||||
+24
-1
@@ -206,8 +206,31 @@ export type WebsocketEvent =
|
||||
| WebsocketEntitiesEvent
|
||||
| WebsocketCommentReactionEvent;
|
||||
|
||||
type CursorPosition = {
|
||||
type: {
|
||||
client: number;
|
||||
clock: number;
|
||||
};
|
||||
tname: string | null;
|
||||
item: {
|
||||
client: number;
|
||||
clock: number;
|
||||
};
|
||||
assoc: number;
|
||||
};
|
||||
|
||||
type Cursor = {
|
||||
anchor: CursorPosition;
|
||||
head: CursorPosition;
|
||||
};
|
||||
|
||||
export type AwarenessChangeEvent = {
|
||||
states: { user?: { id: string }; cursor: any; scrollY: number | undefined }[];
|
||||
states: {
|
||||
clientId: number;
|
||||
user?: { id: string };
|
||||
cursor: Cursor;
|
||||
scrollY: number | undefined;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const EmptySelectValue = "__empty__";
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import isEqual from "fast-deep-equal";
|
||||
import uniq from "lodash/uniq";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { yDocToProsemirrorJSON } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { schema, serializer } from "@server/editor";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Document, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
@@ -45,8 +43,6 @@ export default async function documentCollaborativeUpdater({
|
||||
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const content = yDocToProsemirrorJSON(ydoc, "default") as ProsemirrorData;
|
||||
const node = Node.fromJSON(schema, content);
|
||||
const text = serializer.serialize(node, undefined);
|
||||
const isUnchanged = isEqual(document.content, content);
|
||||
const lastModifiedById =
|
||||
sessionCollaboratorIds[sessionCollaboratorIds.length - 1] ??
|
||||
@@ -72,7 +68,6 @@ export default async function documentCollaborativeUpdater({
|
||||
|
||||
await document.update(
|
||||
{
|
||||
text,
|
||||
content,
|
||||
state: Buffer.from(state),
|
||||
lastModifiedById,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Optional } from "utility-types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { Document, Event, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = Optional<
|
||||
@@ -81,53 +82,58 @@ export default async function documentCreator({
|
||||
}
|
||||
}
|
||||
|
||||
const document = await Document.create(
|
||||
{
|
||||
id,
|
||||
urlId,
|
||||
parentDocumentId,
|
||||
editorVersion,
|
||||
collectionId,
|
||||
teamId: user.teamId,
|
||||
createdAt,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
lastModifiedById: user.id,
|
||||
createdById: user.id,
|
||||
template,
|
||||
templateId,
|
||||
publishedAt,
|
||||
importId,
|
||||
sourceMetadata,
|
||||
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
|
||||
icon: templateDocument ? templateDocument.icon : icon,
|
||||
color: templateDocument ? templateDocument.color : color,
|
||||
title:
|
||||
title ??
|
||||
(templateDocument
|
||||
? template
|
||||
? templateDocument.title
|
||||
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
|
||||
: ""),
|
||||
text:
|
||||
text ??
|
||||
(templateDocument
|
||||
? template
|
||||
? templateDocument.text
|
||||
: TextHelper.replaceTemplateVariables(templateDocument.text, user)
|
||||
: ""),
|
||||
content: templateDocument
|
||||
? ProsemirrorHelper.replaceTemplateVariables(
|
||||
await DocumentHelper.toJSON(templateDocument),
|
||||
user
|
||||
)
|
||||
: content,
|
||||
state,
|
||||
},
|
||||
{
|
||||
silent: !!createdAt,
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
const titleWithReplacements =
|
||||
title ??
|
||||
(templateDocument
|
||||
? template
|
||||
? templateDocument.title
|
||||
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
|
||||
: "");
|
||||
|
||||
const contentWithReplacements = text
|
||||
? ProsemirrorHelper.toProsemirror(text).toJSON()
|
||||
: templateDocument
|
||||
? template
|
||||
? templateDocument.content
|
||||
: SharedProsemirrorHelper.replaceTemplateVariables(
|
||||
await DocumentHelper.toJSON(templateDocument),
|
||||
user
|
||||
)
|
||||
: content;
|
||||
|
||||
const document = Document.build({
|
||||
id,
|
||||
urlId,
|
||||
parentDocumentId,
|
||||
editorVersion,
|
||||
collectionId,
|
||||
teamId: user.teamId,
|
||||
createdAt,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
lastModifiedById: user.id,
|
||||
createdById: user.id,
|
||||
template,
|
||||
templateId,
|
||||
publishedAt,
|
||||
importId,
|
||||
sourceMetadata,
|
||||
fullWidth: fullWidth ?? templateDocument?.fullWidth,
|
||||
icon: icon ?? templateDocument?.icon,
|
||||
color: color ?? templateDocument?.color,
|
||||
title: titleWithReplacements,
|
||||
content: contentWithReplacements,
|
||||
state,
|
||||
});
|
||||
|
||||
document.text = DocumentHelper.toMarkdown(document, {
|
||||
includeTitle: false,
|
||||
});
|
||||
|
||||
await document.save({
|
||||
silent: !!createdAt,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "documents.create",
|
||||
|
||||
@@ -52,7 +52,6 @@ export default async function documentDuplicator({
|
||||
DocumentHelper.toProsemirror(document),
|
||||
["comment"]
|
||||
),
|
||||
text: document.text,
|
||||
...sharedProperties,
|
||||
});
|
||||
|
||||
@@ -86,7 +85,6 @@ export default async function documentDuplicator({
|
||||
DocumentHelper.toProsemirror(childDocument),
|
||||
["comment"]
|
||||
),
|
||||
text: childDocument.text,
|
||||
...sharedProperties,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("revisions", "name", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("revisions", "name");
|
||||
},
|
||||
};
|
||||
@@ -830,7 +830,9 @@ class Document extends ArchivableModel<
|
||||
}
|
||||
|
||||
this.content = revision.content;
|
||||
this.text = revision.text;
|
||||
this.text = DocumentHelper.toMarkdown(revision, {
|
||||
includeTitle: false,
|
||||
});
|
||||
this.title = revision.title;
|
||||
this.icon = revision.icon;
|
||||
this.color = revision.color;
|
||||
|
||||
@@ -16,6 +16,5 @@ describe("#findLatest", () => {
|
||||
await Revision.createFromDocument(document);
|
||||
const revision = await Revision.findLatest(document.id);
|
||||
expect(revision?.title).toBe("Changed 2");
|
||||
expect(revision?.text).toBe("Content");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Length as SimpleLength,
|
||||
} from "sequelize-typescript";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import { DocumentValidation, RevisionValidation } from "@shared/validations";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
@@ -42,6 +42,7 @@ class Revision extends IdModel<
|
||||
@Column(DataType.SMALLINT)
|
||||
version?: number | null;
|
||||
|
||||
/** The editor version at the time of the revision */
|
||||
@SimpleLength({
|
||||
max: 255,
|
||||
msg: `editorVersion must be 255 characters or less`,
|
||||
@@ -49,6 +50,7 @@ class Revision extends IdModel<
|
||||
@Column
|
||||
editorVersion: string;
|
||||
|
||||
/** The document title at the time of the revision */
|
||||
@Length({
|
||||
max: DocumentValidation.maxTitleLength,
|
||||
msg: `Revision title must be ${DocumentValidation.maxTitleLength} characters or less`,
|
||||
@@ -56,22 +58,29 @@ class Revision extends IdModel<
|
||||
@Column
|
||||
title: string;
|
||||
|
||||
/** An optional name for the revision */
|
||||
@Length({
|
||||
max: RevisionValidation.maxNameLength,
|
||||
msg: `Revision name must be ${RevisionValidation.maxNameLength} characters or less`,
|
||||
})
|
||||
@Column
|
||||
name: string | null;
|
||||
|
||||
/**
|
||||
* The content of the revision as Markdown.
|
||||
*
|
||||
* @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if exporting lossy markdown.
|
||||
* This column will be removed in a future migration.
|
||||
* @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if
|
||||
* exporting lossy markdown. This column will be removed in a future migration
|
||||
* and is no longer being written.
|
||||
*/
|
||||
@Column(DataType.TEXT)
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* The content of the revision as JSON.
|
||||
*/
|
||||
/** The content of the revision as JSON. */
|
||||
@Column(DataType.JSONB)
|
||||
content: ProsemirrorData | null;
|
||||
|
||||
/** An icon to use as the document icon. */
|
||||
/** The icon at the time of the revision. */
|
||||
@Length({
|
||||
max: 50,
|
||||
msg: `icon must be 50 characters or less`,
|
||||
@@ -79,7 +88,7 @@ class Revision extends IdModel<
|
||||
@Column
|
||||
icon: string | null;
|
||||
|
||||
/** The color of the icon. */
|
||||
/** The color at the time of the revision. */
|
||||
@IsHexColor
|
||||
@Column
|
||||
color: string | null;
|
||||
@@ -126,7 +135,6 @@ class Revision extends IdModel<
|
||||
static buildFromDocument(document: Document) {
|
||||
return this.build({
|
||||
title: document.title,
|
||||
text: document.text,
|
||||
icon: document.icon,
|
||||
color: document.color,
|
||||
content: document.content,
|
||||
|
||||
@@ -147,10 +147,15 @@ export class DocumentHelper {
|
||||
* Returns the document as Markdown. This is a lossy conversion and should only be used for export.
|
||||
*
|
||||
* @param document The document or revision to convert
|
||||
* @param options Options for the conversion
|
||||
* @returns The document title and content as a Markdown string
|
||||
*/
|
||||
static toMarkdown(
|
||||
document: Document | Revision | Collection | ProsemirrorData
|
||||
document: Document | Revision | Collection | ProsemirrorData,
|
||||
options?: {
|
||||
/** Whether to include the document title (default: true) */
|
||||
includeTitle?: boolean;
|
||||
}
|
||||
) {
|
||||
const text = serializer
|
||||
.serialize(DocumentHelper.toProsemirror(document))
|
||||
@@ -165,7 +170,10 @@ export class DocumentHelper {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (document instanceof Document || document instanceof Revision) {
|
||||
if (
|
||||
(document instanceof Document || document instanceof Revision) &&
|
||||
options?.includeTitle !== false
|
||||
) {
|
||||
const iconType = determineIconType(document.icon);
|
||||
|
||||
const title = `${iconType === IconType.Emoji ? document.icon + " " : ""}${
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MentionType, ProsemirrorData } from "@shared/types";
|
||||
import { buildProseMirrorDoc } from "@server/test/factories";
|
||||
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
|
||||
|
||||
describe("ProseMirrorHelper", () => {
|
||||
describe("ProsemirrorHelper", () => {
|
||||
describe("getNodeForMentionEmail", () => {
|
||||
it("should return the paragraph node", () => {
|
||||
const mentionAttrs: MentionAttrs = {
|
||||
|
||||
@@ -118,10 +118,13 @@ export class ProsemirrorHelper {
|
||||
/**
|
||||
* Converts a plain object into a Prosemirror Node.
|
||||
*
|
||||
* @param data The object to parse
|
||||
* @param data The ProsemirrorData object or string to parse.
|
||||
* @returns The content as a Prosemirror Node
|
||||
*/
|
||||
static toProsemirror(data: ProsemirrorData) {
|
||||
static toProsemirror(data: ProsemirrorData | string) {
|
||||
if (typeof data === "string") {
|
||||
return parser.parse(data);
|
||||
}
|
||||
return Node.fromJSON(schema, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import "./fileOperation";
|
||||
import "./integration";
|
||||
import "./pins";
|
||||
import "./reaction";
|
||||
import "./revision";
|
||||
import "./searchQuery";
|
||||
import "./share";
|
||||
import "./star";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { User, Revision } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { and, isTeamMutable, or } from "./utils";
|
||||
|
||||
allow(User, ["update"], Revision, (actor, revision) =>
|
||||
and(
|
||||
//
|
||||
or(actor.id === revision?.userId, actor.isAdmin),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
@@ -42,7 +42,7 @@ async function presentDocument(
|
||||
|
||||
const text =
|
||||
!asData || options?.includeText
|
||||
? document.text || DocumentHelper.toMarkdown(data)
|
||||
? DocumentHelper.toMarkdown(data, { includeTitle: false })
|
||||
: undefined;
|
||||
|
||||
const res: Record<string, any> = {
|
||||
|
||||
@@ -12,6 +12,7 @@ async function presentRevision(revision: Revision, diff?: string) {
|
||||
id: revision.id,
|
||||
documentId: revision.documentId,
|
||||
title: strippedTitle,
|
||||
name: revision.name,
|
||||
data: await DocumentHelper.toJSON(revision),
|
||||
icon: revision.icon ?? emoji,
|
||||
color: revision.color,
|
||||
|
||||
@@ -2,6 +2,7 @@ import isEqual from "fast-deep-equal";
|
||||
import revisionCreator from "@server/commands/revisionCreator";
|
||||
import { Revision, Document, User } from "@server/models";
|
||||
import { DocumentEvent, RevisionEvent, Event } from "@server/types";
|
||||
import DocumentUpdateTextTask from "../tasks/DocumentUpdateTextTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class RevisionsProcessor extends BaseProcessor {
|
||||
@@ -36,6 +37,8 @@ export default class RevisionsProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
await DocumentUpdateTextTask.schedule(event);
|
||||
|
||||
const user = await User.findByPk(event.actorId, {
|
||||
paranoid: false,
|
||||
rejectOnEmpty: true,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { schema, serializer } from "@server/editor";
|
||||
import { Document } from "@server/models";
|
||||
import { DocumentEvent } from "@server/types";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
export default class DocumentUpdateTextTask extends BaseTask<DocumentEvent> {
|
||||
public async perform(event: DocumentEvent) {
|
||||
const document = await Document.findByPk(event.documentId);
|
||||
if (!document?.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = Node.fromJSON(schema, document.content);
|
||||
document.text = serializer.serialize(node);
|
||||
await document.save({ silent: true });
|
||||
}
|
||||
}
|
||||
@@ -169,14 +169,14 @@ describe("revisions.create", () => {
|
||||
|
||||
// Should emit 3 `subscriptions.create` events.
|
||||
expect(events.length).toEqual(3);
|
||||
expect(events[0].name).toEqual("subscriptions.create");
|
||||
expect(events[1].name).toEqual("subscriptions.create");
|
||||
expect(events[2].name).toEqual("subscriptions.create");
|
||||
expect(
|
||||
events.every((event) => event.name === "subscriptions.create")
|
||||
).toEqual(true);
|
||||
|
||||
// Each event should point to same document.
|
||||
expect(events[0].documentId).toEqual(document.id);
|
||||
expect(events[1].documentId).toEqual(document.id);
|
||||
expect(events[2].documentId).toEqual(document.id);
|
||||
expect(events.every((event) => event.documentId === document.id)).toEqual(
|
||||
true
|
||||
);
|
||||
|
||||
// Events should mention correct `userId`.
|
||||
const userIds = events.map((event) => event.userId);
|
||||
@@ -272,16 +272,15 @@ describe("revisions.create", () => {
|
||||
|
||||
// Should emit 2 `subscriptions.create` events.
|
||||
expect(events.length).toEqual(2);
|
||||
expect(events[0].name).toEqual("subscriptions.create");
|
||||
expect(events[1].name).toEqual("subscriptions.create");
|
||||
|
||||
// Each event should point to same document.
|
||||
expect(events[0].documentId).toEqual(document.id);
|
||||
expect(events[1].documentId).toEqual(document.id);
|
||||
|
||||
// Events should mention correct `userId`.
|
||||
expect(events[0].userId).toEqual(collaborator0.id);
|
||||
expect(events[1].userId).toEqual(collaborator1.id);
|
||||
expect(events.every((event) => event.documentId === document.id)).toEqual(
|
||||
true
|
||||
);
|
||||
expect(events.some((event) => event.userId === collaborator0.id)).toEqual(
|
||||
true
|
||||
);
|
||||
expect(events.some((event) => event.userId === collaborator1.id)).toEqual(
|
||||
true
|
||||
);
|
||||
|
||||
// One notification as one collaborator performed edit and the other is
|
||||
// unsubscribed
|
||||
|
||||
@@ -702,7 +702,7 @@ router.post(
|
||||
pagination(),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsListReq>) => {
|
||||
const { includeListOnly, statusFilter } = ctx.input.body;
|
||||
const { includeListOnly, query, statusFilter } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
const collectionIds = await user.collectionIds({ transaction });
|
||||
@@ -728,6 +728,12 @@ router.post(
|
||||
where[Op.and].push({ id: collectionIds });
|
||||
}
|
||||
|
||||
if (query) {
|
||||
where[Op.and].push(
|
||||
Sequelize.literal(`unaccent(LOWER(name)) like unaccent(LOWER(:query))`)
|
||||
);
|
||||
}
|
||||
|
||||
const statusQuery = [];
|
||||
if (statusFilter?.includes(CollectionStatusFilter.Archived)) {
|
||||
statusQuery.push({
|
||||
@@ -743,6 +749,8 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
const replacements = { query: `%${query}%` };
|
||||
|
||||
const [collections, total] = await Promise.all([
|
||||
Collection.scope(
|
||||
statusFilter?.includes(CollectionStatusFilter.Archived)
|
||||
@@ -757,6 +765,7 @@ router.post(
|
||||
}
|
||||
).findAll({
|
||||
where,
|
||||
replacements,
|
||||
order: [
|
||||
Sequelize.literal('"collection"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
@@ -765,7 +774,12 @@ router.post(
|
||||
limit: ctx.state.pagination.limit,
|
||||
transaction,
|
||||
}),
|
||||
Collection.count({ where, transaction }),
|
||||
Collection.count({
|
||||
where,
|
||||
// @ts-expect-error Types are incorrect for count
|
||||
replacements,
|
||||
transaction,
|
||||
}),
|
||||
]);
|
||||
|
||||
const nullIndex = collections.findIndex(
|
||||
|
||||
@@ -178,6 +178,9 @@ export type CollectionsUpdateReq = z.infer<typeof CollectionsUpdateSchema>;
|
||||
export const CollectionsListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
includeListOnly: z.boolean().default(false),
|
||||
|
||||
query: z.string().optional(),
|
||||
|
||||
/** Collection statuses to include in results */
|
||||
statusFilter: z.nativeEnum(CollectionStatusFilter).array().optional(),
|
||||
}),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@shared/types";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { createContext } from "@server/context";
|
||||
import { parser } from "@server/editor";
|
||||
import {
|
||||
Document,
|
||||
View,
|
||||
@@ -3257,21 +3258,26 @@ describe("#documents.restore", () => {
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const previousText = revision.text;
|
||||
const previous = revision.content;
|
||||
const revisionId = revision.id;
|
||||
|
||||
// update the document contents
|
||||
document.text = "UPDATED";
|
||||
document.content = parser.parse("updated")?.toJSON();
|
||||
await document.save();
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
revisionId,
|
||||
},
|
||||
headers: {
|
||||
"x-api-version": 3,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.text).toEqual(previousText);
|
||||
expect(body.data.data).toEqual(previous);
|
||||
});
|
||||
|
||||
it("should not allow restoring a revision in another document", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UserMembership, Revision } from "@server/models";
|
||||
import {
|
||||
buildAdmin,
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildUser,
|
||||
@@ -42,6 +43,61 @@ describe("#revisions.info", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#revisions.update", () => {
|
||||
it("should update a document revision", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
const res = await server.post("/api/revisions.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
name: "new name",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual("new name");
|
||||
});
|
||||
|
||||
it("should allow an admin to update a document revision", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const document = await buildDocument({
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
const res = await server.post("/api/revisions.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: revision.id,
|
||||
name: "new name",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual("new name");
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/revisions.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
name: "new name",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#revisions.diff", () => {
|
||||
it("should return the document HTML if no previous revision", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
@@ -4,11 +4,12 @@ import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Document, Revision } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentRevision } from "@server/presenters";
|
||||
import { presentPolicies, presentRevision } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
@@ -57,6 +58,36 @@ router.post(
|
||||
includeStyles: false,
|
||||
})
|
||||
),
|
||||
policies: presentPolicies(user, [after]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"revisions.update",
|
||||
auth(),
|
||||
validate(T.RevisionsUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.RevisionsUpdateReq>) => {
|
||||
const { id, name } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const revision = await Revision.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
const document = await Document.findByPk(revision.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "update", document);
|
||||
authorize(user, "update", revision);
|
||||
|
||||
revision.name = name;
|
||||
await revision.save({ transaction });
|
||||
|
||||
ctx.body = {
|
||||
data: await presentRevision(revision),
|
||||
policies: presentPolicies(user, [revision]),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -110,6 +141,7 @@ router.post(
|
||||
|
||||
ctx.body = {
|
||||
data: content,
|
||||
policies: presentPolicies(user, [revision]),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -144,6 +176,7 @@ router.post(
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
policies: presentPolicies(user, revisions),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
import { RevisionValidation } from "@shared/validations";
|
||||
import { Revision } from "@server/models";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
@@ -25,6 +26,19 @@ export const RevisionsDiffSchema = BaseSchema.extend({
|
||||
|
||||
export type RevisionsDiffReq = z.infer<typeof RevisionsDiffSchema>;
|
||||
|
||||
export const RevisionsUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: z.string().uuid(),
|
||||
|
||||
name: z
|
||||
.string()
|
||||
.min(RevisionValidation.minNameLength)
|
||||
.max(RevisionValidation.maxNameLength),
|
||||
}),
|
||||
});
|
||||
|
||||
export type RevisionsUpdateReq = z.infer<typeof RevisionsUpdateSchema>;
|
||||
|
||||
export const RevisionsListSchema = z.object({
|
||||
body: z.object({
|
||||
direction: z
|
||||
|
||||
@@ -29,6 +29,7 @@ export type SharesInfoReq = z.infer<typeof SharesInfoSchema>;
|
||||
|
||||
export const SharesListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
query: z.string().optional(),
|
||||
sort: z
|
||||
.string()
|
||||
.refine((val) => Object.keys(Share.getAttributes()).includes(val), {
|
||||
|
||||
@@ -57,6 +57,58 @@ describe("#shares.list", () => {
|
||||
expect(body.data[0].documentTitle).toBe(document.title);
|
||||
});
|
||||
|
||||
it("should allow filtering by document title", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
title: "hardcoded",
|
||||
});
|
||||
await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
query: "test",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should allow filtering by document title and return matching shares", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
title: "test",
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
query: "test",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(share.id);
|
||||
expect(body.data[0].documentTitle).toBe("test");
|
||||
});
|
||||
|
||||
it("should not return revoked shares", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
|
||||
@@ -98,9 +98,10 @@ router.post(
|
||||
pagination(),
|
||||
validate(T.SharesListSchema),
|
||||
async (ctx: APIContext<T.SharesListReq>) => {
|
||||
const { sort, direction } = ctx.input.body;
|
||||
const { sort, direction, query } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "listShares", user.team);
|
||||
const collectionIds = await user.collectionIds();
|
||||
|
||||
const where: WhereOptions<Share> = {
|
||||
teamId: user.teamId,
|
||||
@@ -111,12 +112,21 @@ router.post(
|
||||
},
|
||||
};
|
||||
|
||||
const documentWhere: WhereOptions<Document> = {
|
||||
teamId: user.teamId,
|
||||
collectionId: collectionIds,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
documentWhere.title = {
|
||||
[Op.iLike]: `%${query}%`,
|
||||
};
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
delete where.userId;
|
||||
}
|
||||
|
||||
const collectionIds = await user.collectionIds();
|
||||
|
||||
const options: FindOptions = {
|
||||
where,
|
||||
include: [
|
||||
@@ -125,9 +135,7 @@ router.post(
|
||||
required: true,
|
||||
paranoid: true,
|
||||
as: "document",
|
||||
where: {
|
||||
collectionId: collectionIds,
|
||||
},
|
||||
where: documentWhere,
|
||||
include: [
|
||||
{
|
||||
model: Collection.scope({
|
||||
|
||||
+1
-1
@@ -32,6 +32,6 @@ export const UserPreferenceDefaults: UserPreferences = {
|
||||
[UserPreference.RememberLastPath]: true,
|
||||
[UserPreference.UseCursorPointer]: true,
|
||||
[UserPreference.CodeBlockLineNumers]: true,
|
||||
[UserPreference.SortCommentsByOrderInDocument]: false,
|
||||
[UserPreference.SortCommentsByOrderInDocument]: true,
|
||||
[UserPreference.EnableSmartText]: true,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GapCursor } from "prosemirror-gapcursor";
|
||||
import { Node, NodeType } from "prosemirror-model";
|
||||
import { Command, EditorState, TextSelection } from "prosemirror-state";
|
||||
import {
|
||||
@@ -499,3 +500,46 @@ export function selectTable(): Command {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
export function moveOutOfTable(direction: 1 | -1): Command {
|
||||
return (state, dispatch): boolean => {
|
||||
if (dispatch) {
|
||||
if (state.selection instanceof GapCursor) {
|
||||
return false;
|
||||
}
|
||||
if (!isInTable(state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if current cursor position is at the top or bottom of the table
|
||||
const rect = selectedRect(state);
|
||||
const topOfTable =
|
||||
rect.top === 0 && rect.bottom === 1 && direction === -1;
|
||||
const bottomOfTable =
|
||||
rect.top === rect.map.height - 1 &&
|
||||
rect.bottom === rect.map.height &&
|
||||
direction === 1;
|
||||
|
||||
if (!topOfTable && !bottomOfTable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const map = rect.map.map;
|
||||
const $start = state.doc.resolve(rect.tableStart + map[0] - 1);
|
||||
const $end = state.doc.resolve(rect.tableStart + map[map.length - 1] + 2);
|
||||
|
||||
// @ts-expect-error findGapCursorFrom is a ProseMirror internal method.
|
||||
const $found = GapCursor.findGapCursorFrom(
|
||||
direction > 0 ? $end : $start,
|
||||
direction,
|
||||
true
|
||||
);
|
||||
|
||||
if ($found) {
|
||||
dispatch(state.tr.setSelection(new GapCursor($found)));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
setTableAttr,
|
||||
deleteColSelection,
|
||||
deleteRowSelection,
|
||||
moveOutOfTable,
|
||||
} from "../commands/table";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { FixTablesPlugin } from "../plugins/FixTables";
|
||||
@@ -95,6 +96,8 @@ export default class Table extends Node {
|
||||
deleteColSelection(),
|
||||
deleteRowSelection()
|
||||
),
|
||||
ArrowDown: moveOutOfTable(1),
|
||||
ArrowUp: moveOutOfTable(-1),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
import { isMac } from "./browser";
|
||||
|
||||
/**
|
||||
* Returns the display string for the alt key
|
||||
*/
|
||||
export const altDisplay = isMac() ? "⌥" : "Alt";
|
||||
|
||||
/**
|
||||
* Returns the display string for the meta key
|
||||
*/
|
||||
export const metaDisplay = isMac() ? "⌘" : "Ctrl";
|
||||
|
||||
/**
|
||||
* Returns the name of the modifier key
|
||||
*/
|
||||
export const meta = isMac() ? "cmd" : "ctrl";
|
||||
|
||||
/**
|
||||
* Returns true if the given event is a modifier key (Cmd or Ctrl on Mac, Alt on
|
||||
* @param event The event to check
|
||||
* @returns True if the event is a modifier key
|
||||
*/
|
||||
export function isModKey(
|
||||
event: KeyboardEvent | MouseEvent | React.KeyboardEvent
|
||||
) {
|
||||
return isMac() ? event.metaKey : event.ctrlKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string with the appropriate display strings for the given key
|
||||
*
|
||||
* @param key The key to display
|
||||
* @returns The display string for the key
|
||||
*/
|
||||
export function normalizeKeyDisplay(key: string) {
|
||||
return key
|
||||
.replace(/Meta/i, metaDisplay)
|
||||
.replace(/Cmd/i, metaDisplay)
|
||||
.replace(/Alt/i, altDisplay)
|
||||
.replace(/Control/i, metaDisplay)
|
||||
.replace(/Shift/i, "⇧");
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ export const DocumentValidation = {
|
||||
maxStateLength: 1500 * 1024,
|
||||
};
|
||||
|
||||
export const RevisionValidation = {
|
||||
minNameLength: 1,
|
||||
maxNameLength: 255,
|
||||
};
|
||||
|
||||
export const PinValidation = {
|
||||
/** The maximum number of pinned documents on an individual collection or home screen */
|
||||
max: 8,
|
||||
|
||||
Reference in New Issue
Block a user