Skip to content

Commit ff0e053

Browse files
authored
Merge pull request #126 from objectstack-ai/test/ux-component-coverage
test(ux): render + interaction coverage for new UX components
2 parents 233fcfd + e43cc07 commit ff0e053

3 files changed

Lines changed: 185 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React from "react";
2+
import { render, fireEvent } from "@testing-library/react-native";
3+
4+
import { DensitySelector } from "~/components/common/DensitySelector";
5+
import { useUIStore } from "~/stores/ui-store";
6+
7+
describe("DensitySelector", () => {
8+
beforeEach(() => {
9+
// Reset the shared store to the default before each case.
10+
useUIStore.setState({ density: "comfortable" });
11+
});
12+
13+
it("renders both density options", () => {
14+
const { getByText } = render(<DensitySelector />);
15+
expect(getByText("Comfortable")).toBeTruthy();
16+
expect(getByText("Compact")).toBeTruthy();
17+
});
18+
19+
it("switches the global density to compact on press", () => {
20+
const { getByLabelText } = render(<DensitySelector />);
21+
expect(useUIStore.getState().density).toBe("comfortable");
22+
23+
fireEvent.press(getByLabelText("Compact"));
24+
expect(useUIStore.getState().density).toBe("compact");
25+
});
26+
27+
it("toggles back to comfortable", () => {
28+
useUIStore.setState({ density: "compact" });
29+
const { getByLabelText } = render(<DensitySelector />);
30+
31+
fireEvent.press(getByLabelText("Comfortable"));
32+
expect(useUIStore.getState().density).toBe("comfortable");
33+
});
34+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React from "react";
2+
import { render, fireEvent } from "@testing-library/react-native";
3+
4+
import { DetailViewRenderer } from "~/components/renderers/DetailViewRenderer";
5+
import type { FieldDefinition, FormViewMeta } from "~/components/renderers/types";
6+
7+
/**
8+
* Inline select/status editing on the detail screen. The editable badge is an
9+
* internal component (`EditableSelectField`), so it's exercised through
10+
* `DetailViewRenderer` with an `onFieldEdit` handler.
11+
*/
12+
describe("DetailViewRenderer — inline select edit", () => {
13+
const record = { id: "r1", status: "todo" };
14+
const fields: FieldDefinition[] = [
15+
{
16+
name: "status",
17+
label: "Status",
18+
type: "select",
19+
options: [
20+
{ value: "todo", label: "To Do" },
21+
{ value: "in_progress", label: "In Progress" },
22+
{ value: "done", label: "Done" },
23+
],
24+
},
25+
];
26+
// A curated view guarantees the field renders regardless of value.
27+
const view: FormViewMeta = { sections: [{ fields: ["status"] }] };
28+
29+
it("stays read-only (no edit affordance) without an onFieldEdit handler", () => {
30+
const { getByText, queryByLabelText } = render(
31+
<DetailViewRenderer record={record} fields={fields} view={view} />,
32+
);
33+
// The current value still shows as its option label…
34+
expect(getByText("To Do")).toBeTruthy();
35+
// …but there is no tappable edit control.
36+
expect(queryByLabelText("Edit Status")).toBeNull();
37+
});
38+
39+
it("opens the picker and persists a new value via onFieldEdit", () => {
40+
const onFieldEdit = jest.fn().mockResolvedValue(undefined);
41+
const { getByLabelText } = render(
42+
<DetailViewRenderer
43+
record={record}
44+
fields={fields}
45+
view={view}
46+
onFieldEdit={onFieldEdit}
47+
/>,
48+
);
49+
50+
fireEvent.press(getByLabelText("Edit Status"));
51+
fireEvent.press(getByLabelText("In Progress"));
52+
53+
expect(onFieldEdit).toHaveBeenCalledWith("status", "in_progress");
54+
});
55+
56+
it("does not persist when the current value is re-selected", () => {
57+
const onFieldEdit = jest.fn();
58+
const { getByLabelText } = render(
59+
<DetailViewRenderer
60+
record={record}
61+
fields={fields}
62+
view={view}
63+
onFieldEdit={onFieldEdit}
64+
/>,
65+
);
66+
67+
fireEvent.press(getByLabelText("Edit Status"));
68+
fireEvent.press(getByLabelText("To Do")); // already the current value
69+
70+
expect(onFieldEdit).not.toHaveBeenCalled();
71+
});
72+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from "react";
2+
import { render, fireEvent } from "@testing-library/react-native";
3+
4+
import "~/lib/i18n"; // initialize i18next so t() resolves (the component chain doesn't)
5+
import { QuickCreateSheet } from "~/components/home/QuickCreateSheet";
6+
import type { AppMeta } from "~/hooks/useApps";
7+
8+
// The global expo-router mock hands back a fresh `push` per call, so override it
9+
// here with a shared spy we can assert on.
10+
const mockPush = jest.fn();
11+
jest.mock("expo-router", () => ({
12+
useRouter: () => ({ push: mockPush, replace: jest.fn(), back: jest.fn() }),
13+
}));
14+
15+
const apps: AppMeta[] = [
16+
{
17+
name: "todo_app",
18+
label: "Todo",
19+
navigation: [
20+
{
21+
id: "g1",
22+
type: "group",
23+
label: "Tasks",
24+
children: [
25+
{ id: "n1", type: "object", label: "All Tasks", objectName: "todo_task" },
26+
],
27+
},
28+
// Non-object leaves are ignored by quick-create.
29+
{ id: "n2", type: "dashboard", label: "Dashboard", dashboardName: "d1" },
30+
],
31+
},
32+
{
33+
name: "crm",
34+
label: "CRM",
35+
navigation: [
36+
{ id: "n3", type: "object", label: "Contacts", objectName: "contact" },
37+
// Same app/object surfaced under a second view — must dedupe.
38+
{ id: "n4", type: "object", label: "Contacts (Kanban)", objectName: "contact" },
39+
],
40+
},
41+
];
42+
43+
describe("QuickCreateSheet", () => {
44+
beforeEach(() => mockPush.mockClear());
45+
46+
it("lists creatable objects flattened from the apps' navigation, deduped", () => {
47+
const { getByText, queryByText, queryAllByText } = render(
48+
<QuickCreateSheet open onOpenChange={jest.fn()} apps={apps} />,
49+
);
50+
51+
// Object leaves (incl. those nested in a group) surface.
52+
expect(getByText("All Tasks")).toBeTruthy();
53+
expect(getByText("Contacts")).toBeTruthy();
54+
// The dashboard leaf is not a creatable object.
55+
expect(queryByText("Dashboard")).toBeNull();
56+
// The second view over `contact` is deduped away.
57+
expect(queryByText("Contacts (Kanban)")).toBeNull();
58+
expect(queryAllByText("Contacts")).toHaveLength(1);
59+
});
60+
61+
it("opens the object's create form and closes the sheet on press", () => {
62+
const onOpenChange = jest.fn();
63+
const { getByLabelText } = render(
64+
<QuickCreateSheet open onOpenChange={onOpenChange} apps={apps} />,
65+
);
66+
67+
fireEvent.press(getByLabelText("All Tasks"));
68+
69+
expect(onOpenChange).toHaveBeenCalledWith(false);
70+
expect(mockPush).toHaveBeenCalledWith("/(app)/todo_app/todo_task/new");
71+
});
72+
73+
it("shows an empty hint when no app publishes a creatable object", () => {
74+
const { getByText } = render(
75+
<QuickCreateSheet open onOpenChange={jest.fn()} apps={[]} />,
76+
);
77+
expect(getByText("No objects available to create.")).toBeTruthy();
78+
});
79+
});

0 commit comments

Comments
 (0)