@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "25.4.0",
3
+ "version": "25.4.1",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- ...base,
587
- ...lookup.options.filter(
588
- (candidate) =>
589
- !base.some((option) => option.value === candidate.value),
590
- ),
591
- ]
636
+ ? mergeUniqueOptions(
637
+ runtimeOptions,
638
+ canonicalLookupOptions,
639
+ selectedResolvedOptions,
640
+ )
592
641
  : base.length > 0
593
642
  ? base
594
- : lookup.options;
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) setQuery("");
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(() => onSearchRef.current?.(query.trim()), 200);
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. Atualize
144
- `options` com a resposta e use `loading` durante a consulta. Esse modo já habilita o campo de busca.
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. |
@@ -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