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/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