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
43 changes: 43 additions & 0 deletions __tests__/components/DetailViewHiddenFields.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DetailViewRenderer record={sparse} fields={sparseFields} />,
);

// 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(
<DetailViewRenderer
record={sparse}
fields={sparseFields}
view={{ sections: [{ fields: ["subject", "description"] }] }}
/>,
);
// Curated view honours the author's choice — empty Description still shows.
expect(getByText("Subject")).toBeTruthy();
expect(getByText("Description")).toBeTruthy();
});
});
53 changes: 53 additions & 0 deletions __tests__/components/OptionBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<OptionBadge field={statusField} value={value} />);
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(<OptionBadge field={statusField} value="completed" />);
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(<OptionBadge field={text} value="hi" />).toJSON()).toBeNull();
expect(render(<OptionBadge field={statusField} value="" />).toJSON()).toBeNull();
});
});
22 changes: 19 additions & 3 deletions app/(tabs)/notifications.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -68,8 +68,10 @@ export default function NotificationsScreen() {
notifications,
unreadCount,
isLoading,
error,
markRead,
markAllRead,
refetch,
} = useNotifications();
const router = useRouter();
const { t } = useTranslation();
Expand Down Expand Up @@ -116,8 +118,22 @@ export default function NotificationsScreen() {
</View>
)}

{/* 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 && (
<EmptyState
icon={WifiOff}
variant="error"
title={t("notifications.unavailableTitle")}
description={t("notifications.unavailableDesc")}
actionLabel={t("common.retry")}
onAction={() => void refetch()}
/>
)}

{/* Empty state — genuinely no notifications */}
{!isLoading && notifications.length === 0 && !error && (
<EmptyState
icon={Bell}
title={t("notifications.emptyTitle")}
Expand Down
9 changes: 8 additions & 1 deletion components/renderers/DetailViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -478,13 +478,20 @@ export function DetailViewRenderer({
.map((f) => 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));
Expand Down
20 changes: 14 additions & 6 deletions components/renderers/ListViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -442,11 +442,19 @@ export function ListViewRenderer({
<View className="mt-1.5 flex-row flex-wrap gap-x-4">
{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 (
<OptionBadge
key={col.field}
field={{ ...fieldDef, name: col.field, type: fieldType }}
value={item[col.field]}
/>
);
}
const displayVal = formatDisplayValue(item[col.field], fieldType, fieldDef);
return (
<View key={col.field} className="flex-row items-center">
<Text className="text-xs text-muted-foreground">
Expand Down
55 changes: 55 additions & 0 deletions components/renderers/fields/FieldRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <StatusBadge label={opt?.label ?? String(value)} color={opt?.color} />;
}

/** 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 (
<View className="self-start rounded-full px-2.5 py-1" style={bg}>
<Text className="text-xs font-semibold" style={fg}>
{label}
</Text>
</View>
);
}
return (
<View className={cn("self-start rounded-full px-2.5 py-1", badgeClasses(color))}>
<Text className={cn("text-xs font-semibold", badgeClasses(color))}>{label}</Text>
Expand Down
4 changes: 3 additions & 1 deletion locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@
"markAllRead": "تحديد الكل كمقروء",
"markAllReadA11y": "تحديد جميع الإشعارات كمقروءة",
"emptyTitle": "لا توجد إشعارات",
"emptyDesc": "لقد اطّلعت على كل شيء. ستظهر الإشعارات الجديدة هنا."
"emptyDesc": "لقد اطّلعت على كل شيء. ستظهر الإشعارات الجديدة هنا.",
"unavailableTitle": "الإشعارات غير متاحة",
"unavailableDesc": "تعذّر الوصول إلى خدمة الإشعارات. اسحب للتحديث أو حاول مرة أخرى."
},
"more": {
"profileFallbackName": "مستخدم",
Expand Down
4 changes: 3 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@
"markAllRead": "全部标为已读",
"markAllReadA11y": "将所有通知标为已读",
"emptyTitle": "暂无通知",
"emptyDesc": "你已全部处理完毕。新通知将显示在这里。"
"emptyDesc": "你已全部处理完毕。新通知将显示在这里。",
"unavailableTitle": "通知不可用",
"unavailableDesc": "无法连接通知服务。下拉刷新或稍后重试。"
},
"more": {
"profileFallbackName": "用户",
Expand Down
Loading