feat(2.0): Fase 2 — editor de templates na config + paleta + create_project
- seção "Templates de Payload (avançado)" no formconfig: card por operação, editor linha-a-linha (flatten/unflatten em JS puro) com toggle Ver JSON, Gerar template (seed + CFs descobertos), ativar/desativar - paleta de variáveis clicável (copia tag) + referência do Redmine do ambiente (trackers/status/prioridades/atividades/custom fields com valores) - buildFormContext (form.*) e wiring do create_project nos dois controllers, com fallback 1.x em erro de render - validado: render da UI (4 cards + paleta + JS) e E2E create_project via template no Redmine dev (tipos nativos, identifier minúsculo, parent, trackers) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7ac1f8ac44
commit
1053b9f506
7 changed files with 427 additions and 1 deletions
|
|
@ -13,6 +13,13 @@ e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
|
|||
equivalentes ao comportamento 1.x e descoberta de custom fields obrigatórios.
|
||||
Sem template ativo, o plugin se comporta exatamente como o 1.x; erro de render
|
||||
também cai no 1.x (nunca perde lançamento).
|
||||
- **Editor de Templates de Payload na config** (Fase 2): um card por operação com
|
||||
editor **linha-a-linha** (caminho do campo → valor/tag) e toggle "Ver JSON",
|
||||
botão **"Gerar template"** (seed + custom fields descobertos), ativar/desativar,
|
||||
e **paleta de variáveis** clicável (copia a tag) com referência do ambiente
|
||||
Redmine (trackers/status/prioridades/atividades/custom fields e valores).
|
||||
- Operação `create_project` integrada ao motor de templates (contexto `form.*`
|
||||
com os campos da aba, incluindo `form.cf_<id>` dos custom fields).
|
||||
|
||||
## [1.6.0]
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,43 @@ if (isset($_POST["update"]) || isset($_POST["sync"])) {
|
|||
Html::back();
|
||||
}
|
||||
|
||||
// Save a payload template (motor 2.0).
|
||||
if (isset($_POST['save_template'])) {
|
||||
$op = (string) ($_POST['template_op'] ?? '');
|
||||
if (in_array($op, PluginRedmineTemplateengine::OPERATIONS, true)) {
|
||||
$json = (string) ($_POST['template_json'] ?? '');
|
||||
// Defensivo contra escaping de aspas na camada de entrada.
|
||||
if ($json !== '' && json_decode($json, true) === null && json_decode(stripslashes($json), true) !== null) {
|
||||
$json = stripslashes($json);
|
||||
}
|
||||
if ($json !== '' && json_decode($json, true) === null) {
|
||||
Session::addMessageAfterRedirect(__('Template não é JSON válido — nada foi salvo.', 'redmine'), false, ERROR);
|
||||
} else {
|
||||
PluginRedmineTemplateengine::save($op, $json, !empty($_POST['template_active']) && $json !== '');
|
||||
Session::addMessageAfterRedirect(__('Template salvo.', 'redmine'));
|
||||
}
|
||||
}
|
||||
Html::back();
|
||||
}
|
||||
|
||||
// Generate the seed template for an operation (comportamento 1.x + custom fields descobertos).
|
||||
if (isset($_POST['gen_template'])) {
|
||||
$op = (string) ($_POST['template_op'] ?? '');
|
||||
if (in_array($op, PluginRedmineTemplateengine::OPERATIONS, true)) {
|
||||
$seed = PluginRedmineTemplateengine::mergeDiscoveredCustomFields(
|
||||
$op,
|
||||
PluginRedmineTemplateengine::defaultTemplate($op)
|
||||
);
|
||||
PluginRedmineTemplateengine::save(
|
||||
$op,
|
||||
json_encode($seed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
false
|
||||
);
|
||||
Session::addMessageAfterRedirect(__('Template gerado (inativo). Revise os campos e ative quando estiver pronto.', 'redmine'));
|
||||
}
|
||||
Html::back();
|
||||
}
|
||||
|
||||
// Add a manual user mapping override (GLPI user -> Redmine user).
|
||||
if (isset($_POST["add_usermap"])) {
|
||||
$uid = (int) ($_POST['override_users_id'] ?? 0);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,24 @@ if (isset($_POST["action"])) {
|
|||
Session::addMessageAfterRedirect(__('Entidade vinculada com sucesso!', 'redmine'));
|
||||
} else {
|
||||
// CREATE OPERATION
|
||||
$new_redmine_id = false;
|
||||
|
||||
// Motor 2.0: template ativo tem precedência; erro de RENDER cai no 1.x.
|
||||
$tpl = PluginRedmineTemplateengine::getActive('create_project');
|
||||
if ($tpl) {
|
||||
try {
|
||||
$ctx = PluginRedmineTemplateengine::buildFormContext($_POST);
|
||||
$rendered = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
|
||||
if (!empty($rendered['project'])) {
|
||||
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($rendered['project']);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Toolbox::logInFile('redmine', "Template create_project falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n");
|
||||
$tpl = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$tpl) {
|
||||
$payload = [
|
||||
'name' => $_POST['name'],
|
||||
'identifier' => $_POST['identifier'],
|
||||
|
|
@ -93,6 +111,8 @@ if (isset($_POST["action"])) {
|
|||
}
|
||||
|
||||
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload);
|
||||
} // fim do motor 1.x
|
||||
|
||||
if ($new_redmine_id) {
|
||||
$DB->insert('glpi_plugin_redmine_entities', [
|
||||
'entities_id' => $entities_id,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,24 @@ if (isset($_POST["action"])) {
|
|||
Session::addMessageAfterRedirect("Projeto vinculado com sucesso!");
|
||||
} else {
|
||||
// CREATE OPERATION
|
||||
$new_redmine_id = false;
|
||||
|
||||
// Motor 2.0: template ativo tem precedência; erro de RENDER cai no 1.x.
|
||||
$tpl = PluginRedmineTemplateengine::getActive('create_project');
|
||||
if ($tpl) {
|
||||
try {
|
||||
$ctx = PluginRedmineTemplateengine::buildFormContext($_POST);
|
||||
$rendered = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
|
||||
if (!empty($rendered['project'])) {
|
||||
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($rendered['project']);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Toolbox::logInFile('redmine', "Template create_project falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n");
|
||||
$tpl = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$tpl) {
|
||||
$payload = [
|
||||
'name' => $_POST['name'],
|
||||
'identifier' => $_POST['identifier'],
|
||||
|
|
@ -100,6 +118,7 @@ if (isset($_POST["action"])) {
|
|||
}
|
||||
|
||||
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload);
|
||||
} // fim do motor 1.x
|
||||
|
||||
if ($new_redmine_id) {
|
||||
global $DB;
|
||||
|
|
|
|||
|
|
@ -196,7 +196,50 @@ class PluginRedmineConfig extends CommonDBTM {
|
|||
];
|
||||
}
|
||||
|
||||
// Templates de payload (motor 2.0) + paleta de variáveis
|
||||
$tpl_labels = [
|
||||
'create_issue' => __('Criar tarefa (issue)', 'redmine'),
|
||||
'log_time' => __('Lançar tempo', 'redmine'),
|
||||
'update_status' => __('Atualizar status', 'redmine'),
|
||||
'create_project' => __('Criar projeto', 'redmine'),
|
||||
];
|
||||
$templates = [];
|
||||
foreach (PluginRedmineTemplateengine::OPERATIONS as $op) {
|
||||
$row = PluginRedmineTemplateengine::getRow($op);
|
||||
$templates[$op] = [
|
||||
'label' => $tpl_labels[$op] ?? $op,
|
||||
'template' => (string) ($row['template'] ?? ''),
|
||||
'is_active' => (int) ($row['is_active'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
// Referência do ambiente Redmine (ids -> nomes) para consulta na paleta
|
||||
$palette_ref = [];
|
||||
$ref_src = [
|
||||
__('Trackers', 'redmine') => $metadata['trackers'] ?? [],
|
||||
__('Status', 'redmine') => $metadata['statuses'] ?? [],
|
||||
__('Prioridades', 'redmine') => $metadata['priorities'] ?? [],
|
||||
__('Atividades', 'redmine') => $metadata['activities'] ?? [],
|
||||
];
|
||||
foreach ($ref_src as $glabel => $list) {
|
||||
foreach ($list as $el) {
|
||||
if (isset($el['id'], $el['name'])) {
|
||||
$palette_ref[$glabel][] = $el['id'] . ' = ' . $el['name'];
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (($metadata['project_custom_fields'] ?? []) as $f) {
|
||||
$vals = array_column($f['possible_values'] ?? [], 'value');
|
||||
$palette_ref[__('Custom fields (projeto)', 'redmine')][] =
|
||||
'cf ' . $f['id'] . ' = ' . $f['name']
|
||||
. (!empty($f['is_required']) ? ' *obrigatório*' : '')
|
||||
. ($vals ? ' [' . implode(' | ', $vals) . ']' : '');
|
||||
}
|
||||
|
||||
\Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/config_form.html.twig', [
|
||||
'templates' => $templates,
|
||||
'palette_vars' => PluginRedmineTemplateengine::variableCatalog(),
|
||||
'palette_ref' => $palette_ref,
|
||||
'action_url' => Toolbox::getItemTypeFormURL(__CLASS__),
|
||||
'csrf_token_value' => Session::getNewCSRFToken(),
|
||||
'redmine_url' => $config['redmine_url'] ?? '',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@ class PluginRedmineTemplateengine {
|
|||
return count($it) > 0 ? $it->current() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linha do template da operação (ativa ou não), ou null.
|
||||
*/
|
||||
public static function getRow($operation) {
|
||||
global $DB;
|
||||
if (!$DB->tableExists('glpi_plugin_redmine_templates')) {
|
||||
return null;
|
||||
}
|
||||
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_templates', 'WHERE' => ['operation' => $operation]]);
|
||||
return count($it) > 0 ? $it->current() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grava (upsert) o template de uma operação.
|
||||
*/
|
||||
|
|
@ -129,10 +141,78 @@ class PluginRedmineTemplateengine {
|
|||
return $template;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Paleta (catálogo de variáveis para a UI)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Catálogo de variáveis disponíveis, com a tag pronta para copiar.
|
||||
* @return array de ['group','tag','desc']
|
||||
*/
|
||||
public static function variableCatalog() {
|
||||
$cat = [];
|
||||
$add = function ($group, $var, $desc) use (&$cat) {
|
||||
$cat[] = ['group' => $group, 'tag' => '{{ ' . $var . ' }}', 'desc' => $desc];
|
||||
};
|
||||
$add('chamado', 'chamado.id', 'ID do chamado');
|
||||
$add('chamado', 'chamado.titulo | truncar:255', 'Título (truncado p/ o limite do Redmine)');
|
||||
$add('chamado', 'chamado.descricao | sem_html', 'Descrição sem HTML');
|
||||
$add('chamado', 'chamado.status', 'Status do chamado (número GLPI)');
|
||||
$add('chamado', 'chamado.entidade', 'ID da entidade do chamado');
|
||||
$add('tarefa', 'tarefa.horas', 'Duração em horas decimais (1h30 = 1.5)');
|
||||
$add('tarefa', 'tarefa.segundos', 'Duração em segundos');
|
||||
$add('tarefa', 'tarefa.descricao', 'Descrição da tarefa (sem HTML)');
|
||||
$add('tarefa', 'tarefa.comentario', 'Descrição ou fallback ("Tempo lançado via GLPI...")');
|
||||
$add('tarefa', 'tarefa.data', 'Data da tarefa (AAAA-MM-DD)');
|
||||
$add('vinculo', 'vinculo.projeto_redmine', 'ID do projeto Redmine vinculado');
|
||||
$add('vinculo', 'vinculo.issue_redmine', 'ID da issue Redmine do chamado');
|
||||
$add('vinculo', 'vinculo.categoria_padrao', 'Categoria padrão do vínculo');
|
||||
$add('config', 'config.tracker_padrao', 'Tracker padrão (formconfig)');
|
||||
$add('config', 'config.prioridade_padrao', 'Prioridade padrão (formconfig)');
|
||||
$add('config', 'config.atividade_padrao', 'Atividade padrão (formconfig)');
|
||||
$add('config', 'config.observadores', 'Observadores padrão (array de ids)');
|
||||
$add('map', 'map.status[chamado.status]', 'Status Redmine mapeado do status do chamado');
|
||||
$add('form (criar projeto)', 'form.nome', 'Nome digitado no form');
|
||||
$add('form (criar projeto)', 'form.identificador | identificador', 'Identificador normalizado');
|
||||
$add('form (criar projeto)', 'form.descricao', 'Descrição do form');
|
||||
$add('form (criar projeto)', 'form.publico', 'Público (true/false)');
|
||||
$add('form (criar projeto)', 'form.subprojeto_de', 'Projeto pai escolhido');
|
||||
$add('form (criar projeto)', 'form.trackers', 'Trackers selecionados (array)');
|
||||
$add('form (criar projeto)', 'form.modulos', 'Módulos selecionados (array)');
|
||||
$add('form (criar projeto)', 'form.cf_<id>', 'Custom field do form (ex.: form.cf_4)');
|
||||
return $cat;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Contexto
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Contexto para o create_project (aba Redmine de Projeto/Entidade):
|
||||
* base (config/map) + namespace form.* a partir do POST do formulário.
|
||||
*/
|
||||
public static function buildFormContext(array $post) {
|
||||
$ctx = self::buildContext();
|
||||
$form = [
|
||||
'nome' => (string) ($post['name'] ?? ''),
|
||||
'identificador' => (string) ($post['identifier'] ?? ''),
|
||||
'descricao' => (string) ($post['description'] ?? ''),
|
||||
'publico' => !empty($post['is_public']),
|
||||
'subprojeto_de' => !empty($post['parent_id']) ? (int) $post['parent_id'] : null,
|
||||
'trackers' => !empty($post['tracker_ids']) && is_array($post['tracker_ids'])
|
||||
? array_values(array_filter(array_map('intval', $post['tracker_ids']))) : null,
|
||||
'modulos' => !empty($post['enabled_module_names']) && is_array($post['enabled_module_names'])
|
||||
? array_values($post['enabled_module_names']) : null,
|
||||
];
|
||||
foreach (($post['custom_field_values'] ?? []) as $cfid => $v) {
|
||||
if ($v !== '' && $v !== null && $v !== '0' && $v !== 0) {
|
||||
$form['cf_' . (int) $cfid] = $v;
|
||||
}
|
||||
}
|
||||
$ctx['form'] = $form;
|
||||
return $ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta o contexto de variáveis (a "paleta") para o render.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -140,3 +140,223 @@
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ====================================================================
|
||||
Templates de Payload (motor 2.0) — editor linha-a-linha + paleta
|
||||
==================================================================== #}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title mb-0"><i class="ti ti-code me-1"></i>{{ __('Templates de Payload (avançado)', 'redmine') }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
{{ __('Personalize o JSON enviado ao Redmine em cada operação. Sem template ativo, o plugin usa o comportamento padrão. "Gerar template" cria o ponto de partida equivalente ao padrão + campos obrigatórios descobertos no seu Redmine.', 'redmine') }}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{# ---- Paleta ---- #}
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header py-2"><strong>{{ __('Variáveis (clique para copiar)', 'redmine') }}</strong></div>
|
||||
<div class="card-body p-2" style="max-height: 420px; overflow-y: auto;">
|
||||
{% set lastgroup = '' %}
|
||||
{% for v in palette_vars %}
|
||||
{% if v.group != lastgroup %}
|
||||
{% set lastgroup = v.group %}
|
||||
<div class="mt-2 mb-1 text-uppercase text-muted" style="font-size: .7rem;">{{ v.group }}</div>
|
||||
{% endif %}
|
||||
<div class="rm-var d-flex justify-content-between align-items-center px-1 rounded" data-tag="{{ v.tag }}" title="{{ v.desc }}">
|
||||
<code style="font-size:.75rem">{{ v.tag }}</code>
|
||||
<i class="ti ti-copy text-muted" style="font-size:.7rem"></i>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if palette_ref is not empty %}
|
||||
<hr class="my-2">
|
||||
<div class="text-uppercase text-muted mb-1" style="font-size:.7rem">{{ __('Referência do seu Redmine', 'redmine') }}</div>
|
||||
{% for glabel, items in palette_ref %}
|
||||
<div class="mt-1 mb-1"><strong style="font-size:.75rem">{{ glabel }}</strong></div>
|
||||
{% for item in items %}
|
||||
<div style="font-size:.72rem" class="text-muted">{{ item }}</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Editores por operação ---- #}
|
||||
<div class="col-lg-8">
|
||||
{% for op, t in templates %}
|
||||
<form method="post" action="{{ action_url }}">
|
||||
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
|
||||
<input type="hidden" name="template_op" value="{{ op }}">
|
||||
<div class="card mb-3 rm-tpl-card">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong>{{ t.label }}</strong>
|
||||
<code class="ms-1" style="font-size:.7rem">{{ op }}</code>
|
||||
{% if t.is_active %}
|
||||
<span class="badge bg-green-lt text-green ms-1">{{ __('ativo', 'redmine') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-lt ms-1">{{ __('inativo (usa o padrão)', 'redmine') }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary rm-tpl-toggle">{{ __('Ver JSON', 'redmine') }}</button>
|
||||
<button type="submit" name="gen_template" value="1" class="btn btn-sm btn-outline-primary"
|
||||
onclick="return confirm('{{ __('Gerar/regravar o template desta operação a partir do padrão? O conteúdo atual será substituído (fica inativo até você ativar).', 'redmine') }}');">
|
||||
<i class="ti ti-wand me-1"></i>{{ __('Gerar template', 'redmine') }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="rm-tpl-lines"></div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary rm-tpl-add mt-1">
|
||||
<i class="ti ti-plus me-1"></i>{{ __('Adicionar campo', 'redmine') }}
|
||||
</button>
|
||||
<textarea name="template_json" class="form-control rm-tpl-json d-none mt-1" rows="10"
|
||||
spellcheck="false" style="font-family: monospace; font-size:.8rem">{{ t.template }}</textarea>
|
||||
</div>
|
||||
<div class="card-footer py-2 d-flex justify-content-between align-items-center">
|
||||
<label class="form-check m-0">
|
||||
<input type="checkbox" class="form-check-input" name="template_active" value="1" {{ t.is_active ? 'checked' : '' }}>
|
||||
<span class="form-check-label">{{ __('Ativo', 'redmine') }}</span>
|
||||
</label>
|
||||
<button type="submit" name="save_template" value="1" class="btn btn-sm btn-primary">
|
||||
<i class="ti ti-device-floppy me-1"></i>{{ _x('button', 'Save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% verbatim %}
|
||||
<script>
|
||||
(function () {
|
||||
// ---- Paleta: clique copia a tag ----
|
||||
document.querySelectorAll('.rm-var').forEach(function (el) {
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('click', function () {
|
||||
var tag = el.dataset.tag;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(tag);
|
||||
} else {
|
||||
window.prompt('Copie a tag:', tag);
|
||||
}
|
||||
el.classList.add('bg-green-lt');
|
||||
setTimeout(function () { el.classList.remove('bg-green-lt'); }, 500);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Editor linha-a-linha <-> JSON ----
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
function flatten(obj, prefix, out) {
|
||||
for (var k in obj) {
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, k)) continue;
|
||||
var v = obj[k];
|
||||
var p = prefix ? prefix + '.' + k : k;
|
||||
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||||
flatten(v, p, out);
|
||||
} else {
|
||||
out.push([p, (typeof v === 'string') ? v : JSON.stringify(v)]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function parseVal(s) {
|
||||
s = String(s).trim();
|
||||
if (s === '') return '';
|
||||
try {
|
||||
var v = JSON.parse(s);
|
||||
if (typeof v === 'number' || typeof v === 'boolean' || Array.isArray(v) || v === null) return v;
|
||||
} catch (e) { /* string */ }
|
||||
return s;
|
||||
}
|
||||
function unflatten(rows) {
|
||||
var root = {};
|
||||
rows.forEach(function (r) {
|
||||
var p = r[0], val = r[1];
|
||||
if (!p) return;
|
||||
var parts = p.split('.');
|
||||
var cur = root;
|
||||
parts.forEach(function (seg, i) {
|
||||
if (i === parts.length - 1) {
|
||||
cur[seg] = parseVal(val);
|
||||
} else {
|
||||
if (typeof cur[seg] !== 'object' || cur[seg] === null || Array.isArray(cur[seg])) cur[seg] = {};
|
||||
cur = cur[seg];
|
||||
}
|
||||
});
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.rm-tpl-card').forEach(function (card) {
|
||||
var ta = card.querySelector('.rm-tpl-json');
|
||||
var linesBox = card.querySelector('.rm-tpl-lines');
|
||||
var toggle = card.querySelector('.rm-tpl-toggle');
|
||||
var addBtn = card.querySelector('.rm-tpl-add');
|
||||
var form = card.closest('form');
|
||||
var rawMode = false;
|
||||
|
||||
function addRow(p, v) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'd-flex gap-2 mb-1 rm-tpl-row';
|
||||
row.innerHTML =
|
||||
'<input class="form-control form-control-sm rm-k" style="max-width:42%;font-family:monospace;font-size:.78rem" placeholder="caminho.do.campo" value="' + esc(p) + '">' +
|
||||
'<input class="form-control form-control-sm rm-v" style="font-family:monospace;font-size:.78rem" placeholder="valor fixo ou tag da paleta" value="' + esc(v) + '">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-danger rm-del">×</button>';
|
||||
row.querySelector('.rm-del').addEventListener('click', function () { row.remove(); });
|
||||
linesBox.appendChild(row);
|
||||
}
|
||||
function renderLines() {
|
||||
linesBox.innerHTML = '';
|
||||
var data = {};
|
||||
var raw = ta.value.trim();
|
||||
if (raw !== '') {
|
||||
try { data = JSON.parse(raw); } catch (e) { rawMode = true; setMode(); return; }
|
||||
}
|
||||
flatten(data, '', []).forEach(function (r) { addRow(r[0], r[1]); });
|
||||
if (!linesBox.children.length) addRow('', '');
|
||||
}
|
||||
function syncToTextarea() {
|
||||
if (rawMode) return;
|
||||
var rows = Array.prototype.map.call(linesBox.querySelectorAll('.rm-tpl-row'), function (r) {
|
||||
return [r.querySelector('.rm-k').value.trim(), r.querySelector('.rm-v').value];
|
||||
}).filter(function (r) { return r[0] !== ''; });
|
||||
ta.value = rows.length ? JSON.stringify(unflatten(rows), null, 2) : '';
|
||||
}
|
||||
function setMode() {
|
||||
ta.classList.toggle('d-none', !rawMode);
|
||||
linesBox.classList.toggle('d-none', rawMode);
|
||||
addBtn.classList.toggle('d-none', rawMode);
|
||||
toggle.textContent = rawMode ? 'Ver campos' : 'Ver JSON';
|
||||
}
|
||||
toggle.addEventListener('click', function () {
|
||||
if (rawMode) {
|
||||
var raw = ta.value.trim();
|
||||
if (raw !== '') {
|
||||
try { JSON.parse(raw); } catch (e) { alert('JSON inválido — corrija antes de voltar para os campos.'); return; }
|
||||
}
|
||||
rawMode = false;
|
||||
renderLines();
|
||||
} else {
|
||||
syncToTextarea();
|
||||
rawMode = true;
|
||||
}
|
||||
setMode();
|
||||
});
|
||||
addBtn.addEventListener('click', function () { addRow('', ''); });
|
||||
form.addEventListener('submit', function () { syncToTextarea(); });
|
||||
renderLines();
|
||||
setMode();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endverbatim %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue