From 08f34421eda14c190fc0fb9ca8572f0d32c63d23 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 10 Jun 2026 21:04:32 +0500 Subject: [PATCH] =?UTF-8?q?fix(ux):=20P2=20=E2=80=94=20coloured=20status?= =?UTF-8?q?=20badges,=20collapse=20empty=20detail=20fields,=20honest=20not?= =?UTF-8?q?ifications=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish from the mobile UX walkthrough: - Status/select values render as the option's colour. Option metadata carries hex colours (e.g. `#10B981`), which the named-colour map couldn't resolve, so every badge fell back to flat grey. `StatusBadge` now renders a hex option as an inline tint, and list rows show the option badge (via the exported `OptionBadge`) instead of plain "Status: Completed" text. - The detail auto-layout collapses empty-valued fields instead of rendering a column of "—". Curated views still show every field the author listed. - The notifications screen distinguishes a failed fetch (service unavailable → retry) from a genuinely empty inbox, so a 404 no longer masquerades as "you're all caught up". New i18n keys in en/zh/ar. Deliberately deferred: a web content max-width cap. Web is the secondary target (the app runs full-width-constrained on device), and a good global width cap is a broad layout change not worth the regression risk here. Verified in the browser: list + detail badges are coloured (Completed green, High amber, Urgent red…), empty detail fields are gone, notifications shows the unavailable state. +6 tests, 1281 pass, lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DetailViewHiddenFields.test.tsx | 43 +++++++++++++++ __tests__/components/OptionBadge.test.tsx | 53 ++++++++++++++++++ app/(tabs)/notifications.tsx | 22 +++++++- components/renderers/DetailViewRenderer.tsx | 9 ++- components/renderers/ListViewRenderer.tsx | 20 +++++-- components/renderers/fields/FieldRenderer.tsx | 55 +++++++++++++++++++ locales/ar.json | 4 +- locales/en.json | 4 +- locales/zh.json | 4 +- 9 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 __tests__/components/OptionBadge.test.tsx diff --git a/__tests__/components/DetailViewHiddenFields.test.tsx b/__tests__/components/DetailViewHiddenFields.test.tsx index ef3b0b5..862ba15 100644 --- a/__tests__/components/DetailViewHiddenFields.test.tsx +++ b/__tests__/components/DetailViewHiddenFields.test.tsx @@ -44,4 +44,47 @@ describe("DetailViewRenderer — fallback field filtering", () => { // A field the metadata marks hidden is filtered out of the fallback too. expect(queryByText("Secret Token")).toBeNull(); }); + + it("collapses empty-valued fields in the auto-layout", () => { + const sparse = { + id: "rec_2", + subject: "Has a subject", + description: "", // empty string + due_date: null, // null + tags: [], // empty array + }; + const sparseFields: FieldDefinition[] = [ + { name: "subject", label: "Subject", type: "text" }, + { name: "description", label: "Description", type: "textarea" }, + { name: "due_date", label: "Due Date", type: "date" }, + { name: "tags", label: "Tags", type: "tags" }, + ]; + const { queryByText, getByText } = render( + , + ); + + // Populated field shows; empty ones are collapsed (no row of "—"). + expect(getByText("Subject")).toBeTruthy(); + expect(queryByText("Description")).toBeNull(); + expect(queryByText("Due Date")).toBeNull(); + expect(queryByText("Tags")).toBeNull(); + }); + + it("keeps every field (even empty) when a curated view lists them", () => { + const sparse = { id: "rec_3", subject: "S", description: "" }; + const sparseFields: FieldDefinition[] = [ + { name: "subject", label: "Subject", type: "text" }, + { name: "description", label: "Description", type: "textarea" }, + ]; + const { getByText } = render( + , + ); + // Curated view honours the author's choice — empty Description still shows. + expect(getByText("Subject")).toBeTruthy(); + expect(getByText("Description")).toBeTruthy(); + }); }); diff --git a/__tests__/components/OptionBadge.test.tsx b/__tests__/components/OptionBadge.test.tsx new file mode 100644 index 0000000..ef7c120 --- /dev/null +++ b/__tests__/components/OptionBadge.test.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { render } from "@testing-library/react-native"; +import { Text } from "react-native"; + +import { OptionBadge } from "~/components/renderers/fields/FieldRenderer"; +import type { FieldDefinition } from "~/components/renderers/types"; + +/** + * Select/status option colours in object metadata are usually hex (`#10B981`), + * which the named-colour Tailwind map can't resolve — they fell back to a flat + * grey pill. `OptionBadge` renders the option's hex as an inline tint so list + * rows and the detail view show the real status colour. + */ +describe("OptionBadge", () => { + const statusField: FieldDefinition = { + name: "status", + type: "status", + options: [ + { value: "completed", label: "Completed", color: "#10B981" }, + { value: "blocked", label: "Blocked", color: "red" }, + ], + }; + + /** Pull the text node's resolved style color for an option value. */ + function colorFor(value: string): unknown { + const { UNSAFE_getByType } = render(); + const text = UNSAFE_getByType(Text); + const style = Array.isArray(text.props.style) + ? Object.assign({}, ...text.props.style.filter(Boolean)) + : text.props.style; + return style?.color; + } + + it("renders the option label", () => { + const { getByText } = render(); + expect(getByText("Completed")).toBeTruthy(); + }); + + it("applies a hex option colour as the badge text colour", () => { + expect(colorFor("completed")).toBe("#10B981"); + }); + + it("does not inline-style a named colour (uses the Tailwind class path)", () => { + // Named colours flow through className, not an inline `color` style. + expect(colorFor("blocked")).toBeUndefined(); + }); + + it("renders nothing for a non-select field or empty value", () => { + const text = { name: "subject", type: "text" } as FieldDefinition; + expect(render().toJSON()).toBeNull(); + expect(render().toJSON()).toBeNull(); + }); +}); diff --git a/app/(tabs)/notifications.tsx b/app/(tabs)/notifications.tsx index ef122cf..97797bd 100644 --- a/app/(tabs)/notifications.tsx +++ b/app/(tabs)/notifications.tsx @@ -1,7 +1,7 @@ import React from "react"; import { View, Text, ScrollView, Pressable } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { Bell, CheckCheck, Circle } from "lucide-react-native"; +import { Bell, CheckCheck, Circle, WifiOff } from "lucide-react-native"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { cn } from "~/lib/utils"; @@ -68,8 +68,10 @@ export default function NotificationsScreen() { notifications, unreadCount, isLoading, + error, markRead, markAllRead, + refetch, } = useNotifications(); const router = useRouter(); const { t } = useTranslation(); @@ -116,8 +118,22 @@ export default function NotificationsScreen() { )} - {/* Empty state */} - {!isLoading && notifications.length === 0 && ( + {/* Service-unavailable state — distinct from a genuinely empty inbox so a + failed fetch (e.g. the notifications service isn't mounted) doesn't + masquerade as "you're all caught up". */} + {!isLoading && notifications.length === 0 && error && ( + void refetch()} + /> + )} + + {/* Empty state — genuinely no notifications */} + {!isLoading && notifications.length === 0 && !error && ( f.name), ); + const isEmptyValue = (v: unknown) => + v == null || v === "" || (Array.isArray(v) && v.length === 0); + const buildSections = (allKeys: string[]): FormSection[] => { const keys = allKeys.filter( (k) => !k.startsWith("_") && k !== "id" && !INTERNAL_FIELDS.has(k) && - !hiddenByMeta.has(k), + !hiddenByMeta.has(k) && + // In the auto-layout (no curated view) an empty field is just a "—" + // taking up a line; collapse it. Curated views still show every + // field the author chose, empty or not. + !(record != null && isEmptyValue(record[k])), ); const business = keys.filter((k) => !SYSTEM_FIELDS.has(k)); const system = keys.filter((k) => SYSTEM_FIELDS.has(k)); diff --git a/components/renderers/ListViewRenderer.tsx b/components/renderers/ListViewRenderer.tsx index f6419b3..ce43f16 100644 --- a/components/renderers/ListViewRenderer.tsx +++ b/components/renderers/ListViewRenderer.tsx @@ -14,7 +14,7 @@ import { ListSkeleton } from "~/components/ui/ListSkeleton"; import { Button } from "~/components/ui/Button"; import { SearchBar } from "~/components/common/SearchBar"; import { BatchActionBar } from "~/components/batch/BatchActionBar"; -import { formatDisplayValue } from "./fields/FieldRenderer"; +import { formatDisplayValue, isSelectType, OptionBadge } from "./fields/FieldRenderer"; import { FilterDrawer, FilterButton } from "./FilterDrawer"; import { SwipeableRow } from "./SwipeableRow"; import type { @@ -442,11 +442,19 @@ export function ListViewRenderer({ {secondaryCols.map((col) => { const fieldDef = fields.find((f) => f.name === col.field); - const displayVal = formatDisplayValue( - item[col.field], - (fieldDef?.type ?? col.type ?? "text") as FieldType, - fieldDef, - ); + const fieldType = (fieldDef?.type ?? col.type ?? "text") as FieldType; + // Select/status values render as the option's coloured badge + // (the colour carries the meaning), not flat "Label: value". + if (isSelectType(fieldType)) { + return ( + + ); + } + const displayVal = formatDisplayValue(item[col.field], fieldType, fieldDef); return ( diff --git a/components/renderers/fields/FieldRenderer.tsx b/components/renderers/fields/FieldRenderer.tsx index aeaf3b4..d6eba32 100644 --- a/components/renderers/fields/FieldRenderer.tsx +++ b/components/renderers/fields/FieldRenderer.tsx @@ -21,6 +21,11 @@ import type { FieldDefinition, FieldType, SelectOption } from "../types"; const SELECT_TYPES = new Set(["select", "radio", "picklist", "status"]); const MULTI_TYPES = new Set(["multiselect", "checkboxes", "tags"]); +/** Whether a field type renders as a single coloured option badge. */ +export function isSelectType(type: string | undefined): boolean { + return SELECT_TYPES.has(type ?? ""); +} + /** Find the option matching `value` in a field's option list. */ function findOption( field: FieldDefinition | undefined, @@ -63,8 +68,58 @@ function badgeClasses(color: string | undefined): string { } } +/** Expand a `#rgb`/`#rrggbb` colour to a 6-digit hex, or null if not hex. */ +function normalizeHex(color: string | undefined): string | null { + const c = (color ?? "").trim(); + if (/^#[0-9a-f]{6}$/i.test(c)) return c; + if (/^#[0-9a-f]{3}$/i.test(c)) { + return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`; + } + return null; +} + +/** + * Inline badge style for a hex option colour — a light tint background with the + * colour itself as the text. Metadata authors often specify hex (`#10B981`) + * rather than a named colour, which `badgeClasses` can't map; without this they + * all fall back to a flat grey pill. + */ +function hexBadgeStyle(hex: string): { bg: { backgroundColor: string }; fg: { color: string } } { + // 8-digit hex appends an alpha byte; `1F` ≈ 12% opacity for the tint. + return { bg: { backgroundColor: `${hex}1F` }, fg: { color: hex } }; +} + +/** + * A coloured option badge for a select/status field value — the same pill the + * detail/form renderers use, exported so compact surfaces (list rows, related + * lists) render the option's colour instead of flat text. Returns null for a + * non-select field or an empty value. + */ +export function OptionBadge({ + field, + value, +}: { + field: FieldDefinition | undefined; + value: unknown; +}) { + if (!field || !isSelectType(field.type) || value == null || value === "") return null; + const opt = findOption(field, value); + return ; +} + /** A coloured pill for a select/status value. */ function StatusBadge({ label, color }: { label: string; color?: string }) { + const hex = normalizeHex(color); + if (hex) { + const { bg, fg } = hexBadgeStyle(hex); + return ( + + + {label} + + + ); + } return ( {label} diff --git a/locales/ar.json b/locales/ar.json index 52cc96e..ebf30b3 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -151,7 +151,9 @@ "markAllRead": "تحديد الكل كمقروء", "markAllReadA11y": "تحديد جميع الإشعارات كمقروءة", "emptyTitle": "لا توجد إشعارات", - "emptyDesc": "لقد اطّلعت على كل شيء. ستظهر الإشعارات الجديدة هنا." + "emptyDesc": "لقد اطّلعت على كل شيء. ستظهر الإشعارات الجديدة هنا.", + "unavailableTitle": "الإشعارات غير متاحة", + "unavailableDesc": "تعذّر الوصول إلى خدمة الإشعارات. اسحب للتحديث أو حاول مرة أخرى." }, "more": { "profileFallbackName": "مستخدم", diff --git a/locales/en.json b/locales/en.json index d72ab84..87810ef 100644 --- a/locales/en.json +++ b/locales/en.json @@ -148,7 +148,9 @@ "markAllRead": "Mark all read", "markAllReadA11y": "Mark all notifications read", "emptyTitle": "No Notifications", - "emptyDesc": "You're all caught up. New notifications will appear here." + "emptyDesc": "You're all caught up. New notifications will appear here.", + "unavailableTitle": "Notifications Unavailable", + "unavailableDesc": "We couldn't reach the notifications service. Pull to refresh or try again." }, "more": { "profileFallbackName": "User", diff --git a/locales/zh.json b/locales/zh.json index 223973b..2f49418 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -146,7 +146,9 @@ "markAllRead": "全部标为已读", "markAllReadA11y": "将所有通知标为已读", "emptyTitle": "暂无通知", - "emptyDesc": "你已全部处理完毕。新通知将显示在这里。" + "emptyDesc": "你已全部处理完毕。新通知将显示在这里。", + "unavailableTitle": "通知不可用", + "unavailableDesc": "无法连接通知服务。下拉刷新或稍后重试。" }, "more": { "profileFallbackName": "用户",