Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions __tests__/components/FormViewConditionalFields.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import React from "react";
import { render, fireEvent } from "@testing-library/react-native";
import { SafeAreaProvider } from "react-native-safe-area-context";

import { FormViewRenderer } from "~/components/renderers/FormViewRenderer";
import { ConfirmProvider } from "~/components/ui/ConfirmDialog";
import type { FieldDefinition, FormViewMeta } from "~/components/renderers/types";

const cel = (source: string) => ({ dialect: "cel" as const, source });

// FormViewRenderer now reads useConfirm (unsaved-changes guard) and
// useSafeAreaInsets (sticky save bar), so the tree needs both providers.
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<SafeAreaProvider
initialMetrics={{
frame: { x: 0, y: 0, width: 390, height: 844 },
insets: { top: 0, left: 0, right: 0, bottom: 0 },
}}
>
<ConfirmProvider>{children}</ConfirmProvider>
</SafeAreaProvider>
);
const renderForm = (ui: React.ReactElement) => render(ui, { wrapper: Wrapper });

/**
* Integration coverage for ObjectStack 8.0 conditional fields wired into the
* form renderer: `visibleWhen` (hide), `readonlyWhen` (lock), `requiredWhen`
Expand Down Expand Up @@ -38,23 +54,23 @@ describe("FormViewRenderer — conditional fields", () => {
// The field label renders the text plus a possible required marker, so match
// with a regex (partial) rather than an exact string.
it("hides a field whose visibleWhen is false", () => {
const { queryByText } = render(
const { queryByText } = renderForm(
<FormViewRenderer view={view} fields={fields} initialValues={{ type: "po" }} />,
);
expect(queryByText(/Type/)).toBeTruthy();
expect(queryByText(/Invoice No/)).toBeNull();
});

it("shows a field whose visibleWhen is true", () => {
const { queryByText } = render(
const { queryByText } = renderForm(
<FormViewRenderer view={view} fields={fields} initialValues={{ type: "invoice" }} />,
);
expect(queryByText(/Invoice No/)).toBeTruthy();
});

it("blocks submit when a conditionally-required visible field is empty", () => {
const onSubmit = jest.fn();
const { getByText } = render(
const { getByText } = renderForm(
<FormViewRenderer
view={view}
fields={fields}
Expand All @@ -69,7 +85,7 @@ describe("FormViewRenderer — conditional fields", () => {

it("does NOT block submit for a conditionally-required field that is hidden", () => {
const onSubmit = jest.fn();
const { getByText } = render(
const { getByText } = renderForm(
<FormViewRenderer
view={view}
fields={fields}
Expand All @@ -86,7 +102,7 @@ describe("FormViewRenderer — conditional fields", () => {

it("allows submit when the conditionally-required field is filled", () => {
const onSubmit = jest.fn();
const { getByText } = render(
const { getByText } = renderForm(
<FormViewRenderer
view={view}
fields={fields}
Expand All @@ -100,7 +116,7 @@ describe("FormViewRenderer — conditional fields", () => {
});

it("reveals a field live when the driving value changes", () => {
const { queryByText, getByPlaceholderText } = render(
const { queryByText, getByPlaceholderText } = renderForm(
<FormViewRenderer view={view} fields={fields} initialValues={{ type: "po" }} />,
);
// Hidden initially (type === "po").
Expand Down Expand Up @@ -131,15 +147,15 @@ describe("FormViewRenderer — conditional sections", () => {
};

it("hides a section whose visibleOn is false", () => {
const { queryByText } = render(
const { queryByText } = renderForm(
<FormViewRenderer view={view} fields={fields} initialValues={{ kind: "personal" }} />,
);
expect(queryByText("Business")).toBeNull();
expect(queryByText(/Tax ID/)).toBeNull();
});

it("shows a section whose visibleOn is true", () => {
const { queryByText } = render(
const { queryByText } = renderForm(
<FormViewRenderer view={view} fields={fields} initialValues={{ kind: "business" }} />,
);
expect(queryByText("Business")).toBeTruthy();
Expand All @@ -148,7 +164,7 @@ describe("FormViewRenderer — conditional sections", () => {

it("does not validate required fields inside a hidden section", () => {
const onSubmit = jest.fn();
const { getByText } = render(
const { getByText } = renderForm(
<FormViewRenderer
view={view}
fields={fields}
Expand Down
41 changes: 34 additions & 7 deletions app/(app)/[appName]/[objectName]/[id]/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useClient, useMutation } from "@objectstack/client-react";
import { useTranslation } from "react-i18next";
import { useEffect, useState, useCallback } from "react";
import { AlertCircle } from "lucide-react-native";
import { FormViewRenderer } from "~/components/renderers";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { EmptyState } from "~/components/ui/EmptyState";
import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { useConfirm } from "~/components/ui/ConfirmDialog";
import { useObjectMeta } from "~/hooks/useObjectMeta";

export default function EditRecordScreen() {
Expand All @@ -18,7 +20,10 @@ export default function EditRecordScreen() {
}>();
const client = useClient();
const router = useRouter();
const { t } = useTranslation();
const confirm = useConfirm();
const { meta, fields } = useObjectMeta(objectName);
const [dirty, setDirty] = useState(false);
const { mutate, isLoading: isSubmitting } = useMutation(objectName!, "update", {
onSuccess: () => {
router.back();
Expand Down Expand Up @@ -53,11 +58,32 @@ export default function EditRecordScreen() {
meta?.label ??
objectName?.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) ??
"Record";
const title = t("records.editTitle", { name: displayName });

const goBack = useCallback(() => {
if (router.canGoBack()) router.back();
else router.replace("/(tabs)/apps");
}, [router]);

// Header back bypasses the form's own Cancel guard, so confirm here too.
const handleBack = useCallback(async () => {
if (dirty) {
const ok = await confirm({
title: t("records.discardTitle"),
message: t("records.discardMessage"),
confirmLabel: t("common.discard"),
cancelLabel: t("common.cancel"),
destructive: true,
});
if (!ok) return;
}
goBack();
}, [dirty, confirm, t, goBack]);

if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title={`Edit ${displayName}`} />
<ScreenHeader title={title} />
<View className="px-4 pt-4">
<ListSkeleton count={5} />
</View>
Expand All @@ -68,13 +94,13 @@ export default function EditRecordScreen() {
if (loadError) {
return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title={`Edit ${displayName}`} />
<ScreenHeader title={title} />
<EmptyState
icon={AlertCircle}
variant="error"
title="Couldn't Load Record"
title={t("records.loadOneError")}
description={loadError}
actionLabel="Retry"
actionLabel={t("common.retry")}
onAction={fetchRecord}
/>
</SafeAreaView>
Expand All @@ -83,14 +109,15 @@ export default function EditRecordScreen() {

return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title={`Edit ${displayName}`} />
<ScreenHeader title={title} onBack={handleBack} />
<FormViewRenderer
fields={fields}
initialValues={record ?? {}}
onSubmit={(values) => mutate({ id, data: values } as Record<string, unknown>)}
onCancel={() => router.back()}
onCancel={goBack}
onDirtyChange={setDirty}
isSubmitting={isSubmitting}
submitLabel="Save"
submitLabel={t("common.save")}
/>
</SafeAreaView>
);
Expand Down
36 changes: 33 additions & 3 deletions app/(app)/[appName]/[objectName]/new.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useMutation } from "@objectstack/client-react";
import { useTranslation } from "react-i18next";
import { useCallback, useState } from "react";
import { FormViewRenderer } from "~/components/renderers";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { useConfirm } from "~/components/ui/ConfirmDialog";
import { useObjectMeta } from "~/hooks/useObjectMeta";

export default function CreateRecordScreen() {
Expand All @@ -11,7 +14,10 @@ export default function CreateRecordScreen() {
objectName: string;
}>();
const router = useRouter();
const { t } = useTranslation();
const confirm = useConfirm();
const { meta, fields } = useObjectMeta(objectName);
const [dirty, setDirty] = useState(false);
const { mutate, isLoading: isSubmitting } = useMutation(objectName!, "create", {
onSuccess: () => {
router.back();
Expand All @@ -23,15 +29,39 @@ export default function CreateRecordScreen() {
objectName?.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) ??
"Record";

const goBack = useCallback(() => {
if (router.canGoBack()) router.back();
else router.replace("/(tabs)/apps");
}, [router]);

// Header back bypasses the form's own Cancel guard, so confirm here too.
const handleBack = useCallback(async () => {
if (dirty) {
const ok = await confirm({
title: t("records.discardTitle"),
message: t("records.discardMessage"),
confirmLabel: t("common.discard"),
cancelLabel: t("common.cancel"),
destructive: true,
});
if (!ok) return;
}
goBack();
}, [dirty, confirm, t, goBack]);

return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title={`New ${displayName}`} />
<ScreenHeader
title={t("records.newTitle", { name: displayName })}
onBack={handleBack}
/>
<FormViewRenderer
fields={fields}
onSubmit={(values) => mutate(values as Record<string, unknown>)}
onCancel={() => router.back()}
onCancel={goBack}
onDirtyChange={setDirty}
isSubmitting={isSubmitting}
submitLabel="Create"
submitLabel={t("common.create")}
/>
</SafeAreaView>
);
Expand Down
Loading
Loading