From 70706a9a56b23e7142876d2ae505fb6139aa491b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 15:09:16 +0500 Subject: [PATCH] feat(ux): detail action overflow + dark-mode fix, form sticky save + unsaved guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail view: - Cap inline header actions at two; the surplus joins the record_more actions in the ⋯ menu, so the action bar can't overflow horizontally on a phone. - Make every icon theme-aware (action buttons, the ⋯ trigger, the menu rows, the prev/next chevrons). A hardcoded near-black (#0f172a) icon was invisible on a dark card once dark mode shipped. - i18n the Edit/Delete/Previous/Next/Actions/More-actions/no-related/load-error strings. Form view: - Pin the Save/Cancel buttons in a sticky bar at the bottom of the screen (inside the keyboard-avoider) so Save is reachable without scrolling to the end of a long form. - Track a dirty flag and confirm a discard before leaving with unsaved edits: the in-form Cancel is guarded directly, and `onDirtyChange` lets the host screen guard its header back (wired in new.tsx + edit.tsx). - i18n the Save/Saving/Cancel labels, the required-field message, and the New/Edit screen titles. Tests: FormViewRenderer now needs ConfirmProvider + SafeAreaProvider, so its conditional-fields suite renders through a wrapper. Full suite 1305 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FormViewConditionalFields.test.tsx | 34 ++++-- .../[appName]/[objectName]/[id]/edit.tsx | 41 +++++-- app/(app)/[appName]/[objectName]/new.tsx | 36 ++++++- components/renderers/DetailViewRenderer.tsx | 102 ++++++++++++------ components/renderers/FormViewRenderer.tsx | 86 ++++++++++----- locales/ar.json | 11 ++ locales/en.json | 11 ++ locales/zh.json | 11 ++ 8 files changed, 258 insertions(+), 74 deletions(-) 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": "删除记录",