@softize/opus 25.2.0 → 25.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/bin/lib/assistant-schema.d.mts +3 -2
- package/bin/lib/assistant-schema.mjs +41 -21
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +2 -1
- package/bin/lib/gen-stubs.mjs +3 -0
- package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +19 -0
- package/docs/adr/0053-read-action-declares-the-resource-assistant.md +8 -1
- package/docs/adr/0054-action-list-grouping-is-a-declared-server-cut.md +82 -0
- package/docs/protocol.md +9 -2
- package/package.json +1 -1
- package/src/core/actions.ts +5 -2
- package/src/core/contracts.ts +2 -0
- package/src/core/list-grouping.ts +45 -0
- package/src/core/runtime.ts +2 -0
- package/src/core/types.ts +5 -0
- package/src/ui/components/patterns/grouped-list.ts +88 -0
- package/src/ui/components/patterns/list.tsx +395 -61
- package/src/ui/components/primitives/floating-layer.tsx +4 -4
- package/src/ui/docs/content/action-list.md +42 -9
- package/src/ui/docs/content/actions.md +6 -1
- package/src/ui/docs/content/floating-layer.md +17 -10
- package/src/ui/drivers/context.tsx +50 -0
- package/src/ui/drivers/react.tsx +9 -69
- package/src/ui/lib/modal-layers.ts +18 -10
- package/src/ui/meta.ts +1 -1
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
Calendar as CalendarIcon,
|
|
39
39
|
Check,
|
|
40
40
|
ChevronDown,
|
|
41
|
+
ChevronRight,
|
|
41
42
|
Eraser,
|
|
42
43
|
Filter,
|
|
43
44
|
RotateCw,
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
useLookupAction,
|
|
66
67
|
type DictLike,
|
|
67
68
|
} from "../../drivers/react.tsx";
|
|
69
|
+
import { useGroupedListAction } from "./grouped-list.ts";
|
|
68
70
|
import { cn } from "../../lib/cn.ts";
|
|
69
71
|
import { FillBoundary, useFillLeaf } from "./fill-region.ts";
|
|
70
72
|
import { objectSchemaShape } from "../../lib/object-schema.ts";
|
|
@@ -82,6 +84,7 @@ import {
|
|
|
82
84
|
PaginationPrevious,
|
|
83
85
|
} from "../primitives/pagination.tsx";
|
|
84
86
|
import { Button } from "../primitives/button.tsx";
|
|
87
|
+
import { focusRing } from "../primitives/control.ts";
|
|
85
88
|
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
86
89
|
import { Calendar } from "../primitives/calendar.tsx";
|
|
87
90
|
import { Checkbox } from "../primitives/checkbox.tsx";
|
|
@@ -205,6 +208,8 @@ export interface ActionListState {
|
|
|
205
208
|
limit: number | null;
|
|
206
209
|
/** Página atual (1-based). Mudar filtro/busca/sort/período volta pra 1. */
|
|
207
210
|
page: number;
|
|
211
|
+
/** Dimensão ativa de agrupamento. null mantém a listagem plana. */
|
|
212
|
+
groupBy?: string | null;
|
|
208
213
|
}
|
|
209
214
|
|
|
210
215
|
/** Um estado vazio/default — útil pra montar o inicial em quem controla. */
|
|
@@ -220,6 +225,7 @@ export function emptyListState(): ActionListState {
|
|
|
220
225
|
columns: null,
|
|
221
226
|
limit: null,
|
|
222
227
|
page: 1,
|
|
228
|
+
groupBy: null,
|
|
223
229
|
};
|
|
224
230
|
}
|
|
225
231
|
|
|
@@ -270,6 +276,7 @@ export interface ListActionLike {
|
|
|
270
276
|
sort?: { fields: string[]; default?: SortSpec[] } | undefined;
|
|
271
277
|
text?: { fields: string[]; placeholder?: unknown } | undefined;
|
|
272
278
|
periods?: PeriodSpec[] | undefined;
|
|
279
|
+
grouping?: { fields: string[] } | undefined;
|
|
273
280
|
}
|
|
274
281
|
|
|
275
282
|
/** Estado compartilhado pelos filtros declarativos, independente da listagem. */
|
|
@@ -1750,6 +1757,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1750
1757
|
onStateChange,
|
|
1751
1758
|
}: ActionListProps<TInput, TItem>) {
|
|
1752
1759
|
const confirmBodyId = useId();
|
|
1760
|
+
const groupRegionId = useId();
|
|
1753
1761
|
const { layout, ref: rootRef } = useFillLeaf<HTMLDivElement>(layoutProp);
|
|
1754
1762
|
// Query-backed (useListAction): busca declarativa por [name, input] E refetch
|
|
1755
1763
|
// automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
|
|
@@ -1778,6 +1786,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1778
1786
|
columns: raw.columns ?? null,
|
|
1779
1787
|
limit: raw.limit ?? null,
|
|
1780
1788
|
page: raw.page ?? 1,
|
|
1789
|
+
groupBy: raw.groupBy ?? null,
|
|
1781
1790
|
};
|
|
1782
1791
|
const setState = (next: ActionListState): void => {
|
|
1783
1792
|
if (onStateChange !== undefined) onStateChange(next);
|
|
@@ -1804,6 +1813,34 @@ export function ActionList<TInput, TItem>({
|
|
|
1804
1813
|
),
|
|
1805
1814
|
];
|
|
1806
1815
|
const activeView = state.view ?? viewEntries[0]?.[0] ?? "table";
|
|
1816
|
+
const groupingFields = action.grouping?.fields ?? [];
|
|
1817
|
+
const dicts = useDicts();
|
|
1818
|
+
const availableGroupingFields = groupingFields.filter((field) => {
|
|
1819
|
+
if (action.filters?.group !== undefined) return false;
|
|
1820
|
+
const spec = action.filters?.[field];
|
|
1821
|
+
if (
|
|
1822
|
+
spec?.type !== "select" ||
|
|
1823
|
+
(spec.options?.kind !== "static" && spec.options?.kind !== "dictionary")
|
|
1824
|
+
)
|
|
1825
|
+
return false;
|
|
1826
|
+
const options = [
|
|
1827
|
+
...optionsFor(field, spec, filterOptions),
|
|
1828
|
+
...dictOptionsFor(spec, dicts),
|
|
1829
|
+
].filter(
|
|
1830
|
+
(option, index, all) =>
|
|
1831
|
+
all.findIndex((candidate) => candidate.value === option.value) ===
|
|
1832
|
+
index,
|
|
1833
|
+
);
|
|
1834
|
+
return options.length > 0 && options.length <= 12;
|
|
1835
|
+
});
|
|
1836
|
+
const requestedGroupBy = state.groupBy ?? null;
|
|
1837
|
+
const activeGroupBy =
|
|
1838
|
+
children === undefined &&
|
|
1839
|
+
activeView === "table" &&
|
|
1840
|
+
requestedGroupBy !== null &&
|
|
1841
|
+
availableGroupingFields.includes(requestedGroupBy)
|
|
1842
|
+
? requestedGroupBy
|
|
1843
|
+
: null;
|
|
1807
1844
|
const hasSegment = children === undefined && viewEntries.length > 1;
|
|
1808
1845
|
const periods = action.periods ?? [];
|
|
1809
1846
|
const hasToolbar =
|
|
@@ -1860,16 +1897,109 @@ export function ActionList<TInput, TItem>({
|
|
|
1860
1897
|
} as TInput;
|
|
1861
1898
|
}, [input, state, pageSize, filterDefaults]);
|
|
1862
1899
|
|
|
1863
|
-
const inputKey = JSON.stringify(effectiveInput);
|
|
1864
|
-
const
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1900
|
+
const inputKey = JSON.stringify([effectiveInput, activeGroupBy]);
|
|
1901
|
+
const groupSpec =
|
|
1902
|
+
activeGroupBy === null ? undefined : action.filters?.[activeGroupBy];
|
|
1903
|
+
const declaredGroupOptions =
|
|
1904
|
+
groupSpec === undefined
|
|
1905
|
+
? []
|
|
1906
|
+
: [
|
|
1907
|
+
...optionsFor(activeGroupBy ?? "", groupSpec, filterOptions),
|
|
1908
|
+
...dictOptionsFor(groupSpec, dicts),
|
|
1909
|
+
].filter(
|
|
1910
|
+
(option, index, all) =>
|
|
1911
|
+
all.findIndex((candidate) => candidate.value === option.value) ===
|
|
1912
|
+
index,
|
|
1913
|
+
);
|
|
1914
|
+
const selectedGroupFilter =
|
|
1915
|
+
activeGroupBy === null
|
|
1916
|
+
? undefined
|
|
1917
|
+
: (state.filters[activeGroupBy] ?? filterDefaults?.[activeGroupBy]);
|
|
1918
|
+
const groupOptions = declaredGroupOptions.filter((option) =>
|
|
1919
|
+
Array.isArray(selectedGroupFilter) && selectedGroupFilter.length > 0
|
|
1920
|
+
? selectedGroupFilter.includes(option.value)
|
|
1921
|
+
: typeof selectedGroupFilter === "string" && selectedGroupFilter !== ""
|
|
1922
|
+
? selectedGroupFilter === option.value
|
|
1923
|
+
: true,
|
|
1924
|
+
);
|
|
1925
|
+
const [groupPaging, setGroupPaging] = useState<{
|
|
1926
|
+
key: string;
|
|
1927
|
+
pages: Record<string, number>;
|
|
1928
|
+
}>({ key: "", pages: {} });
|
|
1929
|
+
const groupPages = groupPaging.key === inputKey ? groupPaging.pages : {};
|
|
1930
|
+
const [groupCollapse, setGroupCollapse] = useState<{
|
|
1931
|
+
key: string;
|
|
1932
|
+
values: Set<string>;
|
|
1933
|
+
}>({ key: "", values: new Set() });
|
|
1934
|
+
const collapsedGroups =
|
|
1935
|
+
groupCollapse.key === inputKey ? groupCollapse.values : new Set<string>();
|
|
1936
|
+
const flat = useListAction<TItem, Record<string, unknown>>(
|
|
1937
|
+
action as unknown as { name: string; kind: "list" },
|
|
1938
|
+
effectiveInput as Record<string, unknown>,
|
|
1939
|
+
{ enabled: activeGroupBy === null },
|
|
1940
|
+
);
|
|
1941
|
+
const grouped = useGroupedListAction<TItem, Record<string, unknown>>(
|
|
1942
|
+
action as unknown as { name: string; kind: "list" },
|
|
1943
|
+
effectiveInput as Record<string, unknown>,
|
|
1944
|
+
activeGroupBy,
|
|
1945
|
+
groupOptions.map((option) => option.value),
|
|
1946
|
+
groupSpec?.multiple === true,
|
|
1947
|
+
groupPages,
|
|
1948
|
+
state.limit ?? pageSize,
|
|
1949
|
+
);
|
|
1950
|
+
const items =
|
|
1951
|
+
activeGroupBy === null
|
|
1952
|
+
? flat.items
|
|
1953
|
+
: grouped.flatMap((group) => group.items);
|
|
1954
|
+
const visibleItems =
|
|
1955
|
+
activeGroupBy === null
|
|
1956
|
+
? items
|
|
1957
|
+
: grouped
|
|
1958
|
+
.filter((group) => !collapsedGroups.has(group.value))
|
|
1959
|
+
.flatMap((group) => group.items);
|
|
1960
|
+
const total =
|
|
1961
|
+
activeGroupBy === null
|
|
1962
|
+
? flat.total
|
|
1963
|
+
: grouped.every((group) => group.total !== undefined)
|
|
1964
|
+
? grouped.reduce((sum, group) => sum + (group.total ?? 0), 0)
|
|
1965
|
+
: undefined;
|
|
1966
|
+
const isLoading =
|
|
1967
|
+
activeGroupBy === null
|
|
1968
|
+
? flat.isLoading
|
|
1969
|
+
: grouped.length > 0 && grouped.some((group) => group.isLoading);
|
|
1970
|
+
const isFetching =
|
|
1971
|
+
activeGroupBy === null
|
|
1972
|
+
? flat.isFetching
|
|
1973
|
+
: grouped.some((group) => group.isFetching);
|
|
1974
|
+
const missingGroupTotal =
|
|
1975
|
+
activeGroupBy !== null &&
|
|
1976
|
+
grouped.some(
|
|
1977
|
+
(group) =>
|
|
1978
|
+
!group.isLoading && !group.isError && group.total === undefined,
|
|
1868
1979
|
);
|
|
1980
|
+
const isError =
|
|
1981
|
+
activeGroupBy === null
|
|
1982
|
+
? flat.isError
|
|
1983
|
+
: missingGroupTotal || grouped.some((group) => group.isError);
|
|
1984
|
+
const error =
|
|
1985
|
+
activeGroupBy === null
|
|
1986
|
+
? flat.error
|
|
1987
|
+
: missingGroupTotal
|
|
1988
|
+
? {
|
|
1989
|
+
code: "action_list.grouping_total_missing",
|
|
1990
|
+
category: "internal" as const,
|
|
1991
|
+
message: "Não foi possível carregar os grupos desta lista.",
|
|
1992
|
+
severity: "error" as const,
|
|
1993
|
+
retriable: false,
|
|
1994
|
+
}
|
|
1995
|
+
: grouped.find((group) => group.error !== undefined)?.error;
|
|
1996
|
+
const refetch = async (): Promise<void> => {
|
|
1997
|
+
if (activeGroupBy === null) return flat.refetch();
|
|
1998
|
+
await Promise.all(grouped.map((group) => group.refetch()));
|
|
1999
|
+
};
|
|
1869
2000
|
// — Colunas: prop explícita > derivadas do contrato (+ células custom). O column
|
|
1870
2001
|
// picker (state.columns) escolhe o subconjunto visível; null = o default (!hidden).
|
|
1871
2002
|
const visibleKeys = state.columns;
|
|
1872
|
-
const dicts = useDicts();
|
|
1873
2003
|
const derived: Array<ActionListColumn<TItem> & { spec?: ListColumnSpec }> =
|
|
1874
2004
|
useMemo(() => {
|
|
1875
2005
|
if (columns !== undefined) return columns;
|
|
@@ -1972,7 +2102,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1972
2102
|
const selectable = (item: TItem): boolean =>
|
|
1973
2103
|
batchActions.some((a) => a.can?.(item) ?? true);
|
|
1974
2104
|
const selectableItems =
|
|
1975
|
-
batchActions.length > 0 ?
|
|
2105
|
+
batchActions.length > 0 ? visibleItems.filter(selectable) : [];
|
|
1976
2106
|
const hasSelection =
|
|
1977
2107
|
batchActions.length > 0 &&
|
|
1978
2108
|
selectableItems.length > 0 &&
|
|
@@ -1981,7 +2111,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1981
2111
|
// Página/recorte novo → seleção limpa (os ids podem nem estar mais na tela).
|
|
1982
2112
|
setSelected(new Set());
|
|
1983
2113
|
}, [inputKey]);
|
|
1984
|
-
const selectedItems =
|
|
2114
|
+
const selectedItems = visibleItems.filter((it) => selected.has(rowId(it)));
|
|
1985
2115
|
const eligibleFor = (a: ActionListBatchAction<TItem>): TItem[] =>
|
|
1986
2116
|
selectedItems.filter((it) => a.can?.(it) ?? true);
|
|
1987
2117
|
const runBatch = async (a: ActionListBatchAction<TItem>): Promise<void> => {
|
|
@@ -2065,6 +2195,41 @@ export function ActionList<TInput, TItem>({
|
|
|
2065
2195
|
data-slot="action-list-display"
|
|
2066
2196
|
>
|
|
2067
2197
|
<div className="space-y-3">
|
|
2198
|
+
{children === undefined &&
|
|
2199
|
+
activeView === "table" &&
|
|
2200
|
+
availableGroupingFields.length > 0 && (
|
|
2201
|
+
<>
|
|
2202
|
+
<div className="space-y-2">
|
|
2203
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
2204
|
+
Agrupar por
|
|
2205
|
+
</span>
|
|
2206
|
+
<Select
|
|
2207
|
+
aria-label="Agrupar por"
|
|
2208
|
+
size="sm"
|
|
2209
|
+
className="w-full"
|
|
2210
|
+
value={activeGroupBy ?? "__none__"}
|
|
2211
|
+
onChange={(value) =>
|
|
2212
|
+
setState({
|
|
2213
|
+
...state,
|
|
2214
|
+
groupBy: value === "__none__" ? null : value,
|
|
2215
|
+
page: 1,
|
|
2216
|
+
})
|
|
2217
|
+
}
|
|
2218
|
+
options={[
|
|
2219
|
+
{ value: "__none__", label: "Sem agrupamento" },
|
|
2220
|
+
...availableGroupingFields.map((field) => ({
|
|
2221
|
+
value: field,
|
|
2222
|
+
label: text(
|
|
2223
|
+
action.filters?.[field]?.label,
|
|
2224
|
+
field,
|
|
2225
|
+
),
|
|
2226
|
+
})),
|
|
2227
|
+
]}
|
|
2228
|
+
/>
|
|
2229
|
+
</div>
|
|
2230
|
+
<Separator />
|
|
2231
|
+
</>
|
|
2232
|
+
)}
|
|
2068
2233
|
{activeView === "table" && pickerColumns.length > 0 && (
|
|
2069
2234
|
<>
|
|
2070
2235
|
<div className="space-y-2">
|
|
@@ -2107,7 +2272,9 @@ export function ActionList<TInput, TItem>({
|
|
|
2107
2272
|
)}
|
|
2108
2273
|
<div className="space-y-2">
|
|
2109
2274
|
<span className="text-xs font-medium text-muted-foreground">
|
|
2110
|
-
|
|
2275
|
+
{activeGroupBy === null
|
|
2276
|
+
? "Itens por página"
|
|
2277
|
+
: "Itens por grupo"}
|
|
2111
2278
|
</span>
|
|
2112
2279
|
<Select
|
|
2113
2280
|
size="sm"
|
|
@@ -2152,43 +2319,54 @@ export function ActionList<TInput, TItem>({
|
|
|
2152
2319
|
(hasSelection ? 1 : 0) +
|
|
2153
2320
|
(rowActions !== undefined ? 1 : 0),
|
|
2154
2321
|
);
|
|
2155
|
-
const
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2322
|
+
const renderTableRows = (
|
|
2323
|
+
rows: TItem[],
|
|
2324
|
+
options?: { hidden?: boolean; idPrefix?: string },
|
|
2325
|
+
) =>
|
|
2326
|
+
rows.map((item, idx) => (
|
|
2327
|
+
<TableRow
|
|
2328
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
2329
|
+
key={idx}
|
|
2330
|
+
id={
|
|
2331
|
+
options?.idPrefix === undefined
|
|
2332
|
+
? undefined
|
|
2333
|
+
: `${options.idPrefix}-${idx}`
|
|
2334
|
+
}
|
|
2335
|
+
hidden={options?.hidden}
|
|
2336
|
+
className={onRowClick !== undefined ? "cursor-pointer" : undefined}
|
|
2337
|
+
onClick={onRowClick !== undefined ? () => onRowClick(item) : undefined}
|
|
2338
|
+
>
|
|
2339
|
+
{hasSelection && (
|
|
2340
|
+
<TableCell className="w-px" onClick={(e) => e.stopPropagation()}>
|
|
2341
|
+
<Checkbox
|
|
2342
|
+
aria-label="Selecionar linha"
|
|
2343
|
+
disabled={!selectable(item)}
|
|
2344
|
+
checked={selected.has(rowId(item))}
|
|
2345
|
+
onCheckedChange={(v) => {
|
|
2346
|
+
const next = new Set(selected);
|
|
2347
|
+
if (v === true) next.add(rowId(item));
|
|
2348
|
+
else next.delete(rowId(item));
|
|
2349
|
+
setSelected(next);
|
|
2350
|
+
}}
|
|
2351
|
+
/>
|
|
2352
|
+
</TableCell>
|
|
2353
|
+
)}
|
|
2354
|
+
{derived.map((col) => (
|
|
2355
|
+
<TableCell key={col.key} className={col.className}>
|
|
2356
|
+
{col.cell(item)}
|
|
2357
|
+
</TableCell>
|
|
2358
|
+
))}
|
|
2359
|
+
{rowActions !== undefined && (
|
|
2360
|
+
<TableCell
|
|
2361
|
+
className="w-px whitespace-nowrap text-right"
|
|
2362
|
+
onClick={(e) => e.stopPropagation()}
|
|
2363
|
+
>
|
|
2364
|
+
<RowActions>{rowActions(item)}</RowActions>
|
|
2365
|
+
</TableCell>
|
|
2366
|
+
)}
|
|
2367
|
+
</TableRow>
|
|
2368
|
+
));
|
|
2369
|
+
const tableRows = renderTableRows(items);
|
|
2192
2370
|
let content: ReactNode;
|
|
2193
2371
|
if (children !== undefined) {
|
|
2194
2372
|
// Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
|
|
@@ -2263,14 +2441,33 @@ export function ActionList<TInput, TItem>({
|
|
|
2263
2441
|
{rowActions !== undefined && <TableHead className="w-px" />}
|
|
2264
2442
|
</TableRow>
|
|
2265
2443
|
</TableHeader>
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2444
|
+
{activeGroupBy === null ? (
|
|
2445
|
+
<TableBody
|
|
2446
|
+
className={
|
|
2447
|
+
tableStateActive
|
|
2448
|
+
? "h-full [&_[data-slot=data-state]]:h-full"
|
|
2449
|
+
: undefined
|
|
2450
|
+
}
|
|
2451
|
+
>
|
|
2452
|
+
{tableFillsAvailableHeight ? (
|
|
2453
|
+
<DataState
|
|
2454
|
+
loading={initialLoading}
|
|
2455
|
+
error={isError ? error : null}
|
|
2456
|
+
empty={isEmpty}
|
|
2457
|
+
emptyMessage={emptyMessage}
|
|
2458
|
+
{...(errorMessage !== undefined ? { errorMessage } : {})}
|
|
2459
|
+
{...(retryLabel !== undefined ? { retryLabel } : {})}
|
|
2460
|
+
onRetry={() => refetch()}
|
|
2461
|
+
colSpan={tableColumnCount}
|
|
2462
|
+
>
|
|
2463
|
+
{tableRows}
|
|
2464
|
+
</DataState>
|
|
2465
|
+
) : (
|
|
2466
|
+
tableRows
|
|
2467
|
+
)}
|
|
2468
|
+
</TableBody>
|
|
2469
|
+
) : tableFillsAvailableHeight && tableStateActive ? (
|
|
2470
|
+
<TableBody className="h-full [&_[data-slot=data-state]]:h-full">
|
|
2274
2471
|
<DataState
|
|
2275
2472
|
loading={initialLoading}
|
|
2276
2473
|
error={isError ? error : null}
|
|
@@ -2281,12 +2478,127 @@ export function ActionList<TInput, TItem>({
|
|
|
2281
2478
|
onRetry={() => refetch()}
|
|
2282
2479
|
colSpan={tableColumnCount}
|
|
2283
2480
|
>
|
|
2284
|
-
{
|
|
2481
|
+
{null}
|
|
2285
2482
|
</DataState>
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
)
|
|
2289
|
-
|
|
2483
|
+
</TableBody>
|
|
2484
|
+
) : (
|
|
2485
|
+
grouped.map((group, groupIndex) => {
|
|
2486
|
+
const option = groupOptions.find(
|
|
2487
|
+
(candidate) => candidate.value === group.value,
|
|
2488
|
+
);
|
|
2489
|
+
const collapsed = collapsedGroups.has(group.value);
|
|
2490
|
+
const loadedPages = groupPages[group.value] ?? 1;
|
|
2491
|
+
const canLoadMore =
|
|
2492
|
+
group.total !== undefined && group.items.length < group.total;
|
|
2493
|
+
const groupLabel = option?.label ?? group.value;
|
|
2494
|
+
const groupId = `${groupRegionId}-${groupIndex}-${group.value.replace(
|
|
2495
|
+
/[^a-zA-Z0-9_-]/g,
|
|
2496
|
+
"-",
|
|
2497
|
+
)}`;
|
|
2498
|
+
const bodyId = `${groupId}-body`;
|
|
2499
|
+
const controlledIds = [
|
|
2500
|
+
...group.items.map((_, index) => `${bodyId}-${index}`),
|
|
2501
|
+
...(canLoadMore ? [`${bodyId}-load-more`] : []),
|
|
2502
|
+
];
|
|
2503
|
+
return (
|
|
2504
|
+
<TableBody
|
|
2505
|
+
key={group.value}
|
|
2506
|
+
data-group={group.value}
|
|
2507
|
+
aria-labelledby={`${groupId}-label`}
|
|
2508
|
+
>
|
|
2509
|
+
<TableRow className="!bg-muted/40 hover:!bg-muted/60">
|
|
2510
|
+
<TableHead
|
|
2511
|
+
id={`${groupId}-label`}
|
|
2512
|
+
scope="rowgroup"
|
|
2513
|
+
colSpan={tableColumnCount}
|
|
2514
|
+
className="h-auto py-1.5 text-foreground"
|
|
2515
|
+
>
|
|
2516
|
+
<button
|
|
2517
|
+
type="button"
|
|
2518
|
+
aria-expanded={!collapsed}
|
|
2519
|
+
aria-controls={
|
|
2520
|
+
controlledIds.length > 0
|
|
2521
|
+
? controlledIds.join(" ")
|
|
2522
|
+
: undefined
|
|
2523
|
+
}
|
|
2524
|
+
aria-label={`${groupLabel}, ${
|
|
2525
|
+
group.total === undefined
|
|
2526
|
+
? "carregando"
|
|
2527
|
+
: `${group.total} ${group.total === 1 ? "item" : "itens"}`
|
|
2528
|
+
}`}
|
|
2529
|
+
className={cn(
|
|
2530
|
+
"flex w-full items-center gap-2 rounded-sm text-left font-medium outline-none",
|
|
2531
|
+
focusRing,
|
|
2532
|
+
)}
|
|
2533
|
+
onClick={() => {
|
|
2534
|
+
const next = new Set(collapsedGroups);
|
|
2535
|
+
if (collapsed) next.delete(group.value);
|
|
2536
|
+
else {
|
|
2537
|
+
next.add(group.value);
|
|
2538
|
+
const hidden = new Set(group.items.map(rowId));
|
|
2539
|
+
setSelected(
|
|
2540
|
+
new Set(
|
|
2541
|
+
[...selected].filter((id) => !hidden.has(id)),
|
|
2542
|
+
),
|
|
2543
|
+
);
|
|
2544
|
+
}
|
|
2545
|
+
setGroupCollapse({ key: inputKey, values: next });
|
|
2546
|
+
}}
|
|
2547
|
+
>
|
|
2548
|
+
{collapsed ? (
|
|
2549
|
+
<ChevronRight className="size-4" />
|
|
2550
|
+
) : (
|
|
2551
|
+
<ChevronDown className="size-4" />
|
|
2552
|
+
)}
|
|
2553
|
+
<span className="flex items-center gap-1.5">
|
|
2554
|
+
{option?.content ?? option?.label ?? group.value}
|
|
2555
|
+
</span>
|
|
2556
|
+
<span className="text-muted-foreground">
|
|
2557
|
+
{group.total ?? "…"}
|
|
2558
|
+
</span>
|
|
2559
|
+
</button>
|
|
2560
|
+
</TableHead>
|
|
2561
|
+
</TableRow>
|
|
2562
|
+
{renderTableRows(group.items, {
|
|
2563
|
+
hidden: collapsed,
|
|
2564
|
+
idPrefix: bodyId,
|
|
2565
|
+
})}
|
|
2566
|
+
{canLoadMore && (
|
|
2567
|
+
<TableRow
|
|
2568
|
+
id={`${bodyId}-load-more`}
|
|
2569
|
+
hidden={collapsed}
|
|
2570
|
+
className="hover:bg-transparent"
|
|
2571
|
+
>
|
|
2572
|
+
<TableCell
|
|
2573
|
+
colSpan={tableColumnCount}
|
|
2574
|
+
className="text-center"
|
|
2575
|
+
>
|
|
2576
|
+
<Button
|
|
2577
|
+
size="sm"
|
|
2578
|
+
variant="ghost"
|
|
2579
|
+
aria-label={`Carregar mais de ${groupLabel}`}
|
|
2580
|
+
busy={group.isFetching}
|
|
2581
|
+
onClick={() =>
|
|
2582
|
+
setGroupPaging((current) => ({
|
|
2583
|
+
key: inputKey,
|
|
2584
|
+
pages: {
|
|
2585
|
+
...(current.key === inputKey
|
|
2586
|
+
? current.pages
|
|
2587
|
+
: {}),
|
|
2588
|
+
[group.value]: loadedPages + 1,
|
|
2589
|
+
},
|
|
2590
|
+
}))
|
|
2591
|
+
}
|
|
2592
|
+
>
|
|
2593
|
+
Carregar mais
|
|
2594
|
+
</Button>
|
|
2595
|
+
</TableCell>
|
|
2596
|
+
</TableRow>
|
|
2597
|
+
)}
|
|
2598
|
+
</TableBody>
|
|
2599
|
+
);
|
|
2600
|
+
})
|
|
2601
|
+
)}
|
|
2290
2602
|
</Table>
|
|
2291
2603
|
);
|
|
2292
2604
|
}
|
|
@@ -2365,7 +2677,14 @@ export function ActionList<TInput, TItem>({
|
|
|
2365
2677
|
: 1;
|
|
2366
2678
|
const goTo = (page: number): void => setState({ ...state, page });
|
|
2367
2679
|
const footer =
|
|
2368
|
-
total !== undefined && !isError ? (
|
|
2680
|
+
total !== undefined && !isError && activeGroupBy !== null ? (
|
|
2681
|
+
<div
|
|
2682
|
+
data-slot="action-list-footer"
|
|
2683
|
+
className="flex min-h-10 items-center justify-end border-t border-border px-3 py-1 text-xs text-muted-foreground"
|
|
2684
|
+
>
|
|
2685
|
+
{total} {total === 1 ? "item" : "itens"} no total
|
|
2686
|
+
</div>
|
|
2687
|
+
) : total !== undefined && !isError ? (
|
|
2369
2688
|
<div
|
|
2370
2689
|
data-slot="action-list-footer"
|
|
2371
2690
|
className="grid min-h-10 grid-cols-[1fr_auto_1fr] items-center gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground"
|
|
@@ -2459,7 +2778,9 @@ export function ActionList<TInput, TItem>({
|
|
|
2459
2778
|
className={cn(
|
|
2460
2779
|
"min-h-0",
|
|
2461
2780
|
layout === "fill" && "overflow-auto",
|
|
2462
|
-
busy &&
|
|
2781
|
+
busy &&
|
|
2782
|
+
items.length > 0 &&
|
|
2783
|
+
"pointer-events-none opacity-60",
|
|
2463
2784
|
)}
|
|
2464
2785
|
>
|
|
2465
2786
|
{body}
|
|
@@ -2568,6 +2889,11 @@ export function listParamsToState(
|
|
|
2568
2889
|
const periodParam = params.get("period");
|
|
2569
2890
|
const range =
|
|
2570
2891
|
periodParam?.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/) ?? null;
|
|
2892
|
+
const groupParam = params.get("group");
|
|
2893
|
+
const groupBy =
|
|
2894
|
+
groupParam !== null && (action.grouping?.fields ?? []).includes(groupParam)
|
|
2895
|
+
? groupParam
|
|
2896
|
+
: null;
|
|
2571
2897
|
return {
|
|
2572
2898
|
q: params.get("q") ?? "",
|
|
2573
2899
|
sort,
|
|
@@ -2579,6 +2905,7 @@ export function listParamsToState(
|
|
|
2579
2905
|
columns: cols !== null && cols !== "" ? cols.split(",") : null,
|
|
2580
2906
|
limit: Number.isInteger(limitParam) && limitParam > 0 ? limitParam : null,
|
|
2581
2907
|
page: Number.isInteger(page) && page > 1 ? page : 1,
|
|
2908
|
+
groupBy,
|
|
2582
2909
|
};
|
|
2583
2910
|
}
|
|
2584
2911
|
|
|
@@ -2622,6 +2949,13 @@ export function listStateToParams(
|
|
|
2622
2949
|
if (state.columns !== null) qs.set("cols", state.columns.join(","));
|
|
2623
2950
|
if (state.limit !== null) qs.set("limit", String(state.limit));
|
|
2624
2951
|
if (state.page > 1) qs.set("page", String(state.page));
|
|
2952
|
+
if (
|
|
2953
|
+
state.groupBy !== undefined &&
|
|
2954
|
+
state.groupBy !== null &&
|
|
2955
|
+
(action.grouping?.fields ?? []).includes(state.groupBy)
|
|
2956
|
+
) {
|
|
2957
|
+
qs.set("group", state.groupBy);
|
|
2958
|
+
}
|
|
2625
2959
|
const s = qs.toString();
|
|
2626
2960
|
return s === "" ? "" : `?${s}`;
|
|
2627
2961
|
}
|
|
@@ -14,7 +14,9 @@ export interface FloatingLayerProps {
|
|
|
14
14
|
/**
|
|
15
15
|
* Esc enquanto a pessoa está na camada, com ou sem modal aberto. É chamado uma vez por tecla.
|
|
16
16
|
* Nenhum modal fecha nesse caso, e o Opus não fecha a camada sozinho, porque o estado de aberto
|
|
17
|
-
* pertence ao consumidor.
|
|
17
|
+
* pertence ao consumidor. Sem o handler, o Esc com a pessoa na camada fecha o modal de trabalho
|
|
18
|
+
* aberto: passe `undefined` enquanto a camada não tiver o que fechar, como com o painel fechado e
|
|
19
|
+
* só o botão que o abre.
|
|
18
20
|
*/
|
|
19
21
|
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
|
20
22
|
}
|
|
@@ -49,9 +51,7 @@ export function FloatingLayer({
|
|
|
49
51
|
const element = document.createElement("div");
|
|
50
52
|
element.setAttribute("data-slot", "floating-layer");
|
|
51
53
|
document.body.appendChild(element);
|
|
52
|
-
const unregister = registerFloatingLayer(element, (
|
|
53
|
-
escapeRef.current?.(event),
|
|
54
|
-
);
|
|
54
|
+
const unregister = registerFloatingLayer(element, () => escapeRef.current);
|
|
55
55
|
setHost(element);
|
|
56
56
|
// Desfaz o registro, retira o hospedeiro e, se o foco estava nele com um modal aberto,
|
|
57
57
|
// devolve o foco ao modal.
|