publisher-property-editor
v0.5.0
Published
Editor de imóveis em React — componente único com contrato de injeção (services + commands), sem estado global nem dependência de rede.
Readme
publisher-property-editor
Editor de imóveis em React — um componente, dirigido por injeção. Você monta o
<PropertyEditor>, passa os dados do imóvel e implementa as portas (services,
commands, …); a biblioteca cuida da UI e da edição. Ela não fala com rede, não tem store
global e não decide nada sobre persistência ou permissão — tudo isso entra por você.
| | |
|---|---|
| Pacote | publisher-property-editor |
| Versão | 0.4.0 |
| Formato | ESM — dist/index.js · tipos dist/index.d.ts · CSS dist/style.css |
| Peer deps | react@^18.2 \|\| ^19 · react-dom@^18.2 \|\| ^19 |
Docs internas: arquitetura · decisões · atributos e modelos.
Índice — Instalação · Início rápido ·
Contrato de injeção · Props · services ·
commands · data ·
Permissões · Seções e layout ·
Roteamento · Atributos · Tipos.
Instalação
pnpm add publisher-property-editorO CSS é distribuído à parte e é obrigatório — já embute os estilos de
react-master-gallery, jaster-form e tippy.js:
import "publisher-property-editor/style.css";Todos os estilos vivem sob o escopo .property-editor, aplicado na raiz do <main>
renderizado — sem reset global nem vazamento pra fora.
react e react-dom são peer dependencies (você já os tem). Runtime extra que a lib traz
e o seu bundler resolve: @radix-ui/*, @headlessui/react, react-select,
react-master-gallery, @react-google-maps/api, moment, ramda e afins.
Início rápido
O mínimo para renderizar e salvar. services e commands têm suas seções abaixo — aqui
só o essencial:
import PropertyEditor from "publisher-property-editor";
import "publisher-property-editor/style.css";
<PropertyEditor
user={{ id: "usr_1", name: "Ana", email: "[email protected]", role: "master" }}
attributes={[
{ key: "reference", value: "REF-001" },
{ key: "model", value: "[email protected]" },
{ key: "title", value: "Casa à venda no Centro" },
{ key: "published", value: true },
// …definições do modelo + valores salvos (ver Atributos)
]}
models={{ "[email protected]": { name: "casa-padrao", label: "Casa Padrão" } }}
categories={[
{ name: "main", label: "Principal" },
{ name: "feature", label: "Benfeitorias" },
{ name: "improvement", label: "Características" },
]}
permissions={["property:edit", "property:publish"]}
settings={{ mobile: false }}
services={services} // ver "services"
commands={commands} // ver "commands"
data={{}}
onUpdate={(values) => salvarNoBackend(values)}
/>;
attributesé copiado para o estado interno na montagem. Alterações posteriores da prop são ignoradas — para carregar outro imóvel, remonte comkey={propertyId}.
O contrato de injeção
A biblioteca dispara intenções; o host as realiza. As portas seguem sempre o mesmo princípio — a lib declara a necessidade, você decide a implementação.
| Porta | Prop | Obrig. | O host fornece |
|---|---|:--:|---|
| services | services | ✅ | I/O e leituras: CEP, cidades, integrações, menu de opções, upload |
| commands | commands | ✅ | Ações de negócio disparadas pelo editor: salvar, publicar… |
| services.notify | (em services) | — | Como apresentar notificações |
| services.inspect / getInspectMenu | (em services) | — | Modo inspeção / itens do menu de contexto |
| permissão | can / permissions | ✅¹ | Decisão de permissão por chave |
| roteamento | <LinkProvider> | — | Componente de link do seu roteador |
| layout | sections / chrome | — | Quais abas, em que ordem, e o cromo |
¹ permissions é obrigatório no tipo, mas ignorado se você passar can.
As opcionais têm fallback: sem notify o editor fica calado; sem inspect o modo
inspeção não existe; sem can a permissão vem de permissions[]; sem LinkProvider os
links viram âncoras; sem sections usa o conjunto padrão com cromo.
Props
| Prop | Tipo | Obrig. | Descrição |
|---|---|:--:|---|
| attributes | AttributeType[] | ✅ | Estado editável do imóvel. Ver Atributos. |
| models | { [id]: { name, label } } | ✅ | Catálogo de modelos. O label do modelo corrente aparece no cabeçalho. |
| user | User | ✅ | Usuário logado. Define o ACL junto de permissions/can. |
| permissions | string[] | ✅ | Chaves de permissão do usuário. Ignorado se can for passado. |
| categories | { name, label }[] | ✅ | Grupos da aba Atributos. name casa com categories.view do atributo. |
| services | PropertyServices | ✅ | Porta de I/O. Ver services. |
| commands | CommandFunctions | ✅ | Porta de ações. Ver commands. |
| settings | Settings | ✅ | { mobile?, readOnly?, darkMode? } — só mobile tem efeito hoje. |
| data | object | ✅ | Semente do estado volátil. Pode ser {}. |
| onUpdate | (data) => any | ✅ | Repassado ao contexto; quem chama é o seu command, não o componente. |
| owner | User | — | Responsável pelo imóvel. Exibido no rodapé e usado no ACL de edição. |
| can | PermissionCheck | — | Verificação de permissão injetada. Ver Permissões. |
| sections | SectionSchema[] | — | Quais abas e em que ordem. Ver Seções. |
| chrome | boolean | — | false renderiza só as seções, sem cabeçalho/abas/rodapé. Padrão true. |
| active | boolean | — | Foco do painel. false esmaece e desliga o modo inspeção. Omitido, detecta por clique. |
| pinnedAttributes | string[] | — | Chaves fixadas no topo da aba Atributos. |
| tags | string[] | — | Tags do imóvel. "highlighted" liga o selo de destaque. |
Comportamento a conhecer
attributesé copiado no mount — remonte comkeypara trocar de imóvel.settings.readOnlyesettings.darkModeexistem no tipo mas ainda não têm efeito.user.role === "master"(ouuser.master === true) concede tudo, ignorandopermissions— salvo se você injetarcan(aí a decisão é 100% sua).
services — porta de I/O
Objeto implementado por você. Tudo que sai para a rede — e o menu de opções — passa aqui.
type PropertyServices = {
// Menu "Opções" do cabeçalho — obrigatórios
getOptions(context: TableContext): Item[];
handleAction(key: string, item: any, context: TableContext): void;
// Aba Localização
fetchAddress(cep: string): Promise<{ city; uf; address; neighborhood; complement? }>;
fetchCities(): Promise<{ name: string; state: string }[]>;
createCity(data: { name: string; state: string }): Promise<any>;
fetchDistricts(data: { city: string }): Promise<{ name: string }[]>;
createDistrict(data: { name; city; state }): Promise<unknown>;
// Aba Principal — carga preguiçosa de selects
loadTypes(model: string, context: TableContext): Promise<Option[]>;
fetchItem(key: "client" | "agent", context: TableContext): Promise<Option[]>;
// Aba Publicação
fetchIntegrations(): Promise<Integration[]>;
// Opcionais
notify?(input: NotifyInput): void;
inspect?(key: string, context: TableContext, action?: string): void;
getInspectMenu?(key: string, context: TableContext): InspectMenuItem[];
};Option = { value: string; label: string }. Item = a linha do menu de opções:
{ key, label, icon?, link?, target?, disabled?, reason? }.
Quando cada um é chamado
| Método | Gatilho |
|---|---|
| getOptions | A cada render do cabeçalho. Deve ser puro e barato. |
| handleAction | Clique num item do menu Opções. Recebe o key que você definiu. |
| fetchAddress | Botão Atualizar endereço. A resposta preenche rua, cidade e bairro. |
| fetchCities | Montagem da aba Localização. |
| fetchDistricts | Sempre que a cidade selecionada muda. |
| createCity / createDistrict | Após fetchAddress trazer cidade/bairro fora da lista, com confirmação. |
| loadTypes | 1º render do select Tipo. Resultado cacheado em data.options. |
| fetchItem | 1º render dos selects Cliente/Corretor. Idem cache. |
| fetchIntegrations | Montagem da aba Publicação. |
| notify | Cadastro de cidade/bairro e falha ao buscar CEP. |
Contrato morto: o tipo ainda exige
dispatch,setPropertyTagsefileUpload— o componente nunca os chama (upload passa pelo commandhandleUpload). Implemente como no-op até serem removidos do tipo.
notify — notificação sem impor UI
A lib não embute UI de notificação: descreve a intenção, você apresenta. Opcional — sem ela, o editor segue calado.
type NotifyInput =
| { type?: "info" | "success" | "warning" | "error"; message: string }
| { promise: Promise<unknown>; pending?: string; success?: string; error?: string };
const notify: PropertyServices["notify"] = (input) => {
if ("promise" in input) {
const { promise, ...messages } = input;
toast.promise(promise, messages); // acompanha pending/success/error
return;
}
toast[input.type ?? "info"](input.message);
};Menu de opções — getOptions / handleAction
O menu Opções do cabeçalho é 100% seu: getOptions devolve a lista, o clique vai para
handleAction. Itens aceitam disabled + reason — um item bloqueado aparece apagado,
com cadeado e o motivo num tooltip, sem sumir (mais honesto que ocultar):
const getOptions: PropertyServices["getOptions"] = ({ can }) => [
{ key: "publish", label: "Publicar", icon: <World /> },
{ key: "remove", label: "Excluir", icon: <Trash />,
disabled: !can("property:delete"),
reason: "Sem permissão para excluir o imóvel" },
];
const handleAction: PropertyServices["handleAction"] = (key, item, { command }) => {
if (key === "publish") command("publish");
if (key === "remove") command("DeleteProperty");
};Modo inspeção — inspect / getInspectMenu
Ajuda a documentar a própria ferramenta. Opcional: sem inspect, o modo não existe.
- ALT+clique: com ALT segurado e o painel ativo, os componentes inspecionáveis
(publicar, salvar, opções, atualizar endereço) acendem; o clique — em vez de agir —
dispara
inspect(key, context). Funciona até em botões desabilitados. - Clique-direito: abre um menu cujos itens você injeta por
getInspectMenu— rótulos (no seu idioma), ícones, quais itens. No clique de um item, a lib chamainspect(key, context, item.action). A biblioteca não conhece item algum.
type InspectMenuItem = { action: string; label: string; icon?: ReactNode; disabled?: boolean };
const getInspectMenu: PropertyServices["getInspectMenu"] = (key) => [
{ action: "about", label: "Sobre", icon: <Info /> },
{ action: "action", label: "O que faz", icon: <Bolt /> },
];
const inspect: PropertyServices["inspect"] = (key, _ctx, action) => {
abrirDrawer(key, action); // você controla o conteúdo e o layout
};A prop active controla o foco quando há vários painéis na tela.
commands — porta de ações
Mapa { [chave]: (props, context, controls?) => any }. O componente dispara uma chave;
você decide o que ela faz. Nenhum é obrigatório — chave ausente é ignorada em silêncio.
Disparados pelo componente:
| Chave | Quando | props |
|---|---|---|
| onSubmit | Botão Salvar alterações | { values: { [key]: value }, modifiedAt } |
| publish | Botão de publicação (e menu) | { id, isUpdate } |
| archive | Menu Opções (via run no handleAction) | { id } |
| handleUpload | Seleção ou arraste de imagens | { id, files: File[] } |
| Attributes_${name} | Menu de contexto de um atributo (jaster-form) | { name, item } |
Commands de ciclo assíncrono (publish/archive/onSubmit) — o padrão recomendado
Um command é uma operação do host — no modelo recomendado, uma função pura
(props) => Promise<Result>. Quem cuida do estado visual (loading, publishState,
lock) é o editor, pelo ciclo da promise. O command só:
- faz qualquer confirmação/pré-condição;
- chama
controls.begin()quando a operação de verdade começa; - resolve com o resultado (ex.:
{ modified_at }), que o editor aplica aodata.
export const publish: CommandFunction = async ({ id, isUpdate }, _ctx, controls) => {
if (!isUpdate) await confirmar(); // antes de begin → sem "publicando..." mentiroso
controls?.begin(); // editor mostra o estado pendente + trava o botão
return api.publish(id); // Promise<{ modified_at }>
};O begin é o único acoplamento à apresentação — e é opt-in: um command que não o chama
mantém o comportamento antigo (transiciona publishState você mesmo via context.setData).
Na rejeição após begin, o editor reverte o estado; o feedback rico do erro é seu
(mostre-o dentro da operação, antes de rejeitar). Rejeitar antes de begin (ex.:
cancelar a confirmação) não muda nada.
Toda ação do menu Opções (destacar, duplicar, excluir, trocar responsável…) não é
disparada pelo componente — vai do seu getOptions para o seu handleAction, que decide o
que fazer. Para dar a uma ação de menu o ciclo async do editor (como archive), o
handleAction chama context.run("archive", { id }) em vez de command.
O 2º argumento é o context (leituras e o estilo antigo); o 3º é controls ({ begin }).
A superfície de context (service, command, run, runAsync, data, setData,
services, user, can…) está em
docs/ARCHITECTURE.md.
data — estado volátil
Estado de UI que o componente e os commands compartilham — não é persistido. A prop
data semeia; a maior parte é gerenciada pelo próprio editor.
| Campo | Tipo | Efeito |
|---|---|---|
| publishState | "published" \| "archived" \| "publishing" \| "archiving" \| "synchronizing" | Define o botão do cabeçalho. Ausente, é derivado do atributo published. |
| publishLock | boolean | Desabilita o botão de publicação. |
| syncRequired | boolean | Troca Público pelo Atualizar pulsante. |
| deleted | boolean | Substitui o editor pela tela de imóvel excluído. |
| tags | string[] | "highlighted" exibe o selo de destaque. |
| pinnedAttributes | string[] | Atributos fixados no topo da aba Atributos. |
| modifiedAt | string | ISO date exibida no rodapé. |
| options | { [attrKey]: Option[] } | Cache dos selects. Gerenciado pelo componente. |
Os estados de ciclo (publishing/archiving/synchronizing → published/archived) são
dirigidos pelo editor pelo ciclo da promise do command — você não os transiciona. Basta
o command chamar controls.begin() e resolver com { modified_at } (ver
commands). O modifiedAt do resultado é aplicado aqui.
Permissões — can
O editor consulta permissões por chaves nomeadas — um vocabulário fixo:
| Chave | Lida pela biblioteca? | Efeito |
|---|:--:|---|
| property:edit | ✅ | Sem ela, o botão Salvar alterações desabilita. |
| property:publish | ✅ | Sem ela, o botão de publicação desabilita. |
| property:private · property:delete · … | — | Convenção sua — quem consome é o seu getOptions. |
Duas formas de responder:
- Simples — passe
permissions: string[]. A lib deriva:master || permissions.includes(key). - Injetada — passe
can: (key) => booleane mapeie cada chave para o ACL real do seu app. Aícané a autoridade final: a lib não aplica regra própria por cima (nem o bypass de master) epermissionsé ignorado.
<PropertyEditor can={(key) => app.can(MAPA[key])} … />Seções e layout — sections
As abas do editor vêm de um schema. Omitido, usa o conjunto padrão; passe sections
para reordenar, filtrar, renomear, exigir permissão ou registrar seções próprias.
type SectionSchema =
| string // chave do registro embutido
| {
type: string; // chave, ou "spacer"
variant?: "full" | "icon"; // ícone+texto (padrão) ou só ícone (tooltip)
label?: string; // renomear a aba
icon?: ReactNode; // trocar o ícone
permission?: string; // só aparece se can(permission)
component?: (props) => ReactNode; // seção própria (substitui a do registro)
};Seções embutidas (chave → rótulo padrão): main→Principal, attributes→Atributos,
address→Localização, media→Mídias, publish→Publicação, inspect→Análise,
private→Privado.
{ type: "spacer" }divide a barra em duas zonas: à esquerda as abas normais; à direita, as seções seguintes viram um grupo compacto só-ícone (auxiliares como Análise e Privado).chrome={false}renderiza apenas as seções (sem cabeçalho/abas/rodapé) — útil para exibir uma página isolada, ex.:sections={["address"]} chrome={false}.
<PropertyEditor
sections={[
"main",
"attributes",
{ type: "media", label: "Fotos" }, // renomeada
{ type: "publish", permission: "property:publish" }, // condicional
"spacer",
"inspect", // vira botão à direita
"private",
]}
…
/>Para montar uma UI de configuração de layout, a lib exporta o catálogo sem arrastar as seções pesadas:
import { SECTION_META, DEFAULT_SECTIONS } from "publisher-property-editor";
// SECTION_META: { type, label }[] · DEFAULT_SECTIONS: string[]Roteamento — LinkProvider
Onde o editor renderiza um link (menu de opções, integrações), ele usa um Link
injetável. Sem configuração, é uma âncora comum (navegação com recarga). Para preservar a
navegação SPA do seu app, injete o Link do seu roteador:
import PropertyEditor, { LinkProvider } from "publisher-property-editor";
import { Link as RouterLink } from "react-router-dom";
<LinkProvider component={({ href, ...props }) => <RouterLink to={href} {...props} />}>
<PropertyEditor … />
</LinkProvider>;Atributos
O imóvel não é um objeto tipado — é uma lista plana de AttributeType:
type AttributeType = {
key: string; // identificador único e estável
value?: any; // valor do usuário (ausente = não preenchido)
type?: "bool" | "number" | "options" | string;
label?: string;
categories?: { view?: string }; // casa com o `name` de `categories`
$custom?: boolean; // atributo fora do modelo
};Monte a lista concatenando identidade + definições do modelo + valores salvos. As chaves
reservadas (id, reference, model, title, published, media.pictures,
geo-position, $private…), os tipos, as categorias e os modelos estão detalhados em
docs/ATTRIBUTES.md.
Tipos exportados
import PropertyEditor, {
// valores
Link, LinkProvider, useLinkComponent,
SECTION_META, DEFAULT_SECTIONS,
} from "publisher-property-editor";
import type {
PropertyProps, PropertyServices, CommandFunction, CommandFunctions, CommandControls,
User, Integration, AttributeGroup, Settings,
PermissionCheck, NotifyInput, NotifyType, InspectMenuItem,
SectionSchema, SectionMeta,
TableContext, AttributeType, AttributeMethods,
LinkProps, LinkComponent,
} from "publisher-property-editor";Desenvolvimento
Monorepo pnpm. Da raiz:
pnpm install
pnpm dev # playground (apps/playground) em Vite
pnpm -C packages/property-editor build # dist/index.js + .d.ts + style.css
pnpm -C packages/property-editor test # Vitest
pnpm -C packages/property-editor typecheckBuild com tsup (ESM + tipos) + Tailwind CLI para o CSS. O playground implementa
services/commands com mocks e é a referência viva de integração — se em dúvida
sobre como implementar uma porta, olhe apps/playground/src/editor-host/.
