diff --git a/__tests__/components/ListViewRenderer.test.ts b/__tests__/components/ListViewRenderer.test.ts index d1612a5..7a7f914 100644 --- a/__tests__/components/ListViewRenderer.test.ts +++ b/__tests__/components/ListViewRenderer.test.ts @@ -118,4 +118,27 @@ describe("buildListItems", () => { const headers = items.filter((i) => i.__kind === "group"); expect(headers).toEqual([{ __kind: "group", label: "—", count: 3 }]); }); + + it("groups by the override field over the view's configured grouping", () => { + const meta: ListViewMeta = { + grouping: { fields: [{ field: "status", order: "asc", collapsed: false }] }, + }; + // Override regroups by `id` instead of the view's `status`. + const items = buildListItems(data, meta, "id"); + const headers = items.filter((i) => i.__kind === "group"); + expect(headers).toEqual([ + { __kind: "group", label: "1", count: 1 }, + { __kind: "group", label: "2", count: 1 }, + { __kind: "group", label: "3", count: 1 }, + ]); + }); + + it("forces a flat list when the override is null, ignoring view grouping", () => { + const meta: ListViewMeta = { + grouping: { fields: [{ field: "status", order: "asc", collapsed: false }] }, + }; + const items = buildListItems(data, meta, null); + expect(items).toHaveLength(3); + expect(items.every((i) => i.__kind === "row")).toBe(true); + }); }); diff --git a/components/renderers/ListViewRenderer.tsx b/components/renderers/ListViewRenderer.tsx index 4e8c402..4ab955e 100644 --- a/components/renderers/ListViewRenderer.tsx +++ b/components/renderers/ListViewRenderer.tsx @@ -7,12 +7,23 @@ import { RefreshControl, } from "react-native"; import { FlashList } from "@shopify/flash-list"; +import { useTranslation } from "react-i18next"; +import { useColorScheme } from "nativewind"; import { webContentMaxWidth } from "~/lib/responsive"; -import { ChevronDown, ChevronUp, Check, Search as SearchIcon, AlertCircle } from "lucide-react-native"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + Check, + Layers, + Search as SearchIcon, + AlertCircle, +} from "lucide-react-native"; import { cn } from "~/lib/utils"; import { EmptyState } from "~/components/common/EmptyState"; import { ListSkeleton } from "~/components/ui/ListSkeleton"; 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 { formatDisplayValue, isSelectType, OptionBadge } from "./fields/FieldRenderer"; @@ -115,17 +126,26 @@ export function isGroupHeader(item: ListItem): item is GroupHeaderItem { } /** - * Flatten records into a render list, inserting group-header sentinels when - * `meta.grouping` is present. Only the first grouping field is honoured here; + * Flatten records into a render list, inserting group-header sentinels when a + * grouping field is in effect. Only the first grouping field is honoured here; * deeper nesting is a later phase. * + * `groupFieldOverride` lets the user's in-session group-by choice take + * precedence over the view's configured grouping: pass a field name to group by + * it, `null` to force a flat list, or omit it (`undefined`) to fall back to + * `meta.grouping`. + * * Exported for unit testing. */ export function buildListItems( data: Record[], meta: ListViewMeta | null | undefined, + groupFieldOverride?: string | null, ): ListItem[] { - const groupField = meta?.grouping?.fields?.[0]?.field; + const groupField = + groupFieldOverride !== undefined + ? (groupFieldOverride ?? undefined) + : meta?.grouping?.fields?.[0]?.field; if (!groupField) { return data.map((record) => ({ __kind: "row", record })); } @@ -224,10 +244,21 @@ export function ListViewRenderer({ onBatchDelete, onBatchEdit, }: ListViewRendererProps) { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + const accent = isDark ? "#60a5fa" : "#1e40af"; + const [sortField, setSortField] = useState(null); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [filterVisible, setFilterVisible] = useState(false); const [activeFilterCount, setActiveFilterCount] = useState(0); + // Group-by override: `undefined` defers to the view's configured grouping, + // `null` forces a flat list, a string groups by that field. + const [groupOverride, setGroupOverride] = useState( + undefined, + ); + const [groupSheetOpen, setGroupSheetOpen] = useState(false); /* ---- Selection state ---- */ const selectionMode = selectionModeProp ?? view?.selection?.type ?? "none"; @@ -334,8 +365,17 @@ export function ListViewRenderer({ return []; }, [view, fields, records]); + /* ---- Grouping (view default, overridable in-session) ---- */ + const effectiveGroupField = + groupOverride !== undefined + ? groupOverride + : (view?.grouping?.fields?.[0]?.field ?? null); + /* ---- Spec-aligned display options ---- */ - const listItems = useMemo(() => buildListItems(records, view), [records, view]); + const listItems = useMemo( + () => buildListItems(records, view, effectiveGroupField), + [records, view, effectiveGroupField], + ); const densityClass = rowDensityClass(view?.rowHeight); const striped = !!view?.striped; const bordered = view?.bordered !== false; @@ -343,6 +383,10 @@ export function ListViewRenderer({ () => columns.filter((c) => c.summary && c.summary !== "none"), [columns], ); + const sortableColumns = useMemo( + () => columns.filter((c) => c.sortable !== false && !c.hidden), + [columns], + ); /** Resolve a record's background colour from spec `rowColor`, if configured. */ const rowBackground = useCallback( @@ -581,12 +625,12 @@ export function ListViewRenderer({ } - title="Couldn't Load Records" + title={t("records.loadError")} description={error.message} action={ onRefresh ? ( ) : undefined } @@ -605,7 +649,7 @@ export function ListViewRenderer({ )} @@ -627,40 +671,73 @@ export function ListViewRenderer({ )} - {/* Sort chips */} - {columns.some((c) => c.sortable !== false) && ( - - {columns - .filter((c) => c.sortable !== false && !c.hidden) - .slice(0, 4) - .map((col) => { - const isActive = sortField === col.field; - return ( - 0 && ( + + {/* Group-by control */} + setGroupSheetOpen(true)} + > + + + {effectiveGroupField + ? (columns.find((c) => c.field === effectiveGroupField)?.label ?? + effectiveGroupField) + : t("records.groupBy")} + + + + {/* Sort chips — active chip shows direction; the rest hint sortability */} + {sortableColumns.slice(0, 4).map((col) => { + const isActive = sortField === col.field; + return ( + handleSort(col.field)} + > + handleSort(col.field)} > - - {col.label ?? col.field} - - {isActive && - (sortDir === "asc" ? ( - + {col.label ?? col.field} + + + {isActive ? ( + sortDir === "asc" ? ( + ) : ( - - ))} - - ); - })} + + ) + ) : ( + + )} + + + ); + })} )} @@ -691,8 +768,8 @@ export function ListViewRenderer({ } - title="No Records" - description="No records found for this view." + title={t("records.noRecords")} + description={t("records.noRecordsDescription")} /> ) } @@ -735,6 +812,60 @@ export function ListViewRenderer({ onApply={handleFilterApply} /> )} + + {/* Group-by picker */} + + + { + setGroupOverride(null); + setGroupSheetOpen(false); + }} + > + + {t("records.groupNone")} + + {!effectiveGroupField && } + + {sortableColumns.map((col) => { + const selected = effectiveGroupField === col.field; + return ( + { + setGroupOverride(col.field); + setGroupSheetOpen(false); + }} + > + + {col.label ?? col.field} + + {selected && } + + ); + })} + + ); } diff --git a/locales/ar.json b/locales/ar.json index a58ac14..50d1cbe 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -51,6 +51,10 @@ "noRecords": "لا توجد سجلات", "noRecordsDescription": "لم يتم العثور على سجلات لهذا العرض.", "searchRecords": "بحث في السجلات…", + "loadError": "تعذّر تحميل السجلات", + "sortBy": "ترتيب", + "groupBy": "تجميع", + "groupNone": "بدون", "createRecord": "إنشاء سجل", "editRecord": "تعديل سجل", "deleteRecord": "حذف سجل", diff --git a/locales/en.json b/locales/en.json index ba55de8..bd32e9c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -51,6 +51,10 @@ "noRecords": "No Records", "noRecordsDescription": "No records found for this view.", "searchRecords": "Search records…", + "loadError": "Couldn't Load Records", + "sortBy": "Sort", + "groupBy": "Group", + "groupNone": "None", "createRecord": "Create Record", "editRecord": "Edit Record", "deleteRecord": "Delete Record", diff --git a/locales/zh.json b/locales/zh.json index e468f2d..e44378f 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -51,6 +51,10 @@ "noRecords": "暂无记录", "noRecordsDescription": "此视图没有找到记录。", "searchRecords": "搜索记录…", + "loadError": "无法加载记录", + "sortBy": "排序", + "groupBy": "分组", + "groupNone": "无", "createRecord": "创建记录", "editRecord": "编辑记录", "deleteRecord": "删除记录",