mirror of
https://github.com/docmost/docmost.git
synced 2026-08-22 02:02:12 +10:00
feat(ee): bases (#2295)
* feat(ee): bases Table and kanban UI, formula engine package, and the base-embed editor extension. * - default status - type fix - error helper * fix: base trash list handling * feat: base nodeview menu * feat: translation * fix number precision * feat(base): add focused-cell atom and cell coordinate types * feat(base): add cell focus-ring style * feat(base): add pure next-cell navigation helper * feat(base): keyboard navigation controller and grid wiring * update offerings * feat(base): cell focus ring, click-to-focus, and gridcell ARIA * feat(base): row ARIA index and selected state * feat(base): seed editor value on type-to-edit for free-text cells * feat(base): make column headers keyboard-focusable as tab stops * fix(base): remove focus outline on grid container * fix(base): show cell focus ring only while the grid is focused * feat(base): keyboard-navigate the row-number column for selection * fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order * fix(base): tab from long-text editor moves to next cell instead of leaving the table * fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop * fix(base): show cell focus ring only while the grid body itself is focused * fix(base): render view-tab rename as an inline pill so the tab band height stays put * fix(base): refer to the feature as 'base' rather than 'database' * fix: change permissions object shape * license file * fix tsconfig * fix base cache * fix: preserve sidebar title/icon on partial page updates * fix: skip duplicate row fetch when opening new kanban card * fix refetch * fix focus * fix spacing * fix(base): select grid cell on mousedown to avoid stale focus ring flash The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell. Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints. * fix: activate New row button via keyboard (Enter/Space) The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell. Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys. * fix(base): keep add-property popover within viewport on mobile Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it. Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active. * fix(base): enable grid cell editing on touch devices Cells could only enter edit mode via double-click or a physical keyboard, so touch devices had no way to edit a cell. Treat a touch/pen tap as the edit gesture, distinguishing a tap from a scroll by movement and branching per pointer type so mouse double-click stays unchanged. Also reveal the row expand button on hover-less devices so the row detail view stays reachable. * feat(editor): add base and kanban inserts to the toolbar * feat(base): insert row below via Shift+Enter on the primary cell * fix(base): place caret at end instead of selecting all when editing cells * fix(base): prevent popover inputs from losing focus on mobile in row detail modal * fix grid cells on mobile * sync * fix: read-only export * feat(base): add prefixed nanoid id schemas and generators * feat(base): enforce strict property/choice id validation * feat(base): make property id varchar with per-base composite pk * feat(base): pass property id as text to cell extractors * feat(base): scope property lookups per base and generate property ids in repo * feat(base): generate status template choice ids as nanoid * feat(base): generate choice ids as nanoid on the client * chore(base): seed choice ids with nanoid * fix(base): mint kanban choice ids as nanoid * sync * sync * sync
This commit is contained in:
@@ -10,6 +10,8 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
export const yjsSyncedAtom = atom<boolean>(false);
|
||||
|
||||
export const showAiMenuAtom = atom(false);
|
||||
|
||||
export const showLinkMenuAtom = atom(false);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { NodeViewWrapper, NodeViewProps } from "@tiptap/react";
|
||||
import { ActionIcon, Box, Menu, Text } from "@mantine/core";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseView } from "@/ee/base/components/base-view";
|
||||
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
|
||||
import { useBaseQuery } from "@/ee/base/queries/base-query";
|
||||
import { pinOffsetWatcher } from "@docmost/editor-ext";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { IconDots, IconTable, IconX } from "@tabler/icons-react";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import classes from "./base-embed.module.css";
|
||||
|
||||
const SIDE_GUTTER = 8;
|
||||
|
||||
// Extend the scroll viewport on both sides (toward AppShell.Main's
|
||||
// edges), but offset the grid content with padding-left = extendLeft
|
||||
// so the first cell still lines up with page-content on load.
|
||||
function applyExtension(wrapper: HTMLDivElement) {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
|
||||
const main = wrapper.closest("main") as HTMLElement | null;
|
||||
const mainRect = main?.getBoundingClientRect();
|
||||
const targetLeft = (mainRect?.left ?? 0) + SIDE_GUTTER;
|
||||
const targetRight = mainRect
|
||||
? mainRect.right - SIDE_GUTTER
|
||||
: window.innerWidth - SIDE_GUTTER;
|
||||
|
||||
const extendLeft = Math.max(0, rect.left - targetLeft);
|
||||
const extendRight = Math.max(0, targetRight - rect.right);
|
||||
|
||||
wrapper.style.setProperty("--embed-extend-l", `${extendLeft}px`);
|
||||
wrapper.style.setProperty("--embed-extend-r", `${extendRight}px`);
|
||||
wrapper.style.setProperty("--embed-grid-pad-left", `${extendLeft}px`);
|
||||
// Symmetric right-side padding so the user can pan past the last
|
||||
// column into empty space.
|
||||
// This gives the table breathing room on the right when scrolled fully right.
|
||||
wrapper.style.setProperty("--embed-grid-pad-right", `${extendRight}px`);
|
||||
// Inline sticky band clears whatever fixed surface sits above the editor —
|
||||
// the page header AND the fixed formatting toolbar. `--editor-pin-offset`
|
||||
// is the same offset the default ProseMirror table header-pin uses
|
||||
// (published by pinOffsetWatcher); fall back to the page-header height.
|
||||
// Standalone leaves --sticky-band-top unset (resolves to the rule default
|
||||
// of 0).
|
||||
wrapper.style.setProperty(
|
||||
"--sticky-band-top",
|
||||
"var(--editor-pin-offset, var(--page-header-height))",
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageId = node.attrs.pageId as string | null;
|
||||
const pendingKey = node.attrs.pendingKey as string | null;
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// Suppress the query while the slash command awaits the server-assigned
|
||||
// pageId; useBaseQuery would otherwise fire with an empty key.
|
||||
const { data: base, isLoading, isError } = useBaseQuery(
|
||||
pendingKey ? "" : pageId ?? "",
|
||||
);
|
||||
const { data: page } = usePageQuery({ pageId: pageId ?? undefined });
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const update = () => applyExtension(wrapper);
|
||||
update();
|
||||
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(wrapper);
|
||||
// Sidebar collapse changes <main>'s left/width without resizing
|
||||
// the wrapper itself, so observe <main> too.
|
||||
const main = wrapper.closest("main");
|
||||
if (main) ro.observe(main);
|
||||
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [isLoading, isError, pageId]);
|
||||
|
||||
// Keep --editor-pin-offset published while the embed is mounted, so the
|
||||
// sticky column header clears the fixed toolbar even when this document
|
||||
// has no default ProseMirror table holding the watcher open.
|
||||
useEffect(() => {
|
||||
pinOffsetWatcher.acquire();
|
||||
return () => pinOffsetWatcher.release();
|
||||
}, []);
|
||||
|
||||
// Error/invalid states render a compact message, not a tall reserved box.
|
||||
// The 200px min-height (which avoids a layout jump when the real table
|
||||
// mounts) is reserved only for the skeleton/loading/table states.
|
||||
const isCompact = !pendingKey && (!pageId || isError);
|
||||
|
||||
const showControls = editor.isEditable && !pendingKey;
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (pendingKey) {
|
||||
// Slash command inserted the embed and is awaiting the server's
|
||||
// assigned pageId. Match the shape the create endpoint will
|
||||
// return for an inline-embed (Title + Text 1 + Text 2, one
|
||||
// empty row — see BaseService.create's `defaults`) so the swap
|
||||
// to the real table doesn't visibly collapse a large fake table
|
||||
// down to a small empty one.
|
||||
content = <BaseTableSkeleton rows={1} columns={3} />;
|
||||
} else if (!pageId) {
|
||||
content = (
|
||||
<Box p="md">
|
||||
<Text c="red">Invalid base embed (missing page id)</Text>
|
||||
</Box>
|
||||
);
|
||||
} else if (isLoading) {
|
||||
content = (
|
||||
<Box p="md">
|
||||
<Text c="dimmed">Loading...</Text>
|
||||
</Box>
|
||||
);
|
||||
} else if (isError) {
|
||||
content = (
|
||||
<Box p="md" bg="gray.0" style={{ borderRadius: 8 }}>
|
||||
<Text c="dimmed">You don't have access to this base.</Text>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<BaseView
|
||||
pageId={pageId}
|
||||
embedded
|
||||
editable={hasBases && editor.isEditable && (base?.permissions?.canEdit ?? false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={classes.handleGutter}
|
||||
data-menu-open={menuOpen ? "true" : "false"}
|
||||
>
|
||||
{showControls && (
|
||||
<div
|
||||
className={classes.controls}
|
||||
contentEditable={false}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Menu position="bottom-end" withinPortal onChange={setMenuOpen}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="sm"
|
||||
aria-label={t("Base options")}
|
||||
>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => deleteNode()}
|
||||
>
|
||||
{t("Remove from page")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
<div data-drag-preview hidden className={classes.dragPreview}>
|
||||
<IconTable size={16} />
|
||||
<span>{page?.title?.trim() || "Untitled base"}</span>
|
||||
</div>
|
||||
<div ref={wrapperRef} style={{ minHeight: isCompact ? undefined : 200 }}>
|
||||
{content}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.handleGutter {
|
||||
position: relative;
|
||||
margin-left: -1.5rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.controls::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.handleGutter:hover .controls,
|
||||
.handleGutter[data-menu-open="true"] .controls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.controls {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.handleGutter {
|
||||
margin-left: -1rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dragPreview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 260px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dragPreview[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dragPreview svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dragPreview span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import api from "@/lib/api-client";
|
||||
|
||||
function findBaseEmbedPlaceholderPos(
|
||||
editor: Editor,
|
||||
pendingKey: string,
|
||||
): number | null {
|
||||
let foundPos: number | null = null;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === "base" && node.attrs.pendingKey === pendingKey) {
|
||||
foundPos = pos;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return foundPos;
|
||||
}
|
||||
|
||||
export async function insertBaseEmbedBlock(
|
||||
editor: Editor,
|
||||
opts: { template?: "kanban"; range?: Range } = {},
|
||||
): Promise<void> {
|
||||
// @ts-ignore
|
||||
const parentPageId = editor.storage?.pageId as string | undefined;
|
||||
if (!parentPageId) return;
|
||||
|
||||
const pendingKey = uuid7();
|
||||
|
||||
const chain = editor.chain().focus();
|
||||
if (opts.range) chain.deleteRange(opts.range);
|
||||
chain.insertBaseEmbed({ pageId: null, pendingKey }).run();
|
||||
|
||||
try {
|
||||
const res = await api.post<{ id: string }>("/bases/create", {
|
||||
parentPageId,
|
||||
...(opts.template ? { template: opts.template } : {}),
|
||||
});
|
||||
|
||||
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
||||
if (pos === null) return;
|
||||
editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
pageId: res.data.id,
|
||||
pendingKey: null,
|
||||
});
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
} catch {
|
||||
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
||||
if (pos !== null) {
|
||||
editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
const node = tr.doc.nodeAt(pos);
|
||||
if (node) tr.delete(pos, pos + node.nodeSize);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
notifications.show({ message: "Failed to create base", color: "red" });
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
/* Push the bar to the bottom of the full-height editor container. */
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 8px;
|
||||
/* Match the content indent used by .byline and .ProseMirror so the bar
|
||||
lines up with the title and paragraph rather than the container edge. */
|
||||
padding-left: 3rem;
|
||||
padding-right: 3rem;
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.chipRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconTable, IconLayoutKanban } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
yjsSyncedAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import classes from "./empty-page-get-started.module.css";
|
||||
|
||||
type EmptyPageGetStartedProps = {
|
||||
pageId: string;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export function EmptyPageGetStarted({
|
||||
pageId,
|
||||
editable,
|
||||
}: EmptyPageGetStartedProps) {
|
||||
const { t } = useTranslation();
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const isSynced = useAtomValue(yjsSyncedAtom);
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
const convertMutation = useConvertPageToBaseMutation();
|
||||
|
||||
const [isEmpty, setIsEmpty] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const sync = () => setIsEmpty(editor.isEmpty);
|
||||
sync();
|
||||
editor.on("update", sync);
|
||||
editor.on("create", sync);
|
||||
return () => {
|
||||
editor.off("update", sync);
|
||||
editor.off("create", sync);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null;
|
||||
|
||||
const chips = [
|
||||
{
|
||||
key: "base",
|
||||
label: t("Base"),
|
||||
icon: IconTable,
|
||||
onClick: () => convertMutation.mutate({ pageId }),
|
||||
disabled: convertMutation.isPending,
|
||||
},
|
||||
{
|
||||
key: "kanban",
|
||||
label: t("Kanban"),
|
||||
icon: IconLayoutKanban,
|
||||
onClick: () => convertMutation.mutate({ pageId, template: "kanban" }),
|
||||
disabled: convertMutation.isPending,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper} contentEditable={false}>
|
||||
<span className={classes.label}>{t("Get started with")}</span>
|
||||
<div className={classes.chipRow}>
|
||||
{chips.map((chip) => (
|
||||
<Button
|
||||
key={chip.key}
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
leftSection={<chip.icon size={16} />}
|
||||
onClick={chip.onClick}
|
||||
disabled={chip.disabled}
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+19
@@ -7,10 +7,12 @@ import {
|
||||
IconCaretRightFilled,
|
||||
IconChevronDown,
|
||||
IconInfoCircle,
|
||||
IconLayoutKanban,
|
||||
IconMath,
|
||||
IconMathFunction,
|
||||
IconRotate2,
|
||||
IconSitemap,
|
||||
IconTable,
|
||||
IconTag,
|
||||
} from "@tabler/icons-react";
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
YoutubeIcon,
|
||||
} from "@/components/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
@@ -102,6 +105,22 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
||||
{t("Synced block")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconTable size={16} />}
|
||||
onClick={() => insertBaseEmbedBlock(editor)}
|
||||
>
|
||||
{t("Base (Inline)")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => insertBaseEmbedBlock(editor, { template: "kanban" })}
|
||||
>
|
||||
{t("Kanban")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("Diagrams")}</Menu.Label>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
import clsx from "clsx";
|
||||
@@ -186,7 +186,7 @@ export const LinkEditorPanel = ({
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<AutoTooltipText size="sm" fw={500} truncate lh={1.3}>
|
||||
{page.title || t("Untitled")}
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
</AutoTooltipText>
|
||||
{page.space?.name && (
|
||||
<AutoTooltipText size="xs" c="dimmed" truncate lh={1.4}>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
MentionSuggestionItem,
|
||||
} from "@/features/editor/components/mention/mention.type.ts";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
usePageQuery,
|
||||
@@ -103,7 +104,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
items = items.concat(
|
||||
suggestion.pages.map((page) => ({
|
||||
id: uuid7(),
|
||||
label: page.title || t("Untitled"),
|
||||
label: getPageTitle(page.title, page.isBase, t),
|
||||
entityType: "page",
|
||||
entityId: page.id,
|
||||
slugId: page.slugId,
|
||||
@@ -278,7 +279,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
|
||||
props.command({
|
||||
id: uuid7(),
|
||||
label: createdPage.title || "Untitled",
|
||||
label: getPageTitle(createdPage.title, createdPage.isBase, t),
|
||||
entityType: "page",
|
||||
entityId: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@/features/editor/components/slash-menu/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
import classes from "./slash-menu.module.css";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
|
||||
const CommandList = ({
|
||||
items,
|
||||
@@ -33,6 +36,13 @@ const CommandList = ({
|
||||
const [countAnnouncement, setCountAnnouncement] = useState("");
|
||||
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
|
||||
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
// Title must match the "Base (Inline)" item in menu-items.ts. Without the
|
||||
// bases entitlement the item stays visible but disabled; an expired license
|
||||
// the client can't detect falls through to a handled create failure.
|
||||
const isItemDisabled = (item: SlashMenuItemType) =>
|
||||
!hasBases && item.title === "Base (Inline)";
|
||||
|
||||
const flatItems = useMemo(() => {
|
||||
return Object.values(items).flat();
|
||||
}, [items]);
|
||||
@@ -40,11 +50,11 @@ const CommandList = ({
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = flatItems[index];
|
||||
if (item) {
|
||||
if (item && !isItemDisabled(item)) {
|
||||
command(item);
|
||||
}
|
||||
},
|
||||
[command, flatItems],
|
||||
[command, flatItems, hasBases],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -140,6 +150,7 @@ const CommandList = ({
|
||||
{categoryItems.map((item: SlashMenuItemType) => {
|
||||
flatIndex += 1;
|
||||
const itemIndex = flatIndex;
|
||||
const disabled = isItemDisabled(item);
|
||||
return (
|
||||
<UnstyledButton
|
||||
data-item-index={itemIndex}
|
||||
@@ -147,12 +158,15 @@ const CommandList = ({
|
||||
id={`slash-command-option-${itemIndex}`}
|
||||
role="option"
|
||||
aria-selected={itemIndex === selectedIndex}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
onClick={() => selectItem(itemIndex)}
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: itemIndex === selectedIndex,
|
||||
[classes.disabledItem]: disabled,
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
<Group wrap="nowrap">
|
||||
<ActionIcon variant="default" component="div" aria-hidden="true">
|
||||
<item.icon size={18} />
|
||||
</ActionIcon>
|
||||
@@ -166,6 +180,12 @@ const CommandList = ({
|
||||
{t(item.description)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{disabled && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t("Upgrade")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconInfoCircle,
|
||||
IconLayoutKanban,
|
||||
IconList,
|
||||
IconListNumbers,
|
||||
IconMath,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
VimeoIcon,
|
||||
YoutubeIcon,
|
||||
} from "@/components/icons";
|
||||
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
|
||||
|
||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
basic: [
|
||||
@@ -359,6 +361,24 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "Base (Inline)",
|
||||
description: "Insert an inline base on this page",
|
||||
searchTerms: ["base", "database", "table", "grid", "spreadsheet"],
|
||||
icon: IconTable,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertBaseEmbedBlock(editor, { range });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Kanban",
|
||||
description: "Insert a kanban board on this page",
|
||||
searchTerms: ["kanban", "board", "cards", "status", "task", "database"],
|
||||
icon: IconLayoutKanban,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertBaseEmbedBlock(editor, { range, template: "kanban" });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle block",
|
||||
description: "Insert collapsible block.",
|
||||
|
||||
@@ -25,3 +25,8 @@
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
}
|
||||
|
||||
.disabledItem {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface GlobalDragHandleOptions {
|
||||
* Custom nodes to be included for drag handle
|
||||
*/
|
||||
customNodes: string[];
|
||||
|
||||
atomNodes: string[];
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
@@ -76,6 +78,10 @@ function nodeDOMAtCoords(
|
||||
`[data-type=${node}] p`,
|
||||
`.node-${node} p`,
|
||||
]);
|
||||
const atomSelectors = options.atomNodes.flatMap((node) => [
|
||||
`[data-type=${node}]`,
|
||||
`.node-${node}`,
|
||||
]);
|
||||
|
||||
const selectors = [
|
||||
"li",
|
||||
@@ -95,8 +101,9 @@ function nodeDOMAtCoords(
|
||||
".tableWrapper",
|
||||
...customParagraphSelectors,
|
||||
...customSelectors,
|
||||
...atomSelectors,
|
||||
].join(", ");
|
||||
return document
|
||||
const found = document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find((elem: Element) => {
|
||||
// Skip elements that belong to a nested editor (e.g. transclusion
|
||||
@@ -108,6 +115,11 @@ function nodeDOMAtCoords(
|
||||
elem.matches(selectors)
|
||||
);
|
||||
});
|
||||
if (found && atomSelectors.length > 0) {
|
||||
const atomWrapper = found.closest(atomSelectors.join(", "));
|
||||
if (atomWrapper) return atomWrapper;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function nodePosAtDOM(
|
||||
node: Element,
|
||||
@@ -127,7 +139,7 @@ function isCustomNodeDOM(
|
||||
options: GlobalDragHandleOptions,
|
||||
): boolean {
|
||||
if (!elem) return false;
|
||||
for (const name of options.customNodes) {
|
||||
for (const name of [...options.customNodes, ...options.atomNodes]) {
|
||||
if (
|
||||
elem.getAttribute("data-type") === name ||
|
||||
elem.classList.contains(`node-${name}`)
|
||||
@@ -210,7 +222,10 @@ export function DragHandlePlugin(
|
||||
// The drag landed on a custom-node container (transclusion etc.).
|
||||
// Walk up to the matching node so the drag moves the whole
|
||||
// container, not whatever inner element the click landed on.
|
||||
const customTypes = new Set(options.customNodes);
|
||||
const customTypes = new Set([
|
||||
...options.customNodes,
|
||||
...options.atomNodes,
|
||||
]);
|
||||
for (let d = $sel.depth; d > 0; d--) {
|
||||
if (customTypes.has($sel.node(d).type.name)) {
|
||||
selection = NodeSelection.create(
|
||||
@@ -264,7 +279,23 @@ export function DragHandlePlugin(
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
const previewTemplate =
|
||||
node.querySelector<HTMLElement>("[data-drag-preview]");
|
||||
if (previewTemplate) {
|
||||
const preview = previewTemplate.cloneNode(true) as HTMLElement;
|
||||
preview.removeAttribute("hidden");
|
||||
preview.style.position = "fixed";
|
||||
preview.style.top = "0";
|
||||
preview.style.left = "-10000px";
|
||||
preview.style.pointerEvents = "none";
|
||||
document.body.appendChild(preview);
|
||||
event.dataTransfer.setDragImage(preview, 0, 0);
|
||||
document.addEventListener("dragend", () => preview.remove(), {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
}
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
@@ -497,6 +528,7 @@ const GlobalDragHandle = Extension.create({
|
||||
scrollThreshold: 100,
|
||||
excludedTags: [],
|
||||
customNodes: [],
|
||||
atomNodes: [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -509,6 +541,7 @@ const GlobalDragHandle = Extension.create({
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
excludedTags: this.options.excludedTags,
|
||||
customNodes: this.options.customNodes,
|
||||
atomNodes: this.options.atomNodes,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
TableView,
|
||||
BaseEmbed as BaseEmbedNode,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -91,6 +92,7 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
||||
import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
@@ -231,6 +233,7 @@ export const mainExtensions = [
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
customNodes: ["transclusionSource", "transclusionReference"],
|
||||
atomNodes: ["base"],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
@@ -381,6 +384,11 @@ export const mainExtensions = [
|
||||
TransclusionReference.configure({
|
||||
view: TransclusionReferenceView,
|
||||
}),
|
||||
BaseEmbedNode.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(BaseEmbedView);
|
||||
},
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
@@ -420,7 +428,9 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
|
||||
"Draw.io (diagrams.net)",
|
||||
"Excalidraw (Whiteboard)",
|
||||
"Audio",
|
||||
"Synced block"
|
||||
"Synced block",
|
||||
"Base (Inline)",
|
||||
"Kanban"
|
||||
]);
|
||||
|
||||
const TemplateSlashCommand = Command.configure({
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
|
||||
import clsx from "clsx";
|
||||
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { EmptyPageGetStarted } from "@/features/editor/components/empty-page/empty-page-get-started";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
@@ -90,6 +91,7 @@ export function FullEditor({
|
||||
fluid={fullPageWidth}
|
||||
size={!fullPageWidth && 900}
|
||||
className={classes.editor}
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{editorToolbarEnabled && editable && isEditMode && (
|
||||
<MemoizedFixedToolbar />
|
||||
@@ -113,6 +115,7 @@ export function FullEditor({
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
<EmptyPageGetStarted pageId={pageId} editable={editable} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
currentPageEditModeAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
yjsSyncedAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import {
|
||||
@@ -109,6 +110,7 @@ export default function PageEditor({
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
);
|
||||
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
|
||||
const menuContainerRef = useRef(null);
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
@@ -378,6 +380,14 @@ export default function PageEditor({
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
useEffect(() => {
|
||||
setYjsSynced(isSynced);
|
||||
}, [isSynced, setYjsSynced]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setYjsSynced(false);
|
||||
}, [setYjsSynced]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.ProseMirror {
|
||||
.node-base {
|
||||
/* Suppress the default ProseMirror atom-node selection outline —
|
||||
* the embed reads as a document block, not a focused widget. */
|
||||
&.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Page-reference pills are self-contained chips. The editor's `a` rule
|
||||
* would otherwise add a link underline + bold weight on top of the chip,
|
||||
* making it look different from a standalone base. Keep it a plain chip. */
|
||||
a.pagePill {
|
||||
border-bottom: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
:root {
|
||||
/* Height of the fixed PageHeader at the top of every page. Used by
|
||||
* standalone base layout (paddingTop) and by sticky bands inside
|
||||
* inline base embeds (top offset). One source of truth; if the
|
||||
* page header ever changes height, edit only this. */
|
||||
--page-header-height: 45px;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
@@ -286,3 +294,24 @@
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
/* The full-page base view positions its title with its own outer
|
||||
* wrapper padding (so it can align with the table below). The global
|
||||
* 3rem .ProseMirror padding-x would push the title further in than
|
||||
* the table — drop it inside the base title wrapper only. */
|
||||
.base-page-title .ProseMirror {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Definite height so the inner .tableScrollport scrolls and the sticky table
|
||||
* header pins. Flow on print, else it emits a trailing blank page. */
|
||||
.base-page-root {
|
||||
height: calc(100dvh - var(--app-shell-header-height, 45px));
|
||||
}
|
||||
|
||||
@media print {
|
||||
.base-page-root {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
.editor {
|
||||
height: 100%;
|
||||
min-height: calc(100dvh - var(--app-shell-header-height, 45px) - 96px);
|
||||
padding: 8px 0;
|
||||
margin: 48px auto;
|
||||
|
||||
@media print {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
@import "./indent.css";
|
||||
@import "./columns.css";
|
||||
@import "./status.css";
|
||||
@import "./base-embed.css";
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface TitleEditorProps {
|
||||
title: string;
|
||||
spaceSlug: string;
|
||||
editable: boolean;
|
||||
isBase?: boolean;
|
||||
}
|
||||
|
||||
export function TitleEditor({
|
||||
@@ -43,6 +44,7 @@ export function TitleEditor({
|
||||
title,
|
||||
spaceSlug,
|
||||
editable,
|
||||
isBase,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||
@@ -64,7 +66,7 @@ export function TitleEditor({
|
||||
}),
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: t("Untitled"),
|
||||
placeholder: isBase ? t("Untitled base") : t("Untitled"),
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
History.configure({
|
||||
@@ -106,11 +108,17 @@ export function TitleEditor({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const anchorId = window.location.hash
|
||||
? window.location.hash.substring(1)
|
||||
: undefined;
|
||||
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
|
||||
navigate(pageSlug, { replace: true });
|
||||
// Canonicalize only the path slug; keep query params (?row=, ?view=
|
||||
// deep links) and the hash anchor intact.
|
||||
const pageSlug = buildPageUrl(spaceSlug, slugId, title);
|
||||
navigate(
|
||||
{
|
||||
pathname: pageSlug,
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [title]);
|
||||
|
||||
const saveTitle = useCallback(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ export type IFavorite = {
|
||||
slugId: string;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
isBase: boolean;
|
||||
spaceId: string;
|
||||
};
|
||||
space?: {
|
||||
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ThemeIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useCreatedByQuery } from "@/features/page/queries/page-query";
|
||||
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
import { IconFiles } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -62,17 +62,9 @@ export default function CreatedByMe({ spaceId }: Props) {
|
||||
)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ThemeIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<PageListIcon icon={page.icon} isBase={page.isBase} />
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ThemeIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||
import { IconFileDescription, IconStar } from "@tabler/icons-react";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
import { IconStar } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -63,17 +63,12 @@ export default function FavoritesPages({ spaceId }: Props) {
|
||||
)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{fav.page.icon || (
|
||||
<ThemeIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<PageListIcon
|
||||
icon={fav.page.icon}
|
||||
isBase={fav.page.isBase}
|
||||
/>
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{fav.page.title || t("Untitled")}
|
||||
{getPageTitle(fav.page.title, fav.page.isBase, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useMarkReadMutation } from "../queries/notification-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||
import { formatRelativeTime } from "../notification.utils";
|
||||
import classes from "../notification.module.css";
|
||||
|
||||
@@ -143,7 +143,7 @@ export function NotificationItem({
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{notification.page.title || t("Untitled")}
|
||||
{getPageTitle(notification.page.title, undefined, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
BacklinkDirection,
|
||||
IBacklinkPageItem,
|
||||
} from "@/features/page-details/types/backlink.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
|
||||
interface BacklinksListProps {
|
||||
@@ -86,7 +86,7 @@ export function BacklinksList({
|
||||
{getPageIcon(item.icon ?? "")}
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{item.title || t("Untitled")}
|
||||
{getPageTitle(item.title, undefined, t)}
|
||||
</Text>
|
||||
{item.space?.name && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
|
||||
@@ -15,15 +15,17 @@ import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import type { TFunction } from "i18next";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function getTitle(name: string, icon: string) {
|
||||
if (icon) {
|
||||
return `${icon} ${name}`;
|
||||
function getTitle(node: SpaceTreeNode, t: TFunction) {
|
||||
const name = getPageTitle(node.name, node.isBase, t);
|
||||
if (node.icon) {
|
||||
return `${node.icon} ${name}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export default function Breadcrumb() {
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text fz={"sm"} className={classes.truncatedText}>
|
||||
{getTitle(node.name, node.icon)}
|
||||
{getTitle(node, t)}
|
||||
</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
@@ -75,7 +77,7 @@ export default function Breadcrumb() {
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text fz={"sm"} className={classes.truncatedText}>
|
||||
{getTitle(node.name, node.icon)}
|
||||
{getTitle(node, t)}
|
||||
</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
@@ -83,7 +85,7 @@ export default function Breadcrumb() {
|
||||
|
||||
const renderAnchor = useCallback(
|
||||
(node: SpaceTreeNode, isCurrent = false) => (
|
||||
<Tooltip label={node.name} key={node.id}>
|
||||
<Tooltip label={getPageTitle(node.name, node.isBase, t)} key={node.id}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
||||
@@ -93,11 +95,11 @@ export default function Breadcrumb() {
|
||||
className={classes.truncatedText}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
>
|
||||
{getTitle(node.name, node.icon)}
|
||||
{getTitle(node, t)}
|
||||
</Anchor>
|
||||
</Tooltip>
|
||||
),
|
||||
[spaceSlug],
|
||||
[spaceSlug, t],
|
||||
);
|
||||
|
||||
const getBreadcrumbItems = () => {
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
<>
|
||||
<ConnectionWarning />
|
||||
|
||||
{!readOnly && <PageEditModeToggle size="xs" />}
|
||||
{!readOnly && !page?.isBase && <PageEditModeToggle size="xs" />}
|
||||
|
||||
<PageShareModal readOnly={readOnly} />
|
||||
|
||||
@@ -116,16 +116,18 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Table of contents")}
|
||||
{...tocTriggerProps}
|
||||
>
|
||||
<IconList size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{!page?.isBase && (
|
||||
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Table of contents")}
|
||||
{...tocTriggerProps}
|
||||
>
|
||||
<IconList size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<PageActionMenu readOnly={readOnly} />
|
||||
</>
|
||||
@@ -234,12 +236,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{t("Copy link")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconMarkdown size={16} />}
|
||||
onClick={handleCopyAsMarkdown}
|
||||
>
|
||||
{t("Copy as Markdown")}
|
||||
</Menu.Item>
|
||||
{!page?.isBase && (
|
||||
<Menu.Item
|
||||
leftSection={<IconMarkdown size={16} />}
|
||||
onClick={handleCopyAsMarkdown}
|
||||
>
|
||||
{t("Copy as Markdown")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
@@ -270,22 +274,26 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
{!page?.isBase && <Menu.Divider />}
|
||||
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||
<Group wrap="nowrap">
|
||||
<PageWidthToggle label={t("Full width")} />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
{!page?.isBase && (
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||
<Group wrap="nowrap">
|
||||
<PageWidthToggle label={t("Full width")} />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
{t("Page history")}
|
||||
</Menu.Item>
|
||||
{!page?.isBase && (
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
{t("Page history")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
{!readOnly && !page?.isBase && (
|
||||
<PageVerificationMenuItem
|
||||
pageId={page?.id}
|
||||
onClick={openVerificationModal}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Display title for a page, with a base-aware empty-title fallback: bases
|
||||
* fall back to "Untitled base", normal pages to "Untitled". Single chokepoint
|
||||
* so the fallback stays consistent across the UI.
|
||||
*/
|
||||
export function getPageTitle(
|
||||
title: string | null | undefined,
|
||||
isBase: boolean | undefined,
|
||||
t: TFunction,
|
||||
): string {
|
||||
return title || (isBase ? t("Untitled base") : t("Untitled"));
|
||||
}
|
||||
|
||||
const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
|
||||
const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
|
||||
|
||||
@@ -193,6 +193,7 @@ export function useRestorePageMutation() {
|
||||
spaceId: restoredPage.spaceId,
|
||||
parentPageId: restoredPage.parentPageId,
|
||||
hasChildren: restoredPage.hasChildren || false,
|
||||
isBase: restoredPage.isBase,
|
||||
children: [],
|
||||
};
|
||||
|
||||
@@ -454,7 +455,11 @@ export function invalidateOnUpdatePage(
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === id
|
||||
? { ...sidebarPage, title: title, icon: icon }
|
||||
? {
|
||||
...sidebarPage,
|
||||
...(title !== undefined ? { title } : {}),
|
||||
...(icon !== undefined ? { icon } : {}),
|
||||
}
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Modal, Text, ScrollArea } from "@mantine/core";
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
pageTitle: string;
|
||||
pageContent: any;
|
||||
isBase?: boolean;
|
||||
}
|
||||
|
||||
export default function TrashPageContentModal({
|
||||
@@ -14,6 +17,7 @@ export default function TrashPageContentModal({
|
||||
onClose,
|
||||
pageTitle,
|
||||
pageContent,
|
||||
isBase,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const title = pageTitle || t("Untitled");
|
||||
@@ -32,7 +36,15 @@ export default function TrashPageContentModal({
|
||||
</Modal.Header>
|
||||
<Modal.Body p={0}>
|
||||
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
||||
<ReadonlyPageEditor title={title} content={pageContent} />
|
||||
{isBase ? (
|
||||
<EmptyState
|
||||
icon={IconTable}
|
||||
title={t("Base preview unavailable")}
|
||||
description={t("Restore this base to view its contents.")}
|
||||
/>
|
||||
) : (
|
||||
<ReadonlyPageEditor title={title} content={pageContent} />
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
IconDots,
|
||||
IconRestore,
|
||||
IconTrash,
|
||||
IconFileDescription,
|
||||
} from "@tabler/icons-react";
|
||||
import { TrashBanner } from "@/features/page/trash/components/trash-banner.tsx";
|
||||
import {
|
||||
@@ -31,6 +30,7 @@ import { UserInfo } from "@/components/common/user-info.tsx";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
|
||||
export default function Trash() {
|
||||
const { t } = useTranslation();
|
||||
@@ -47,6 +47,7 @@ export default function Trash() {
|
||||
const [selectedPage, setSelectedPage] = useState<{
|
||||
title: string;
|
||||
content: any;
|
||||
isBase?: boolean;
|
||||
} | null>(null);
|
||||
const [modalOpened, setModalOpened] = useState(false);
|
||||
|
||||
@@ -79,7 +80,11 @@ export default function Trash() {
|
||||
const hasPages = deletedPages && deletedPages.items.length > 0;
|
||||
|
||||
const handlePageClick = (page: any) => {
|
||||
setSelectedPage({ title: page.title, content: page.content });
|
||||
setSelectedPage({
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
isBase: page.isBase,
|
||||
});
|
||||
setModalOpened(true);
|
||||
};
|
||||
|
||||
@@ -118,15 +123,7 @@ export default function Trash() {
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handlePageClick(page)}
|
||||
>
|
||||
{page.icon || (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<PageListIcon icon={page.icon} isBase={page.isBase} />
|
||||
<div>
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
@@ -207,6 +204,7 @@ export default function Trash() {
|
||||
onClose={() => setModalOpened(false)}
|
||||
pageTitle={selectedPage.title}
|
||||
pageContent={selectedPage.content}
|
||||
isBase={selectedPage.isBase}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||||
import {
|
||||
@@ -145,7 +146,15 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
getOffset: pointerOutsideOfPreview({ x: '16px', y: '8px' }),
|
||||
render: ({ container }) => {
|
||||
const root = createRoot(container);
|
||||
root.render(<DocTreeDragPreview label={getDragLabel(node)} />);
|
||||
// flushSync forces the preview to paint into `container`
|
||||
// synchronously, before pragmatic-dnd snapshots it for the
|
||||
// native drag image. Without it, createRoot's async render
|
||||
// leaves the container empty at snapshot time, so the browser
|
||||
// falls back to a default snapshot of the source row (and the
|
||||
// stale image can linger on screen).
|
||||
flushSync(() => {
|
||||
root.render(<DocTreeDragPreview label={getDragLabel(node)} />);
|
||||
});
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import CopyPageModal from "@/features/page/components/copy-page-modal.tsx";
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
@@ -127,7 +128,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
className={classes.actionIcon}
|
||||
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
|
||||
aria-label={t("Page menu for {{name}}", { name: getPageTitle(node.name, node.isBase, t) })}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
IconFileDescription,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
IconTable,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import { getPageById } from "@/features/page/services/page-service.ts";
|
||||
import {
|
||||
useUpdatePageMutation,
|
||||
@@ -161,7 +163,13 @@ export function SpaceTreeRow({
|
||||
<EmojiPicker
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
icon={
|
||||
node.icon ? node.icon : <IconFileDescription size="18" />
|
||||
node.icon ? (
|
||||
node.icon
|
||||
) : node.isBase ? (
|
||||
<IconTable size={18} />
|
||||
) : (
|
||||
<IconFileDescription size="18" />
|
||||
)
|
||||
}
|
||||
readOnly={!canEdit}
|
||||
removeEmojiAction={handleRemoveEmoji}
|
||||
@@ -169,7 +177,7 @@ export function SpaceTreeRow({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={classes.text}>{node.name || t("untitled")}</span>
|
||||
<span className={classes.text}>{getPageTitle(node.name, node.isBase, t)}</span>
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} canEdit={canEdit} />
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
mergeRootTrees,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
@@ -200,7 +201,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
[],
|
||||
);
|
||||
const getDragLabel = useCallback(
|
||||
(n: SpaceTreeNode) => n.name || t("untitled"),
|
||||
(n: SpaceTreeNode) => getPageTitle(n.name, n.isBase, t),
|
||||
[t],
|
||||
);
|
||||
|
||||
|
||||
@@ -91,8 +91,6 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---- pragmatic-tree additions ---- */
|
||||
|
||||
.rowWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SpaceTreeNode = {
|
||||
spaceId: string;
|
||||
parentPageId: string;
|
||||
hasChildren: boolean;
|
||||
isBase?: boolean;
|
||||
canEdit?: boolean;
|
||||
children: SpaceTreeNode[];
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
hasChildren: page.hasChildren,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
isBase: page.isBase,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
children: [],
|
||||
};
|
||||
@@ -42,10 +43,6 @@ export function findBreadcrumbPath(
|
||||
path: SpaceTreeNode[] = [],
|
||||
): SpaceTreeNode[] | null {
|
||||
for (const node of tree) {
|
||||
if (!node.name || node.name.trim() === "") {
|
||||
node.name = "untitled";
|
||||
}
|
||||
|
||||
if (node.id === pageId) {
|
||||
return [...path, node];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface IPage {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
isLocked: boolean;
|
||||
isBase: boolean;
|
||||
lastUpdatedById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -11,6 +11,9 @@ export enum SpaceCaslSubject {
|
||||
Page = "page",
|
||||
}
|
||||
|
||||
// Bases are pages and inherit Page permissions — a separate Base
|
||||
// subject was redundant and has been dropped from the server's casl
|
||||
// rules too. Anything that used to check Base now checks Page.
|
||||
export type SpaceAbility =
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Settings]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Member]
|
||||
|
||||
@@ -23,7 +23,6 @@ export const useQuerySubscription = () => {
|
||||
const data: WebSocketEvent = event;
|
||||
|
||||
let entity = null;
|
||||
let queryKeyId = null;
|
||||
|
||||
switch (data.operation) {
|
||||
case "invalidate":
|
||||
@@ -106,21 +105,23 @@ export const useQuerySubscription = () => {
|
||||
case "deleteTreeNode":
|
||||
invalidateOnDeletePage(data.payload.node.id);
|
||||
break;
|
||||
case "updateOne":
|
||||
case "updateOne": {
|
||||
entity = data.entity[0];
|
||||
if (entity === "pages") {
|
||||
// we have to do this because the usePageQuery cache key is the slugId.
|
||||
queryKeyId = data.payload.slugId;
|
||||
} else {
|
||||
queryKeyId = data.id;
|
||||
}
|
||||
const keyIds =
|
||||
entity === "pages" ? [data.payload.slugId, data.id] : [data.id];
|
||||
|
||||
// only update if data was already in cache
|
||||
if (queryClient.getQueryData([...data.entity, queryKeyId])) {
|
||||
queryClient.setQueryData([...data.entity, queryKeyId], {
|
||||
...queryClient.getQueryData([...data.entity, queryKeyId]),
|
||||
...data.payload,
|
||||
});
|
||||
for (const keyId of keyIds) {
|
||||
if (!keyId) continue;
|
||||
const cached = queryClient.getQueryData<Record<string, unknown>>([
|
||||
...data.entity,
|
||||
keyId,
|
||||
]);
|
||||
if (cached) {
|
||||
queryClient.setQueryData([...data.entity, keyId], {
|
||||
...cached,
|
||||
...data.payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entity === "pages") {
|
||||
@@ -132,20 +133,8 @@ export const useQuerySubscription = () => {
|
||||
data.payload.icon,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: [data.entity, data.id] },
|
||||
(oldData: any) => {
|
||||
const update = (entity: Record<string, unknown>) =>
|
||||
entity.id === data.id ? { ...entity, ...data.payload } : entity;
|
||||
return Array.isArray(oldData)
|
||||
? oldData.map(update)
|
||||
: update(oldData as Record<string, unknown>);
|
||||
},
|
||||
);
|
||||
*/
|
||||
break;
|
||||
}
|
||||
case "refetchRootTreeNodeEvent": {
|
||||
const spaceId = data.spaceId;
|
||||
queryClient.refetchQueries({
|
||||
|
||||
@@ -48,6 +48,11 @@ export const useTreeSocket = () => {
|
||||
icon: event.payload.icon,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (event.payload?.isBase !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
isBase: event.payload.isBase,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user