diff --git a/__tests__/components/ListViewRenderer.test.ts b/__tests__/components/ListViewRenderer.test.ts index 7a7f914..e4290e8 100644 --- a/__tests__/components/ListViewRenderer.test.ts +++ b/__tests__/components/ListViewRenderer.test.ts @@ -141,4 +141,26 @@ describe("buildListItems", () => { expect(items).toHaveLength(3); expect(items.every((i) => i.__kind === "row")).toBe(true); }); + + it("labels group headers with the select field's option label, not the raw value", () => { + const meta: ListViewMeta = { + grouping: { fields: [{ field: "status", order: "asc", collapsed: false }] }, + }; + const fields = [ + { + name: "status", + type: "select" as const, + options: [ + { label: "Open", value: "open" }, + { label: "Closed", value: "closed" }, + ], + }, + ]; + const items = buildListItems(data, meta, undefined, fields); + const headers = items.filter((i) => i.__kind === "group"); + expect(headers).toEqual([ + { __kind: "group", label: "Open", count: 2 }, + { __kind: "group", label: "Closed", count: 1 }, + ]); + }); }); diff --git a/__tests__/hooks/useDashboardData.test.ts b/__tests__/hooks/useDashboardData.test.ts index bf25bb8..b37df11 100644 --- a/__tests__/hooks/useDashboardData.test.ts +++ b/__tests__/hooks/useDashboardData.test.ts @@ -250,6 +250,31 @@ describe("useWidgetQuery", () => { expect(result.current.value).toBe(0); }); + it("labels chart category buckets with option labels when provided", () => { + mockUseQuery.mockReturnValue({ + data: { + records: [ + { id: "1", status: "open" }, + { id: "2", status: "open" }, + { id: "3", status: "done" }, + ], + }, + isLoading: false, + }); + const widget: DashboardWidgetMeta = { + name: "by_status", + object: "tasks", + type: "bar", + aggregate: "count", + categoryField: "status", + categoryLabels: { open: "Open", done: "Completed" }, + }; + const { result } = renderHook(() => useWidgetQuery(widget)); + const series = result.current.chartData ?? []; + expect(series.find((p) => p.value === 2)?.label).toBe("Open"); + expect(series.find((p) => p.value === 1)?.label).toBe("Completed"); + }); + it("counts rows per bucket for a count-aggregate chart (no valueField)", () => { // The common dataset case: a `count` measure has no source field, so chart // buckets must count rows — not aggregate an absent value field (→ all 0). @@ -362,6 +387,45 @@ describe("resolveDatasetWidget", () => { }); }); + it("builds categoryLabels from the dataset object's field options", () => { + const widget: DashboardWidgetMeta = { + name: "by_status", + type: "bar", + dataset: "task_metrics", + values: ["task_count"], + dimensions: ["status"], + }; + const objectFields = [ + { + name: "status", + type: "select" as const, + options: [ + { label: "Not Started", value: "not_started" }, + { label: "Completed", value: "completed" }, + ], + }, + ]; + const resolved = resolveDatasetWidget(widget, dataset, objectFields); + expect(resolved.categoryField).toBe("status"); + expect(resolved.categoryLabels).toEqual({ + not_started: "Not Started", + completed: "Completed", + }); + }); + + it("omits categoryLabels when the dimension field has no options", () => { + const widget: DashboardWidgetMeta = { + name: "hours_by_status", + type: "bar", + dataset: "task_metrics", + values: ["est_hours"], + dimensions: ["status"], + }; + // No object fields supplied → nothing to map. + const resolved = resolveDatasetWidget(widget, dataset); + expect(resolved.categoryLabels).toBeUndefined(); + }); + it("defaults a format-less ratio measure to a `0%` percent metric", () => { const ds: DatasetMeta = { ...dataset, diff --git a/__tests__/lib/ui-store.test.ts b/__tests__/lib/ui-store.test.ts index c94900b..188497d 100644 --- a/__tests__/lib/ui-store.test.ts +++ b/__tests__/lib/ui-store.test.ts @@ -20,4 +20,10 @@ describe("ui-store", () => { useUIStore.getState().setLanguage("zh"); expect(useUIStore.getState().language).toBe("zh"); }); + + it("defaults density to comfortable and can switch to compact", () => { + expect(useUIStore.getState().density).toBe("comfortable"); + useUIStore.getState().setDensity("compact"); + expect(useUIStore.getState().density).toBe("compact"); + }); }); diff --git a/app/(app)/[appName]/dashboard/[dashboardName].tsx b/app/(app)/[appName]/dashboard/[dashboardName].tsx index 4e58869..db2c6a7 100644 --- a/app/(app)/[appName]/dashboard/[dashboardName].tsx +++ b/app/(app)/[appName]/dashboard/[dashboardName].tsx @@ -8,6 +8,7 @@ import type { DashboardMeta, DashboardWidgetMeta, DatasetMeta, + FieldDefinition, } from "~/components/renderers"; import type { WidgetDataPayload } from "~/components/renderers"; import { resolveDatasetWidget, useWidgetQuery } from "~/hooks/useDashboardData"; @@ -94,11 +95,41 @@ export default function DashboardScreen() { }), ); + // Fetch each dataset object's field metadata once, so chart category + // buckets can render select-field option *labels* ("Completed") rather + // than the raw stored values ("completed"). + const objectNames = [...new Set([...datasets.values()].map((d) => d.object))]; + const objectFields = new Map(); + await Promise.all( + objectNames.map(async (objectName) => { + try { + const obj = (await client.meta.getItem("object", objectName)) as + | { fields?: Record> } + | undefined; + if (obj?.fields) { + objectFields.set( + objectName, + Object.entries(obj.fields).map( + ([name, f]) => ({ name, ...f }) as FieldDefinition, + ), + ); + } + } catch { + // Object metadata unavailable — chart labels fall back to raw values. + } + }), + ); + const meta: DashboardMeta = { ...raw, - widgets: widgets.map((w) => - resolveDatasetWidget(w, w.dataset ? datasets.get(w.dataset) : undefined), - ), + widgets: widgets.map((w) => { + const ds = w.dataset ? datasets.get(w.dataset) : undefined; + return resolveDatasetWidget( + w, + ds, + ds ? objectFields.get(ds.object) : undefined, + ); + }), }; setDashboard(meta); } catch { diff --git a/app/appearance.tsx b/app/appearance.tsx index 3c24a53..cb36738 100644 --- a/app/appearance.tsx +++ b/app/appearance.tsx @@ -3,10 +3,11 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { useTranslation } from "react-i18next"; import { ScreenHeader } from "~/components/common/ScreenHeader"; import { ThemeSelector } from "~/components/common/ThemeSelector"; +import { DensitySelector } from "~/components/common/DensitySelector"; /** - * Appearance — switch the app's color scheme (light / dark / system). Backed by - * `useUIStore.setTheme` → NativeWind, applied live across every screen. + * Appearance — switch the app's color scheme (light / dark / system) and the + * default list density. Both are backed by `useUIStore` and applied live. */ export default function AppearanceScreen() { const { t } = useTranslation(); @@ -15,6 +16,7 @@ export default function AppearanceScreen() { + ); diff --git a/components/common/DensitySelector.tsx b/components/common/DensitySelector.tsx new file mode 100644 index 0000000..952ec12 --- /dev/null +++ b/components/common/DensitySelector.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { View, Text, Pressable } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useColorScheme } from "nativewind"; +import { Check, Rows3, Rows4 } from "lucide-react-native"; +import * as Haptics from "expo-haptics"; +import { useUIStore, type Density } from "~/stores/ui-store"; +import { cn } from "~/lib/utils"; + +const OPTIONS: { mode: Density; icon: typeof Rows3; labelKey: string }[] = [ + { mode: "comfortable", icon: Rows3, labelKey: "appearance.comfortable" }, + { mode: "compact", icon: Rows4, labelKey: "appearance.compact" }, +]; + +/** + * Default list density selector — comfortable / compact. Sets the fallback row + * spacing used by list views that don't dictate their own `rowHeight`. + */ +export function DensitySelector({ className }: { className?: string }) { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const accent = colorScheme === "dark" ? "#60a5fa" : "#1e40af"; + const density = useUIStore((s) => s.density); + const setDensity = useUIStore((s) => s.setDensity); + + return ( + + + {t("appearance.density")} + + {OPTIONS.map(({ mode, icon: Icon, labelKey }) => { + const isActive = density === mode; + return ( + { + if (!isActive) { + void Haptics.selectionAsync(); + setDensity(mode); + } + }} + > + + + + {t(labelKey)} + + + {isActive && } + + ); + })} + + ); +} diff --git a/components/renderers/ListViewRenderer.tsx b/components/renderers/ListViewRenderer.tsx index 4ab955e..85f7100 100644 --- a/components/renderers/ListViewRenderer.tsx +++ b/components/renderers/ListViewRenderer.tsx @@ -26,6 +26,7 @@ import { Button } from "~/components/ui/Button"; import { BottomSheet } from "~/components/ui/BottomSheet"; import { SearchBar } from "~/components/common/SearchBar"; import { BatchActionBar } from "~/components/batch/BatchActionBar"; +import { useUIStore } from "~/stores/ui-store"; import { formatDisplayValue, isSelectType, OptionBadge } from "./fields/FieldRenderer"; import { FilterDrawer, FilterButton } from "./FilterDrawer"; import { SwipeableRow } from "./SwipeableRow"; @@ -141,6 +142,7 @@ export function buildListItems( data: Record[], meta: ListViewMeta | null | undefined, groupFieldOverride?: string | null, + fields?: FieldDefinition[], ): ListItem[] { const groupField = groupFieldOverride !== undefined @@ -150,16 +152,29 @@ export function buildListItems( return data.map((record) => ({ __kind: "row", record })); } - const buckets = new Map[]>(); + // Bucket by the raw value (stable key), but remember the value itself so the + // header can display the field's option *label* — e.g. a `status` group reads + // "Completed", not "completed". + const groupFieldDef = fields?.find((f) => f.name === groupField); + const buckets = new Map[] }>(); for (const record of data) { - const key = groupValueLabel(record[groupField]); + const value = record[groupField]; + const key = groupValueLabel(value); const bucket = buckets.get(key); - if (bucket) bucket.push(record); - else buckets.set(key, [record]); + if (bucket) bucket.records.push(record); + else buckets.set(key, { value, records: [record] }); } const items: ListItem[] = []; - for (const [label, records] of buckets) { + for (const [key, { value, records }] of buckets) { + // Map the raw value to its display label via the field definition (select → + // option label, boolean → Yes/No, date → formatted); fall back to the key. + const label = + value == null || value === "" + ? key + : groupFieldDef + ? formatDisplayValue(value, groupFieldDef.type, groupFieldDef) + : key; items.push({ __kind: "group", label, count: records.length }); for (const record of records) items.push({ __kind: "row", record }); } @@ -373,10 +388,15 @@ export function ListViewRenderer({ /* ---- Spec-aligned display options ---- */ const listItems = useMemo( - () => buildListItems(records, view, effectiveGroupField), - [records, view, effectiveGroupField], + () => buildListItems(records, view, effectiveGroupField, fields), + [records, view, effectiveGroupField, fields], + ); + // A view's explicit rowHeight wins; otherwise fall back to the user's global + // density preference (comfortable → medium, compact → tighter rows). + const userDensity = useUIStore((s) => s.density); + const densityClass = rowDensityClass( + view?.rowHeight ?? (userDensity === "compact" ? "compact" : "medium"), ); - const densityClass = rowDensityClass(view?.rowHeight); const striped = !!view?.striped; const bordered = view?.bordered !== false; const summaryColumns = useMemo( diff --git a/components/renderers/types.ts b/components/renderers/types.ts index afd8de3..9bd8b09 100644 --- a/components/renderers/types.ts +++ b/components/renderers/types.ts @@ -216,6 +216,13 @@ export interface DashboardWidgetMeta { * `resolveDatasetWidget` from a dataset's derived measure (e.g. a ratio). */ derivedMetric?: DerivedMetricSpec; + /** + * Value → display-label map for the `categoryField`'s options (8.0). Lets a + * chart's category buckets render the select field's option label + * ("Completed") instead of the raw stored value ("completed"). Populated by + * `resolveDatasetWidget` from the dataset object's field metadata. + */ + categoryLabels?: Record; } /** Core aggregate operators the RN widget hook can evaluate client-side. */ diff --git a/hooks/useDashboardData.ts b/hooks/useDashboardData.ts index b0d9a4b..ed9b45f 100644 --- a/hooks/useDashboardData.ts +++ b/hooks/useDashboardData.ts @@ -6,6 +6,7 @@ import type { DatasetMeasure, DatasetMeta, DerivedMetricSpec, + FieldDefinition, MeasureAggregate, ResolvedMetricComponent, } from "~/components/renderers/types"; @@ -171,6 +172,24 @@ function spanFromLayoutWidth(w: number | undefined): number { return typeof w === "number" && w >= 8 ? 2 : 1; } +/** + * Build a `{ value → label }` map from a field's select options, or `undefined` + * when the field has none (e.g. a date/number dimension). Used to label chart + * category buckets with the option label instead of the raw stored value. + */ +function optionLabelMap( + fields: FieldDefinition[] | undefined, + fieldName: string, +): Record | undefined { + const opts = fields?.find((f) => f.name === fieldName)?.options; + if (!opts || opts.length === 0) return undefined; + const map: Record = {}; + for (const o of opts) { + if (o.label != null) map[String(o.value)] = o.label; + } + return Object.keys(map).length > 0 ? map : undefined; +} + /** Narrow a measure's aggregate to the core five the hook evaluates. */ function normalizeAggregate(agg: string | undefined): MeasureAggregate { return agg === "sum" || agg === "avg" || agg === "min" || agg === "max" @@ -226,6 +245,7 @@ function resolveDerivedMeasure( export function resolveDatasetWidget( widget: DashboardWidgetMeta, dataset: DatasetMeta | undefined, + objectFields?: FieldDefinition[], ): DashboardWidgetMeta { if (!widget.dataset || !dataset) return widget; @@ -241,6 +261,13 @@ export function resolveDatasetWidget( const options = (widget.options ?? {}) as Record; const color = typeof options.color === "string" ? options.color : undefined; + // Map the category dimension's stored values to their option labels so chart + // buckets read "Completed", not "completed". The labels live on the dataset + // object's field metadata, keyed by the dimension's `field`. + const categoryLabels = dimension?.field + ? optionLabelMap(objectFields, dimension.field) + : undefined; + // A `ratio` derived measure renders as a percentage; default its format to // `0%` so a bare quotient (e.g. 0.25) still reads as "25%". const format = @@ -265,6 +292,7 @@ export function resolveDatasetWidget( categoryField: dimension?.field, span: widget.span ?? spanFromLayoutWidth(widget.layout?.w), derivedMetric, + ...(categoryLabels ? { categoryLabels } : null), chartConfig: { ...options, ...(color ? { colors: [color] } : null), @@ -442,11 +470,17 @@ export function useWidgetQuery(widget: DashboardWidgetMeta): WidgetDataPayload { // Chart and other types — group into a `{label,value}[]` series so the // renderer can draw a real chart, plus a headline total. const agg = widget.aggregate ?? "sum"; + const labels = widget.categoryLabels; const chartData = buildChartData( records, widget.categoryField, widget.valueField, agg, + ).map((p) => + // Map a select category's raw bucket value to its option label + // ("completed" → "Completed"); leave unmapped buckets (dates, free + // text) untouched. + labels && labels[p.label] != null ? { ...p, label: labels[p.label] } : p, ); const value = chartData.reduce((sum, p) => sum + p.value, 0); return { diff --git a/locales/ar.json b/locales/ar.json index 7014f05..d0120d7 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -289,6 +289,9 @@ "title": "المظهر", "light": "فاتح", "dark": "داكن", - "system": "اتباع النظام" + "system": "اتباع النظام", + "density": "كثافة القائمة", + "comfortable": "مريح", + "compact": "مضغوط" } } diff --git a/locales/en.json b/locales/en.json index eabb7a6..154e5b8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -278,6 +278,9 @@ "title": "Appearance", "light": "Light", "dark": "Dark", - "system": "System" + "system": "System", + "density": "List density", + "comfortable": "Comfortable", + "compact": "Compact" } } diff --git a/locales/zh.json b/locales/zh.json index 6b26ef4..bd4880e 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -274,6 +274,9 @@ "title": "外观", "light": "浅色", "dark": "深色", - "system": "跟随系统" + "system": "跟随系统", + "density": "列表密度", + "comfortable": "宽松", + "compact": "紧凑" } } diff --git a/stores/ui-store.ts b/stores/ui-store.ts index 89feace..2851525 100644 --- a/stores/ui-store.ts +++ b/stores/ui-store.ts @@ -5,20 +5,31 @@ import i18n from "~/lib/i18n"; import type { SupportedLanguage } from "~/lib/i18n"; export type ThemeMode = "light" | "dark" | "system"; +/** Default list row spacing when a view doesn't dictate its own. */ +export type Density = "comfortable" | "compact"; const storage = createMMKV({ id: "objectstack-ui" }); const THEME_KEY = "theme"; +const DENSITY_KEY = "density"; function loadTheme(): ThemeMode { const v = storage.getString(THEME_KEY); return v === "light" || v === "dark" || v === "system" ? v : "system"; } +function loadDensity(): Density { + return storage.getString(DENSITY_KEY) === "compact" ? "compact" : "comfortable"; +} + interface UIState { /** Current theme mode */ theme: ThemeMode; /** Set theme — applies the NativeWind color scheme and persists it */ setTheme: (theme: ThemeMode) => void; + /** Default list density (fallback when a view has no explicit rowHeight) */ + density: Density; + /** Set list density and persist it */ + setDensity: (density: Density) => void; /** Current language code */ language: SupportedLanguage; /** Change language (updates i18next and store) */ @@ -39,6 +50,11 @@ export const useUIStore = create((set) => ({ storage.set(THEME_KEY, theme); set({ theme }); }, + density: loadDensity(), + setDensity: (density) => { + storage.set(DENSITY_KEY, density); + set({ density }); + }, language: (i18n.language ?? "en") as SupportedLanguage, setLanguage: (lang) => { i18n.changeLanguage(lang);