rl-core-front 0.18.7 → 0.18.9
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 +1 -1
- package/src/_services/api/axios.factory.ts +3 -0
- package/src/_utils/storage.ts +31 -0
- package/src/components/app-shell.tsx +173 -114
- package/src/components/ui/column-visibility-modal.tsx +11 -4
- package/src/components/ui/combobox.tsx +46 -0
- package/src/components/ui/filter-sheet.tsx +9 -3
- package/src/contexts/auth-context.tsx +6 -1
- package/src/contexts/color-mode-context.tsx +29 -22
- package/src/contexts/i18n-context.tsx +5 -15
- package/src/contexts/socket-context.tsx +10 -1
- package/src/features/audit/components/record-audit-button.tsx +3 -1
- package/src/features/audit/hooks/use-audit-trail.ts +3 -1
- package/src/features/logs/hooks/use-request-logs.ts +3 -1
- package/src/features/notifications/hooks/use-notifications.ts +3 -1
- package/src/features/profile/profile-screen.tsx +3 -1
- package/src/features/queues/hooks/use-queue-jobs.ts +3 -1
- package/src/features/rbac/panels/catalog-panel.tsx +3 -1
- package/src/features/rbac/panels/groups-panel.tsx +3 -1
- package/src/features/rbac/panels/permissions-panel.tsx +3 -1
- package/src/features/rbac/panels/roles-panel.tsx +8 -4
- package/src/features/recovery/reset-password-screen.tsx +17 -5
- package/src/features/users/hooks/use-users.ts +3 -1
- package/src/hooks/use-column-visibility.ts +30 -18
- package/src/hooks/use-document-title.ts +49 -18
- package/src/hooks/use-filter-schema.ts +3 -1
- package/src/hooks/use-filters.ts +36 -10
- package/src/hooks/use-is-hydrated.ts +21 -0
- package/src/hooks/use-job-progress.ts +11 -4
- package/src/hooks/use-list-query.ts +9 -4
- package/src/hooks/use-stored-value.ts +35 -0
- package/src/hooks/use-tab-state.ts +62 -28
- package/src/hooks/use-table-view.ts +15 -17
|
@@ -68,6 +68,7 @@ export function SocketProvider({
|
|
|
68
68
|
}): JSX.Element {
|
|
69
69
|
const { user, refreshProfile } = useAuth();
|
|
70
70
|
const [status, setStatus] = useState<SocketStatus>("disconnected");
|
|
71
|
+
const [lastUserId, setLastUserId] = useState<string | undefined>(undefined);
|
|
71
72
|
const [reconnectCount, setReconnectCount] = useState(0);
|
|
72
73
|
const socketRef = useRef<Socket | null>(null);
|
|
73
74
|
|
|
@@ -85,12 +86,20 @@ export function SocketProvider({
|
|
|
85
86
|
// socket.io entraria em ciclo de retentativa.
|
|
86
87
|
const userId = user?.id;
|
|
87
88
|
|
|
89
|
+
// Quem manda no estado é a sessão: com um usuário novo há uma conexão nova a
|
|
90
|
+
// abrir, e o "conectando" é consequência disso — não do efeito, que só vai
|
|
91
|
+
// rodar depois da pintura e deixaria a barra dizendo "desconectado" por um
|
|
92
|
+
// quadro.
|
|
93
|
+
if (userId !== lastUserId) {
|
|
94
|
+
setLastUserId(userId);
|
|
95
|
+
setStatus(userId ? "connecting" : "disconnected");
|
|
96
|
+
}
|
|
97
|
+
|
|
88
98
|
useEffect(() => {
|
|
89
99
|
if (!userId) {
|
|
90
100
|
return;
|
|
91
101
|
}
|
|
92
102
|
|
|
93
|
-
setStatus("connecting");
|
|
94
103
|
const socket = io(`${SOCKET_URL}${NAMESPACE}`, {
|
|
95
104
|
withCredentials: true,
|
|
96
105
|
transports: ["websocket"],
|
|
@@ -44,7 +44,9 @@ export function useNotifications(): UseNotificationsResult {
|
|
|
44
44
|
// Carga inicial e, de novo, a cada reconexão: enquanto o socket esteve fora
|
|
45
45
|
// chegaram notificações que ninguém empurrou para esta aba.
|
|
46
46
|
useEffect(() => {
|
|
47
|
-
void
|
|
47
|
+
void (async (): Promise<void> => {
|
|
48
|
+
await fetch();
|
|
49
|
+
})();
|
|
48
50
|
}, [fetch, reconnectCount]);
|
|
49
51
|
|
|
50
52
|
// Sem toast aqui: o badge do sino já é o aviso, e disparar os dois pelo mesmo
|
|
@@ -116,7 +116,9 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
116
116
|
}, [latest, run, state, queue, page, pageSize, sorting]);
|
|
117
117
|
|
|
118
118
|
useEffect(() => {
|
|
119
|
-
void
|
|
119
|
+
void (async (): Promise<void> => {
|
|
120
|
+
await fetch();
|
|
121
|
+
})();
|
|
120
122
|
}, [fetch]);
|
|
121
123
|
|
|
122
124
|
const setState = (next: QueueJobState): void => {
|
|
@@ -76,7 +76,9 @@ export function GroupsPanel(): JSX.Element {
|
|
|
76
76
|
if (u) {setCandidates(u.items);}
|
|
77
77
|
};
|
|
78
78
|
useEffect(() => {
|
|
79
|
-
void
|
|
79
|
+
void (async (): Promise<void> => {
|
|
80
|
+
await load();
|
|
81
|
+
})();
|
|
80
82
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
81
83
|
}, []);
|
|
82
84
|
|
|
@@ -4,7 +4,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
|
|
4
4
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
|
5
5
|
import type { JSX } from "react";
|
|
6
6
|
import { useEffect, useMemo, useState } from "react";
|
|
7
|
-
import { useForm } from "react-hook-form";
|
|
7
|
+
import { useForm, useWatch } from "react-hook-form";
|
|
8
8
|
import * as yup from "yup";
|
|
9
9
|
|
|
10
10
|
import {
|
|
@@ -62,14 +62,16 @@ export function RolesPanel(): JSX.Element {
|
|
|
62
62
|
handleSubmit,
|
|
63
63
|
reset,
|
|
64
64
|
setValue,
|
|
65
|
-
|
|
65
|
+
control,
|
|
66
66
|
formState: { errors },
|
|
67
67
|
} = useForm<RoleFormValues>({
|
|
68
68
|
resolver: yupResolver(schema),
|
|
69
69
|
defaultValues: { name: "", description: "", permissionIds: [] },
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
// `useWatch` e não `watch`: o segundo devolve uma função nova a cada render,
|
|
73
|
+
// e com ela o React Compiler desiste de memoizar o painel inteiro.
|
|
74
|
+
const permissionIds = useWatch({ control, name: "permissionIds" });
|
|
73
75
|
|
|
74
76
|
const load = async (): Promise<void> => {
|
|
75
77
|
const [r, p] = await Promise.all([
|
|
@@ -80,7 +82,9 @@ export function RolesPanel(): JSX.Element {
|
|
|
80
82
|
if (p) {setPerms(p);}
|
|
81
83
|
};
|
|
82
84
|
useEffect(() => {
|
|
83
|
-
void
|
|
85
|
+
void (async (): Promise<void> => {
|
|
86
|
+
await load();
|
|
87
|
+
})();
|
|
84
88
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
89
|
}, []);
|
|
86
90
|
|
|
@@ -24,6 +24,9 @@ import {
|
|
|
24
24
|
import { useI18n } from "#core/contexts";
|
|
25
25
|
import { useRequest } from "#core/hooks/use-request";
|
|
26
26
|
|
|
27
|
+
/** O que se mostra quando o link não tem token ou o servidor o recusa. */
|
|
28
|
+
const INVALID_TOKEN: PasswordTokenCheck = { valid: false, status: "invalid" };
|
|
29
|
+
|
|
27
30
|
const INVALID_MESSAGE_KEY: Record<PasswordTokenStatus, string> = {
|
|
28
31
|
valid: "recovery.linkInvalid",
|
|
29
32
|
invalid: "recovery.linkInvalid",
|
|
@@ -38,22 +41,31 @@ export function ResetPasswordScreen(): JSX.Element {
|
|
|
38
41
|
const { run, loading, error } = useRequest({ silent: true });
|
|
39
42
|
const { t } = useI18n();
|
|
40
43
|
const [done, setDone] = useState(false);
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
// Link sem token nenhum já nasce recusado: não há o que consultar, e passar
|
|
45
|
+
// por um efeito mostraria o "verificando" de um link que nunca foi válido.
|
|
46
|
+
const [check, setCheck] = useState<PasswordTokenCheck | null>(
|
|
47
|
+
token ? null : INVALID_TOKEN,
|
|
48
|
+
);
|
|
49
|
+
const [checking, setChecking] = useState(Boolean(token));
|
|
50
|
+
const [lastToken, setLastToken] = useState(token);
|
|
51
|
+
|
|
52
|
+
if (token !== lastToken) {
|
|
53
|
+
setLastToken(token);
|
|
54
|
+
setCheck(token ? null : INVALID_TOKEN);
|
|
55
|
+
setChecking(Boolean(token));
|
|
56
|
+
}
|
|
43
57
|
|
|
44
58
|
// Valida o link ANTES de exibir o formulário — token ausente, expirado,
|
|
45
59
|
// já usado ou inexistente nunca chega a renderizar os campos de senha.
|
|
46
60
|
useEffect(() => {
|
|
47
61
|
if (!token) {
|
|
48
|
-
setCheck({ valid: false, status: "invalid" });
|
|
49
|
-
setChecking(false);
|
|
50
62
|
return;
|
|
51
63
|
}
|
|
52
64
|
let alive = true;
|
|
53
65
|
authService
|
|
54
66
|
.inspectPasswordToken(token)
|
|
55
67
|
.then((res) => alive && setCheck(res))
|
|
56
|
-
.catch(() => alive && setCheck(
|
|
68
|
+
.catch(() => alive && setCheck(INVALID_TOKEN))
|
|
57
69
|
.finally(() => alive && setChecking(false));
|
|
58
70
|
return () => {
|
|
59
71
|
alive = false;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback,
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
|
|
5
5
|
import { safeStorage } from "#core/_utils/storage";
|
|
6
|
+
import { useStoredValue } from "#core/hooks/use-stored-value";
|
|
6
7
|
|
|
7
8
|
const STORAGE_PREFIX = "cols:";
|
|
8
9
|
|
|
10
|
+
/** Nada escondido: uma referência só, para não re-renderizar quem observa. */
|
|
11
|
+
const NONE: string[] = [];
|
|
12
|
+
|
|
9
13
|
export interface UseColumnVisibilityResult {
|
|
10
14
|
hiddenColumns: string[];
|
|
11
15
|
isColumnVisible: (columnId: string) => boolean;
|
|
@@ -24,20 +28,29 @@ export function useColumnVisibility(
|
|
|
24
28
|
storageKey?: string,
|
|
25
29
|
defaultHidden: string[] = [],
|
|
26
30
|
): UseColumnVisibilityResult {
|
|
27
|
-
const
|
|
31
|
+
const key = storageKey ? `${STORAGE_PREFIX}${storageKey}` : undefined;
|
|
32
|
+
const stored = useStoredValue(key);
|
|
33
|
+
const [sessionHidden, setSessionHidden] = useState<string[]>(defaultHidden);
|
|
34
|
+
|
|
35
|
+
// A lista do primeiro render: vinda como parâmetro, ela é outro array a cada
|
|
36
|
+
// render, e devolvê-la direto faria a tabela inteira se achar mudada.
|
|
37
|
+
const [fallback] = useState(defaultHidden);
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
if (!
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
const hiddenColumns = useMemo<string[]>(() => {
|
|
40
|
+
if (!key) {
|
|
41
|
+
return sessionHidden;
|
|
42
|
+
}
|
|
43
|
+
if (!stored) {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
33
46
|
try {
|
|
34
|
-
// O `try`
|
|
35
|
-
//
|
|
36
|
-
|
|
47
|
+
// O `try` é do JSON: o acesso ao armazenamento já é seguro, mas o
|
|
48
|
+
// conteúdo pode estar corrompido por uma versão anterior da tela.
|
|
49
|
+
return JSON.parse(stored) as string[];
|
|
37
50
|
} catch {
|
|
38
|
-
|
|
51
|
+
return NONE;
|
|
39
52
|
}
|
|
40
|
-
}, [
|
|
53
|
+
}, [key, stored, sessionHidden, fallback]);
|
|
41
54
|
|
|
42
55
|
const isColumnVisible = useCallback(
|
|
43
56
|
(columnId: string) => !hiddenColumns.includes(columnId),
|
|
@@ -46,14 +59,13 @@ export function useColumnVisibility(
|
|
|
46
59
|
|
|
47
60
|
const saveHiddenColumns = useCallback(
|
|
48
61
|
(nextHidden: string[]) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
62
|
+
if (!key) {
|
|
63
|
+
setSessionHidden(nextHidden);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
safeStorage.set(key, JSON.stringify(nextHidden));
|
|
55
67
|
},
|
|
56
|
-
[
|
|
68
|
+
[key],
|
|
57
69
|
);
|
|
58
70
|
|
|
59
71
|
return { hiddenColumns, isColumnVisible, saveHiddenColumns };
|
|
@@ -2,39 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
|
|
5
|
+
/** O que fica entre o nome do sistema e o da tela. */
|
|
6
|
+
const SEPARATOR = " - ";
|
|
7
|
+
|
|
5
8
|
/**
|
|
6
|
-
* O
|
|
9
|
+
* O nome do sistema — o que o `metadata` do projeto mandou.
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* Sai do próprio `document.title`, e por isso o projeto não precisa dizer duas
|
|
12
|
+
* vezes como se chama (o prefixo "Dev - " que ele põe no `metadata` vem junto
|
|
13
|
+
* de graça). É relido sempre que o título muda por fora: quem escreve por fora
|
|
14
|
+
* é o App Router, e o que ele escreve é justamente o `metadata` da rota.
|
|
11
15
|
*/
|
|
12
|
-
let baseTitle
|
|
16
|
+
let baseTitle = "";
|
|
13
17
|
|
|
14
|
-
/** O que
|
|
15
|
-
|
|
18
|
+
/** O último título que este hook escreveu — é como se reconhece o de fora. */
|
|
19
|
+
let applied = "";
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* A aba do navegador diz em que tela se está.
|
|
19
23
|
*
|
|
20
24
|
* Com cinco abas do sistema abertas, todas se chamavam só pelo nome dele — e
|
|
21
|
-
* achar a do financeiro era clicar uma a uma.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
25
|
+
* achar a do financeiro era clicar uma a uma. O sistema vem primeiro e a tela
|
|
26
|
+
* depois: é como se lê um caminho, e a aba do sistema continua reconhecível
|
|
27
|
+
* pelo começo, que é onde o olho procura.
|
|
28
|
+
*
|
|
29
|
+
* Escrever uma vez não basta, e é por isso que há um `MutationObserver`: o
|
|
30
|
+
* App Router reescreve o `<title>` do `metadata` **depois** do efeito — na
|
|
31
|
+
* hidratação e a cada navegação —, então o nome da tela aparecia e sumia no
|
|
32
|
+
* mesmo quadro. Entre uma rota e outra o `<head>` chega a ficar sem `<title>`
|
|
33
|
+
* nenhum; esse vazio não vira `baseTitle`, senão o nome do sistema se perdia
|
|
34
|
+
* e a aba passava a mostrar só a tela.
|
|
24
35
|
*
|
|
25
36
|
* `null` devolve o título de origem — é o que a tela sem migalha faz.
|
|
26
37
|
*/
|
|
27
38
|
export const useDocumentTitle = (title: string | null): void => {
|
|
28
39
|
useEffect(() => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
const apply = (): void => {
|
|
41
|
+
const current = document.title;
|
|
42
|
+
|
|
43
|
+
if (current && current !== applied) {
|
|
44
|
+
baseTitle = current;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const next =
|
|
48
|
+
title && baseTitle ? `${baseTitle}${SEPARATOR}${title}` : title || baseTitle;
|
|
49
|
+
|
|
50
|
+
// Sem reescrever o que já está lá: a escrita dispara o observador, e
|
|
51
|
+
// comparar aqui é o que impede o laço.
|
|
52
|
+
if (!next || next === current) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
applied = next;
|
|
57
|
+
document.title = next;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
apply();
|
|
32
61
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
const observer = new MutationObserver(apply);
|
|
63
|
+
observer.observe(document.head, {
|
|
64
|
+
childList: true,
|
|
65
|
+
subtree: true,
|
|
66
|
+
characterData: true,
|
|
67
|
+
});
|
|
37
68
|
|
|
38
|
-
|
|
69
|
+
return () => observer.disconnect();
|
|
39
70
|
}, [title]);
|
|
40
71
|
};
|
package/src/hooks/use-filters.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
|
|
5
5
|
import type { AdvancedGroup, AdvancedValidation } from "#core/_utils/advanced-filter";
|
|
6
6
|
import {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import type { FilterField, FilterValues } from "#core/_utils/filter";
|
|
14
14
|
import { buildFilter, parseFilterValues } from "#core/_utils/filter";
|
|
15
15
|
import { useFilterSchema } from "#core/hooks/use-filter-schema";
|
|
16
|
+
import { useIsHydrated } from "#core/hooks/use-is-hydrated";
|
|
16
17
|
|
|
17
18
|
export type FilterMode = "simple" | "advanced";
|
|
18
19
|
|
|
@@ -83,13 +84,17 @@ export function useFilters(
|
|
|
83
84
|
const [tree, setTreeState] = useState<AdvancedGroup | null>(null);
|
|
84
85
|
const [hydrated, setHydrated] = useState(false);
|
|
85
86
|
|
|
87
|
+
const isClient = useIsHydrated();
|
|
88
|
+
|
|
86
89
|
// Hidrata da URL uma vez, quando o catálogo chega: sem ele não dá para saber
|
|
87
90
|
// o tipo de cada campo, e portanto nem como ler o JSON. O combinador no topo
|
|
88
91
|
// do JSON é o que revela que o filtro veio do modo avançado.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
//
|
|
93
|
+
// No render, e não num efeito: a listagem espera o `hydrated` para buscar, e
|
|
94
|
+
// por um efeito a espera custaria um render a mais em toda tela com filtro.
|
|
95
|
+
// A querystring só é lida com `isClient` — no servidor não há `window`.
|
|
96
|
+
if (isClient && !hydrated && !loading) {
|
|
97
|
+
setHydrated(true);
|
|
93
98
|
const raw = new URLSearchParams(window.location.search).get(QUERY_PARAM);
|
|
94
99
|
if (raw && isAdvancedFilter(raw)) {
|
|
95
100
|
const parsed = parseAdvancedFilter(raw, schema);
|
|
@@ -100,13 +105,34 @@ export function useFilters(
|
|
|
100
105
|
} else if (raw) {
|
|
101
106
|
setValuesState(parseFilterValues(raw, schema));
|
|
102
107
|
} else if (defaultValues) {
|
|
103
|
-
// Espelha na URL como qualquer outro filtro: sem isso a tela mostraria
|
|
104
|
-
// um estado que o link não carrega.
|
|
105
108
|
setValuesState(defaultValues);
|
|
106
|
-
writeToUrl(buildFilter(defaultValues, schema));
|
|
107
109
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* O padrão também vai para a URL: sem isso a tela mostraria um estado que o
|
|
114
|
+
* link não carrega.
|
|
115
|
+
*
|
|
116
|
+
* Escrever é efeito colateral, então mora num efeito — e só na primeira vez,
|
|
117
|
+
* guardado pelo `ref`: depois de hidratado, limpar os filtros deixa a URL sem
|
|
118
|
+
* `?filter=`, e repetir a escrita aqui faria o padrão voltar sozinho.
|
|
119
|
+
*/
|
|
120
|
+
const defaultApplied = useRef(false);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!hydrated || defaultApplied.current) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
defaultApplied.current = true;
|
|
127
|
+
|
|
128
|
+
if (!defaultValues) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (new URLSearchParams(window.location.search).get(QUERY_PARAM)) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
writeToUrl(buildFilter(defaultValues, schema));
|
|
135
|
+
}, [hydrated, defaultValues, schema]);
|
|
110
136
|
|
|
111
137
|
const setValues = useCallback(
|
|
112
138
|
(next: FilterValues): void => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
/** Nunca muda depois de hidratar, então não há a que se inscrever. */
|
|
6
|
+
const subscribeNever = (): (() => void) => () => {};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Já estamos no navegador, com o que só existe lá?
|
|
10
|
+
*
|
|
11
|
+
* Serve a quem precisa esperar a hidratação para agir — escrever na URL,
|
|
12
|
+
* aplicar classe no `<html>` — sem copiar nada para um estado dentro de um
|
|
13
|
+
* efeito. No servidor é `false`; no cliente vira `true` junto com o primeiro
|
|
14
|
+
* render que já enxerga `window`.
|
|
15
|
+
*/
|
|
16
|
+
export const useIsHydrated = (): boolean =>
|
|
17
|
+
useSyncExternalStore(
|
|
18
|
+
subscribeNever,
|
|
19
|
+
() => true,
|
|
20
|
+
() => false,
|
|
21
|
+
);
|
|
@@ -98,10 +98,15 @@ export function useJobProgress<TSummary = unknown>(
|
|
|
98
98
|
IDLE as UseJobProgressResult<TSummary>,
|
|
99
99
|
);
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
const [lastJobId, setLastJobId] = useState(jobId);
|
|
102
|
+
|
|
103
|
+
// Job novo começa do zero: sem isto a barra do anterior ficaria em 100%. O
|
|
104
|
+
// ajuste é no render, então nem por um quadro a barra do job anterior
|
|
105
|
+
// aparece sob o novo.
|
|
106
|
+
if (jobId !== lastJobId) {
|
|
107
|
+
setLastJobId(jobId);
|
|
103
108
|
setResult(IDLE as UseJobProgressResult<TSummary>);
|
|
104
|
-
}
|
|
109
|
+
}
|
|
105
110
|
|
|
106
111
|
const sync = useCallback(async (): Promise<void> => {
|
|
107
112
|
if (!jobId) {
|
|
@@ -132,7 +137,9 @@ export function useJobProgress<TSummary = unknown>(
|
|
|
132
137
|
}, [jobId]);
|
|
133
138
|
|
|
134
139
|
useEffect(() => {
|
|
135
|
-
void
|
|
140
|
+
void (async (): Promise<void> => {
|
|
141
|
+
await sync();
|
|
142
|
+
})();
|
|
136
143
|
}, [sync, reconnectCount]);
|
|
137
144
|
|
|
138
145
|
useEffect(() => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { SortingState } from "@tanstack/react-table";
|
|
4
4
|
import type { Dispatch, SetStateAction } from "react";
|
|
5
|
-
import {
|
|
5
|
+
import { useMemo, useState } from "react";
|
|
6
6
|
|
|
7
7
|
import type { ListQuery } from "#core/_services/api/list-query";
|
|
8
8
|
import type { FilterValues } from "#core/_utils/filter";
|
|
@@ -50,11 +50,16 @@ export function useListQuery(
|
|
|
50
50
|
const [pageSize, setPageSize] = useState(initialPageSize);
|
|
51
51
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
52
52
|
|
|
53
|
+
const [lastFilter, setLastFilter] = useState(filter);
|
|
54
|
+
|
|
53
55
|
// Trocar o filtro volta para a primeira página: a página 3 de um resultado
|
|
54
|
-
// que agora tem uma página só voltaria vazia.
|
|
55
|
-
|
|
56
|
+
// que agora tem uma página só voltaria vazia. O ajuste é no render, então a
|
|
57
|
+
// busca já sai com a página certa — por um efeito, a primeira query iria com
|
|
58
|
+
// a página antiga e seria descartada.
|
|
59
|
+
if (filter !== lastFilter) {
|
|
60
|
+
setLastFilter(filter);
|
|
56
61
|
setPage(0);
|
|
57
|
-
}
|
|
62
|
+
}
|
|
58
63
|
|
|
59
64
|
const sort = sorting[0];
|
|
60
65
|
const query = useMemo<ListQuery>(
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
import { safeStorage } from "#core/_utils/storage";
|
|
6
|
+
|
|
7
|
+
const noop = (): void => {};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* O que está guardado numa chave do armazenamento, lido no próprio render.
|
|
11
|
+
*
|
|
12
|
+
* A preferência salva não é estado da tela: é estado de fora dela, e copiá-la
|
|
13
|
+
* para um `useState` dentro de um efeito custava um render a mais e mostrava o
|
|
14
|
+
* padrão por um quadro. `useSyncExternalStore` resolve as duas pontas — no
|
|
15
|
+
* servidor devolve `null`, e no cliente o valor certo já no primeiro render
|
|
16
|
+
* depois da hidratação.
|
|
17
|
+
*
|
|
18
|
+
* Devolve a string crua de propósito: é o que compara por identidade sem
|
|
19
|
+
* truque. Quem guarda JSON faz o `parse` num `useMemo`, senão cada leitura
|
|
20
|
+
* seria um objeto novo e o React re-renderizaria para sempre.
|
|
21
|
+
*/
|
|
22
|
+
export const useStoredValue = (key: string | undefined): string | null => {
|
|
23
|
+
const subscribe = useCallback(
|
|
24
|
+
(onChange: () => void): (() => void) =>
|
|
25
|
+
key ? safeStorage.subscribe(key, onChange) : noop,
|
|
26
|
+
[key],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const getSnapshot = useCallback(
|
|
30
|
+
(): string | null => (key ? safeStorage.get(key) : null),
|
|
31
|
+
[key],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => null);
|
|
35
|
+
};
|