rl-core-front 0.18.2 → 0.18.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -324,11 +324,18 @@ export function Combobox({
324
324
  * fecha em seguida. No modo múltiplo o campo é onde ficam os itens já
325
325
  * escolhidos: cobri-lo esconderia justamente o que se acabou de marcar,
326
326
  * e o painel continua aberto para marcar o próximo.
327
+ *
328
+ * Só quando abre para **baixo**: ali o que fica sobre o campo é a
329
+ * busca, e soltar o clique nela não faz nada. Virado para cima — campo
330
+ * no fim da tela, lista longa — cobrir o campo punha a **última opção**
331
+ * sob o cursor, e o `pointerup` do mesmo clique que abriu a escolhia:
332
+ * o painel fechava na hora com um valor que ninguém pediu, e a tela
333
+ * parecia um select que "não abre". Para cima, o painel encosta no
334
+ * campo sem cobri-lo.
327
335
  */
328
336
  className={cn(
329
337
  "max-h-[min(18rem,var(--radix-dropdown-menu-content-available-height))] w-(--radix-dropdown-menu-trigger-width) overflow-y-auto p-1",
330
- !multiple &&
331
- "data-[side=bottom]:-mt-(--radix-dropdown-menu-trigger-height) data-[side=top]:-mb-(--radix-dropdown-menu-trigger-height)",
338
+ !multiple && "data-[side=bottom]:-mt-(--radix-dropdown-menu-trigger-height)",
332
339
  )}
333
340
  align="start"
334
341
  sideOffset={multiple ? 4 : 0}
@@ -31,7 +31,11 @@ import {
31
31
  GripVertical,
32
32
  SlidersHorizontal,
33
33
  } from "lucide-react";
34
- import type { JSX, PointerEvent as ReactPointerEvent } from "react";
34
+ import type {
35
+ CSSProperties,
36
+ JSX,
37
+ PointerEvent as ReactPointerEvent,
38
+ } from "react";
35
39
  import { Fragment, useEffect, useMemo, useRef, useState } from "react";
36
40
 
37
41
  import { countConditions } from "#core/_utils/advanced-filter";
@@ -257,6 +261,15 @@ interface DataTableProps<T extends RowData> {
257
261
  * enquanto a listagem estiver na tela; não é gravada.
258
262
  */
259
263
  collapsibleGroups?: boolean;
264
+ /**
265
+ * As seções que nascem fechadas — por chave.
266
+ *
267
+ * Para a listagem longa em que só uma seção interessa de saída: o ano
268
+ * agrupado por mês abre com o mês corrente à vista e os outros na faixa.
269
+ * Lida uma vez, na montagem; quem quiser reaplicar quando os dados trocam
270
+ * remonta a tabela (`key`). Sem `collapsibleGroups` não tem efeito.
271
+ */
272
+ defaultCollapsedGroups?: string[];
260
273
  /**
261
274
  * A ordem das seções.
262
275
  *
@@ -286,6 +299,26 @@ export interface DataTableColumnMeta {
286
299
  * É o caso da coluna de ações: sem isso o `<table>` a esticava.
287
300
  */
288
301
  shrink?: boolean;
302
+ /**
303
+ * A célula quebra em várias linhas em vez de cortar com reticências.
304
+ *
305
+ * O padrão é cortar: numa tabela, a linha que cresce para caber um texto
306
+ * desloca todas as vizinhas, e correr o olho por uma coluna deixa de ser
307
+ * possível. Quem precisa do texto inteiro tem o detalhe da linha.
308
+ *
309
+ * Liga-se onde a célula não é texto corrido e sim um conjunto que empilha —
310
+ * marcadores, chips, uma lista de badges: ali a quebra é o desenho, e cortar
311
+ * esconderia item, não fim de frase.
312
+ */
313
+ wrap?: boolean;
314
+ /**
315
+ * O teto de largura da célula, quando o padrão não serve.
316
+ *
317
+ * Aceita qualquer largura CSS (`"12rem"`, `"320px"`). Existe porque a coluna
318
+ * que carrega o nome principal da linha merece mais espaço que a de apoio, e
319
+ * sem isso as duas cortariam no mesmo ponto.
320
+ */
321
+ maxWidth?: string;
289
322
  /**
290
323
  * A coluna existe, mas não aparece até alguém ligá-la em "Colunas".
291
324
  *
@@ -418,6 +451,38 @@ function shrinkClass(columnDef: { meta?: unknown }): string | undefined {
418
451
  return meta?.shrink ? "w-px whitespace-nowrap" : undefined;
419
452
  }
420
453
 
454
+ /**
455
+ * O teto padrão de uma célula — 24rem, a largura em que uma frase ainda se lê
456
+ * inteira e duas colunas de apoio ainda cabem ao lado.
457
+ */
458
+ const CELL_MAX_WIDTH = "24rem";
459
+
460
+ /**
461
+ * Como a célula se comporta quando o conteúdo não cabe: corta com reticências.
462
+ *
463
+ * É o padrão, e não um ajuste por tela: sem ele, cada tabela nova volta a
464
+ * crescer em altura no primeiro texto longo, e quem monta a coluna precisa
465
+ * lembrar de podá-la — lembrar é o que ninguém faz.
466
+ *
467
+ * A coluna que encolhe (`shrink`) fica fora: ela já não cresce, e um teto ali
468
+ * cortaria botão de ação. A que declara `wrap` também: nela a quebra é o
469
+ * desenho.
470
+ */
471
+ function cellStyle(columnDef: {
472
+ meta?: unknown;
473
+ }): { className?: string; style?: CSSProperties } {
474
+ const meta = columnDef.meta as DataTableColumnMeta | undefined;
475
+
476
+ if (meta?.shrink || meta?.wrap) {
477
+ return { className: shrinkClass(columnDef) };
478
+ }
479
+
480
+ return {
481
+ className: "truncate",
482
+ style: { maxWidth: meta?.maxWidth ?? CELL_MAX_WIDTH },
483
+ };
484
+ }
485
+
421
486
  export function DataTable<T extends RowData>({
422
487
  columns,
423
488
  data,
@@ -448,11 +513,14 @@ export function DataTable<T extends RowData>({
448
513
  renderGroupHeader,
449
514
  groupOrder,
450
515
  collapsibleGroups = false,
516
+ defaultCollapsedGroups,
451
517
  }: DataTableProps<T>): JSX.Element {
452
518
  const { t } = useI18n();
453
519
  const [colsOpen, setColsOpen] = useState(false);
454
520
  /** As seções fechadas, por chave. */
455
- const [collapsedGroups, setCollapsedGroups] = useState<string[]>([]);
521
+ const [collapsedGroups, setCollapsedGroups] = useState<string[]>(
522
+ () => defaultCollapsedGroups ?? [],
523
+ );
456
524
  const [filtersOpen, setFiltersOpen] = useState(false);
457
525
  /** A linha na mão e a linha embaixo do dedo, enquanto o arrasto dura. */
458
526
  const [draggedRow, setDraggedRow] = useState<string | null>(null);
@@ -1114,7 +1182,7 @@ export function DataTable<T extends RowData>({
1114
1182
  {row.getVisibleCells().map((cell) => (
1115
1183
  <TableCell
1116
1184
  key={cell.id}
1117
- className={shrinkClass(cell.column.columnDef)}
1185
+ {...cellStyle(cell.column.columnDef)}
1118
1186
  >
1119
1187
  {flexRender(
1120
1188
  cell.column.columnDef.cell,
@@ -1139,7 +1207,7 @@ export function DataTable<T extends RowData>({
1139
1207
  {grupo.headers.map((header) => (
1140
1208
  <TableCell
1141
1209
  key={header.id}
1142
- className={shrinkClass(header.column.columnDef)}
1210
+ {...cellStyle(header.column.columnDef)}
1143
1211
  >
1144
1212
  {header.isPlaceholder
1145
1213
  ? null
@@ -10,6 +10,7 @@ import {
10
10
  } from "react";
11
11
 
12
12
  import type { FilterValues } from "#core/_utils/filter";
13
+ import { safeStorage } from "#core/_utils/storage";
13
14
  import { RequestOutcome } from "#core/features/logs/enums/request-outcome.enum";
14
15
  import { logsService, RequestLog } from "#core/features/logs/services/logs.service";
15
16
  import type { FiltersState } from "#core/hooks/use-filters";
@@ -17,15 +18,22 @@ import { useLatestRequest } from "#core/hooks/use-latest-request";
17
18
  import { useListQuery } from "#core/hooks/use-list-query";
18
19
  import { useRequest } from "#core/hooks/use-request";
19
20
 
21
+ /** Onde o navegador guarda o último desfecho escolhido na barra. */
22
+ export const LOGS_OUTCOME_STORAGE_KEY = "tab:logs-outcome";
23
+
20
24
  /**
21
- * A tela abre no sucesso, não em tudo.
25
+ * A tela abre no último desfecho escolhido — e no sucesso, na primeira vez.
22
26
  *
23
- * Fora do módulo para não recriar o objeto a cada render: ele é dependência do
24
- * efeito de hidratação, e um literal novo por render o refaria sem parar.
27
+ * O desfecho é filtro (§11) e por isso vive na URL, que o F5 e o link já
28
+ * carregam. O que a URL não cobre é voltar pelo menu: entra o navegador,
29
+ * como nas abas. Lido uma vez por montagem porque é dependência do efeito de
30
+ * hidratação do filtro, e um literal novo por render o refaria sem parar.
25
31
  */
26
- const DEFAULT_FILTER: FilterValues = {
27
- outcome: { values: [String(RequestOutcome.SUCCESS)] },
28
- };
32
+ const defaultFilter = (): FilterValues => ({
33
+ outcome: {
34
+ values: [safeStorage.get(LOGS_OUTCOME_STORAGE_KEY) ?? String(RequestOutcome.SUCCESS)],
35
+ },
36
+ });
29
37
 
30
38
  export interface UseRequestLogsResult {
31
39
  rows: RequestLog[];
@@ -46,11 +54,8 @@ export function useRequestLogs(): UseRequestLogsResult {
46
54
  const { run, loading } = useRequest();
47
55
 
48
56
  const latest = useLatestRequest();
49
- const list = useListQuery(
50
- "/audit/requests/filter-schema",
51
- 20,
52
- DEFAULT_FILTER,
53
- );
57
+ const [initialFilter] = useState(defaultFilter);
58
+ const list = useListQuery("/audit/requests/filter-schema", 20, initialFilter);
54
59
 
55
60
  const [rows, setRows] = useState<RequestLog[]>([]);
56
61
  const [total, setTotal] = useState(0);
@@ -10,6 +10,7 @@ import { Eye } from "lucide-react";
10
10
  import type { JSX } from "react";
11
11
  import { useMemo, useState } from "react";
12
12
 
13
+ import { safeStorage } from "#core/_utils/storage";
13
14
  import {
14
15
  Badge,
15
16
  Button,
@@ -29,7 +30,10 @@ import {
29
30
  ErrorTypeLabelKey,
30
31
  } from "#core/features/logs/enums/error-type.enum";
31
32
  import { RequestOutcomeLabelKey } from "#core/features/logs/enums/request-outcome.enum";
32
- import { useRequestLogs } from "#core/features/logs/hooks/use-request-logs";
33
+ import {
34
+ LOGS_OUTCOME_STORAGE_KEY,
35
+ useRequestLogs,
36
+ } from "#core/features/logs/hooks/use-request-logs";
33
37
  import { RequestLog } from "#core/features/logs/services/logs.service";
34
38
 
35
39
  function statusVariant(
@@ -72,6 +76,8 @@ export function RequestLogsPanel(): JSX.Element {
72
76
  outcome: { values: [value] },
73
77
  });
74
78
  logs.setPage(0);
79
+ // O navegador lembra para a próxima visita pelo menu; a URL já leva o resto.
80
+ safeStorage.set(LOGS_OUTCOME_STORAGE_KEY, value);
75
81
  };
76
82
 
77
83
  const outcomeOptions = useMemo<SegmentedOption[]>(
@@ -12,6 +12,7 @@ import {
12
12
  } from "#core/features/queues/services/queues.service";
13
13
  import { useLatestRequest } from "#core/hooks/use-latest-request";
14
14
  import { useRequest } from "#core/hooks/use-request";
15
+ import { useTabState } from "#core/hooks/use-tab-state";
15
16
 
16
17
  const EMPTY_COUNTS: QueueCounts = {
17
18
  waiting: 0,
@@ -47,6 +48,9 @@ export interface UseQueueJobsResult {
47
48
  * Abre em `failed` de propósito: quem entra aqui está atrás do que deu errado.
48
49
  * O que está andando bem não precisa de tela.
49
50
  */
51
+ const QUEUE_JOB_STATES = Object.values(QueueJobState);
52
+ const QUEUE_NAMES = Object.values(QueueName);
53
+
50
54
  export function useQueueJobs(): UseQueueJobsResult {
51
55
  const { run, loading } = useRequest();
52
56
  const latest = useLatestRequest();
@@ -56,8 +60,21 @@ export function useQueueJobs(): UseQueueJobsResult {
56
60
  const [total, setTotal] = useState(0);
57
61
  const [page, setPage] = useState(0);
58
62
  const [pageSize, setPageSize] = useState(20);
59
- const [state, setStateValue] = useState<QueueJobState>(QueueJobState.FAILED);
60
- const [queue, setQueueValue] = useState<QueueName>(QueueName.JOBS);
63
+ // Os dois seletores ficam salvos — na URL para o F5 e o link, no navegador
64
+ // para o menu — cada um com o próprio parâmetro, que na mesma tela não
65
+ // podem dividir o `?tab=`.
66
+ const { tab: state, setTab: setStateValue } = useTabState(
67
+ "queues-state",
68
+ QUEUE_JOB_STATES,
69
+ QueueJobState.FAILED,
70
+ { param: "state" },
71
+ );
72
+ const { tab: queue, setTab: setQueueValue } = useTabState(
73
+ "queues-queue",
74
+ QUEUE_NAMES,
75
+ QueueName.JOBS,
76
+ { param: "queue" },
77
+ );
61
78
  // Sem ordem escolhida, a lista sai na ordem do Redis — que é a mais recente
62
79
  // primeiro e não custa varredura nenhuma no backend.
63
80
  const [sorting, setSorting] = useState<SortingState>([]);
@@ -8,6 +8,7 @@ import { ActionsPanel } from "#core/features/rbac/panels/actions-panel";
8
8
  import { PermissionsPanel } from "#core/features/rbac/panels/permissions-panel";
9
9
  import { ResourcesPanel } from "#core/features/rbac/panels/resources-panel";
10
10
  import { ScopesPanel } from "#core/features/rbac/panels/scopes-panel";
11
+ import { useTabState } from "#core/hooks/use-tab-state";
11
12
 
12
13
  /**
13
14
  * A mecânica do RBAC — as peças de que um código `resource:action:scope` é
@@ -49,6 +50,14 @@ export function DefinitionsPanel(): JSX.Element {
49
50
  },
50
51
  ].filter((tab) => hasPermission(tab.perm));
51
52
 
53
+ // `sub`, e não `tab`: este está dentro da aba Definições do RBAC, que já usa `?tab=`.
54
+ const { tab, setTab } = useTabState(
55
+ "rbac-definitions",
56
+ tabs.map((item) => item.value),
57
+ undefined,
58
+ { param: "sub" },
59
+ );
60
+
52
61
  if (tabs.length === 0) {
53
62
  return <></>;
54
63
  }
@@ -56,7 +65,7 @@ export function DefinitionsPanel(): JSX.Element {
56
65
  return (
57
66
  <div className="space-y-4">
58
67
  <p className="text-sm text-muted-foreground">{t("rbac.definitionsHint")}</p>
59
- <Tabs defaultValue={tabs[0].value}>
68
+ <Tabs value={tab} onValueChange={setTab}>
60
69
  <TabsList>
61
70
  {tabs.map((tab) => (
62
71
  <TabsTrigger key={tab.value} value={tab.value}>
@@ -5,7 +5,16 @@ import { useCallback, useLayoutEffect, useRef, useState } from "react";
5
5
  import { safeStorage } from "#core/_utils/storage";
6
6
 
7
7
  const STORAGE_PREFIX = "tab:";
8
- const QUERY_PARAM = "tab";
8
+ const DEFAULT_PARAM = "tab";
9
+
10
+ export interface UseTabStateOptions {
11
+ /**
12
+ * O nome do parâmetro na URL. `tab` por padrão; muda quando a tela tem mais
13
+ * de um seletor (a fila e a situação, em Filas) ou abas dentro de abas
14
+ * (Definições dentro do RBAC) — dois com o mesmo nome se sobrescreveriam.
15
+ */
16
+ param?: string;
17
+ }
9
18
 
10
19
  export interface UseTabStateResult<T extends string> {
11
20
  tab: T;
@@ -16,13 +25,13 @@ export interface UseTabStateResult<T extends string> {
16
25
  setTab: (next: string) => void;
17
26
  }
18
27
 
19
- const readFromUrl = (): string | null =>
20
- new URLSearchParams(window.location.search).get(QUERY_PARAM);
28
+ const readFromUrl = (param: string): string | null =>
29
+ new URLSearchParams(window.location.search).get(param);
21
30
 
22
- /** Troca só o `?tab=`, preservando o resto — o `?filter=` da listagem mora ao lado. */
23
- const writeToUrl = (value: string): void => {
31
+ /** Troca só o próprio parâmetro, preservando o resto — o `?filter=` da listagem mora ao lado. */
32
+ const writeToUrl = (param: string, value: string): void => {
24
33
  const params = new URLSearchParams(window.location.search);
25
- params.set(QUERY_PARAM, value);
34
+ params.set(param, value);
26
35
  window.history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
27
36
  };
28
37
 
@@ -50,6 +59,7 @@ export function useTabState<T extends string>(
50
59
  storageKey: string,
51
60
  tabs: readonly T[],
52
61
  defaultTab: T = tabs[0],
62
+ { param = DEFAULT_PARAM }: UseTabStateOptions = {},
53
63
  ): UseTabStateResult<T> {
54
64
  const [tab, setStoredTab] = useState<T>(defaultTab);
55
65
 
@@ -74,7 +84,7 @@ export function useTabState<T extends string>(
74
84
  // O de layout corre antes de o navegador desenhar — a primeira imagem já é
75
85
  // a aba salva. No servidor o React o ignora, que é o que se quer.
76
86
  useLayoutEffect(() => {
77
- const fromUrl = readFromUrl();
87
+ const fromUrl = readFromUrl(param);
78
88
  const fromStorage = safeStorage.get(`${STORAGE_PREFIX}${storageKey}`);
79
89
  const resolved = isTab(fromUrl) ? fromUrl : isTab(fromStorage) ? fromStorage : defaultTab;
80
90
 
@@ -84,9 +94,9 @@ export function useTabState<T extends string>(
84
94
  // isso, copiar o link depois de cair na aba do storage levaria à padrão.
85
95
  // Lista vazia (nenhuma aba permitida) não tem o que dizer.
86
96
  if (resolved !== undefined && fromUrl !== resolved) {
87
- writeToUrl(resolved);
97
+ writeToUrl(param, resolved);
88
98
  }
89
- }, [storageKey, defaultTab, isTab]);
99
+ }, [storageKey, defaultTab, isTab, param]);
90
100
 
91
101
  const setTab = useCallback(
92
102
  (next: string): void => {
@@ -95,10 +105,10 @@ export function useTabState<T extends string>(
95
105
  }
96
106
 
97
107
  setStoredTab(next);
98
- writeToUrl(next);
108
+ writeToUrl(param, next);
99
109
  safeStorage.set(`${STORAGE_PREFIX}${storageKey}`, next);
100
110
  },
101
- [storageKey, isTab],
111
+ [storageKey, isTab, param],
102
112
  );
103
113
 
104
114
  return { tab, setTab };
package/src/index.ts CHANGED
@@ -121,7 +121,7 @@ export {
121
121
  TAB_SHORTCUT_DIGITS,
122
122
  useShortcut,
123
123
  } from "#core/hooks/use-shortcut";
124
- export type { UseTabStateResult } from "#core/hooks/use-tab-state";
124
+ export type { UseTabStateOptions, UseTabStateResult } from "#core/hooks/use-tab-state";
125
125
  export { useTabState } from "#core/hooks/use-tab-state";
126
126
  export type { TableView, UseTableViewResult } from "#core/hooks/use-table-view";
127
127
  export { useTableView } from "#core/hooks/use-table-view";