@softize/opus 25.4.0 → 25.4.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
CHANGED
|
@@ -7,6 +7,18 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
7
7
|
`opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
|
|
8
8
|
mudança cobra do seu código.
|
|
9
9
|
|
|
10
|
+
## 25.4.1 — 2026-10-05
|
|
11
|
+
|
|
12
|
+
Corrige a busca remota de filtros `lookup` quando a `ActionList` também recebe opções iniciais em
|
|
13
|
+
`filterOptions`, como nas dimensões disponíveis para agrupamento. Enquanto há um termo de busca, as
|
|
14
|
+
opções iniciais agora são filtradas localmente e combinadas com o resultado do servidor; antes,
|
|
15
|
+
elas permaneciam todas visíveis e podiam esconder o efeito da busca. Não há migração para o
|
|
16
|
+
consumidor. Valores já selecionados preservam o rótulo durante e depois da busca, inclusive quando
|
|
17
|
+
a opção veio apenas do servidor. Fechar o seletor limpa o termo também no lookup remoto. No
|
|
18
|
+
`Select` com `onSearch`, esse reset é comunicado imediatamente com `""` apenas quando uma busca não
|
|
19
|
+
vazia chegou a ser enviada; abrir e fechar sem digitar não repete a consulta inicial. Respostas
|
|
20
|
+
remotas fora de ordem não substituem mais o resultado da consulta mais recente.
|
|
21
|
+
|
|
10
22
|
## 25.4.0 — 2026-10-05
|
|
11
23
|
|
|
12
24
|
`ListAction.grouping.fields` passa a aceitar filtros `select` ou `lookup` cujas opções completas
|
package/package.json
CHANGED
|
@@ -516,14 +516,16 @@ function useLookupOptions(
|
|
|
516
516
|
): {
|
|
517
517
|
options: SelectOption[];
|
|
518
518
|
loading: boolean;
|
|
519
|
+
query: string;
|
|
519
520
|
onSearch?: (q: string) => void;
|
|
520
521
|
} {
|
|
522
|
+
const [query, setQuery] = useState("");
|
|
521
523
|
const lookup = spec.options?.kind === "lookup" ? spec.options : undefined;
|
|
522
524
|
const search = useLookupAction<Record<string, unknown>, SelectOption>({
|
|
523
525
|
name: lookup?.source ?? "noop",
|
|
524
526
|
kind: "list",
|
|
525
527
|
} as unknown as ActionDef);
|
|
526
|
-
if (lookup === undefined) return { options: [], loading: false };
|
|
528
|
+
if (lookup === undefined) return { options: [], loading: false, query: "" };
|
|
527
529
|
const depInput: Record<string, unknown> = {};
|
|
528
530
|
for (const d of lookup.depends ?? []) {
|
|
529
531
|
if (!isEmptyValue(allValues[d])) depInput[d] = allValues[d];
|
|
@@ -534,7 +536,9 @@ function useLookupOptions(
|
|
|
534
536
|
label: i.label,
|
|
535
537
|
})),
|
|
536
538
|
loading: search.isLoading,
|
|
539
|
+
query,
|
|
537
540
|
onSearch: (q: string) => {
|
|
541
|
+
setQuery(q);
|
|
538
542
|
void search.run({ ...(q !== "" ? { q } : {}), ...depInput });
|
|
539
543
|
},
|
|
540
544
|
};
|
|
@@ -577,21 +581,66 @@ function FilterField({
|
|
|
577
581
|
);
|
|
578
582
|
const lookup = useLookupOptions(spec, allValues);
|
|
579
583
|
const dicts = useDicts();
|
|
584
|
+
const resolvedOptionByValue = useRef(new Map<string, SelectOption>());
|
|
580
585
|
// Precedência: runtime/estáticas (prop `options`) > dictionary do provider;
|
|
581
586
|
// sem nenhuma, as do lookup (server-side).
|
|
582
587
|
const base = options.length > 0 ? options : dictOptionsFor(spec, dicts);
|
|
588
|
+
const baseByValue = new Map(base.map((option) => [option.value, option]));
|
|
589
|
+
const canonicalLookupOptions = lookup.options.map(
|
|
590
|
+
(option) => baseByValue.get(option.value) ?? option,
|
|
591
|
+
);
|
|
592
|
+
for (const option of canonicalLookupOptions) {
|
|
593
|
+
if (!resolvedOptionByValue.current.has(option.value)) {
|
|
594
|
+
resolvedOptionByValue.current.set(option.value, option);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (const option of base) {
|
|
598
|
+
resolvedOptionByValue.current.set(option.value, option);
|
|
599
|
+
}
|
|
600
|
+
const selectedValues = new Set(
|
|
601
|
+
Array.isArray(value)
|
|
602
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
603
|
+
: typeof value === "string"
|
|
604
|
+
? value === ""
|
|
605
|
+
? []
|
|
606
|
+
: [value]
|
|
607
|
+
: defaultValue === ""
|
|
608
|
+
? []
|
|
609
|
+
: [defaultValue],
|
|
610
|
+
);
|
|
611
|
+
const runtimeOptions =
|
|
612
|
+
lookup.onSearch !== undefined && lookup.query !== ""
|
|
613
|
+
? base.filter((option) =>
|
|
614
|
+
selectedValues.has(option.value) ||
|
|
615
|
+
option.label
|
|
616
|
+
.toLocaleLowerCase()
|
|
617
|
+
.includes(lookup.query.toLocaleLowerCase()),
|
|
618
|
+
)
|
|
619
|
+
: base;
|
|
620
|
+
const selectedResolvedOptions = [...selectedValues]
|
|
621
|
+
.map((selectedValue) => resolvedOptionByValue.current.get(selectedValue))
|
|
622
|
+
.filter((option): option is SelectOption => option !== undefined);
|
|
623
|
+
const mergeUniqueOptions = (
|
|
624
|
+
...groups: SelectOption[][]
|
|
625
|
+
): SelectOption[] => {
|
|
626
|
+
const merged = new Map<string, SelectOption>();
|
|
627
|
+
for (const group of groups) {
|
|
628
|
+
for (const option of group) {
|
|
629
|
+
if (!merged.has(option.value)) merged.set(option.value, option);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return [...merged.values()];
|
|
633
|
+
};
|
|
583
634
|
const effOptions =
|
|
584
635
|
lookup.onSearch !== undefined && base.length > 0
|
|
585
|
-
?
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
),
|
|
591
|
-
]
|
|
636
|
+
? mergeUniqueOptions(
|
|
637
|
+
runtimeOptions,
|
|
638
|
+
canonicalLookupOptions,
|
|
639
|
+
selectedResolvedOptions,
|
|
640
|
+
)
|
|
592
641
|
: base.length > 0
|
|
593
642
|
? base
|
|
594
|
-
:
|
|
643
|
+
: mergeUniqueOptions(canonicalLookupOptions, selectedResolvedOptions);
|
|
595
644
|
const singleValue = (value as string | undefined) ?? defaultValue;
|
|
596
645
|
const changedFromDefault = singleValue !== defaultValue;
|
|
597
646
|
const acceptsEmptyValue = spec.allowEmpty !== false;
|
|
@@ -95,7 +95,7 @@ interface SelectCustomBase extends SelectBaseProps {
|
|
|
95
95
|
/** Liga a busca no próprio campo (lista longa/que cresce). */
|
|
96
96
|
searchable?: boolean;
|
|
97
97
|
/** Busca SERVER-SIDE (debounced): quando definida, o filtro local desliga e o pai
|
|
98
|
-
* atualiza `options`. Implica `searchable
|
|
98
|
+
* atualiza `options`. Implica `searchable`; ao fechar, limpa uma busca enviada. */
|
|
99
99
|
onSearch?: (query: string) => void;
|
|
100
100
|
/** "Buscando…" enquanto o fetch corre (use com `onSearch`). */
|
|
101
101
|
loading?: boolean;
|
|
@@ -279,6 +279,10 @@ function CustomSelect(
|
|
|
279
279
|
/** Fechando por Esc: o foco volta pro gatilho, e focar abre. Marca pra ignorar UM foco. */
|
|
280
280
|
const closingRef = React.useRef(false);
|
|
281
281
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
|
282
|
+
const wasOpenRef = React.useRef(false);
|
|
283
|
+
const lastSearchedQueryRef = React.useRef("");
|
|
284
|
+
const onSearchRef = React.useRef(onSearch);
|
|
285
|
+
onSearchRef.current = onSearch;
|
|
282
286
|
|
|
283
287
|
const selectedOption = options.find((o) => o.value === singleValue);
|
|
284
288
|
|
|
@@ -303,7 +307,14 @@ function CustomSelect(
|
|
|
303
307
|
* reabrir filtrada por algo que a pessoa procurou minutos antes e já esqueceu.
|
|
304
308
|
*/
|
|
305
309
|
React.useEffect(() => {
|
|
306
|
-
if (!open)
|
|
310
|
+
if (!open && wasOpenRef.current) {
|
|
311
|
+
setQuery("");
|
|
312
|
+
if (lastSearchedQueryRef.current !== "") {
|
|
313
|
+
lastSearchedQueryRef.current = "";
|
|
314
|
+
onSearchRef.current?.("");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
wasOpenRef.current = open;
|
|
307
318
|
}, [open]);
|
|
308
319
|
|
|
309
320
|
// O cmdk carimba o id DELE no input (a11y interna) e descarta o nosso — reaplicamos por
|
|
@@ -312,11 +323,13 @@ function CustomSelect(
|
|
|
312
323
|
if (id !== undefined && inputRef.current !== null) inputRef.current.id = id;
|
|
313
324
|
}, [id]);
|
|
314
325
|
|
|
315
|
-
const onSearchRef = React.useRef(onSearch);
|
|
316
|
-
onSearchRef.current = onSearch;
|
|
317
326
|
React.useEffect(() => {
|
|
318
327
|
if (onSearchRef.current === undefined || !open) return;
|
|
319
|
-
const t = setTimeout(() =>
|
|
328
|
+
const t = setTimeout(() => {
|
|
329
|
+
const normalizedQuery = query.trim();
|
|
330
|
+
lastSearchedQueryRef.current = normalizedQuery;
|
|
331
|
+
onSearchRef.current?.(normalizedQuery);
|
|
332
|
+
}, 200);
|
|
320
333
|
return () => clearTimeout(t);
|
|
321
334
|
}, [query, open]);
|
|
322
335
|
|
|
@@ -409,7 +409,10 @@ render(
|
|
|
409
409
|
- **`options: { kind: 'dictionary', ref: 'sessionStatus' }`:** resolve opções e rótulos por um
|
|
410
410
|
dicionário registrado no `OpusProvider`. `filterOptions` substitui essa fonte quando informado.
|
|
411
411
|
- **`options: { kind: 'lookup', source: 'x.lookup' }`:** consulta uma action com `{ q }` e os valores
|
|
412
|
-
das dependências. A resposta segue o formato `{ value, label }`.
|
|
412
|
+
das dependências. A resposta segue o formato `{ value, label }`. Quando o consumidor também
|
|
413
|
+
fornece `filterOptions`, essas opções são filtradas localmente pelo termo e combinadas com a
|
|
414
|
+
resposta remota. Para o mesmo `value`, o rótulo e a apresentação de `filterOptions` prevalecem;
|
|
415
|
+
valores selecionados preservam o rótulo mesmo depois que a busca é limpa.
|
|
413
416
|
|
|
414
417
|
```tsx
|
|
415
418
|
filters: {
|
|
@@ -140,8 +140,10 @@ render(
|
|
|
140
140
|
|
|
141
141
|
## Busca no servidor
|
|
142
142
|
|
|
143
|
-
Com `onSearch`, o consumidor consulta as opções ao abrir e ao digitar, com debounce.
|
|
144
|
-
|
|
143
|
+
Com `onSearch`, o consumidor consulta as opções ao abrir e ao digitar, com debounce. Ao fechar o
|
|
144
|
+
seletor depois de uma busca, recebe `""` imediatamente para restaurar as opções iniciais; abrir e
|
|
145
|
+
fechar sem digitar não repete a consulta vazia. Atualize `options` com a resposta e use `loading`
|
|
146
|
+
durante a consulta. Esse modo já habilita o campo de busca.
|
|
145
147
|
|
|
146
148
|
```tsx preview col md
|
|
147
149
|
const ASSIGNEES = [
|
|
@@ -395,7 +397,7 @@ render(
|
|
|
395
397
|
| `placeholder` | `string` | `'Selecione…'`; sem padrão com `native` | Texto do campo vazio. Com `native`, só existe quando declarado e vira uma opção desabilitada no início da lista. |
|
|
396
398
|
| `searchPlaceholder` | `string` | `'Buscar…'` | Placeholder da linha de busca dentro da lista. |
|
|
397
399
|
| `emptyText` | `string` | `'Nada encontrado.'` | Mensagem quando a busca não acha nada. |
|
|
398
|
-
| `onSearch` | `(query: string) => void` | | Busca no servidor, chamada ao abrir a lista e depois de uma breve pausa na digitação. Desliga o filtro local; o consumidor atualiza `options` com a resposta. |
|
|
400
|
+
| `onSearch` | `(query: string) => void` | | Busca no servidor, chamada ao abrir a lista e depois de uma breve pausa na digitação. Ao fechar depois de uma busca enviada, recebe `""` imediatamente para restaurar as opções iniciais. Desliga o filtro local; o consumidor atualiza `options` com a resposta. |
|
|
399
401
|
| `loading` | `boolean` | | Mostra “Buscando…” enquanto a consulta de `onSearch` está em andamento. |
|
|
400
402
|
| `clearable` | `boolean` | `false` | Mostra, quando há seleção, uma ação que limpa o valor sem abrir a lista. |
|
|
401
403
|
| `icon` | `React.ReactNode` | | Ícone decorativo no início do controle, em `1rem` e no tom atenuado. |
|
package/src/ui/drivers/react.tsx
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import {
|
|
15
15
|
useCallback,
|
|
16
16
|
useMemo,
|
|
17
|
+
useRef,
|
|
17
18
|
useState,
|
|
18
19
|
} from 'react'
|
|
19
20
|
import {
|
|
@@ -126,9 +127,11 @@ export function useLookupAction<TInput = unknown, TItem = unknown>(
|
|
|
126
127
|
): UseLookupActionResult<TInput, TItem> {
|
|
127
128
|
const client = useClient('useLookupAction')
|
|
128
129
|
const [state, setState] = useState<ListState<TItem>>(() => idleLookup<TItem>())
|
|
130
|
+
const latestRunRef = useRef(0)
|
|
129
131
|
|
|
130
132
|
const run = useCallback(
|
|
131
133
|
async (input: TInput): Promise<ListState<TItem>> => {
|
|
134
|
+
const runId = ++latestRunRef.current
|
|
132
135
|
setState((prev) => ({
|
|
133
136
|
...prev,
|
|
134
137
|
error: undefined,
|
|
@@ -162,13 +165,14 @@ export function useLookupAction<TInput = unknown, TItem = unknown>(
|
|
|
162
165
|
isError: true,
|
|
163
166
|
}
|
|
164
167
|
|
|
165
|
-
setState(next)
|
|
168
|
+
if (runId === latestRunRef.current) setState(next)
|
|
166
169
|
return next
|
|
167
170
|
},
|
|
168
171
|
[client, action],
|
|
169
172
|
)
|
|
170
173
|
|
|
171
174
|
const reset = useCallback(() => {
|
|
175
|
+
latestRunRef.current += 1
|
|
172
176
|
setState(idleLookup<TItem>())
|
|
173
177
|
}, [])
|
|
174
178
|
|