Compare commits

..
32 changed files with 293 additions and 1110 deletions
@@ -18,7 +18,6 @@ export type FieldProps = {
rowId: string;
readOnly: boolean;
onChange: (value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
};
type FieldShellProps = {
@@ -100,10 +99,9 @@ type DetailFieldProps = {
row: IBaseRow;
readOnly: boolean;
onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange: (editing: boolean) => void;
};
export function DetailField({ property, row, readOnly, onUpdate, onEditingChange }: DetailFieldProps) {
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
const descriptor = getDescriptor(property.type);
const value = descriptor?.systemAccessor
? descriptor.systemAccessor(row)
@@ -114,7 +112,6 @@ export function DetailField({ property, row, readOnly, onUpdate, onEditingChange
rowId: row.id,
readOnly,
onChange: (next: unknown) => onUpdate(property.id, next),
onEditingChange
};
switch (property.type) {
@@ -9,13 +9,7 @@ const normalize = (s: string) => {
return trimmed.length ? trimmed : null;
};
export function FieldLongText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
@@ -29,7 +23,6 @@ export function FieldLongText({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
@@ -57,10 +50,7 @@ export function FieldLongText({
className={classes.fieldTextarea}
classNames={{ input: classes.fieldTextareaInput }}
value={draft}
onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -11,13 +11,7 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
export function FieldNumber({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
const [draft, setDraft] = useState(toDraft(value));
@@ -42,7 +36,6 @@ export function FieldNumber({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(toDraft(value));
@@ -61,7 +54,6 @@ export function FieldNumber({
onFocus={() => {
setDraft(toDraft(value));
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => {
const v = e.target.value;
@@ -5,13 +5,7 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
export function FieldText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
@@ -26,7 +20,6 @@ export function FieldText({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
@@ -61,10 +54,7 @@ export function FieldText({
className={classes.fieldInput}
value={draft}
maxLength={1000}
onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -17,7 +17,6 @@ type PropertyRowProps = {
onMenuOpenChange: (opened: boolean) => void;
onMenuDirtyChange: (dirty: boolean) => void;
onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
autoFocusValue?: boolean;
onAutoFocused?: () => void;
};
@@ -30,7 +29,6 @@ export function PropertyRow({
onMenuOpenChange,
onMenuDirtyChange,
onUpdate,
onEditingChange,
autoFocusValue,
onAutoFocused,
}: PropertyRowProps) {
@@ -114,7 +112,6 @@ export function PropertyRow({
row={row}
readOnly={!canEdit}
onUpdate={onUpdate}
onEditingChange={onEditingChange}
/>
</div>
);
@@ -75,7 +75,6 @@ export function RowDetailModal({
const isSaving = updateRowMutation.isPending;
const opened = !!openRowId;
const [editingField, setEditingField] = useState(false);
// One field menu open at a time, mirroring the grid header's semantics.
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
@@ -91,7 +90,6 @@ export function RowDetailModal({
useEffect(() => {
setOpenMenuId(null);
menuDirtyRef.current = false;
setEditingField(false);
}, [openRowId]);
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
@@ -295,7 +293,7 @@ export function RowDetailModal({
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onEditingChange={setEditingField}
onClose={onClose}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
@@ -319,7 +317,6 @@ export function RowDetailModal({
autoFocusValue={property.id === newPropertyId}
onAutoFocused={clearNewProperty}
menuOpened={openMenuId === property.id}
onEditingChange={setEditingField}
onMenuOpenChange={(nextOpened) =>
handleMenuOpenChange(property.id, nextOpened)
}
@@ -370,38 +367,16 @@ export function RowDetailModal({
) : null}
</div>
<div className={classes.kbdHint}>
{editingField ? (
{rowIndex >= 0 && rows.length > 1 && (
<>
<span className={classes.kbdGroup}>
<kbd className={classes.kbd}>Ctrl/Cmd</kbd>
<span className={classes.kbdPlus} >+</span>
<kbd className={classes.kbd}>Enter</kbd>
<span>{t("to save")}</span>
</span>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
<span className={classes.kbdGroup}>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to reset")}</span>
</span>
</>
) : (
<>
{rowIndex >= 0 && rows.length > 1 && (
<>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
</>
)}
<>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</>
</>
)}
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</div>
</footer>
</>
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { timeAgo } from "@/lib/time.ts";
@@ -9,7 +9,7 @@ type RowDetailTitleProps = {
primaryProperty: IBaseProperty | undefined;
canEdit: boolean;
onCommit: (value: string) => void;
onEditingChange?: (editing: boolean) => void;
onClose: () => void;
};
export function RowDetailTitle({
@@ -17,24 +17,13 @@ export function RowDetailTitle({
primaryProperty,
canEdit,
onCommit,
onEditingChange,
onClose,
}: RowDetailTitleProps) {
const { t } = useTranslation();
const initial = primaryProperty
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: "";
const [value, setValue] = useState(initial);
const cancelRef = useRef(false);
const commit = () => {
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setValue(initial);
return;
}
if (value !== initial) onCommit(value);
};
// Re-sync when the row changes underneath us (navigation or remote edit).
useEffect(() => {
@@ -54,18 +43,18 @@ export function RowDetailTitle({
aria-label={primaryProperty?.name ?? t("Untitled")}
value={value}
maxLength={1000}
onFocus={() => {
onEditingChange?.(true);
}}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={commit}
onBlur={() => {
if (value !== initial) onCommit(value);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if (e.key === "Enter") {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === "Escape") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
onClose();
}
}}
/>
@@ -416,25 +416,9 @@
}
.kbdHint {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
width: 100%;
}
.kbdGroup {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
height: fit-content;
}
.kbdPlus {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: 11px;
}
.kbdSeparator {
@@ -66,7 +66,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
}
// Clear search term in editor
if (isEditorReady(editor)) {
editor.commands.setSearchTerms([""]);
editor.commands.setSearchTerm("");
}
};
@@ -117,7 +117,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
useEffect(() => {
if (!isEditorReady(editor)) return;
editor.commands.setSearchTerms([searchText]);
editor.commands.setSearchTerm(searchText);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
}, [searchText]);
@@ -181,10 +181,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
const location = useLocation();
useEffect(() => {
if (pageFindState.isOpen) {
closeDialog();
}
}, [location.pathname]);
closeDialog();
}, [location]);
return (
<Dialog
@@ -1,205 +0,0 @@
import { ActionIcon, Dialog, Flex, Text, Tooltip } from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
import React, { useCallback, useEffect, useRef, useState } from "react";
import classes from "./search-replace.module.css";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
interface SearchNavigationDialogProps {
editor: ReturnType<typeof useEditor>;
}
interface SearchNavigationEvent extends CustomEvent {
detail: {
searchTerms: string[];
wholeWord?: boolean;
};
}
function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const openRef = useRef(false);
const [resultState, setResultState] = useState({
resultIndex: 0,
resultsLength: 0,
});
const goToSelection = () => {
if (!isEditorReady(editor)) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
const position = results[resultIndex];
setResultState({
resultsLength: results.length,
resultIndex,
});
if (!position) return;
requestAnimationFrame(() => {
document
.querySelector(".search-result-current")
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
};
const next = () => {
if (!isEditorReady(editor)) return;
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
if (!isEditorReady(editor)) return;
editor.commands.previousSearchResult();
goToSelection();
};
const close = useCallback(() => {
if (!openRef.current) return;
openRef.current = false;
setOpen(false);
if (isEditorReady(editor)) {
editor.commands.setSearchTerms([""]);
}
const nextParams = new URLSearchParams(location.search);
nextParams.delete("q");
nextParams.delete("m");
const nextSearch = nextParams.toString();
navigate(
{
pathname: location.pathname,
search: nextSearch ? `?${nextSearch}` : "",
hash: location.hash,
},
{ replace: true },
);
}, [editor, location.hash, location.pathname, location.search, navigate]);
useEffect(() => {
const handleOpen = (event: Event) => {
const { searchTerms: terms, wholeWord = true } = (
event as SearchNavigationEvent
).detail;
if (!terms?.length || !isEditorReady(editor)) return;
openRef.current = false;
editor.commands.setSearchTerms(terms);
editor.commands.setWholeWord(wholeWord);
editor.commands.resetIndex();
const { results, resultIndex } = editor.storage.searchAndReplace;
openRef.current = true;
if (results.length === 0) {
close();
return;
}
setOpen(true);
setResultState({
resultIndex,
resultsLength: results.length,
});
goToSelection();
};
const handleClose = () => {
if (openRef.current) {
close();
}
};
document.addEventListener("openSearchNavigationDialog", handleOpen);
document.addEventListener("openFindDialogFromEditor", handleClose);
document.addEventListener("closeFindDialogFromEditor", handleClose);
return () => {
document.removeEventListener("openSearchNavigationDialog", handleOpen);
document.removeEventListener("openFindDialogFromEditor", handleClose);
document.removeEventListener("closeFindDialogFromEditor", handleClose);
};
}, [close, editor]);
useEffect(() => {
const handleTransaction = () => {
if (!openRef.current || editor.isDestroyed) return;
const { results } = editor.storage.searchAndReplace;
if (results.length === 0) {
close();
}
};
editor.on("transaction", handleTransaction);
return () => {
editor.off("transaction", handleTransaction);
};
}, [close, editor]);
return (
<Dialog
className={classes.findDialog}
opened={open}
size="xs"
radius="md"
w="auto"
position={{ top: 90, right: 50 }}
withBorder
aria-label="Search navigation"
>
<Flex align="center" gap="xs">
<Text size="xs" style={{ flex: 1 }}>
{resultState.resultsLength > 0
? `${resultState.resultIndex + 1}/${resultState.resultsLength}`
: t("Not found")}
</Text>
<Tooltip label="Previous match">
<ActionIcon
variant="subtle"
color="gray"
onClick={previous}
aria-label="Previous match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Next match">
<ActionIcon
variant="subtle"
color="gray"
onClick={next}
aria-label="Next match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Close">
<ActionIcon
variant="subtle"
color="gray"
onClick={close}
aria-label="Close"
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Dialog>
);
}
export default SearchNavigationDialog;
@@ -1,47 +0,0 @@
import { useEffect, useRef } from "react";
import type { useEditor } from "@tiptap/react";
interface UseSearchNavigationParamsProps {
editor: ReturnType<typeof useEditor>;
isSynced: boolean;
pageId: string;
searchParams: URLSearchParams;
showStatic: boolean;
}
export function useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
}: UseSearchNavigationParamsProps) {
const appliedSearchKeyRef = useRef<string | null>(null);
const searchKey = `${pageId}:${searchParams.toString()}`;
useEffect(() => {
const searchQueries = searchParams.getAll("q");
if (!searchQueries.length) {
appliedSearchKeyRef.current = null;
return;
}
if (
!editor ||
editor.isDestroyed ||
!editor.view.dom.isConnected ||
appliedSearchKeyRef.current === searchKey
) {
return;
}
const match = searchParams.get("m");
appliedSearchKeyRef.current = searchKey;
document.dispatchEvent(
new CustomEvent("openSearchNavigationDialog", {
detail: { searchTerms: searchQueries, wholeWord: match === "whole" },
}),
);
}, [editor, isSynced, searchKey, searchParams, showStatic]);
}
@@ -1,112 +0,0 @@
import { Editor, Node } from "@tiptap/core";
import { GapCursor } from "@tiptap/pm/gapcursor";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { TiptapDocument } from "./document";
const Footnotes = Node.create({
name: "footnotes",
group: "",
content: "paragraph*",
isolating: true,
renderHTML() {
return ["ol", { class: "footnotes" }, 0];
},
});
const AtomBlock = Node.create({
name: "atomBlock",
group: "block",
atom: true,
renderHTML() {
return ["div", { "data-atom-block": "" }];
},
});
const IsolatingBlock = Node.create({
name: "isolatingBlock",
group: "block",
content: "paragraph+",
isolating: true,
renderHTML() {
return ["div", { "data-isolating-block": "" }, 0];
},
});
function createEditor(content: object[]) {
const element = document.createElement("div");
document.body.appendChild(element);
return new Editor({
element,
extensions: [
TiptapDocument,
StarterKit.configure({ document: false }),
Footnotes,
AtomBlock,
IsolatingBlock,
],
content: { type: "doc", content },
});
}
function pressKey(editor: Editor, key: string, keyCode: number) {
editor.view.dom.dispatchEvent(
new KeyboardEvent("keydown", {
key,
keyCode,
bubbles: true,
cancelable: true,
}),
);
}
describe("TiptapDocument", () => {
it("stops on the gap when arrowing down from a selected block node", () => {
const editor = createEditor([
{ type: "atomBlock" },
{ type: "atomBlock" },
{ type: "paragraph" },
]);
const gapPos = editor.state.doc.child(0).nodeSize;
editor.view.dispatch(
editor.state.tr.setSelection(
NodeSelection.create(editor.state.doc, 0),
),
);
pressKey(editor, "ArrowDown", 40);
expect(editor.state.selection).toBeInstanceOf(GapCursor);
expect(editor.state.selection.head).toBe(gapPos);
editor.destroy();
});
it("stops on the gap when arrowing right out of an isolating block", () => {
const paragraph = (text: string) => ({
type: "paragraph",
content: [{ type: "text", text }],
});
const editor = createEditor([
{ type: "isolatingBlock", content: [paragraph("a")] },
{ type: "isolatingBlock", content: [paragraph("b")] },
{ type: "paragraph" },
]);
const gapPos = editor.state.doc.child(0).nodeSize;
const endOfFirstText = gapPos - 2;
editor.view.dispatch(
editor.state.tr.setSelection(
TextSelection.create(editor.state.doc, endOfFirstText),
),
);
pressKey(editor, "ArrowRight", 39);
expect(editor.state.selection).toBeInstanceOf(GapCursor);
expect(editor.state.selection.head).toBe(gapPos);
editor.destroy();
});
});
@@ -1,8 +0,0 @@
import { Document } from "@tiptap/extension-document";
// With `block+ footnotes?`, ProseMirror's defaultType after the first block is
// `footnotes` (not a textblock), so GapCursor.valid() rejects every top-level gap.
export const TiptapDocument = Document.extend({
content: "block+ footnotes?",
allowGapCursor: true,
});
@@ -1,6 +1,6 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { TiptapDocument } from "@/features/editor/extensions/document";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
@@ -148,7 +148,9 @@ export const mainExtensions = [
codeBlock: false,
code: false,
}),
TiptapDocument,
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
@@ -65,13 +65,11 @@ import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import SearchNavigationDialog from "@/features/editor/components/search-and-replace/search-navigation-dialog.tsx";
import { useSearchNavigationParams } from "@/features/editor/components/search-and-replace/use-search-navigation-params.ts";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams, useSearchParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { extractPageSlugId, platformModifierKey } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
@@ -201,7 +199,6 @@ function CollabPageEditor({
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const [searchParams] = useSearchParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback(
@@ -440,14 +437,6 @@ function CollabPageEditor({
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
});
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
@@ -471,7 +460,6 @@ function CollabPageEditor({
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && <SearchNavigationDialog editor={editor} />}
{editor && editorIsEditable && (
<div>
+1 -35
View File
@@ -25,37 +25,11 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
return `${titleSlug}-${pageSlugId}`;
};
function appendSearchParams(
url: string,
search?: string[],
wholeWord?: boolean,
): string {
if(search?.length === 0){
return url;
}
const params = new URLSearchParams();
search
?.map((term) => term.trim())
.filter(Boolean)
.forEach((term) => params.append("q", term));
if (wholeWord) {
params.set("m", "whole");
}
const queryString = params.toString();
return queryString ? `${url}?${queryString}` : url;
}
export const buildPageUrl = (
spaceName: string,
pageSlugId: string,
pageTitle?: string,
anchorId?: string,
search?: string[],
wholeWord?: boolean,
): string => {
let url: string;
if (spaceName === undefined) {
@@ -63,9 +37,6 @@ export const buildPageUrl = (
} else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
url = appendSearchParams(url, search, wholeWord);
return anchorId ? `${url}#${anchorId}` : url;
};
@@ -74,19 +45,14 @@ export const buildSharedPageUrl = (opts: {
pageSlugId: string;
pageTitle?: string;
anchorId?: string;
search?: string[];
wholeWord?: boolean;
}): string => {
const { shareId, pageSlugId, pageTitle, anchorId, search, wholeWord } = opts;
const { shareId, pageSlugId, pageTitle, anchorId } = opts;
let url: string;
if (!shareId) {
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
url = appendSearchParams(url, search, wholeWord);
return anchorId ? `${url}#${anchorId}` : url;
};
@@ -25,8 +25,6 @@ import { SearchMobileControl } from "@/features/search/components/search-control
import styles from "./docs.module.css";
const MemoizedDocsSidebarTree = React.memo(DocsSidebarTree);
const MANTINE_COLOR_SCHEME_ATTRIBUTE = "data-mantine-color-scheme";
const DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE = "data-docs-print-color-scheme";
type DocsShellProps = {
surface: DocsSurface;
@@ -49,45 +47,6 @@ export default function DocsShell({
);
const [mobileTocOpen, setMobileTocOpen] = useAtom(docsMobileTocAtom);
React.useEffect(() => {
const root = document.documentElement;
let previousColorScheme: string | null = null;
let isPrinting = false;
const restoreColorScheme = () => {
if (!isPrinting) return;
if (previousColorScheme === null) {
root.removeAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE);
} else {
root.setAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE, previousColorScheme);
}
root.removeAttribute(DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE);
isPrinting = false;
};
const useLightPrintTheme = () => {
if (isPrinting) return;
previousColorScheme = root.getAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE);
root.setAttribute(
DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE,
previousColorScheme ?? "light",
);
root.setAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE, "light");
isPrinting = true;
};
window.addEventListener("beforeprint", useLightPrintTheme);
window.addEventListener("afterprint", restoreColorScheme);
return () => {
window.removeEventListener("beforeprint", useLightPrintTheme);
window.removeEventListener("afterprint", restoreColorScheme);
restoreColorScheme();
};
}, []);
return (
<DocsSurfaceProvider value={surface}>
<div className={clsx(styles.root, "public-typography")}>
@@ -15,6 +15,8 @@
--docs-accent: #2b7af1;
--docs-accent-soft: color-mix(in srgb, var(--docs-accent) 10%, transparent);
/* Cloudflare-style single-ink model: one foreground for headings, bold, and
* body on a just-off-white page; neither end of the scale is pure. */
--docs-bg: oklch(99% 0 0);
--docs-fg: oklch(21% 0 0);
--docs-content-fg: var(--docs-fg);
@@ -408,7 +410,7 @@
}
}
/* Expanded parents read as section headers. */
/* Expanded parents read as section headers, Cloudflare-style. */
.treeRow[data-open-parent="true"] {
color: var(--docs-fg);
font-weight: 500;
@@ -541,6 +543,8 @@
}
}
/* ---------- Sidebar branding experiments (GitBook card / ReadMe line) ---------- */
/* ---------- Footer branding ---------- */
.footer {
@@ -769,11 +773,13 @@
color: inherit;
}
/* Modest semibold heading scale (Cloudflare-style); class doubled to outrank
* the shared editor and .public-typography rules. */
.root.root :global(.ProseMirror) h1 {
font-size: 1.75rem;
font-size: 2.1875rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.3;
letter-spacing: -0.025em;
line-height: 1.25;
}
.root.root :global(.ProseMirror) h2 {
@@ -894,59 +900,3 @@
display: flex;
flex-direction: column;
}
/* ---------- Print ---------- */
@page public-doc {
background-color: #fff;
}
/* Only the article prints; the body grid collapses so it takes the page width. */
@media print {
:global(html):has(.root),
:global(body):has(.root) {
page: public-doc;
background-color: #fff !important;
}
:global(html[data-docs-print-color-scheme="dark"])
.root.root
:global(.codeBlock svg) {
filter: invert(1) hue-rotate(180deg);
}
.header,
.sidebar,
.toc,
.articleActions,
.breadcrumbs,
.pageNav,
.footer {
display: none !important;
}
.root {
--docs-bg: #fff;
--docs-fg: #1f1f1f;
--docs-content-fg: var(--docs-fg);
--docs-nav-fg: #495057;
--docs-muted: #5f6368;
--docs-hover: #f1f3f5;
--docs-faint: #868e96;
--docs-border: #dee2e6;
--docs-header-bg: #fff;
min-height: 0;
background-color: #fff;
color: var(--docs-fg);
}
.body {
display: block;
}
.article {
max-width: none;
padding: 0;
}
}
@@ -117,9 +117,6 @@ export function SearchResultItem({
pageResult.space.slug,
pageResult.slugId,
pageResult.title,
undefined,
pageResult.matchedText,
pageResult.wholeWord
)}
style={{ userSelect: "none" }}
>
@@ -14,8 +14,6 @@ export interface IPageSearch {
updatedAt: Date;
rank: string;
highlight: string;
matchedText: string[];
wholeWord: boolean;
space: Partial<ISpace>;
}
@@ -1,6 +1,5 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
@@ -21,7 +20,7 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto';
import { generateSlugId } from '../../../common/helpers';
import { getPageTitle } from '../../../common/helpers';
import { dbOrTx, executeTx } from '@docmost/db/utils';
import { executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { v7 as uuid7 } from 'uuid';
import {
@@ -175,14 +174,10 @@ export class PageService {
return page;
}
async nextPagePosition(
spaceId: string,
parentPageId?: string,
trx?: KyselyTransaction,
) {
async nextPagePosition(spaceId: string, parentPageId?: string) {
let pagePosition: string;
const lastPageQuery = dbOrTx(this.db, trx)
const lastPageQuery = this.db
.selectFrom('pages')
.select(['position'])
.where('spaceId', '=', spaceId)
@@ -396,46 +391,35 @@ export class PageService {
}
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
return executeTx(this.db, async (trx) => {
await this.pageRepo.lockPageHierarchySpaces(
[rootPage.spaceId, spaceId],
trx,
);
let childPageIds: string[] = [];
const currentRootPage = await this.pageRepo.findById(rootPage.id, {
trx,
});
if (!currentRootPage || currentRootPage.deletedAt) {
throw new NotFoundException('Page to move not found');
}
if (currentRootPage.spaceId !== rootPage.spaceId) {
throw new ConflictException('Page location changed; retry the move');
}
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
});
const allPages = await this.pageRepo.getPageAndDescendants(
currentRootPage.id,
{ includeContent: false, trx },
);
const accessiblePages = await this.filterAccessibleTreePages(
allPages,
currentRootPage.id,
userId,
currentRootPage.spaceId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
const pagesToOrphan = allPages.filter(
(p) =>
!accessibleIds.has(p.id) &&
p.parentPageId &&
accessibleIds.has(p.parentPageId),
);
// Filter to only accessible pages while maintaining tree integrity
const accessiblePages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
userId,
rootPage.spaceId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
// Find inaccessible pages whose parent is being moved - these need to be orphaned
const pagesToOrphan = allPages.filter(
(p) =>
!accessibleIds.has(p.id) &&
p.parentPageId &&
accessibleIds.has(p.parentPageId),
);
await executeTx(this.db, async (trx) => {
// Orphan inaccessible child pages (make them root pages in original space)
for (const page of pagesToOrphan) {
const orphanPosition = await this.nextPagePosition(
currentRootPage.spaceId,
rootPage.spaceId,
null,
trx,
);
await this.pageRepo.updatePage(
{ parentPageId: null, position: orphanPosition },
@@ -445,18 +429,16 @@ export class PageService {
}
// Update root page
const nextPosition = await this.nextPagePosition(spaceId, null, trx);
const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage(
{ spaceId, parentPageId: null, position: nextPosition },
currentRootPage.id,
rootPage.id,
trx,
);
const pageIdsToMove = accessiblePages.map((p) => p.id);
const childPageIds = pageIdsToMove.filter(
(id) => id !== currentRootPage.id,
);
childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id);
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
@@ -519,7 +501,7 @@ export class PageService {
{
pageIds: pageIdsToMove,
spaceId,
workspaceId: currentRootPage.workspaceId,
workspaceId: rootPage.workspaceId,
},
{
attempts: 2,
@@ -530,9 +512,9 @@ export class PageService {
},
);
}
return { childPageIds };
});
return { childPageIds };
}
async duplicatePage(
@@ -843,59 +825,31 @@ export class PageService {
throw new BadRequestException('A page cannot be its own parent');
}
await executeTx(this.db, async (trx) => {
await this.pageRepo.lockPageHierarchySpaces(
[movedPage.spaceId],
trx,
);
const currentPage = await this.pageRepo.findById(dto.pageId, { trx });
if (!currentPage || currentPage.deletedAt) {
throw new NotFoundException('Moved page not found');
}
if (currentPage.spaceId !== movedPage.spaceId) {
throw new ConflictException('Page location changed; retry the move');
}
let parentPageId = null;
if (currentPage.parentPageId === dto.parentPageId) {
parentPageId = undefined;
} else {
if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId, {
trx,
});
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== currentPage.spaceId
) {
throw new NotFoundException('Parent page not found');
}
if (
await this.pageRepo.isPageDescendant(
dto.pageId,
parentPage.id,
trx,
)
) {
throw new BadRequestException(
'A page cannot be moved under its descendant',
);
}
parentPageId = parentPage.id;
let parentPageId = null;
if (movedPage.parentPageId === dto.parentPageId) {
parentPageId = undefined;
} else {
// changing the page's parent
if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId);
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== movedPage.spaceId
) {
throw new NotFoundException('Parent page not found');
}
parentPageId = parentPage.id;
}
}
await this.pageRepo.updatePage(
{
position: dto.position,
parentPageId: parentPageId,
},
dto.pageId,
trx,
);
});
await this.pageRepo.updatePage(
{
position: dto.position,
parentPageId: parentPageId,
},
dto.pageId,
);
}
async getPageBreadCrumbs(childPageId: string) {
@@ -8,8 +8,6 @@ export class SearchResponseDto {
creatorId: string;
rank: number;
highlight: string;
matchedText: string[];
wholeWord: boolean;
createdAt: Date;
updatedAt: Date;
space: Partial<Space>;
+4 -18
View File
@@ -191,25 +191,11 @@ export class SearchService {
//@ts-ignore
const searchResults = results.map((result: SearchResponseDto) => {
result.wholeWord = true
if (!result.highlight) {
result.matchedText = [];
return result;
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' ');
}
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' ');
result.matchedText = [
...new Set(
Array.from(
result.highlight.matchAll(/<b>([^<]*)<\/b>/gi),
(match) => match[1],
),
),
];
return result;
});
@@ -60,21 +60,16 @@ export class ShareSeoController {
const pageId = this.extractPageSlugId(pageSlug);
let title: string;
let searchIndexing = false;
try {
const shared = await this.shareService.getSharedPage(
{ pageId },
workspace.id,
{ includeContent: false },
);
title = shared.page.title;
searchIndexing = shared.share.searchIndexing;
} catch (err) {
const share = await this.shareService.getShareForPage(
pageId,
workspace.id,
);
if (!share) {
return this.sendIndex(indexFilePath, res);
}
const rawTitle = htmlEscape(title ?? 'untitled');
const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
const metaTitle =
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}` : rawTitle;
@@ -83,7 +78,7 @@ export class ShareSeoController {
const metaTags = [
`<meta property="og:title" content="${metaTitle}" />`,
`<meta property="twitter:title" content="${metaTitle}" />`,
!searchIndexing ? `<meta name="robots" content="noindex" />` : '',
!share.searchIndexing ? `<meta name="robots" content="noindex" />` : '',
]
.filter(Boolean)
.join('\n ');
+6 -15
View File
@@ -110,11 +110,7 @@ export class ShareService {
}
}
async getSharedPage(
dto: ShareInfoDto,
workspaceId: string,
opts?: { includeContent?: boolean },
) {
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
//TODO: we should resolve the page from the share id
if (!dto.pageId) throw new NotFoundException('Shared page not found');
@@ -124,13 +120,10 @@ export class ShareService {
throw new NotFoundException('Shared page not found');
}
const includeContent = opts?.includeContent !== false;
const page = includeContent
? await this.pageRepo.findById(dto.pageId, {
includeContent: true,
includeCreator: true,
})
: await this.pageRepo.findById(dto.pageId);
const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true,
includeCreator: true,
});
if (!page || page.deletedAt) {
throw new NotFoundException('Shared page not found');
@@ -144,9 +137,7 @@ export class ShareService {
throw new NotFoundException('Shared page not found');
}
if (includeContent) {
page.content = await this.updatePublicAttachments(page);
}
page.content = await this.updatePublicAttachments(page);
return { page, share };
}
@@ -10,7 +10,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
import { InjectKysely } from 'nestjs-kysely';
import { Space, User } from '@docmost/db/types/entity.types';
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
@@ -218,18 +218,41 @@ export class SpaceMemberService {
dto: RemoveSpaceMemberDto,
workspaceId: string,
): Promise<void> {
const memberTypeId = dto.userId
? { userId: dto.userId }
: dto.groupId
? { groupId: dto.groupId }
: null;
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
if (!memberTypeId) {
let spaceMember: SpaceMember = null;
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
throw new BadRequestException(
'Please provide a valid userId or groupId to remove',
);
}
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId);
}
let affectedUserIds: string[] = [];
if (dto.userId) {
affectedUserIds = [dto.userId];
@@ -239,29 +262,7 @@ export class SpaceMemberService {
);
}
const { space, spaceMember } = await executeTx(this.db, async (trx) => {
const space = await this.spaceRepo.findById(
dto.spaceId,
workspaceId,
{ withLock: true, trx },
);
if (!space) {
throw new NotFoundException('Space not found');
}
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
memberTypeId,
trx,
);
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId, trx);
}
await executeTx(this.db, async (trx) => {
await this.spaceMemberRepo.removeSpaceMemberById(
spaceMember.id,
dto.spaceId,
@@ -279,8 +280,6 @@ export class SpaceMemberService {
dto.spaceId,
{ trx },
);
return { space, spaceMember };
});
this.auditService.log({
@@ -305,40 +304,48 @@ export class SpaceMemberService {
dto: UpdateSpaceMemberRoleDto,
workspaceId: string,
): Promise<void> {
const memberTypeId = dto.userId
? { userId: dto.userId }
: dto.groupId
? { groupId: dto.groupId }
: null;
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
if (!memberTypeId) {
let spaceMember: SpaceMember = null;
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
throw new BadRequestException(
'Please provide a valid userId or groupId to remove',
);
}
const result = await executeTx(this.db, async (trx) => {
const space = await this.spaceRepo.findById(
dto.spaceId,
workspaceId,
{ withLock: true, trx },
);
if (!space) {
throw new NotFoundException('Space not found');
}
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
memberTypeId,
trx,
);
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === dto.role) {
return;
}
if (spaceMember.role === dto.role) {
return { changed: false, space, spaceMember };
}
await executeTx(this.db, async (trx) => {
await trx
.selectFrom('spaces')
.select('id')
.where('id', '=', dto.spaceId)
.forUpdate()
.executeTakeFirst();
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId, trx);
@@ -350,16 +357,8 @@ export class SpaceMemberService {
dto.spaceId,
trx,
);
return { changed: true, space, spaceMember };
});
if (!result.changed) {
return;
}
const { space, spaceMember } = result;
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
resourceType: AuditResource.SPACE_MEMBER,
@@ -388,7 +387,7 @@ export class SpaceMemberService {
spaceId,
trx,
);
if (spaceOwnerCount <= 1) {
if (spaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one space admin with full access',
);
@@ -747,61 +747,44 @@ export class WorkspaceService {
userRoleDto: UpdateWorkspaceUserRoleDto,
workspaceId: string,
) {
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
const newRole = userRoleDto.role.toLowerCase();
const result = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const user = await this.userRepo.findById(
userRoleDto.userId,
workspaceId,
{ trx },
);
if (!user) {
throw new BadRequestException('Workspace member not found');
}
if (
isAdminActingOnOwner(authUser.role, newRole) ||
isAdminActingOnOwner(authUser.role, user.role)
) {
throw new ForbiddenException();
}
if (user.role === newRole) {
return { changed: false, user };
}
if (
user.role === UserRole.OWNER &&
!user.deletedAt &&
!user.deactivatedAt
) {
await this.validateLastWorkspaceOwner(workspaceId, trx);
}
await this.userRepo.updateUser(
{
role: newRole,
},
user.id,
workspaceId,
trx,
);
return { changed: true, user };
});
if (!result.changed) {
return result.user;
if (!user) {
throw new BadRequestException('Workspace member not found');
}
const { user } = result;
// prevent ADMIN from managing OWNER role
if (
isAdminActingOnOwner(authUser.role, newRole) ||
isAdminActingOnOwner(authUser.role, user.role)
) {
throw new ForbiddenException();
}
if (user.role === newRole) {
return user;
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
await this.userRepo.updateUser(
{
role: newRole,
},
user.id,
workspaceId,
);
this.auditService.log({
event: AuditEvent.USER_ROLE_CHANGED,
@@ -865,38 +848,40 @@ export class WorkspaceService {
userId: string,
workspaceId: string,
): Promise<void> {
const user = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const user = await this.userRepo.findById(userId, workspaceId);
const user = await this.userRepo.findById(userId, workspaceId, { trx });
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (user.deactivatedAt) {
throw new BadRequestException('User is already deactivated');
}
if (user.deactivatedAt) {
throw new BadRequestException('User is already deactivated');
}
if (authUser.id === userId) {
throw new BadRequestException('You cannot deactivate yourself');
}
if (authUser.id === userId) {
throw new BadRequestException('You cannot deactivate yourself');
}
if (isAdminActingOnOwner(authUser.role, user.role)) {
if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException(
'You cannot deactivate a user with owner role',
);
}
if (user.role === UserRole.OWNER) {
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (workspaceOwnerCount === 1) {
throw new BadRequestException(
'You cannot deactivate a user with owner role',
'There must be at least one workspace owner',
);
}
}
if (user.role === UserRole.OWNER) {
await this.validateLastWorkspaceOwner(workspaceId, trx);
}
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser(
{ deactivatedAt: new Date() },
userId,
@@ -904,8 +889,6 @@ export class WorkspaceService {
trx,
);
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
return user;
});
this.auditService.log({
@@ -968,34 +951,32 @@ export class WorkspaceService {
userId: string,
workspaceId: string,
): Promise<void> {
const user = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const user = await this.userRepo.findById(userId, workspaceId);
const user = await this.userRepo.findById(userId, workspaceId, { trx });
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (authUser.id === userId) {
throw new BadRequestException('You cannot delete yourself');
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException(
'You cannot delete a user with owner role',
);
}
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
if (user.role === UserRole.OWNER && !user.deactivatedAt) {
await this.validateLastWorkspaceOwner(workspaceId, trx);
}
if (authUser.id === userId) {
throw new BadRequestException('You cannot delete yourself');
}
if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException('You cannot delete a user with owner role');
}
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser(
{
name: 'Deleted user',
@@ -1028,8 +1009,6 @@ export class WorkspaceService {
});
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
return user;
});
this.auditService.log({
@@ -1051,20 +1030,4 @@ export class WorkspaceService {
// empty
}
}
private async validateLastWorkspaceOwner(
workspaceId: string,
trx: KyselyTransaction,
): Promise<void> {
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
trx,
);
if (workspaceOwnerCount <= 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
}
}
@@ -161,22 +161,6 @@ export class PageRepo {
return result;
}
async lockPageHierarchySpaces(
spaceIds: string[],
trx: KyselyTransaction,
): Promise<void> {
const sortedSpaceIds = [...new Set(spaceIds)].sort();
for (const spaceId of sortedSpaceIds) {
await sql`
SELECT pg_advisory_xact_lock(
hashtext('page-hierarchy'),
hashtext(${spaceId})
)
`.execute(trx);
}
}
async insertPage(
insertablePage: InsertablePage,
trx?: KyselyTransaction,
@@ -505,9 +489,9 @@ export class PageRepo {
async getPageAndDescendants(
parentPageId: string,
opts: { includeContent: boolean; trx?: KyselyTransaction },
opts: { includeContent: boolean },
) {
return dbOrTx(this.db, opts.trx)
return this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
@@ -551,36 +535,6 @@ export class PageRepo {
.execute();
}
async isPageDescendant(
ancestorPageId: string,
descendantPageId: string,
trx?: KyselyTransaction,
): Promise<boolean> {
const result = await dbOrTx(this.db, trx)
.withRecursive('page_ancestors', (db) =>
db
.selectFrom('pages')
.select(['id', 'parentPageId'])
.where('id', '=', descendantPageId)
.union((exp) =>
exp
.selectFrom('pages as parent')
.select(['parent.id', 'parent.parentPageId'])
.innerJoin(
'page_ancestors as ancestor',
'ancestor.parentPageId',
'parent.id',
),
),
)
.selectFrom('page_ancestors')
.select('id')
.where('id', '=', ancestorPageId)
.executeTakeFirst();
return Boolean(result);
}
/**
* Get page and all descendants, excluding restricted pages and their subtrees.
* More efficient than getPageAndDescendants + filtering because:
@@ -25,11 +25,7 @@ export class SpaceRepo {
async findById(
spaceId: string,
workspaceId: string,
opts?: {
includeMemberCount?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Space> {
const db = dbOrTx(this.db, opts?.trx);
@@ -45,11 +41,6 @@ export class SpaceRepo {
} else {
query = query.where(sql`LOWER(slug)`, '=', sql`LOWER(${spaceId})`);
}
if (opts?.withLock && opts?.trx) {
query = query.forUpdate();
}
return query.executeTakeFirst();
}
@@ -145,16 +145,12 @@ export class UserRepo {
async roleCountByWorkspaceId(
role: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const { count } = await db
const { count } = await this.db
.selectFrom('users')
.select((eb) => eb.fn.count('role').as('count'))
.where('role', '=', role)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null)
.executeTakeFirst();
return count as number;
@@ -39,11 +39,7 @@ declare module "@tiptap/core" {
/**
* @description Set search term in extension.
*/
setSearchTerms: (searchTerms: string[]) => ReturnType;
/**
* @description Set whole word search in extension.
*/
setWholeWord: (wholeWord: boolean) => ReturnType;
setSearchTerm: (searchTerm: string) => ReturnType;
/**
* @description Set replace term in extension.
*/
@@ -86,26 +82,13 @@ interface TextNodesWithPosition {
}
const getRegex = (
searchTerms: string[],
s: string,
disableRegex: boolean,
caseSensitive: boolean,
wholeWord: boolean,
): RegExp => {
const terms = searchTerms.filter(Boolean).sort((a, b) => b.length - a.length);
const pattern = terms
.map((term) =>
disableRegex ? term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : term,
)
.join("|");
const finalPattern = wholeWord
? `(?<![\\p{L}\\p{N}_])(?:${pattern})(?![\\p{L}\\p{N}_])`
: pattern;
return new RegExp(
finalPattern,
caseSensitive ? "gu" : "giu",
return RegExp(
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s,
caseSensitive ? "gu" : "gui",
);
};
@@ -272,14 +255,12 @@ export interface SearchAndReplaceOptions {
}
export interface SearchAndReplaceStorage {
searchTerms: string[];
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerms: string[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
wholeWord: boolean;
lastWholeWord: boolean;
resultIndex: number;
lastResultIndex: number;
}
@@ -299,13 +280,11 @@ export const SearchAndReplace = Extension.create<
addStorage() {
return {
searchTerms: [],
searchTerm: "",
replaceTerm: "",
results: [],
lastSearchTerms: [],
lastSearchTerm: "",
caseSensitive: false,
wholeWord: false,
lastWholeWord: false,
lastCaseSensitive: false,
resultIndex: 0,
lastResultIndex: 0,
@@ -314,21 +293,10 @@ export const SearchAndReplace = Extension.create<
addCommands() {
return {
setSearchTerms:
(searchTerms: string[]) =>
setSearchTerm:
(searchTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.searchTerms = searchTerms.filter(Boolean);
// clear whole word by default
// should remove if whole word toggle is added to search and replace dialog
editor.storage.searchAndReplace.wholeWord = false;
return false;
},
setWholeWord:
(wholeWord: boolean) =>
({ editor }) => {
editor.storage.searchAndReplace.wholeWord = wholeWord;
editor.storage.searchAndReplace.searchTerm = searchTerm;
return false;
},
@@ -441,10 +409,8 @@ export const SearchAndReplace = Extension.create<
const editor = this.editor;
const { searchResultClass, disableRegex } = this.options;
const setLastSearchTerms = (terms: string[]) =>
(editor.storage.searchAndReplace.lastSearchTerms = [...terms]);
const setLastWholeWord = (t: boolean) =>
(editor.storage.searchAndReplace.lastWholeWord = t);
const setLastSearchTerm = (t: string) =>
(editor.storage.searchAndReplace.lastSearchTerm = t);
const setLastCaseSensitive = (t: boolean) =>
(editor.storage.searchAndReplace.lastCaseSensitive = t);
const setLastResultIndex = (t: number) =>
@@ -459,47 +425,37 @@ export const SearchAndReplace = Extension.create<
const storage = editor.storage.searchAndReplace;
if (!storage) return oldState;
const {
searchTerms,
lastCaseSensitive,
lastSearchTerms,
searchTerm,
lastSearchTerm,
caseSensitive,
wholeWord,
lastWholeWord,
lastCaseSensitive,
resultIndex,
lastResultIndex,
} = storage;
if (
!docChanged &&
searchTerms.length === lastSearchTerms.length &&
searchTerms.every((term, index) => term === lastSearchTerms[index]) &&
lastSearchTerm === searchTerm &&
lastCaseSensitive === caseSensitive &&
lastWholeWord === wholeWord &&
lastResultIndex === resultIndex
)
return oldState;
setLastSearchTerms(searchTerms);
setLastSearchTerm(searchTerm);
setLastCaseSensitive(caseSensitive);
setLastWholeWord(wholeWord);
setLastResultIndex(resultIndex);
if (searchTerms.length === 0) {
if (!searchTerm) {
editor.storage.searchAndReplace.results = [];
return DecorationSet.empty;
}
const { decorationsToReturn, results } = processSearches(
doc,
getRegex(
searchTerms,
disableRegex,
caseSensitive,
wholeWord,
),
searchResultClass,
resultIndex,
);
doc,
getRegex(searchTerm, disableRegex, caseSensitive),
searchResultClass,
resultIndex,
);
editor.storage.searchAndReplace.results = results;