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
22 changes: 22 additions & 0 deletions __tests__/components/ListViewRenderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]);
});
});
64 changes: 64 additions & 0 deletions __tests__/hooks/useDashboardData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions __tests__/lib/ui-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
37 changes: 34 additions & 3 deletions app/(app)/[appName]/dashboard/[dashboardName].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, FieldDefinition[]>();
await Promise.all(
objectNames.map(async (objectName) => {
try {
const obj = (await client.meta.getItem("object", objectName)) as
| { fields?: Record<string, Record<string, unknown>> }
| 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 {
Expand Down
6 changes: 4 additions & 2 deletions app/appearance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -15,6 +16,7 @@ export default function AppearanceScreen() {
<ScreenHeader title={t("appearance.title")} />
<ScrollView className="flex-1" contentContainerClassName="px-5 pt-4">
<ThemeSelector />
<DensitySelector className="mt-6" />
</ScrollView>
</SafeAreaView>
);
Expand Down
67 changes: 67 additions & 0 deletions components/common/DensitySelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className={cn("gap-1", className)}>
<Text className="mb-2 text-sm font-medium text-muted-foreground">
{t("appearance.density")}
</Text>
{OPTIONS.map(({ mode, icon: Icon, labelKey }) => {
const isActive = density === mode;
return (
<Pressable
key={mode}
accessibilityRole="button"
accessibilityState={{ selected: isActive }}
accessibilityLabel={t(labelKey)}
className={cn(
"flex-row items-center justify-between rounded-lg px-4 py-3 active:opacity-70",
isActive ? "bg-primary/10" : "bg-card",
)}
onPress={() => {
if (!isActive) {
void Haptics.selectionAsync();
setDensity(mode);
}
}}
>
<View className="flex-row items-center gap-3">
<Icon size={20} color={isActive ? accent : "#64748b"} />
<Text
className={cn(
"text-base",
isActive ? "font-semibold text-primary" : "text-foreground",
)}
>
{t(labelKey)}
</Text>
</View>
{isActive && <Check size={18} color={accent} />}
</Pressable>
);
})}
</View>
);
}
36 changes: 28 additions & 8 deletions components/renderers/ListViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -141,6 +142,7 @@ export function buildListItems(
data: Record<string, unknown>[],
meta: ListViewMeta | null | undefined,
groupFieldOverride?: string | null,
fields?: FieldDefinition[],
): ListItem[] {
const groupField =
groupFieldOverride !== undefined
Expand All @@ -150,16 +152,29 @@ export function buildListItems(
return data.map((record) => ({ __kind: "row", record }));
}

const buckets = new Map<string, Record<string, unknown>[]>();
// 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<string, { value: unknown; records: Record<string, unknown>[] }>();
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 });
}
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions components/renderers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

/** Core aggregate operators the RN widget hook can evaluate client-side. */
Expand Down
Loading
Loading