fix(territory): combine search and selection in one dropdown field
This commit is contained in:
@@ -1758,3 +1758,14 @@ Relatório: `catalogue-province-coverage-qa-20261028.json`. Instalação pelo pr
|
||||
|
||||
|
||||
28/10 - National coverage: 6 tests / 617 assertions passed across all 11 provincial units, 6 configured corridors and 2 sectors, including descendant hierarchy, sector separation and both province/location entry points. The runtime correction already used the common national hierarchy; Maputo was the reported reproduction, not a geographic limit. Existing publication decisions and unknown district/post/site information remain preserved. This is not a claim that every unrelated portal menu has been audited. No additional runtime change since 1b2ede41; types/build accepted evidence reused. Installation remains pending.
|
||||
|
||||
|
||||
28/10 - Before selector delta checks: accepted national catalogue QA 47dae1b7, 6 tests/617 assertions, remains uninstalled. Existing TerritoryLevel and ProvinceSelect required two separate fields (search then select). Tempo requests one dropdown on the search field. Consolidate the shared controls while retaining saved values, hierarchy, endpoints, paging and permission decisions;browser visual proof remains separate.
|
||||
|
||||
|
||||
|
||||
## 28/10/2026 — selecção territorial num único campo de pesquisa, QA
|
||||
|
||||
Pedido expresso da Tempo: o dropdown deve abrir em «Pesquisar província», sem um segundo controlo «Seleccione». `ProvinceSelect` e `TerritoryLevel` passam a usar o mesmo combobox, aplicado a província, distrito, posto administrativo e localidade. Pesquisar e escolher fica num único campo; a paginação permanece na lista. Escrever ou sair com Escape não substitui a selecção guardada; escolher e limpar são acções expressas. Mantêm-se a hierarquia, os endpoints existentes e os controlos de acesso, sem novos dados ou publicações.
|
||||
|
||||
Quatro testes do contrato de eventos/estado passaram: abertura/escolha, preservação ao escrever/Escape, teclado e bloqueio de resultado antigo durante carregamento, limpeza e dependência do nível anterior. Tipos e compilação QA passaram. As 6 provas nacionais/617 verificações são reutilizadas porque o PHP territorial não mudou. Este delta continua em QA e não comprova apresentação ou sessão real no navegador. Será agrupado com a correcção provincial; falta autenticação SSH antes da entrega, sem nova aprovação organizacional.
|
||||
|
||||
@@ -63,5 +63,28 @@
|
||||
"district_containment": true,
|
||||
"unrelated_provinces_excluded": true,
|
||||
"test_data": "synthetic fixtures rolled back;accepted published source reused"
|
||||
},
|
||||
"selector_delta": {
|
||||
"Tempo_request": "Dropdown belongs to Pesquisar província;remove separate Seleccione control",
|
||||
"scope": [
|
||||
"ProvinceSelect",
|
||||
"TerritoryLevel:province,district,administrative post,locality"
|
||||
],
|
||||
"behaviour": [
|
||||
"One combobox input opens the list and searches the existing endpoint",
|
||||
"Explicit choice commits the existing option;typing or Escape does not clear saved values",
|
||||
"Arrow keys and Enter select;clear is explicit;dependent levels remain disabled without parent",
|
||||
"Paging retained inside dropdown;loading cannot commit a stale option"
|
||||
],
|
||||
"QA": {
|
||||
"event_state_contract_tests": 4,
|
||||
"failures": 0,
|
||||
"types": "passed",
|
||||
"build": "passed",
|
||||
"limit": "Controlled React component contract;not browser visual/session evidence"
|
||||
},
|
||||
"publication_data_permissions_and_territory_rules": "unchanged",
|
||||
"national_province_evidence": "6 tests/617 assertions reused;unchanged PHP runtime",
|
||||
"status": "QA_not_installed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7316,3 +7316,14 @@ Relatório: `catalogue-province-coverage-qa-20261028.json`. Instalação pelo pr
|
||||
|
||||
|
||||
28/10 - National coverage: 6 tests / 617 assertions passed across all 11 provincial units, 6 configured corridors and 2 sectors, including descendant hierarchy, sector separation and both province/location entry points. The runtime correction already used the common national hierarchy; Maputo was the reported reproduction, not a geographic limit. Existing publication decisions and unknown district/post/site information remain preserved. This is not a claim that every unrelated portal menu has been audited. No additional runtime change since 1b2ede41; types/build accepted evidence reused. Installation remains pending.
|
||||
|
||||
|
||||
28/10 - Before selector delta checks: accepted national catalogue QA 47dae1b7, 6 tests/617 assertions, remains uninstalled. Existing TerritoryLevel and ProvinceSelect required two separate fields (search then select). Tempo requests one dropdown on the search field. Consolidate the shared controls while retaining saved values, hierarchy, endpoints, paging and permission decisions;browser visual proof remains separate.
|
||||
|
||||
|
||||
|
||||
## 28/10/2026 — selecção territorial num único campo de pesquisa, QA
|
||||
|
||||
Pedido expresso da Tempo: o dropdown deve abrir em «Pesquisar província», sem um segundo controlo «Seleccione». `ProvinceSelect` e `TerritoryLevel` passam a usar o mesmo combobox, aplicado a província, distrito, posto administrativo e localidade. Pesquisar e escolher fica num único campo; a paginação permanece na lista. Escrever ou sair com Escape não substitui a selecção guardada; escolher e limpar são acções expressas. Mantêm-se a hierarquia, os endpoints existentes e os controlos de acesso, sem novos dados ou publicações.
|
||||
|
||||
Quatro testes do contrato de eventos/estado passaram: abertura/escolha, preservação ao escrever/Escape, teclado e bloqueio de resultado antigo durante carregamento, limpeza e dependência do nível anterior. Tipos e compilação QA passaram. As 6 provas nacionais/617 verificações são reutilizadas porque o PHP territorial não mudou. Este delta continua em QA e não comprova apresentação ou sessão real no navegador. Será agrupado com a correcção provincial; falta autenticação SSH antes da entrega, sem nova aprovação organizacional.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import { useEffect, useId, useState } from 'react';
|
||||
import { api } from '@/pages/investment/shared';
|
||||
import { replaceTerritoryPath } from '@/lib/territory-path.js';
|
||||
import TerritoryGeometryPreview from './territory-geometry-preview';
|
||||
import TerritorySearchSelect from './territory-search-select';
|
||||
|
||||
export type TerritoryOption = {
|
||||
id: string;
|
||||
@@ -135,37 +136,11 @@ export function ProvinceSelect({
|
||||
total={options.length}
|
||||
retry={() => setRetry((value) => value + 1)}
|
||||
/>
|
||||
<input
|
||||
className={className}
|
||||
aria-label={`Pesquisar ${label}`}
|
||||
maxLength={100}
|
||||
placeholder="Pesquisar nome ou código"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
id={id}
|
||||
aria-label={label}
|
||||
className={className}
|
||||
required={required}
|
||||
value={value}
|
||||
disabled={loading || Boolean(error)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{loading ? 'A carregar províncias…' : placeholder}
|
||||
</option>
|
||||
{value && !options.some((option) => option.name === value) && (
|
||||
<option value={value}>
|
||||
{value} (seleção guardada; conferir cadastro)
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.name}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<TerritorySearchSelect id={id} label={label} className={className} required={required}
|
||||
placeholder={placeholder === 'Seleccione uma província' ? 'Pesquisar província' : placeholder}
|
||||
search={search} onSearch={setSearch} options={options} loading={loading} error={error}
|
||||
selected={value ? options.find((option) => option.name === value) ?? { id: value, name: value } : undefined}
|
||||
onChange={(option) => onChange(option?.name ?? '')} />
|
||||
{geometryFeedback && <p role="status">{geometryFeedback}</p>}
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={
|
||||
@@ -334,65 +309,15 @@ export function TerritoryLevel({
|
||||
<label htmlFor={id} className="font-medium">
|
||||
{labels[index]}
|
||||
</label>
|
||||
<input
|
||||
className={control}
|
||||
aria-label={`Pesquisar ${labels[index]}`}
|
||||
disabled={!enabled}
|
||||
maxLength={100}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={`Pesquisar ${labels[index].toLowerCase()}`}
|
||||
/>
|
||||
<select
|
||||
id={id}
|
||||
aria-invalid={Boolean(fieldError)}
|
||||
aria-describedby={fieldError ? `${id}-error` : undefined}
|
||||
className={control}
|
||||
disabled={!enabled || loading || !!error}
|
||||
value={selected?.id ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(options.find((o) => o.id === e.target.value))
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{enabled ? 'Seleccione' : 'Seleccione o nível anterior'}
|
||||
</option>
|
||||
{selected && !options.some((o) => o.id === selected.id) && (
|
||||
<option value={selected.id}>
|
||||
{selected.name}
|
||||
{selected.deleted_at
|
||||
? ' (arquivada)'
|
||||
: ' (selecção actual)'}
|
||||
</option>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
{o.source_code ? ` (${o.source_code})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<TerritorySearchSelect id={id} label={labels[index]} className={control}
|
||||
disabled={!enabled} search={search} onSearch={(query) => { setSearch(query); setPage(1); }}
|
||||
options={options} selected={selected} onChange={onChange} loading={loading} error={fieldError || error}>
|
||||
<div className="flex gap-3 p-2 text-sm">
|
||||
{page > 1 && <button type="button" onClick={() => setPage(page - 1)}>Anterior</button>}
|
||||
{more && <button type="button" onClick={() => setPage(page + 1)}>Mais opções</button>}
|
||||
</div>
|
||||
</TerritorySearchSelect>
|
||||
{loading && <p role="status">A carregar…</p>}
|
||||
{fieldError && (
|
||||
<p id={`${id}-error`} role="alert">
|
||||
{fieldError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3 text-sm">
|
||||
{page > 1 && (
|
||||
<button type="button" onClick={() => setPage(page - 1)}>
|
||||
Anterior
|
||||
</button>
|
||||
)}
|
||||
{more && (
|
||||
<button type="button" onClick={() => setPage(page + 1)}>
|
||||
Mais opções
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
type Choice = { id: string; name: string; source_code?: string | null };
|
||||
|
||||
/** One search field owns the list and commits only an explicitly chosen option. */
|
||||
export default function TerritorySearchSelect<T extends Choice>({
|
||||
id, label, search, onSearch, options, selected, onChange, disabled = false,
|
||||
loading = false, error, required = false, className, placeholder, children,
|
||||
}: {
|
||||
id: string; label: string; search: string; onSearch: (query: string) => void;
|
||||
options: T[]; selected?: T; onChange: (option?: T) => void;
|
||||
disabled?: boolean; loading?: boolean; error?: string; required?: boolean;
|
||||
className?: string; placeholder?: string; children?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||
const expanded = open && !disabled;
|
||||
const listId = `${id}-options`;
|
||||
useEffect(() => { if (expanded) activeRef.current?.scrollIntoView({ block: 'nearest' }); }, [active, expanded]);
|
||||
const close = () => { setOpen(false); setActive(-1); onSearch(''); };
|
||||
const choose = (option?: T) => { onChange(option); close(); };
|
||||
return <div className="relative" onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) close();
|
||||
}}>
|
||||
<input id={id} className={className} role="combobox" aria-label={`Pesquisar ${label}`}
|
||||
aria-autocomplete="list" aria-expanded={expanded} aria-controls={expanded ? listId : undefined}
|
||||
aria-activedescendant={expanded && !loading && options[active] ? `${listId}-${active}` : undefined}
|
||||
aria-invalid={Boolean(error)} aria-describedby={error ? `${id}-error` : undefined}
|
||||
aria-required={required || undefined} required={required} autoComplete="off" maxLength={100}
|
||||
disabled={disabled} value={expanded ? search : selected?.name ?? ''}
|
||||
placeholder={disabled ? 'Seleccione o nível anterior' : placeholder ?? `Pesquisar ${label.toLowerCase()}`}
|
||||
onFocus={() => { setOpen(true); setActive(-1); onSearch(''); }}
|
||||
onClick={() => setOpen(true)}
|
||||
onChange={(event) => { setOpen(true); setActive(-1); onSearch(event.target.value); }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); }
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault(); setOpen(true);
|
||||
setActive((current) => !options.length ? -1 : event.key === 'ArrowDown'
|
||||
? Math.min(current + 1, options.length - 1) : current < 0 ? options.length - 1 : Math.max(0, current - 1));
|
||||
}
|
||||
if (event.key === 'Enter' && expanded) {
|
||||
event.preventDefault();
|
||||
if (!loading && options[active < 0 ? 0 : active]) choose(options[active < 0 ? 0 : active]);
|
||||
}
|
||||
}} />
|
||||
{selected && !disabled && <button type="button" className="mt-1 text-sm underline"
|
||||
aria-label={`Limpar ${label}`} onClick={() => choose()}>Limpar</button>}
|
||||
{error && <p id={`${id}-error`} role="alert">{error}</p>}
|
||||
{expanded && <div className="absolute z-50 mt-1 max-h-72 w-full overflow-y-auto rounded border bg-background p-1 text-foreground shadow-lg">
|
||||
<div id={listId} role="listbox" aria-label={label} aria-busy={loading}>
|
||||
{loading && <p role="status" className="p-2">A carregar…</p>}
|
||||
{!loading && options.length === 0 && <p className="p-2">{error ? 'Não foi possível carregar as opções.' : 'Sem resultados para esta pesquisa.'}</p>}
|
||||
{options.map((option, index) => <button key={option.id} id={`${listId}-${index}`} type="button"
|
||||
ref={index === active ? activeRef : undefined} role="option" tabIndex={-1}
|
||||
aria-selected={option.id === selected?.id} disabled={loading}
|
||||
className={`block w-full rounded p-2 text-left hover:bg-accent ${index === active ? 'bg-accent' : ''}`}
|
||||
onMouseDown={(event) => event.preventDefault()} onClick={() => choose(option)}>
|
||||
{option.name}{option.source_code ? ` (${option.source_code})` : ''}
|
||||
</button>)}
|
||||
</div>
|
||||
{children}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import fs from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import React from 'react';
|
||||
import ts from 'typescript';
|
||||
|
||||
// Exercise the real component's event/state contract; this is not browser visual QA.
|
||||
const require=createRequire(import.meta.url);
|
||||
const source=fs.readFileSync(new URL('../../resources/js/components/territory-search-select.tsx',import.meta.url),'utf8');
|
||||
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
|
||||
function harness(overrides={}){
|
||||
const memory=[];let cursor=0;const chosen=[];
|
||||
const hooks={...React,useState(initial){const i=cursor++;if(!(i in memory))memory[i]=initial;return[memory[i],value=>{memory[i]=typeof value==='function'?value(memory[i]):value;}];},useRef:()=>({current:null}),useEffect:()=>{}};
|
||||
const module={exports:{}};
|
||||
new Function('require','module','exports',compiled)(name=>name==='react'?hooks:require(name),module,module.exports);
|
||||
const options=[{id:'province-a',name:'Nampula'},{id:'province-b',name:'Maputo Província'}];
|
||||
const props={id:'territory',label:'Província',search:'',options,onSearch(query){props.search=query;},onChange(option){chosen.push(option);props.selected=option;},...overrides};
|
||||
let tree;
|
||||
const flat=node=>!node||typeof node!=='object'?[]:[node,...React.Children.toArray(node.props?.children).flatMap(flat)];
|
||||
const render=()=>{cursor=0;tree=module.exports.default(props);return tree;};
|
||||
const find=predicate=>flat(tree).find(predicate);
|
||||
const input=()=>find(node=>node.type==='input');
|
||||
const key=value=>input().props.onKeyDown({key:value,preventDefault(){}});
|
||||
render();return{props,chosen,render,find,input,key,options};
|
||||
}
|
||||
test('one search input owns the dropdown; the separate select is absent',()=>{
|
||||
const h=harness();assert.equal(h.find(node=>node.type==='select'),undefined);
|
||||
assert.equal(h.input().props.role,'combobox');assert.equal(h.input().props['aria-expanded'],false);
|
||||
h.input().props.onFocus();h.render();
|
||||
assert.equal(h.input().props['aria-expanded'],true);
|
||||
assert.ok(h.find(node=>node.props?.role==='listbox'));
|
||||
h.find(node=>node.props?.role==='option'&&node.props.id==='territory-options-1').props.onClick();h.render();
|
||||
assert.deepEqual(h.chosen,[h.options[1]]);assert.equal(h.input().props.value,'Maputo Província');
|
||||
assert.equal(h.input().props['aria-expanded'],false);
|
||||
});
|
||||
test('typing and Escape preserve the existing selection until an option is explicitly chosen',()=>{
|
||||
const selected={id:'saved',name:'Selecção guardada fora desta página'};const h=harness({selected});
|
||||
h.input().props.onFocus();h.render();h.input().props.onChange({target:{value:'Map'}});h.render();
|
||||
assert.equal(h.props.search,'Map');assert.deepEqual(h.chosen,[]);
|
||||
h.key('Escape');h.render();assert.equal(h.input().props.value,selected.name);assert.deepEqual(h.chosen,[]);
|
||||
});
|
||||
test('arrow keys and Enter choose from the list; loading cannot commit an old result',()=>{
|
||||
const h=harness();h.input().props.onFocus();h.render();h.key('ArrowDown');h.render();h.key('ArrowDown');h.render();
|
||||
assert.equal(h.input().props['aria-activedescendant'],'territory-options-1');
|
||||
h.key('Enter');h.render();assert.deepEqual(h.chosen,[h.options[1]]);
|
||||
const busy=harness({loading:true});busy.input().props.onFocus();busy.render();busy.key('Enter');
|
||||
assert.deepEqual(busy.chosen,[]);
|
||||
});
|
||||
test('clear is explicit; disabled dependent levels cannot expose the dropdown',()=>{
|
||||
const h=harness({selected:{id:'saved',name:'Nampula'}});
|
||||
h.find(node=>node.props?.['aria-label']==='Limpar Província').props.onClick();h.render();
|
||||
assert.deepEqual(h.chosen,[undefined]);assert.equal(h.input().props.value,'');
|
||||
const disabled=harness({disabled:true});assert.equal(disabled.input().props.disabled,true);
|
||||
assert.equal(disabled.find(node=>node.props?.role==='listbox'),undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user