diff --git a/README.md b/README.md index fd984a9..970efed 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,12 @@ A cláusula é aplicada via `entityQuery` com filtro CZQL. Comandos de modifica Campos **nullable** permitem limpar o valor escolhendo "Limpar campo (null)" ou digitando `null`. +## Campos PickList + +Campos com `presentationType: PickList` (listas de opções customizadas ou do sistema) exibem uma **lista carregada da API** com nomes legíveis (ex: `Conteúdo`). O app envia automaticamente o ID correto (`/C_GenericTaskSetor/`), evitando erros ao digitar o nome manualmente. + +Campos de **referência** (`ReferenceToObject`, ex: User, Project) continuam exigindo o ID manual ou JSON `{"id":"/Tipo/guid"}`. + ## Variáveis de ambiente | Variável | Descrição | @@ -102,7 +108,8 @@ Exemplo resumido: "typeName": "Task", "filter": "Todos", "fieldName": "C_MeuCampo", - "value": true + "value": { "id": "/C_GenericTaskSetor/abc123" }, + "valueLabel": "Conteúdo (/C_GenericTaskSetor/abc123)" }, "summary": { "total": 10, "updated": 9, "errors": 1 }, "results": [ diff --git a/src/index.ts b/src/index.ts index f99b5e2..1956890 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,12 @@ import { createClarizenClient, } from "./clarizen/client.js"; import { formatFieldHint } from "./metadata/field-hints.js"; +import { isPickListField } from "./metadata/picklist.js"; import { getEntityMetadata, pickEntityType, pickField } from "./metadata/picker.js"; +import { + promptPickListValue, + type FieldValueSelection, +} from "./parse/prompt-picklist.js"; import { formatValueForDisplay, parseFieldValue, @@ -27,7 +32,7 @@ import { fetchAllEntities } from "./query/fetch-all.js"; import { promptEntityFilter } from "./query/prompt-filter.js"; import { buildRunLog, writeRunLog } from "./log/run-log.js"; import { bulkUpdateField } from "./update/bulk-field.js"; -import type { FieldDescription, JsonValue } from "@arco/clarizen-lib"; +import type { Clarizen, FieldDescription, JsonValue } from "@arco/clarizen-lib"; function exitCancel(): never { cancel("Operação cancelada."); @@ -35,8 +40,13 @@ function exitCancel(): never { } async function promptFieldValue( + clarizen: Clarizen, field: FieldDescription, -): Promise { +): Promise { + if (isPickListField(field)) { + return promptPickListValue(clarizen, field); + } + console.log("\n" + formatFieldHint(field) + "\n"); if (field.nullable) { @@ -48,7 +58,7 @@ async function promptFieldValue( ], }); if (isCancel(action)) return action; - if (action === "clear") return null; + if (action === "clear") return { value: null, displayLabel: "(vazio / null)" }; } while (true) { @@ -64,7 +74,8 @@ async function promptFieldValue( if (isCancel(raw)) return raw; try { - return parseFieldValue(raw, field); + const value = parseFieldValue(raw, field); + return { value }; } catch (err) { if (err instanceof ValueParseError) { console.error(`\n Erro: ${err.message}\n`); @@ -107,8 +118,11 @@ async function main(): Promise { const { field } = fieldChoice; const fieldName = field.name!; - const parsedValue = await promptFieldValue(field); - if (isCancel(parsedValue)) exitCancel(); + const valueSelection = await promptFieldValue(clarizen, field); + if (isCancel(valueSelection)) exitCancel(); + + const parsedValue = valueSelection.value; + const valueDisplayLabel = valueSelection.displayLabel; const fetchSpinner = spinner(); fetchSpinner.start("Buscando objetos..."); @@ -137,7 +151,7 @@ Resumo da operação: Tipo: ${entityLabel} Filtro: ${formatFilterSummary(entityFilter)} Campo: ${field.label ?? fieldName} (${fieldName}) — ${field.type ?? "?"} - Valor: ${formatValueForDisplay(parsedValue)} + Valor: ${formatValueForDisplay(parsedValue, valueDisplayLabel)} Objetos: ${records.length.toLocaleString("pt-BR")} registro(s) `); @@ -168,6 +182,7 @@ Resumo da operação: field, fieldName, parsedValue, + valueDisplayLabel, records, result, }), diff --git a/src/log/run-log.ts b/src/log/run-log.ts index d9857bf..1a80d79 100644 --- a/src/log/run-log.ts +++ b/src/log/run-log.ts @@ -26,6 +26,7 @@ export interface RunLog { fieldLabel?: string; fieldType?: string; value: JsonValue; + valueLabel?: string; }; summary: { total: number; @@ -43,6 +44,7 @@ export interface BuildRunLogParams { field: FieldDescription; fieldName: string; parsedValue: JsonValue; + valueDisplayLabel?: string; records: EntityRecord[]; result: BulkUpdateResult; } @@ -60,6 +62,7 @@ export function buildRunLog(params: BuildRunLogParams): RunLog { field, fieldName, parsedValue, + valueDisplayLabel, records, result, } = params; @@ -101,6 +104,7 @@ export function buildRunLog(params: BuildRunLogParams): RunLog { fieldLabel: field.label, fieldType: field.type, value: parsedValue, + valueLabel: valueDisplayLabel, }, summary: { total: records.length, diff --git a/src/metadata/field-hints.ts b/src/metadata/field-hints.ts index c091041..5358ce8 100644 --- a/src/metadata/field-hints.ts +++ b/src/metadata/field-hints.ts @@ -1,4 +1,5 @@ import type { FieldDescription, FieldType } from "@arco/clarizen-lib"; +import { isPickListField } from "./picklist.js"; const TYPE_EXAMPLES: Record = { Boolean: "true, false, sim, não", @@ -20,11 +21,20 @@ export function getFieldTypeExample(type: FieldType | undefined): string { export function formatFieldHint(field: FieldDescription): string { const type = field.type ?? "desconhecido"; - const example = getFieldTypeExample(field.type); - const lines = [ - `Tipo: ${type}`, - `Exemplo: ${example}`, - ]; + const lines: string[] = []; + + if (isPickListField(field)) { + lines.push("Tipo: PickList (lista de opções)"); + lines.push("Escolha um valor na lista — não digite o nome exibido manualmente."); + const refType = (field as { referencedEntities?: string[] }).referencedEntities?.[0]; + if (refType) { + lines.push(`Opções carregadas de: ${refType}`); + } + } else { + const example = getFieldTypeExample(field.type); + lines.push(`Tipo: ${type}`); + lines.push(`Exemplo: ${example}`); + } if (field.nullable) { lines.push("Este campo aceita valor vazio (null) para limpar."); @@ -39,8 +49,9 @@ export function formatFieldHint(field: FieldDescription): string { export function formatFieldOptionLabel(field: FieldDescription): string { const label = field.label?.trim() || field.name || "?"; const name = field.name ?? "?"; - const type = field.type ?? "?"; - return `${label} (${name}) — ${type}`; + const presentation = + field.presentationType === "PickList" ? "PickList" : (field.type ?? "?"); + return `${label} (${name}) — ${presentation}`; } export function isUpdatableField(field: FieldDescription): boolean { diff --git a/src/metadata/picker.ts b/src/metadata/picker.ts index 7e7732d..41fc91f 100644 --- a/src/metadata/picker.ts +++ b/src/metadata/picker.ts @@ -63,7 +63,7 @@ async function pickFromList( return select({ message, options }); } -async function pickFromFiltered( +export async function pickFromFiltered( message: string, allOptions: T[], filterPrompt: string, diff --git a/src/metadata/picklist.ts b/src/metadata/picklist.ts new file mode 100644 index 0000000..9cdd368 --- /dev/null +++ b/src/metadata/picklist.ts @@ -0,0 +1,91 @@ +import type { Clarizen, FieldDescription } from "@arco/clarizen-lib"; +import { resolveDisplayField } from "../query/resolve-display-field.js"; + +const PAGE_SIZE = 500; + +export interface FieldWithRefs extends FieldDescription { + referencedEntities?: string[]; +} + +export interface PickListOption { + id: string; + label: string; +} + +export function isPickListField(field: FieldWithRefs): boolean { + return field.presentationType === "PickList"; +} + +export function getPickListEntityType(field: FieldWithRefs): string | undefined { + return field.referencedEntities?.[0]; +} + +function extractLabel( + entity: Record, + displayField: string, +): string | undefined { + const objectFields = entity.objectFields as Record | undefined; + const value = entity[displayField] ?? objectFields?.[displayField]; + if (value === null || value === undefined) return undefined; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (typeof value === "object" && value !== null && "name" in value) { + const name = (value as { name?: unknown }).name; + if (name !== null && name !== undefined) return String(name); + } + return undefined; +} + +function idSuffix(id: string): string { + return id.includes("/") ? id.split("/").pop()! : id; +} + +export async function fetchPickListOptions( + clarizen: Clarizen, + pickListTypeName: string, + onProgress?: (count: number) => void, +): Promise { + const meta = await clarizen.metadata.describeMetadata({ + typeNames: [pickListTypeName], + flags: ["fields"], + }); + const entityMeta = meta.entityDescriptions?.find( + (e) => e.typeName === pickListTypeName, + ); + if (!entityMeta) { + throw new Error(`Metadata não encontrada para picklist ${pickListTypeName}.`); + } + + const displayField = resolveDisplayField(entityMeta); + const options: PickListOption[] = []; + let from = 0; + + while (true) { + const result = await clarizen.data.entityQuery({ + typeName: pickListTypeName, + fields: [displayField], + paging: { from, limit: PAGE_SIZE }, + }); + + for (const entity of result.entities) { + if (!entity.id) continue; + const id = String(entity.id); + const label = + extractLabel(entity as Record, displayField) ?? + idSuffix(id); + options.push({ id, label }); + } + + onProgress?.(options.length); + + if (!result.paging?.hasMore) break; + from += PAGE_SIZE; + } + + return options.sort((a, b) => a.label.localeCompare(b.label, "pt-BR")); +} + +export function formatPickListOptionLabel(option: PickListOption): string { + return `${option.label} (${option.id})`; +} diff --git a/src/parse/prompt-picklist.ts b/src/parse/prompt-picklist.ts new file mode 100644 index 0000000..ceaef2d --- /dev/null +++ b/src/parse/prompt-picklist.ts @@ -0,0 +1,134 @@ +import type { Clarizen, JsonValue } from "@arco/clarizen-lib"; +import { isCancel, select, spinner, text } from "@clack/prompts"; +import { formatFieldHint } from "../metadata/field-hints.js"; +import { + fetchPickListOptions, + formatPickListOptionLabel, + getPickListEntityType, + type FieldWithRefs, +} from "../metadata/picklist.js"; +import { pickFromFiltered } from "../metadata/picker.js"; +import { parseFieldValue, ValueParseError } from "./value.js"; + +export interface FieldValueSelection { + value: JsonValue; + displayLabel?: string; +} + +async function pickPickListOption( + field: FieldWithRefs, + options: Array<{ value: string; label: string }>, +): Promise { + const fieldLabel = field.label ?? field.name ?? "valor"; + return pickFromFiltered( + `Escolha o valor para ${fieldLabel}`, + options, + "Como deseja buscar na lista?", + ); +} + +async function promptPickListSelection( + clarizen: Clarizen, + field: FieldWithRefs, + pickListTypeName: string, +): Promise { + const loadSpinner = spinner(); + loadSpinner.start(`Carregando opções de ${pickListTypeName}...`); + + const pickListOptions = await fetchPickListOptions( + clarizen, + pickListTypeName, + (count) => { + loadSpinner.message( + `Carregando opções de ${pickListTypeName}... (${count})`, + ); + }, + ); + + loadSpinner.stop(`${pickListOptions.length} opção(ões) carregada(s).`); + + if (pickListOptions.length === 0) { + console.log( + `\nNenhuma opção encontrada para ${pickListTypeName}. Informe o ID manualmente.\n`, + ); + return promptManualEntityValue(field); + } + + const selectOptions = pickListOptions.map((o) => ({ + value: o.id, + label: formatPickListOptionLabel(o), + })); + + const selectedId = await pickPickListOption(field, selectOptions); + if (isCancel(selectedId)) return selectedId; + + const match = pickListOptions.find((o) => o.id === selectedId); + return { + value: { id: selectedId }, + displayLabel: match + ? `${match.label} (${match.id})` + : String(selectedId), + }; +} + +async function promptManualEntityValue( + field: FieldWithRefs, +): Promise { + while (true) { + const raw = await text({ + message: "Informe o ID da entidade (ex: /Tipo/guid)", + validate: (value) => (!value?.trim() ? "Valor obrigatório." : undefined), + }); + if (isCancel(raw)) return raw; + + try { + const value = parseFieldValue(raw, field); + return { value, displayLabel: formatEntityDisplayLabel(value) }; + } catch (err) { + if (err instanceof ValueParseError) { + console.error(`\n Erro: ${err.message}\n`); + continue; + } + throw err; + } + } +} + +function formatEntityDisplayLabel(value: JsonValue): string | undefined { + if (value && typeof value === "object" && "id" in value) { + const id = (value as { id?: unknown }).id; + if (typeof id === "string") return id; + } + return undefined; +} + +export async function promptPickListValue( + clarizen: Clarizen, + field: FieldWithRefs, +): Promise { + console.log("\n" + formatFieldHint(field) + "\n"); + + if (field.nullable) { + const action = await select({ + message: "O que deseja fazer com este campo?", + options: [ + { value: "set", label: "Definir um valor" }, + { value: "clear", label: "Limpar campo (null)" }, + ], + }); + if (isCancel(action)) return action; + if (action === "clear") { + return { value: null, displayLabel: "(vazio / null)" }; + } + } + + const pickListTypeName = getPickListEntityType(field); + if (!pickListTypeName) { + console.log( + "\nPicklist sem tipo de referência na metadata. Informe o ID manualmente.\n", + ); + return promptManualEntityValue(field); + } + + return promptPickListSelection(clarizen, field, pickListTypeName); +} diff --git a/src/parse/value.ts b/src/parse/value.ts index fb18602..e933ea0 100644 --- a/src/parse/value.ts +++ b/src/parse/value.ts @@ -111,8 +111,16 @@ export function parseFieldValue( return parseByType(raw, field.type); } -export function formatValueForDisplay(value: JsonValue): string { +export function formatValueForDisplay( + value: JsonValue, + displayLabel?: string, +): string { + if (displayLabel) return displayLabel; if (value === null) return "(vazio / null)"; if (typeof value === "string") return `"${value}"`; + if (typeof value === "object" && value !== null && "id" in value) { + const id = (value as { id?: unknown }).id; + if (typeof id === "string") return id; + } return JSON.stringify(value); }