diff --git a/__tests__/components/FormViewConditionalFields.test.tsx b/__tests__/components/FormViewConditionalFields.test.tsx
index 908a2be..6e32b84 100644
--- a/__tests__/components/FormViewConditionalFields.test.tsx
+++ b/__tests__/components/FormViewConditionalFields.test.tsx
@@ -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 }) => (
+
+ {children}
+
+);
+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`
@@ -38,7 +54,7 @@ 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(
,
);
expect(queryByText(/Type/)).toBeTruthy();
@@ -46,7 +62,7 @@ describe("FormViewRenderer — conditional fields", () => {
});
it("shows a field whose visibleWhen is true", () => {
- const { queryByText } = render(
+ const { queryByText } = renderForm(
,
);
expect(queryByText(/Invoice No/)).toBeTruthy();
@@ -54,7 +70,7 @@ describe("FormViewRenderer — conditional fields", () => {
it("blocks submit when a conditionally-required visible field is empty", () => {
const onSubmit = jest.fn();
- const { getByText } = render(
+ const { getByText } = renderForm(
{
it("does NOT block submit for a conditionally-required field that is hidden", () => {
const onSubmit = jest.fn();
- const { getByText } = render(
+ const { getByText } = renderForm(
{
it("allows submit when the conditionally-required field is filled", () => {
const onSubmit = jest.fn();
- const { getByText } = render(
+ const { getByText } = renderForm(
{
});
it("reveals a field live when the driving value changes", () => {
- const { queryByText, getByPlaceholderText } = render(
+ const { queryByText, getByPlaceholderText } = renderForm(
,
);
// Hidden initially (type === "po").
@@ -131,7 +147,7 @@ describe("FormViewRenderer — conditional sections", () => {
};
it("hides a section whose visibleOn is false", () => {
- const { queryByText } = render(
+ const { queryByText } = renderForm(
,
);
expect(queryByText("Business")).toBeNull();
@@ -139,7 +155,7 @@ describe("FormViewRenderer — conditional sections", () => {
});
it("shows a section whose visibleOn is true", () => {
- const { queryByText } = render(
+ const { queryByText } = renderForm(
,
);
expect(queryByText("Business")).toBeTruthy();
@@ -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(
();
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();
@@ -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 (
-
+
@@ -68,13 +94,13 @@ export default function EditRecordScreen() {
if (loadError) {
return (
-
+
@@ -83,14 +109,15 @@ export default function EditRecordScreen() {
return (
-
+
mutate({ id, data: values } as Record)}
- onCancel={() => router.back()}
+ onCancel={goBack}
+ onDirtyChange={setDirty}
isSubmitting={isSubmitting}
- submitLabel="Save"
+ submitLabel={t("common.save")}
/>
);
diff --git a/app/(app)/[appName]/[objectName]/new.tsx b/app/(app)/[appName]/[objectName]/new.tsx
index d1e9d36..969174b 100644
--- a/app/(app)/[appName]/[objectName]/new.tsx
+++ b/app/(app)/[appName]/[objectName]/new.tsx
@@ -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() {
@@ -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();
@@ -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 (
-
+
mutate(values as Record)}
- onCancel={() => router.back()}
+ onCancel={goBack}
+ onDirtyChange={setDirty}
isSubmitting={isSubmitting}
- submitLabel="Create"
+ submitLabel={t("common.create")}
/>
);
diff --git a/components/renderers/DetailViewRenderer.tsx b/components/renderers/DetailViewRenderer.tsx
index ba5b233..1acb335 100644
--- a/components/renderers/DetailViewRenderer.tsx
+++ b/components/renderers/DetailViewRenderer.tsx
@@ -1,5 +1,7 @@
import React, { useMemo, useState } from "react";
import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native";
+import { useTranslation } from "react-i18next";
+import { useColorScheme } from "nativewind";
import { webContentMaxWidth } from "~/lib/responsive";
import {
Edit,
@@ -163,14 +165,22 @@ const ACTION_VARIANT_TEXT: Record = {
ghost: "text-foreground",
link: "text-primary",
};
-/** Icon tint per variant (lucide needs an explicit color, not a class). */
-const ACTION_VARIANT_ICON: Record = {
- primary: "#ffffff",
- danger: "#ffffff",
- secondary: "#0f172a",
- ghost: "#0f172a",
- link: "#1e40af",
-};
+/**
+ * Icon tint per variant (lucide needs an explicit color, not a class). The
+ * neutral and link variants must follow the theme — a hardcoded near-black
+ * (#0f172a) icon was invisible on a dark card once dark mode shipped.
+ */
+function actionIconColor(variant: string, isDark: boolean): string {
+ switch (variant) {
+ case "primary":
+ case "danger":
+ return "#ffffff";
+ case "link":
+ return isDark ? "#60a5fa" : "#1e40af";
+ default:
+ return isDark ? "#e2e8f0" : "#0f172a";
+ }
+}
function HeaderActionButton({
action,
@@ -181,7 +191,10 @@ function HeaderActionButton({
onAction?: (action: ActionMeta) => void;
busy: boolean;
}) {
+ const { colorScheme } = useColorScheme();
+ const isDark = colorScheme === "dark";
const variant = action.variant ?? "secondary";
+ const iconColor = actionIconColor(variant, isDark);
const Icon = action.icon ? getIcon(action.icon) : null;
return (
{busy ? (
-
+
) : Icon ? (
-
+
) : null}
{action.label}
@@ -208,6 +221,9 @@ function HeaderActionButton({
);
}
+/** Header `actions` shown inline before the rest spill into the ⋯ menu. */
+const MAX_INLINE_ACTIONS = 2;
+
function DetailActionBar({
onEdit,
onDelete,
@@ -219,10 +235,21 @@ function DetailActionBar({
DetailViewRendererProps,
"onEdit" | "onDelete" | "actions" | "moreActions" | "onAction" | "busyActionName"
>) {
+ const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
+ const isDark = colorScheme === "dark";
const [moreOpen, setMoreOpen] = useState(false);
- const hasMore = !!(moreActions && moreActions.length > 0);
+
+ // Cap inline header actions so the bar can't overflow horizontally on a
+ // phone — the surplus joins the `record_more` actions in the ⋯ menu.
+ const inlineActions = (actions ?? []).slice(0, MAX_INLINE_ACTIONS);
+ const overflowActions = [
+ ...(actions ?? []).slice(MAX_INLINE_ACTIONS),
+ ...(moreActions ?? []),
+ ];
+ const hasMore = overflowActions.length > 0;
const hasActions =
- onEdit || onDelete || (actions && actions.length > 0) || hasMore;
+ onEdit || onDelete || inlineActions.length > 0 || hasMore;
if (!hasActions) return null;
return (
@@ -232,10 +259,12 @@ function DetailActionBar({
className="flex-row items-center gap-1.5 rounded-lg bg-primary px-4 py-2 active:opacity-80"
onPress={onEdit}
accessibilityRole="button"
- accessibilityLabel="Edit record"
+ accessibilityLabel={t("common.edit")}
>
- Edit
+
+ {t("common.edit")}
+
)}
{onDelete && (
@@ -243,13 +272,15 @@ function DetailActionBar({
className="flex-row items-center gap-1.5 rounded-lg bg-destructive px-4 py-2 active:opacity-80"
onPress={onDelete}
accessibilityRole="button"
- accessibilityLabel="Delete record"
+ accessibilityLabel={t("common.delete")}
>
- Delete
+
+ {t("common.delete")}
+
)}
- {actions?.map((action) => (
+ {inlineActions.map((action) => (
setMoreOpen(true)}
accessibilityRole="button"
- accessibilityLabel="More actions"
+ accessibilityLabel={t("records.moreActions")}
>
-
+
-
- {moreActions!.map((action) => {
+
+ {overflowActions.map((action) => {
const Icon = action.icon ? getIcon(action.icon) : null;
const busy = busyActionName === action.name;
const danger = action.variant === "danger";
+ const neutralIcon = isDark ? "#e2e8f0" : "#0f172a";
return (
{busy ? (
-
+
) : Icon ? (
-
+
) : (
)}
@@ -323,6 +359,7 @@ function RelatedListSection({
config: RelatedListConfig;
onRecordPress?: (objectName: string, record: Record) => void;
}) {
+ const { t } = useTranslation();
return (
@@ -333,7 +370,7 @@ function RelatedListSection({
{config.records.length === 0 ? (
- No related records
+ {t("records.noRelated")}
) : (
config.records.map((rec, idx) => (
@@ -381,6 +418,9 @@ function RecordNavigator({
DetailViewRendererProps,
"onPrevious" | "onNext" | "hasPrevious" | "hasNext" | "positionLabel"
>) {
+ const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
+ const accent = colorScheme === "dark" ? "#60a5fa" : "#1e40af";
if (!onPrevious && !onNext) return null;
return (
@@ -393,14 +433,14 @@ function RecordNavigator({
onPress={hasPrevious ? onPrevious : undefined}
disabled={!hasPrevious}
>
-
+
- Previous
+ {t("records.previous")}
@@ -422,9 +462,9 @@ function RecordNavigator({
hasNext ? "text-primary" : "text-muted-foreground",
)}
>
- Next
+ {t("records.next")}
-
+
);
@@ -458,6 +498,8 @@ export function DetailViewRenderer({
allowDelete = true,
footer,
}: DetailViewRendererProps) {
+ const { t } = useTranslation();
+
/* ---- Build sections ---- */
const sections: FormSection[] = useMemo(() => {
const viewSections = view?.sections ?? view?.groups;
@@ -528,12 +570,12 @@ export function DetailViewRenderer({
}
- title="Couldn't Load Record"
+ title={t("records.loadOneError")}
description={error.message}
action={
onRetry ? (
) : undefined
}
diff --git a/components/renderers/FormViewRenderer.tsx b/components/renderers/FormViewRenderer.tsx
index f55e77d..842908e 100644
--- a/components/renderers/FormViewRenderer.tsx
+++ b/components/renderers/FormViewRenderer.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
View,
Text,
@@ -7,8 +7,11 @@ import {
KeyboardAvoidingView,
Platform,
} from "react-native";
+import { useTranslation } from "react-i18next";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChevronDown, ChevronUp } from "lucide-react-native";
import { Button } from "~/components/ui/Button";
+import { useConfirm } from "~/components/ui/ConfirmDialog";
import { webContentMaxWidth } from "~/lib/responsive";
import {
isFieldVisible,
@@ -99,10 +102,15 @@ export interface FormViewRendererProps {
isSubmitting?: boolean;
/** Read-only mode (detail display) */
readonly?: boolean;
- /** Submit button label */
+ /** Submit button label (defaults to the localized "Save"). */
submitLabel?: string;
/** Per-field permissions: field name → { readable, editable } */
fieldPermissions?: Record;
+ /**
+ * Notified whenever the form's dirty state flips. Lets the host screen guard
+ * its own back affordance (the in-form Cancel is already guarded here).
+ */
+ onDirtyChange?: (dirty: boolean) => void;
}
/* ------------------------------------------------------------------ */
@@ -220,11 +228,21 @@ export function FormViewRenderer({
onCancel,
isSubmitting = false,
readonly = false,
- submitLabel = "Save",
+ submitLabel,
fieldPermissions,
+ onDirtyChange,
}: FormViewRendererProps) {
+ const { t } = useTranslation();
+ const confirm = useConfirm();
+ const insets = useSafeAreaInsets();
const [values, setValues] = useState>(initialValues);
const [errors, setErrors] = useState>({});
+ const [dirty, setDirty] = useState(false);
+
+ // Surface dirty state so the host screen can guard its back affordance.
+ useEffect(() => {
+ onDirtyChange?.(dirty);
+ }, [dirty, onDirtyChange]);
/* ---- Build sections ---- */
const sections: FormSection[] = useMemo(() => {
@@ -252,6 +270,7 @@ export function FormViewRenderer({
/* ---- Handlers ---- */
const handleChange = useCallback((field: string, value: unknown) => {
setValues((prev) => ({ ...prev, [field]: value }));
+ setDirty(true);
// Clear error on change
setErrors((prev) => {
if (prev[field]) {
@@ -287,8 +306,9 @@ export function FormViewRenderer({
if (isRequired) {
const val = values[fieldName];
if (val == null || val === "") {
- newErrors[fieldName] =
- `${fieldDef?.label ?? fieldName} is required`;
+ newErrors[fieldName] = t("records.requiredField", {
+ field: fieldDef?.label ?? fieldName,
+ });
}
}
}
@@ -300,7 +320,22 @@ export function FormViewRenderer({
}
onSubmit?.(normalizeForSubmit(values, fields));
- }, [sections, fields, values, onSubmit]);
+ }, [sections, fields, values, onSubmit, t]);
+
+ /** Cancel — confirm a discard first if the form has unsaved edits. */
+ const handleCancel = 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;
+ }
+ onCancel?.();
+ }, [dirty, confirm, t, onCancel]);
/* ---- Render ---- */
return (
@@ -330,30 +365,31 @@ export function FormViewRenderer({
/>
);
})}
+
- {/* Action buttons */}
- {!readonly && onSubmit && (
-
- {onCancel && (
-
- )}
+ {/* Sticky action bar — Save stays reachable without scrolling to the
+ end of a long form. Pinned below the scroll area, inside the
+ keyboard-avoider so it rides above the keyboard. */}
+ {!readonly && onSubmit && (
+
+ {onCancel && (
-
- )}
-
+ )}
+
+
+ )}
);
}
diff --git a/locales/ar.json b/locales/ar.json
index 50d1cbe..b31c9f9 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -3,6 +3,8 @@
"ok": "موافق",
"cancel": "إلغاء",
"save": "حفظ",
+ "saving": "جارٍ الحفظ…",
+ "discard": "تجاهل",
"delete": "حذف",
"edit": "تعديل",
"create": "إنشاء",
@@ -52,9 +54,18 @@
"noRecordsDescription": "لم يتم العثور على سجلات لهذا العرض.",
"searchRecords": "بحث في السجلات…",
"loadError": "تعذّر تحميل السجلات",
+ "loadOneError": "تعذّر تحميل السجل",
"sortBy": "ترتيب",
"groupBy": "تجميع",
"groupNone": "بدون",
+ "discardTitle": "تجاهل التغييرات؟",
+ "discardMessage": "لديك تغييرات غير محفوظة ستُفقد.",
+ "requiredField": "{{field}} مطلوب",
+ "newTitle": "{{name}} جديد",
+ "editTitle": "تعديل {{name}}",
+ "actionsTitle": "إجراءات",
+ "moreActions": "إجراءات أخرى",
+ "noRelated": "لا توجد سجلات مرتبطة",
"createRecord": "إنشاء سجل",
"editRecord": "تعديل سجل",
"deleteRecord": "حذف سجل",
diff --git a/locales/en.json b/locales/en.json
index bd32e9c..40f460b 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -3,6 +3,8 @@
"ok": "OK",
"cancel": "Cancel",
"save": "Save",
+ "saving": "Saving…",
+ "discard": "Discard",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
@@ -52,9 +54,18 @@
"noRecordsDescription": "No records found for this view.",
"searchRecords": "Search records…",
"loadError": "Couldn't Load Records",
+ "loadOneError": "Couldn't Load Record",
"sortBy": "Sort",
"groupBy": "Group",
"groupNone": "None",
+ "discardTitle": "Discard changes?",
+ "discardMessage": "You have unsaved changes that will be lost.",
+ "requiredField": "{{field}} is required",
+ "newTitle": "New {{name}}",
+ "editTitle": "Edit {{name}}",
+ "actionsTitle": "Actions",
+ "moreActions": "More actions",
+ "noRelated": "No related records",
"createRecord": "Create Record",
"editRecord": "Edit Record",
"deleteRecord": "Delete Record",
diff --git a/locales/zh.json b/locales/zh.json
index e44378f..65d0fee 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -3,6 +3,8 @@
"ok": "确定",
"cancel": "取消",
"save": "保存",
+ "saving": "保存中…",
+ "discard": "放弃",
"delete": "删除",
"edit": "编辑",
"create": "创建",
@@ -52,9 +54,18 @@
"noRecordsDescription": "此视图没有找到记录。",
"searchRecords": "搜索记录…",
"loadError": "无法加载记录",
+ "loadOneError": "无法加载记录",
"sortBy": "排序",
"groupBy": "分组",
"groupNone": "无",
+ "discardTitle": "放弃更改?",
+ "discardMessage": "您有未保存的更改,将会丢失。",
+ "requiredField": "{{field}} 为必填项",
+ "newTitle": "新建{{name}}",
+ "editTitle": "编辑{{name}}",
+ "actionsTitle": "操作",
+ "moreActions": "更多操作",
+ "noRelated": "没有关联记录",
"createRecord": "创建记录",
"editRecord": "编辑记录",
"deleteRecord": "删除记录",