diff --git a/apps/client/package.json b/apps/client/package.json index 80836fa8..a8af382c 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.96.0", + "version": "0.97.0", "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -66,7 +66,7 @@ "semver": "7.7.4", "socket.io-client": "4.8.3", "yet-another-react-lightbox": "^3.32.2", - "zod": "4.3.6" + "zod": "4.6.7" }, "devDependencies": { "@eslint/js": "9.28.0", @@ -94,7 +94,7 @@ "prettier": "3.8.1", "typescript": "5.9.3", "typescript-eslint": "8.57.1", - "vite": "8.0.16", - "vitest": "4.1.11" + "vite": "8.3.4", + "vitest": "4.1.12" } } diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 91f79d69..78a8035d 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Nach rechts verschieben", "Delete tab": "Tab löschen", "Tab label": "Tab-Beschriftung", - "Tab actions for {{name}}": "Tab-Aktionen für {{name}}" + "Tab actions for {{name}}": "Tab-Aktionen für {{name}}", + "Inline base": "Inline-Basis" } diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 3f4bde97..7c2e3c6f 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Move right", "Delete tab": "Delete tab", "Tab label": "Tab label", - "Tab actions for {{name}}": "Tab actions for {{name}}" + "Tab actions for {{name}}": "Tab actions for {{name}}", + "Inline base": "Inline base" } diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index 50ab7de9..c74c610a 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Mover a la derecha", "Delete tab": "Eliminar pestaña", "Tab label": "Etiqueta de la pestaña", - "Tab actions for {{name}}": "Acciones de pestaña para {{name}}" + "Tab actions for {{name}}": "Acciones de pestaña para {{name}}", + "Inline base": "Base integrada" } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 8a14001b..267cbe4c 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Déplacer vers la droite", "Delete tab": "Supprimer l’onglet", "Tab label": "Libellé de l’onglet", - "Tab actions for {{name}}": "Actions de l’onglet pour {{name}}" + "Tab actions for {{name}}": "Actions de l’onglet pour {{name}}", + "Inline base": "Base en ligne" } diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index 0374bf1f..1b8cfa23 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Sposta a destra", "Delete tab": "Elimina scheda", "Tab label": "Etichetta scheda", - "Tab actions for {{name}}": "Azioni scheda per {{name}}" + "Tab actions for {{name}}": "Azioni scheda per {{name}}", + "Inline base": "Base inline" } diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index 33979763..bcba16ea 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -1573,5 +1573,6 @@ "Move right": "右に移動", "Delete tab": "タブを削除", "Tab label": "タブラベル", - "Tab actions for {{name}}": "{{name}} のタブアクション" + "Tab actions for {{name}}": "{{name}} のタブアクション", + "Inline base": "インラインベース" } diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index 46d5ffe8..790edd03 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -1573,5 +1573,6 @@ "Move right": "오른쪽으로 이동", "Delete tab": "탭 삭제", "Tab label": "탭 레이블", - "Tab actions for {{name}}": "{{name}} 탭 작업" + "Tab actions for {{name}}": "{{name}} 탭 작업", + "Inline base": "인라인 베이스" } diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 4e6579a9..9247f2ac 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Naar rechts verplaatsen", "Delete tab": "Tabblad verwijderen", "Tab label": "Tabbladlabel", - "Tab actions for {{name}}": "Tabbladacties voor {{name}}" + "Tab actions for {{name}}": "Tabbladacties voor {{name}}", + "Inline base": "Inline basis" } diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index d308dadf..1761d6f9 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Mover para a direita", "Delete tab": "Excluir aba", "Tab label": "Rótulo da aba", - "Tab actions for {{name}}": "Ações da aba para {{name}}" + "Tab actions for {{name}}": "Ações da aba para {{name}}", + "Inline base": "Base embutida" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index dff11b0d..dd0dc502 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Переместить вправо", "Delete tab": "Удалить вкладку", "Tab label": "Название вкладки", - "Tab actions for {{name}}": "Действия с вкладкой для {{name}}" + "Tab actions for {{name}}": "Действия с вкладкой для {{name}}", + "Inline base": "Базовый встроенный элемент" } diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index 340aa45c..9316b415 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Перемістити праворуч", "Delete tab": "Видалити вкладку", "Tab label": "Назва вкладки", - "Tab actions for {{name}}": "Дії вкладки для {{name}}" + "Tab actions for {{name}}": "Дії вкладки для {{name}}", + "Inline base": "Вбудована основа" } diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 374d747b..1d57e4f1 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -1573,5 +1573,6 @@ "Move right": "右移", "Delete tab": "删除标签页", "Tab label": "标签页标签", - "Tab actions for {{name}}": "{{name}}的标签页操作" + "Tab actions for {{name}}": "{{name}}的标签页操作", + "Inline base": "内联基础" } diff --git a/apps/client/src/ee/base/components/cells/cell-email.tsx b/apps/client/src/ee/base/components/cells/cell-email.tsx index 0d93f69f..78b78e19 100644 --- a/apps/client/src/ee/base/components/cells/cell-email.tsx +++ b/apps/client/src/ee/base/components/cells/cell-email.tsx @@ -15,13 +15,12 @@ type CellEmailProps = { const toDraft = (value: unknown) => (typeof value === "string" ? value : ""); const parse = (draft: string) => draft || null; -export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) { +export function CellEmail({ value, property, rowId, isEditing, onCommit }: CellEmailProps) { const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = useEditableTextCell({ value, isEditing, onCommit, - onCancel, toDraft, parse, rowId, diff --git a/apps/client/src/ee/base/components/cells/cell-long-text.test.tsx b/apps/client/src/ee/base/components/cells/cell-long-text.test.tsx new file mode 100644 index 00000000..2527e4a8 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-long-text.test.tsx @@ -0,0 +1,107 @@ +import { MantineProvider } from "@mantine/core"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IBaseProperty } from "@/ee/base/types/base.types"; +import { CellLongText } from "./cell-long-text"; + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +const property = { + id: "p1", + pageId: "base-1", + name: "Long text", + type: "text", + position: "a", + typeOptions: {}, + isPrimary: false, +} as unknown as IBaseProperty; + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +beforeEach(() => { + setMediaMatch(); + // Textarea autosize listens for font loads, which jsdom doesn't implement. + Object.defineProperty(document, "fonts", { + configurable: true, + value: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); +}); + +afterEach(cleanup); + +describe("CellLongText", () => { + it("saves what was typed when Esc closes the editor", () => { + const onCommit = vi.fn(); + const onCancel = vi.fn(); + render( + , + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + const textarea = screen.getByRole("textbox"); + + fireEvent.change(textarea, { target: { value: "what is love" } }); + fireEvent.keyDown(textarea, { key: "Escape" }); + + expect(onCommit).toHaveBeenCalledWith("what is love"); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("autosaves after a 1 s typing pause", () => { + vi.useFakeTimers(); + const onValueChange = vi.fn(); + render( + , + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "what is love" }, + }); + act(() => vi.advanceTimersByTime(1000)); + + expect(onValueChange).toHaveBeenCalledWith("what is love"); + vi.useRealTimers(); + }); +}); diff --git a/apps/client/src/ee/base/components/cells/cell-long-text.tsx b/apps/client/src/ee/base/components/cells/cell-long-text.tsx index 2c390056..32616db4 100644 --- a/apps/client/src/ee/base/components/cells/cell-long-text.tsx +++ b/apps/client/src/ee/base/components/cells/cell-long-text.tsx @@ -4,6 +4,7 @@ import { useDebouncedCallback } from "@mantine/hooks"; import { IBaseProperty } from "@/ee/base/types/base.types"; import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters"; import cellClasses from "@/ee/base/styles/cells.module.css"; +import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave"; type CellLongTextProps = { value: unknown; @@ -27,11 +28,9 @@ export function CellLongText({ isEditing, onCommit, onValueChange, - onCancel, onTabNavigate, }: CellLongTextProps) { const [draft, setDraft] = useState(() => toText(value)); - const cancelledRef = useRef(false); const committedRef = useRef(false); const wasEditingRef = useRef(false); const textareaRef = useRef(null); @@ -40,7 +39,6 @@ export function CellLongText({ // value changes mid-edit so the user's typing is not clobbered. useEffect(() => { if (isEditing && !wasEditingRef.current) { - cancelledRef.current = false; committedRef.current = false; setDraft(toText(value)); requestAnimationFrame(() => { @@ -53,11 +51,11 @@ export function CellLongText({ wasEditingRef.current = isEditing; }, [isEditing, value]); - // Autosave after a typing pause; commit/cancel clear the pending fire so - // a closed editor can never write a stale or discarded draft. + // Autosave after a typing pause; commit clears the pending fire so a + // closed editor can never write a stale draft. const debouncedAutosave = useDebouncedCallback(() => { onValueChange(normalize(draft)); - }, 10_000); + }, AUTOSAVE_DELAY_MS); const commit = () => { if (committedRef.current) return; @@ -65,11 +63,6 @@ export function CellLongText({ debouncedAutosave.cancel(); onCommit(normalize(draft)); }; - const cancel = () => { - cancelledRef.current = true; - debouncedAutosave.cancel(); - onCancel(); - }; const preview = formatLongTextPreview(toText(value)); @@ -78,11 +71,6 @@ export function CellLongText({ opened={isEditing} onChange={(opened) => { if (opened) return; - // Programmatic close after cancel must not re-commit. - if (cancelledRef.current) { - cancelledRef.current = false; - return; - } commit(); }} position="bottom-start" @@ -135,7 +123,7 @@ export function CellLongText({ onTabNavigate?.(e.shiftKey); } else if (e.key === "Escape") { e.preventDefault(); - cancel(); + commit(); } else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); commit(); diff --git a/apps/client/src/ee/base/components/cells/cell-number.tsx b/apps/client/src/ee/base/components/cells/cell-number.tsx index cbb50a99..d964de06 100644 --- a/apps/client/src/ee/base/components/cells/cell-number.tsx +++ b/apps/client/src/ee/base/components/cells/cell-number.tsx @@ -95,7 +95,6 @@ export function CellNumber({ rowId, isEditing, onCommit, - onCancel, }: CellNumberProps) { const typeOptions = property.typeOptions as NumberTypeOptions | undefined; const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = @@ -103,7 +102,6 @@ export function CellNumber({ value, isEditing, onCommit, - onCancel, toDraft, parse: parseNumberDraft, rowId, diff --git a/apps/client/src/ee/base/components/cells/cell-text.test.tsx b/apps/client/src/ee/base/components/cells/cell-text.test.tsx new file mode 100644 index 00000000..68dd0138 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-text.test.tsx @@ -0,0 +1,45 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { IBaseProperty } from "@/ee/base/types/base.types"; +import { CellText } from "./cell-text"; + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +const property = { + id: "p1", + pageId: "base-1", + name: "Text", + type: "text", + position: "a", + typeOptions: {}, + isPrimary: false, +} as unknown as IBaseProperty; + +afterEach(cleanup); + +describe("CellText", () => { + it("saves what was typed when Esc ends the edit", () => { + const onCommit = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + const input = screen.getByRole("textbox"); + + fireEvent.change(input, { target: { value: "hello" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(onCommit).toHaveBeenCalledWith("hello"); + expect(onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/client/src/ee/base/components/cells/cell-text.tsx b/apps/client/src/ee/base/components/cells/cell-text.tsx index 172e0350..12cd8e01 100644 --- a/apps/client/src/ee/base/components/cells/cell-text.tsx +++ b/apps/client/src/ee/base/components/cells/cell-text.tsx @@ -16,13 +16,12 @@ type CellTextProps = { const toDraft = (value: unknown) => (typeof value === "string" ? value : ""); const parse = (draft: string) => draft; -export function CellText({ value, property, rowId, isEditing, onCommit, onCancel }: CellTextProps) { +export function CellText({ value, property, rowId, isEditing, onCommit }: CellTextProps) { const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = useEditableTextCell({ value, isEditing, onCommit, - onCancel, toDraft, parse, rowId, diff --git a/apps/client/src/ee/base/components/cells/cell-url.tsx b/apps/client/src/ee/base/components/cells/cell-url.tsx index c7c4de35..cef3f262 100644 --- a/apps/client/src/ee/base/components/cells/cell-url.tsx +++ b/apps/client/src/ee/base/components/cells/cell-url.tsx @@ -16,13 +16,12 @@ type CellUrlProps = { const toDraft = (value: unknown) => (typeof value === "string" ? value : ""); const parse = (draft: string) => draft || null; -export function CellUrl({ value, property, rowId, isEditing, onCommit, onCancel }: CellUrlProps) { +export function CellUrl({ value, property, rowId, isEditing, onCommit }: CellUrlProps) { const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = useEditableTextCell({ value, isEditing, onCommit, - onCancel, toDraft, parse, rowId, diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx index 7fd11e8a..a7b96d30 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx @@ -1,7 +1,9 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { Textarea } from "@mantine/core"; +import { useDebouncedCallback } from "@mantine/hooks"; import { FieldProps, FieldShell } from "./detail-field"; import classes from "@/ee/base/styles/row-detail-modal.module.css"; +import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave"; const toText = (value: unknown) => (typeof value === "string" ? value : ""); const normalize = (s: string) => { @@ -19,23 +21,21 @@ export function FieldLongText({ const text = toText(value); const [draft, setDraft] = useState(text); const [focused, setFocused] = useState(false); - // Esc sets this; blur() then runs commit synchronously with the stale - // draft, so the revert must be decided here, not via setDraft. - const cancelRef = useRef(false); useEffect(() => { if (!focused) setDraft(text); }, [text, focused]); + const save = () => { + if (normalize(draft) !== normalize(text)) onChange(normalize(draft)); + }; + const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS); + const commit = () => { setFocused(false); onEditingChange?.(false); - if (cancelRef.current) { - cancelRef.current = false; - setDraft(text); - return; - } - if (normalize(draft) !== normalize(text)) onChange(normalize(draft)); + autosave.cancel(); + save(); }; if (readOnly) { @@ -61,11 +61,13 @@ export function FieldLongText({ setFocused(true); onEditingChange?.(true); }} - onChange={(e) => setDraft(e.currentTarget.value)} + onChange={(e) => { + setDraft(e.currentTarget.value); + autosave(); + }} onBlur={commit} onKeyDown={(e) => { if (e.key === "Escape") { - cancelRef.current = true; e.currentTarget.blur(); } else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx index 62ab2255..8ceaf3f6 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { NumberTypeOptions } from "@/ee/base/types/base.types"; import { formatNumber, @@ -22,9 +22,6 @@ export function FieldNumber({ const numValue = typeof value === "number" ? value : null; const [draft, setDraft] = useState(toDraft(value)); const [focused, setFocused] = useState(false); - // Esc sets this; blur() then runs commit synchronously with the stale - // draft, so the revert must be decided here, not via setDraft. - const cancelRef = useRef(false); useEffect(() => { if (!focused) setDraft(toDraft(value)); @@ -43,11 +40,6 @@ export function FieldNumber({ const commit = () => { setFocused(false); onEditingChange?.(false); - if (cancelRef.current) { - cancelRef.current = false; - setDraft(toDraft(value)); - return; - } if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft)); }; @@ -86,7 +78,6 @@ export function FieldNumber({ e.preventDefault(); e.currentTarget.blur(); } else if (e.key === "Escape") { - cancelRef.current = true; e.currentTarget.blur(); } }} diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx index dbf1abbd..d2002a92 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx @@ -1,7 +1,9 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; +import { useDebouncedCallback } from "@mantine/hooks"; import { IconExternalLink, IconMail } from "@tabler/icons-react"; import { FieldProps, FieldShell } from "./detail-field"; import classes from "@/ee/base/styles/row-detail-modal.module.css"; +import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave"; const toText = (value: unknown) => (typeof value === "string" ? value : ""); @@ -15,24 +17,22 @@ export function FieldText({ const text = toText(value); const [draft, setDraft] = useState(text); const [focused, setFocused] = useState(false); - // Esc sets this; blur() then runs commit synchronously with the stale - // draft, so the revert must be decided here, not via setDraft. - const cancelRef = useRef(false); // Track remote/navigation updates while not typing. useEffect(() => { if (!focused) setDraft(text); }, [text, focused]); + const save = () => { + if (draft !== text) onChange(draft); + }; + const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS); + const commit = () => { setFocused(false); onEditingChange?.(false); - if (cancelRef.current) { - cancelRef.current = false; - setDraft(text); - return; - } - if (draft !== text) onChange(draft); + autosave.cancel(); + save(); }; if (readOnly) { @@ -65,14 +65,16 @@ export function FieldText({ setFocused(true); onEditingChange?.(true); }} - onChange={(e) => setDraft(e.currentTarget.value)} + onChange={(e) => { + setDraft(e.currentTarget.value); + autosave(); + }} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); } else if (e.key === "Escape") { - cancelRef.current = true; e.currentTarget.blur(); } }} diff --git a/apps/client/src/ee/base/components/row-detail-modal/row-detail-fields.test.tsx b/apps/client/src/ee/base/components/row-detail-modal/row-detail-fields.test.tsx new file mode 100644 index 00000000..cce45da4 --- /dev/null +++ b/apps/client/src/ee/base/components/row-detail-modal/row-detail-fields.test.tsx @@ -0,0 +1,185 @@ +import { MantineProvider } from "@mantine/core"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentType } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types"; +import { RowDetailTitle } from "./row-detail-title"; +import { FieldProps } from "./fields/detail-field"; +import { FieldText } from "./fields/field-text"; +import { FieldNumber } from "./fields/field-number"; +import { FieldLongText } from "./fields/field-long-text"; + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (value: string) => value }), +})); + +const property = (type: IBaseProperty["type"]) => + ({ + id: "p1", + pageId: "base-1", + name: "Field", + type, + position: "a", + typeOptions: {}, + isPrimary: false, + }) as IBaseProperty; + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +// Real focus, so the blur that Esc triggers reaches onBlur as in the browser. +function typeThenEscape(value: string) { + const input = screen.getByRole("textbox"); + act(() => input.focus()); + fireEvent.change(input, { target: { value } }); + fireEvent.keyDown(input, { key: "Escape" }); + return input; +} + +beforeEach(() => { + setMediaMatch(); + // Textarea autosize listens for font loads, which jsdom doesn't implement. + Object.defineProperty(document, "fonts", { + configurable: true, + value: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); +}); +afterEach(cleanup); + +describe("Esc in the row detail modal", () => { + it("saves a typed title before leaving the field", () => { + const onCommit = vi.fn(); + render( + , + ); + + const input = typeThenEscape("dddddd"); + + expect(onCommit).toHaveBeenCalledWith("dddddd"); + expect(document.activeElement).not.toBe(input); + }); + + it.each([ + ["text", FieldText, "text", "hello", "hello"], + ["number", FieldNumber, "number", "42", 42], + ["long text", FieldLongText, "text", "notes", "notes"], + ] as [string, ComponentType, IBaseProperty["type"], string, unknown][])( + "saves a typed %s field before leaving it", + (_, Field, type, typed, saved) => { + const onChange = vi.fn(); + render( + , + { wrapper: MantineProvider }, + ); + + const input = typeThenEscape(typed); + + expect(onChange).toHaveBeenCalledWith(saved); + expect(document.activeElement).not.toBe(input); + }, + ); +}); + +describe("Typing in the row detail modal", () => { + const titleRow = (title: string) => + ({ id: "r1", cells: { p1: title } }) as unknown as IBaseRow; + const primary = { ...property("text"), isPrimary: true }; + + function type(value: string) { + const input = screen.getByRole("textbox"); + act(() => input.focus()); + fireEvent.change(input, { target: { value } }); + return input; + } + + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("saves a typed title after a 1 s pause, without leaving the field", () => { + const onCommit = vi.fn(); + render( + , + ); + + const input = type("dddddd"); + act(() => vi.advanceTimersByTime(999)); + expect(onCommit).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(1)); + + expect(onCommit).toHaveBeenCalledWith("dddddd"); + expect(document.activeElement).toBe(input); + }); + + it("keeps a typed title when the row's title changes elsewhere", () => { + const props = { primaryProperty: primary, canEdit: true, onCommit: vi.fn() }; + const { rerender } = render( + , + ); + + const input = type("My draft"); + rerender(); + + expect(input).toHaveProperty("value", "My draft"); + }); + + it.each([ + ["text", FieldText, "hello"], + ["long text", FieldLongText, "notes"], + ] as [string, ComponentType, string][])( + "saves a typed %s field after a 1 s pause", + (_, Field, typed) => { + const onChange = vi.fn(); + render( + , + { wrapper: MantineProvider }, + ); + + const input = type(typed); + act(() => vi.advanceTimersByTime(1000)); + + expect(onChange).toHaveBeenCalledWith(typed); + expect(document.activeElement).toBe(input); + }, + ); +}); diff --git a/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.test.tsx b/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.test.tsx new file mode 100644 index 00000000..34bd9371 --- /dev/null +++ b/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.test.tsx @@ -0,0 +1,116 @@ +import { MantineProvider } from "@mantine/core"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IBase, IBaseRow } from "@/ee/base/types/base.types"; +import { RowDetailModal } from "./row-detail-modal"; + +const mocks = vi.hoisted(() => ({ updateRow: vi.fn() })); + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (value: string) => value }), +})); + +vi.mock("@/ee/base/queries/base-row-query", () => ({ + useBaseRowQuery: () => ({ data: undefined, isError: false }), + useUpdateRowMutation: () => ({ mutate: mocks.updateRow, isPending: false }), + useDeleteRowMutation: () => ({ mutate: vi.fn() }), +})); + +vi.mock("@/ee/base/context/base-editable", () => ({ + useBaseEditable: () => true, +})); + +vi.mock("@/ee/base/components/property/create-property-popover", () => ({ + CreatePropertyPopover: () => null, +})); + +const base = { + id: "base-1", + properties: [ + { + id: "p1", + pageId: "base-1", + name: "Name", + type: "text", + position: "a", + typeOptions: {}, + isPrimary: true, + }, + ], +} as unknown as IBase; + +const row = { + id: "r1", + pageId: "base-1", + cells: {}, + position: "a", +} as unknown as IBaseRow; + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function renderModal(onClose: () => void) { + render( + , + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); +} + +beforeEach(() => { + setMediaMatch(); + mocks.updateRow.mockReset(); +}); + +afterEach(cleanup); + +describe("RowDetailModal Esc", () => { + it("saves a typed title, then closes, in one press", () => { + const onClose = vi.fn(); + renderModal(onClose); + const title = screen.getByRole("textbox", { name: "Name" }); + + act(() => title.focus()); + fireEvent.change(title, { target: { value: "dddddd" } }); + fireEvent.keyDown(title, { key: "Escape" }); + + expect(mocks.updateRow).toHaveBeenCalledWith({ + rowId: "r1", + pageId: "base-1", + cells: { p1: "dddddd" }, + }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(mocks.updateRow.mock.invocationCallOrder[0]).toBeLessThan( + onClose.mock.invocationCallOrder[0], + ); + }); +}); diff --git a/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx b/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx index be303a0f..9efe8e32 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx @@ -168,10 +168,11 @@ export function RowDetailModal({ // Mantine's closeOnEscape runs a capture-phase window listener that fires // before inner popovers and inputs see the key, so we manage Esc ourselves - // and yield to: nested dialogs (delete confirm), open popovers - // ([data-position]) and editable elements. Arrows step records under the - // same yield rules. Mantine puts role="dialog" and our content class on - // the same element, which distinguishes this modal from nested ones. + // and yield to nested dialogs (delete confirm) and open popovers + // ([data-position]). Esc in an inline field blurs it first, which saves the + // edit, then closes; arrows stay with the field. Mantine puts role="dialog" + // and our content class on the same element, which distinguishes this + // modal from nested ones. const handleKeyDown = useCallback( (event: KeyboardEvent) => { const isEscape = event.key === "Escape"; @@ -183,11 +184,10 @@ export function RowDetailModal({ if (dialog && !dialog.classList.contains(classes.modalContent)) { return; } - if ( - target.closest("[data-position]") || - target.matches("input, textarea, select, [contenteditable='true']") - ) { - return; + if (target.closest("[data-position]")) return; + if (target.matches("input, textarea, select, [contenteditable='true']")) { + if (!isEscape) return; + target.blur(); } } if (isEscape) { @@ -383,7 +383,7 @@ export function RowDetailModal({ Esc - {t("to reset")} + {t("to close")} ) : ( diff --git a/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx b/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx index 2e023815..f254f756 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from "react"; +import { useDebouncedCallback } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types"; import { timeAgo } from "@/lib/time.ts"; +import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave"; import classes from "@/ee/base/styles/row-detail-modal.module.css"; type RowDetailTitleProps = { @@ -24,21 +26,23 @@ export function RowDetailTitle({ ? (((row.cells ?? {})[primaryProperty.id] as string) ?? "") : ""; const [value, setValue] = useState(initial); - const cancelRef = useRef(false); + const focusedRef = useRef(false); - const commit = () => { - onEditingChange?.(false); - if (cancelRef.current) { - cancelRef.current = false; - setValue(initial); - return; - } + const save = () => { if (value !== initial) onCommit(value); }; + const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS); - // Re-sync when the row changes underneath us (navigation or remote edit). + const commit = () => { + focusedRef.current = false; + onEditingChange?.(false); + autosave.cancel(); + save(); + }; + + // Re-sync when the row changes underneath us (navigation or remote edit), but not mid-typing. useEffect(() => { - setValue(initial); + if (!focusedRef.current) setValue(initial); }, [initial]); const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : ""; @@ -55,13 +59,16 @@ export function RowDetailTitle({ value={value} maxLength={1000} onFocus={() => { + focusedRef.current = true; onEditingChange?.(true); }} - onChange={(e) => setValue(e.currentTarget.value)} + onChange={(e) => { + setValue(e.currentTarget.value); + autosave(); + }} onBlur={commit} onKeyDown={(e) => { if (e.key === "Escape") { - cancelRef.current = true; e.currentTarget.blur(); } else if (e.key === "Enter") { e.preventDefault(); diff --git a/apps/client/src/ee/base/constants/autosave.ts b/apps/client/src/ee/base/constants/autosave.ts new file mode 100644 index 00000000..b06524b3 --- /dev/null +++ b/apps/client/src/ee/base/constants/autosave.ts @@ -0,0 +1,2 @@ +// Typing pause after which a text edit saves itself. +export const AUTOSAVE_DELAY_MS = 1000; diff --git a/apps/client/src/ee/base/hooks/use-editable-text-cell.ts b/apps/client/src/ee/base/hooks/use-editable-text-cell.ts index 8fa5d934..21489797 100644 --- a/apps/client/src/ee/base/hooks/use-editable-text-cell.ts +++ b/apps/client/src/ee/base/hooks/use-editable-text-cell.ts @@ -6,7 +6,6 @@ export type UseEditableTextCellParams = { value: unknown; isEditing: boolean; onCommit: (value: unknown) => void; - onCancel: () => void; /** value -> the draft string shown in the input when editing begins */ toDraft: (value: unknown) => string; /** draft string -> the value passed to onCommit */ @@ -27,7 +26,6 @@ export function useEditableTextCell({ value, isEditing, onCommit, - onCancel, toDraft, parse, rowId, @@ -85,11 +83,10 @@ export function useEditableTextCell({ commitOnce(parse(draft)); } else if (e.key === "Escape") { e.preventDefault(); - committedRef.current = true; - onCancel(); + commitOnce(parse(draft)); } }, - [draft, parse, commitOnce, onCancel], + [draft, parse, commitOnce], ); const handleBlur = useCallback(() => { diff --git a/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.test.ts b/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.test.ts new file mode 100644 index 00000000..bac36567 --- /dev/null +++ b/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useGridKeyboardNav } from "./use-grid-keyboard-nav"; + +type Options = Parameters[0]; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("useGridKeyboardNav while editing", () => { + it("blurs the cell input, which saves it, before leaving edit mode on Esc", () => { + const container = document.createElement("div"); + const input = document.createElement("input"); + container.appendChild(input); + document.body.appendChild(container); + + const order: string[] = []; + input.addEventListener("blur", () => order.push("blur")); + const setEditingCell = vi.fn(() => { + order.push("stop editing"); + }); + + renderHook(() => + useGridKeyboardNav({ + table: { + getVisibleLeafColumns: () => [], + getRowModel: () => ({ rows: [] }), + }, + properties: [], + containerRef: { current: container }, + focusedCell: null, + setFocusedCell: vi.fn(), + editingCell: { rowId: "r1", propertyId: "p1" }, + setEditingCell, + openEditor: vi.fn(), + clearCell: vi.fn(), + beginTypeToEdit: vi.fn(), + scrollCellIntoView: vi.fn(), + selectionCount: 0, + clearSelection: vi.fn(), + deleteSelected: vi.fn(), + toggleRowSelection: vi.fn(), + expandRow: vi.fn(), + addRow: vi.fn(), + } as unknown as Options), + ); + + input.focus(); + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + + expect(order).toEqual(["blur", "stop editing"]); + }); +}); diff --git a/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts b/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts index 09d140e4..168ebf9e 100644 --- a/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts +++ b/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts @@ -171,6 +171,8 @@ export function useGridKeyboardNav({ } case "Escape": { e.preventDefault(); + // Blurring saves the draft, as on Enter. + (document.activeElement as HTMLElement | null)?.blur(); setEditingCell(null); setFocusedCell(editingCell); break; diff --git a/apps/client/src/ee/template/components/template-picker-modal.module.css b/apps/client/src/ee/template/components/template-picker-modal.module.css index 687feb83..4c5a00b1 100644 --- a/apps/client/src/ee/template/components/template-picker-modal.module.css +++ b/apps/client/src/ee/template/components/template-picker-modal.module.css @@ -1,11 +1,6 @@ .row { position: relative; - display: flex; - align-items: center; - gap: var(--mantine-spacing-sm); - padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); border-radius: var(--mantine-radius-sm); - width: 100%; @mixin hover { background-color: light-dark( @@ -15,6 +10,15 @@ } } +.pickButton { + display: flex; + align-items: center; + gap: var(--mantine-spacing-sm); + padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); + border-radius: inherit; + width: 100%; +} + .icon { width: 22px; height: 22px; diff --git a/apps/client/src/ee/template/components/template-picker-modal.tsx b/apps/client/src/ee/template/components/template-picker-modal.tsx index 76c55295..d9fe3238 100644 --- a/apps/client/src/ee/template/components/template-picker-modal.tsx +++ b/apps/client/src/ee/template/components/template-picker-modal.tsx @@ -196,41 +196,39 @@ export default function TemplatePickerModal({ ) : ( filtered.map((tpl) => ( - handlePick(tpl)} - > -
- {tpl.icon ? ( - {tpl.icon} - ) : ( - - )} -
-
{tpl.title}
-
- {tpl.spaceId - ? spaceNamesById.get(tpl.spaceId) ?? t("Space") - : t("Global")} -
+
+ handlePick(tpl)} + > +
+ {tpl.icon ? ( + {tpl.icon} + ) : ( + + )} +
+
{tpl.title}
+
+ {tpl.spaceId + ? spaceNamesById.get(tpl.spaceId) ?? t("Space") + : t("Global")} +
+
- +
)) )} diff --git a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx index 69f7db1e..9dd32687 100644 --- a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx +++ b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx @@ -8,8 +8,12 @@ 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 { IconDots, IconLink, IconTable, IconX } from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; import { usePageQuery } from "@/features/page/queries/page-query"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { getAppUrl } from "@/lib/config.ts"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; import classes from "./base-embed.module.css"; const SIDE_GUTTER = 8; @@ -63,6 +67,14 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) { pendingKey ? "" : pageId ?? "", ); const { data: page } = usePageQuery({ pageId: pageId ?? undefined }); + const clipboard = useClipboard({ timeout: 500 }); + + const handleCopyLink = () => { + clipboard.copy( + getAppUrl() + buildPageUrl(page.space?.slug, page.slugId, page.title), + ); + notifications.show({ message: t("Link copied") }); + }; useEffect(() => { const wrapper = wrapperRef.current; @@ -98,7 +110,8 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) { // mounts) is reserved only for the skeleton/loading/table states. const isCompact = !pendingKey && (!pageId || isError); - const showControls = editor.isEditable && !pendingKey; + // Also on the skeleton, so a placeholder stuck pending can still be removed. + const showControls = editor.isEditable; let content: React.ReactNode; if (pendingKey) { @@ -159,6 +172,13 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) { + } + disabled={!page} + onClick={handleCopyLink} + > + {t("Copy link")} + } onClick={() => deleteNode()} diff --git a/apps/client/src/features/editor/components/base-embed/base-paste-menu.test.tsx b/apps/client/src/features/editor/components/base-embed/base-paste-menu.test.tsx new file mode 100644 index 00000000..115e7e88 --- /dev/null +++ b/apps/client/src/features/editor/components/base-embed/base-paste-menu.test.tsx @@ -0,0 +1,125 @@ +import { MantineProvider } from "@mantine/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BasePasteMenu } from "./base-paste-menu"; + +const mocks = vi.hoisted(() => ({ + pasteState: null as { pos: number } | null, + hasBases: true, + convert: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (value: string) => value }), +})); + +vi.mock("@tiptap/react", () => ({ + posToDOMRect: vi.fn(), + useEditorState: ({ editor, selector }: Record) => + selector({ editor }), +})); + +vi.mock("@tiptap/react/menus", () => ({ + BubbleMenu: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@/features/editor/extensions/base-paste-menu", () => ({ + basePasteMenuKey: { getState: () => mocks.pasteState }, +})); + +vi.mock("@/ee/hooks/use-feature", () => ({ + useHasFeature: () => mocks.hasBases, +})); + +vi.mock("./convert-mention-to-base-embed", () => ({ + convertMentionToBaseEmbed: mocks.convert, +})); + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function renderMenu() { + const editor = { + state: { + doc: { nodeAt: () => ({ type: { name: "mention" }, nodeSize: 1 }) }, + tr: { setMeta: vi.fn() }, + }, + view: { dispatch: vi.fn(), nodeDOM: vi.fn(), hasFocus: () => true }, + commands: { focus: vi.fn() }, + isDestroyed: false, + on: vi.fn(), + off: vi.fn(), + }; + + render( + + ["editor"] + } + /> + , + ); + + return editor; +} + +describe("BasePasteMenu", () => { + beforeEach(() => { + setMediaMatch(); + mocks.pasteState = { pos: 3 }; + mocks.hasBases = true; + mocks.convert.mockReset(); + }); + + afterEach(cleanup); + + it("embeds the pasted base when its option is clicked", () => { + const editor = renderMenu(); + + fireEvent.click(screen.getByRole("option", { name: "Inline base" })); + + expect(mocks.convert).toHaveBeenCalledWith(editor, 3); + }); + + it("leaves Enter to the editor until the option is highlighted", () => { + const editor = renderMenu(); + + expect(fireEvent.keyDown(window, { key: "Enter" })).toBe(true); + expect(mocks.convert).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: "ArrowDown" }); + expect(fireEvent.keyDown(window, { key: "Enter" })).toBe(false); + expect(mocks.convert).toHaveBeenCalledWith(editor, 3); + }); + + it("closes on Escape and keeps the mention", () => { + const editor = renderMenu(); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(editor.view.dispatch).toHaveBeenCalled(); + expect(mocks.convert).not.toHaveBeenCalled(); + }); + + it("stays hidden without the Bases feature", () => { + mocks.hasBases = false; + renderMenu(); + + expect(screen.queryByRole("option")).toBeNull(); + }); +}); diff --git a/apps/client/src/features/editor/components/base-embed/base-paste-menu.tsx b/apps/client/src/features/editor/components/base-embed/base-paste-menu.tsx new file mode 100644 index 00000000..9da99fcf --- /dev/null +++ b/apps/client/src/features/editor/components/base-embed/base-paste-menu.tsx @@ -0,0 +1,186 @@ +import { Button, Paper, Text, VisuallyHidden } from "@mantine/core"; +import { posToDOMRect, useEditorState } from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; +import { basePasteMenuKey } from "@/features/editor/extensions/base-paste-menu"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { convertMentionToBaseEmbed } from "./convert-mention-to-base-embed"; +import classes from "../integration-link/integration-paste-menu.module.css"; + +function getPastedBasePos(editor: EditorMenuProps["editor"]): number | null { + const pasteState = basePasteMenuKey.getState(editor.state); + if (!pasteState) return null; + const node = editor.state.doc.nodeAt(pasteState.pos); + return node?.type.name === "mention" ? pasteState.pos : null; +} + +export function BasePasteMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + const hasBases = useHasFeature(Feature.BASES); + const [highlighted, setHighlighted] = useState(false); + const optionId = useId(); + const promptRef = useRef(null); + + const pastedPos = useEditorState({ + editor, + selector: ({ editor: currentEditor }) => + currentEditor ? getPastedBasePos(currentEditor) : null, + }); + const isOpen = hasBases && pastedPos !== null; + + const shouldShow = useCallback( + () => hasBases && getPastedBasePos(editor) !== null, + [editor, hasBases], + ); + + const getReferencedVirtualElement = useCallback(() => { + const pos = getPastedBasePos(editor); + if (pos === null) return undefined; + const node = editor.state.doc.nodeAt(pos); + if (!node) return undefined; + + const dom = editor.view.nodeDOM(pos) as HTMLElement | null; + const domRect = + dom?.getBoundingClientRect?.() ?? + posToDOMRect(editor.view, pos, pos + node.nodeSize); + + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor]); + + const dismiss = useCallback(() => { + if (!basePasteMenuKey.getState(editor.state)) return; + editor.view.dispatch(editor.state.tr.setMeta(basePasteMenuKey, null)); + + requestAnimationFrame(() => { + const activeElement = document.activeElement; + if ( + !activeElement || + activeElement === document.body || + !activeElement.isConnected + ) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }); + }, [editor]); + + const embed = useCallback(() => { + const pos = getPastedBasePos(editor); + if (pos === null) return; + + dismiss(); + if (convertMentionToBaseEmbed(editor, pos)) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }, [dismiss, editor]); + + useEffect(() => setHighlighted(false), [pastedPos]); + + // Focus stays in the editor, so capture keys before ProseMirror handles them. + useEffect(() => { + if (!isOpen) return; + + const ownsFocus = () => + editor.view.hasFocus() || + Boolean(promptRef.current?.contains(document.activeElement)); + + const onKeyDown = (event: KeyboardEvent) => { + if (!ownsFocus()) return; + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + dismiss(); + return; + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + setHighlighted(true); + return; + } + + // Until the option is highlighted, Enter is still a new line. + if (event.key === "Enter" && highlighted) { + event.preventDefault(); + event.stopPropagation(); + embed(); + } + }; + + // Leaving the editor dismisses the prompt, unless focus moved into the prompt itself. + const onBlur = ({ event }: { event: FocusEvent }) => { + const next = event.relatedTarget; + if (next instanceof Node && promptRef.current?.contains(next)) return; + if (editor.isDestroyed) return; + editor.view.dispatch(editor.state.tr.setMeta(basePasteMenuKey, null)); + }; + + window.addEventListener("keydown", onKeyDown, true); + editor.on("blur", onBlur); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + editor.off("blur", onBlur); + }; + }, [dismiss, editor, embed, highlighted, isOpen]); + + return ( + + {isOpen ? ( + + + {highlighted ? t("Inline base") : ""} + + + {t("Paste as")} + + + + ) : null} + + ); +} diff --git a/apps/client/src/features/editor/components/base-embed/base-paste.test.ts b/apps/client/src/features/editor/components/base-embed/base-paste.test.ts new file mode 100644 index 00000000..8211b6ff --- /dev/null +++ b/apps/client/src/features/editor/components/base-embed/base-paste.test.ts @@ -0,0 +1,151 @@ +import { BaseEmbed, Mention } from "@docmost/editor-ext"; +import { Editor, type JSONContent } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BasePasteMenuExtension, + basePasteMenuKey, +} from "@/features/editor/extensions/base-paste-menu"; +import { handlePaste } from "@/features/editor/components/common/editor-paste-handler"; +import { convertMentionToBaseEmbed } from "./convert-mention-to-base-embed"; + +const mocks = vi.hoisted(() => ({ getPageById: vi.fn() })); + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +vi.mock("@/features/page/services/page-service.ts", async (importOriginal) => ({ + ...(await importOriginal()), + getPageById: mocks.getPageById, +})); + +const PAGE_ID = "019a2b3c-0000-7000-8000-000000000001"; +const LINK = `${window.location.origin}/s/general/p/roadmap-x1y2z3`; + +let editor: Editor; + +function createEditor(content: JSONContent[], cursor: number) { + editor = new Editor({ + extensions: [StarterKit, Mention, BaseEmbed, BasePasteMenuExtension], + content: { type: "doc", content }, + }); + editor.commands.setTextSelection(cursor); +} + +function mentionPos(): number | null { + let found: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "mention") found = pos; + }); + return found; +} + +async function pasteLink({ + isBase, + url = LINK, +}: { + isBase: boolean; + url?: string; +}) { + mocks.getPageById.mockResolvedValue({ + id: PAGE_ID, + slugId: "x1y2z3", + title: "Roadmap", + isBase, + }); + const event = { + clipboardData: { + getData: (type: string) => (type === "text/plain" ? url : ""), + files: [], + }, + preventDefault: vi.fn(), + }; + handlePaste(editor, event as unknown as ClipboardEvent, "page-id"); + await vi.waitFor(() => expect(mentionPos()).not.toBeNull()); +} + +afterEach(() => editor?.destroy()); + +describe("pasting a link to a base", () => { + it("offers to embed it", async () => { + createEditor([{ type: "paragraph" }], 1); + + await pasteLink({ isBase: true }); + + expect(basePasteMenuKey.getState(editor.state)).toEqual({ + pos: mentionPos(), + }); + }); + + it("offers to embed a link to one of its views", async () => { + createEditor([{ type: "paragraph" }], 1); + + await pasteLink({ + isBase: true, + url: `${LINK}?view=0195f3c2-1111-7000-8000-000000000abc`, + }); + + expect(mocks.getPageById).toHaveBeenCalledWith({ pageId: "x1y2z3" }); + expect(basePasteMenuKey.getState(editor.state)).toEqual({ + pos: mentionPos(), + }); + }); + + it("does not offer it for a document", async () => { + createEditor([{ type: "paragraph" }], 1); + + await pasteLink({ isBase: false }); + + expect(basePasteMenuKey.getState(editor.state)).toBeNull(); + }); + + it("does not offer it where a block cannot go", async () => { + createEditor( + [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Plan " }], + }, + ], + 6, + ); + + await pasteLink({ isBase: true }); + + expect(basePasteMenuKey.getState(editor.state)).toBeNull(); + }); +}); + +describe("embedding a pasted base", () => { + it("replaces a mention on its own line with the base", async () => { + createEditor([{ type: "paragraph" }], 1); + await pasteLink({ isBase: true }); + + expect(convertMentionToBaseEmbed(editor, mentionPos()!)).toBe(true); + + // The trailing paragraph comes from the trailing node, as in the page editor. + expect(editor.getJSON().content).toMatchObject([ + { type: "base", attrs: { pageId: PAGE_ID } }, + { type: "paragraph" }, + ]); + }); + + it("splits the paragraph around a mention pasted mid-sentence", async () => { + createEditor( + [{ type: "paragraph", content: [{ type: "text", text: "See for dates" }] }], + 5, + ); + await pasteLink({ isBase: true }); + + expect(convertMentionToBaseEmbed(editor, mentionPos()!)).toBe(true); + + expect(editor.getJSON().content).toMatchObject([ + { type: "paragraph", content: [{ type: "text", text: "See" }] }, + { type: "base", attrs: { pageId: PAGE_ID } }, + { type: "paragraph", content: [{ type: "text", text: "for dates" }] }, + ]); + }); +}); diff --git a/apps/client/src/features/editor/components/base-embed/convert-mention-to-base-embed.ts b/apps/client/src/features/editor/components/base-embed/convert-mention-to-base-embed.ts new file mode 100644 index 00000000..4db885d9 --- /dev/null +++ b/apps/client/src/features/editor/components/base-embed/convert-mention-to-base-embed.ts @@ -0,0 +1,33 @@ +import type { Editor } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { getBlockPlacement } from "@/features/editor/utils/block-placement"; + +function getEmbedPlacement(doc: ProseMirrorNode, pos: number) { + const mention = doc.nodeAt(pos); + const baseType = doc.type.schema.nodes.base; + if (mention?.type.name !== "mention" || !baseType) return null; + + return getBlockPlacement( + doc, + pos, + pos + mention.nodeSize, + baseType.create({ pageId: mention.attrs.entityId }), + ); +} + +export function canConvertMentionToBaseEmbed( + doc: ProseMirrorNode, + pos: number, +): boolean { + return getEmbedPlacement(doc, pos) !== null; +} + +export function convertMentionToBaseEmbed(editor: Editor, pos: number): boolean { + const placement = getEmbedPlacement(editor.state.doc, pos); + if (!placement) return false; + + return editor.commands.command(({ tr }) => { + tr.replaceWith(placement.range.from, placement.range.to, placement.content); + return true; + }); +} diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 642d05a9..212af981 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -131,10 +131,12 @@ export const handlePaste = ( } } - if (INTERNAL_LINK_REGEX.test(clipboardData)) { + // A query, such as a base's ?view=, still links to the same page. + const pageLink = clipboardData.replace(/\?[^#]*/, ""); + if (INTERNAL_LINK_REGEX.test(pageLink)) { // we have to do this validation here to allow the default link extension to takeover if needs be event.preventDefault(); - const url = clipboardData.trim(); + const url = pageLink.trim(); const { from: pos, empty } = editor.state.selection; const match = INTERNAL_LINK_REGEX.exec(url); diff --git a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.module.css b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.module.css index 70b19a63..3f4af274 100644 --- a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.module.css +++ b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.module.css @@ -27,3 +27,23 @@ flex-wrap: wrap; gap: 8px; } + +.chip { + font-weight: 500; + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); + background-color: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-6) + ); + + &:hover { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); + } + + &:disabled { + opacity: 0.6; + } +} diff --git a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx index acc0ce88..eb584b4c 100644 --- a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx +++ b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx @@ -99,6 +99,7 @@ export function EmptyPageGetStarted({ key: "base", label: t("Base"), icon: IconTable, + color: "blue", onClick: () => convertMutation.mutate({ pageId }), disabled: convertMutation.isPending, }, @@ -106,6 +107,7 @@ export function EmptyPageGetStarted({ key: "kanban", label: t("Kanban"), icon: IconLayoutKanban, + color: "violet", onClick: () => convertMutation.mutate({ pageId, template: "kanban" }), disabled: convertMutation.isPending, @@ -118,6 +120,7 @@ export function EmptyPageGetStarted({ key: "template", label: t("Template"), icon: IconTemplate, + color: "pink", onClick: openTemplatePicker, disabled: false, }, @@ -137,10 +140,17 @@ export function EmptyPageGetStarted({ {chips.map((chip) => (