/* eslint-disable @typescript-eslint/no-explicit-any */ import { ToolbarOptions } from "../../../type"; import { getCustomViewFields } from "./getCustomViewFields"; /** * Human-readable summary of what a view holds — "3 advanced filters", * "8 columns", "Sorted by 1 field". * * Drives the "Create New View" preview so the user can see what they are about * to save before saving it, for both the live screen and a view being copied. * It walks the SAME field table the save path walks (`getCustomViewFields`), so * it can never promise something `filterCustomViewData` would not persist — a * field with `enableCustomView: false` is silently absent from both. * * Unknown fields (hosts contribute their own through * `relativeWidgetFilterFields`) are skipped rather than guessed at: a wrong * summary is worse than a short one. */ const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`; /** "localSearch" -> "Local search", for fields with no specific describer. */ const humanize = (field: string) => field .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/^./, (character) => character.toUpperCase()); const truncate = (value: string, max = 18) => value.length > max ? `${value.slice(0, max)}…` : value; /** Count of whichever collection a filter-ish value carries. */ const countEntries = (value: any): number => { if (Array.isArray(value)) return value.length; if (Array.isArray(value?.filters)) return value.filters.length; if (Array.isArray(value?.filterRows)) return value.filterRows.length; return 0; }; const widgetTypeLabels: Record = { GRID: "Grid view", CARD: "Card view", }; /** * "Grouped by Region" / "Grouped by Region +2" for any group collection. * * Handles every shape grouping arrives in: the toolbar's `{label, value}`, the * grid's `{field, dir}` GroupDescriptor, and bare strings. Naming the field * beats a bare count — it makes an active grouping unmistakable in the preview. */ const describeGroups = (groups: any): string | null => { if (!Array.isArray(groups) || !groups.length) return null; const labels = groups .map((group: any) => typeof group === "string" ? group : (group?.label ?? group?.value ?? group?.field), ) .filter(Boolean); if (!labels.length) return `Grouped by ${plural(groups.length, "field")}`; if (labels.length === 1) return `Grouped by ${truncate(labels[0], 22)}`; return `Grouped by ${truncate(labels[0], 16)} +${labels.length - 1}`; }; // A described field may contribute more than one chip (e.g. `setting` carries // both the data-widget type and the column selection). const describers: Record string | string[] | null> = { globalSearch: (value) => typeof value === "string" && value.trim() ? `Search "${truncate(value.trim())}"` : null, advancedSearch: (value) => value?.filters?.length ? plural(value.filters.length, "advanced filter") : null, quickFilter: (value) => value?.filterRows?.length ? plural(value.filterRows.length, "quick filter") : null, // Accepts the unwrapped `appliedGroups` array and the whole `groupedBy` // object: the field is read through `applicableValueProperty` on the live // model but stored flat in a saved payload, and a caller holding the raw // object should not silently summarize to nothing. groupedBy: (value) => describeGroups(Array.isArray(value) ? value : value?.appliedGroups), // Two customizations live under `setting`: which data widget is active // (Grid vs Card) and the column chooser. Reporting only the columns hid the // widget switch even though the view saves and restores it. setting: (value) => { const chips: string[] = []; const widgetLabel = widgetTypeLabels[value?.activeDataWidgetType]; if (widgetLabel) chips.push(widgetLabel); const columns = value?.columnChooser?.appliedColumns?.length; if (columns) chips.push(plural(columns, "column")); return chips; }, sort: (value) => Array.isArray(value) && value.length ? `Sorted by ${plural(value.length, "field")}` : null, // The Grid writes `localSearch.appliedQuery` as `{ logic, filters }` // (`Grid/index.tsx`), while `ToolbarData` types the field as a flat array. // Count both shapes: reading only the array form silently dropped every // column filter from the preview, including the schema-seeded defaults. localSearch: (value) => { const count = countEntries(value); return count ? plural(count, "column filter") : null; }, pagination: (value) => { const take = value?.take; if (take === -1) return "All rows per page"; return typeof take === "number" && take > 0 ? `${take} rows per page` : null; }, }; /** Preview order — what the user reached for first, not table order. */ const chipOrder: Record = { globalSearch: 1, advancedSearch: 2, quickFilter: 3, localSearch: 4, groupedBy: 5, sort: 6, setting: 7, pagination: 8, }; /** * Fallback for fields with no specific describer — anything a data widget * contributes through `relativeWidgetFilterFields`. * * These used to be skipped, which meant a customization could be saved by the * view and yet be missing from the preview of that very save. It reports the * field by name (and count where the value carries a collection) rather than * inventing wording for a shape it does not know: honest and complete beats * specific and wrong. */ const describeUnknownField = (field: string, value: any): string | null => { if (value === null || value === undefined) return null; const label = humanize(field); if (typeof value === "string") return value.trim() ? `${label}: "${truncate(value.trim())}"` : null; if (typeof value === "number") return value ? `${label}: ${value}` : null; if (typeof value === "boolean") return value ? label : null; const count = countEntries(value); if (count) return `${label} (${count})`; // A collection-shaped value that is empty is "not customized", but any other // populated object is a customization worth surfacing by name. if (Array.isArray(value) || Array.isArray(value?.filters)) return null; if (Array.isArray(value?.filterRows)) return null; return typeof value === "object" && Object.keys(value).length ? label : null; }; /** * `fieldsFrom` always supplies the field table (only the live model carries * `relativeWidgetFilterFields`); `readValue` supplies the value to describe, * which differs between the two shapes — see the exported wrappers. */ const summarize = ( fieldsFrom: Record, toolBarOptions: ToolbarOptions, readValue: (filterField: Record) => any, ): string[] => { if (!fieldsFrom) return []; const described: Array<{ field: string; chip: string }> = []; getCustomViewFields(fieldsFrom, toolBarOptions).forEach((filterField) => { if (!filterField.enableCustomView) return; const value = readValue(filterField); const describe = describers[filterField.field]; const result = describe ? describe(value) : describeUnknownField(filterField.field, value); (Array.isArray(result) ? result : [result]).forEach((chip) => { if (chip) described.push({ field: filterField.field, chip }); }); }); // Applied but NOT stored: the data widget's own default grouping // (`groupConfig.defaultGroupFields` -> `uiElementGroupData.defaultGroups`). // The grid falls back to it whenever nothing is grouped through the toolbar // (`deserializedGroupFilelds`), so the grid is visibly grouped while // `groupedBy.appliedGroups` is empty and the field table — which has no entry // for `defaultGroups` — has nothing to report. // // The new view WILL open grouped this way (the widget re-applies its default // on every load), so the preview says so, tagged with where it comes from // rather than pretending it is part of the saved payload. if (!described.some((entry) => entry.field === "groupedBy")) { const defaultGroups = describeGroups(fieldsFrom.defaultGroups); if (defaultGroups) described.push({ field: "groupedBy", chip: `${defaultGroups} (widget default)`, }); } // The field table lists widget-contributed fields first, which pushed what // the user actually reached for — searches, filters, grouping — to the end of // the preview. Order by what they think of first instead; anything unranked // (host-contributed) trails. return described .map((entry, index) => ({ ...entry, index })) .sort((a, b) => { const rank = (chipOrder[a.field] ?? Number.MAX_SAFE_INTEGER) - (chipOrder[b.field] ?? Number.MAX_SAFE_INTEGER); return rank !== 0 ? rank : a.index - b.index; }) .map((entry) => entry.chip); }; /** * Summarize the LIVE toolbar model. Values sit behind * `applicableValueProperty` (e.g. `advancedSearch.appliedQuery`), exactly as * `filterCustomViewData` reads them when saving. */ export const summarizeCustomViewData = ( uiElementGroupData: Record, toolBarOptions: ToolbarOptions, ): string[] => summarize(uiElementGroupData, toolBarOptions, (filterField) => { const propertyValue = uiElementGroupData[filterField.field]; if (!propertyValue) return undefined; return filterField.applicableValueProperty ? propertyValue[filterField.applicableValueProperty] : propertyValue; }); /** * Summarize a SAVED view payload (what `filterCustomViewData` produced and the * metadata service stored). There the `applicableValueProperty` has already * been unwrapped, so the stored value is described directly — reading it a * second time would always yield undefined and silently summarize to nothing. * * The field table still comes from the live model, which is the only place * `relativeWidgetFilterFields` exists. */ export const summarizeSavedViewData = ( savedViewData: Record, uiElementGroupData: Record, toolBarOptions: ToolbarOptions, ): string[] => { if (!savedViewData) return []; return summarize( uiElementGroupData, toolBarOptions, (filterField) => savedViewData[filterField.field], ); }; export default summarizeCustomViewData;