feat(2.0): Salvar único — templates entram no form principal da config

- cards de template movidos para dentro do form principal (inputs em array
  templates_json[op]/templates_active[op]); sem forms aninhados
- "Gerar template" virou ação LOCAL no browser (data-seed preenche o editor;
  nada é salvo até o Salvar único) — sem reload
- removidos os handlers save_template/gen_template e os 4 botões Salvar por card
- o handler de update salva config + mapa + todos os templates numa tacada,
  validando o JSON de cada um (inválido = não salvo, com aviso)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gemini 2026-07-02 17:20:28 -03:00
parent 5ee12c75d2
commit aa5bf5eb75
3 changed files with 154 additions and 153 deletions

View file

@ -10,6 +10,30 @@ if (isset($_POST["update"]) || isset($_POST["sync"])) {
// Save whatever was typed (URL/key/defaults) before anything else. // Save whatever was typed (URL/key/defaults) before anything else.
$config->update($_POST); $config->update($_POST);
// Templates de payload: salvos junto pelo botão Salvar único.
if (!empty($_POST['templates_json']) && is_array($_POST['templates_json'])) {
foreach ($_POST['templates_json'] as $op => $json) {
if (!in_array($op, PluginRedmineTemplateengine::OPERATIONS, true)) {
continue;
}
$json = (string) $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(
sprintf(__('Template "%s" não é JSON válido — não foi salvo.', 'redmine'), $op),
false,
ERROR
);
continue;
}
$active = !empty($_POST['templates_active'][$op]) && $json !== '';
PluginRedmineTemplateengine::save($op, $json, $active);
}
}
if (isset($_POST["sync"])) { if (isset($_POST["sync"])) {
$metadata = PluginRedmineRedmineapi::syncMetadata(); $metadata = PluginRedmineRedmineapi::syncMetadata();
if ($metadata !== false) { if ($metadata !== false) {
@ -26,43 +50,6 @@ if (isset($_POST["update"]) || isset($_POST["sync"])) {
Html::back(); 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). // Add a manual user mapping override (GLPI user -> Redmine user).
if (isset($_POST["add_usermap"])) { if (isset($_POST["add_usermap"])) {
$uid = (int) ($_POST['override_users_id'] ?? 0); $uid = (int) ($_POST['override_users_id'] ?? 0);

View file

@ -206,10 +206,16 @@ class PluginRedmineConfig extends CommonDBTM {
$templates = []; $templates = [];
foreach (PluginRedmineTemplateengine::OPERATIONS as $op) { foreach (PluginRedmineTemplateengine::OPERATIONS as $op) {
$row = PluginRedmineTemplateengine::getRow($op); $row = PluginRedmineTemplateengine::getRow($op);
$seed = PluginRedmineTemplateengine::mergeDiscoveredCustomFields(
$op,
PluginRedmineTemplateengine::defaultTemplate($op)
);
$templates[$op] = [ $templates[$op] = [
'label' => $tpl_labels[$op] ?? $op, 'label' => $tpl_labels[$op] ?? $op,
'template' => (string) ($row['template'] ?? ''), 'template' => (string) ($row['template'] ?? ''),
'is_active' => (int) ($row['is_active'] ?? 0), 'is_active' => (int) ($row['is_active'] ?? 0),
// seed pro botão "Gerar template" (ação local no browser)
'seed' => json_encode($seed, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]; ];
} }

View file

@ -78,6 +78,115 @@
</div> </div>
</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>
{# Variáveis do GLPI como AUTOCOMPLETE nos campos de valor #}
<datalist id="rm-glpi-vars">
{% for v in palette_vars %}
<option value="{{ v.tag }}">{{ v.group }}{{ v.desc }}</option>
{% endfor %}
</datalist>
{# Referência opcional (colapsada) — descoberta das tags para remapeamentos #}
<div class="mb-3">
<a class="small" data-bs-toggle="collapse" href="#rm-glpi-vars-panel" role="button">
<i class="ti ti-variable me-1"></i>{{ __('Variáveis do GLPI disponíveis — o "DE" (referência; nos campos de valor há autocomplete)', 'redmine') }}
</a>
<div class="collapse" id="rm-glpi-vars-panel">
<div class="card card-body p-2 mt-1" style="max-height:300px;overflow-y:auto;columns:2">
{% set lastgroup = '' %}
{% for v in palette_vars %}
{% if v.group != lastgroup %}
{% set lastgroup = v.group %}
<div class="mt-1 mb-1 text-uppercase text-muted" style="font-size:.7rem;break-inside:avoid">{{ v.group }}</div>
{% endif %}
<div class="rm-var px-1 rounded" data-tag="{{ v.tag }}" title="{{ v.desc }}" style="break-inside:avoid">
<code style="font-size:.75rem">{{ v.tag }}</code>
</div>
{% endfor %}
</div>
</div>
</div>
{# ---- Editores por operação (largura total) ---- #}
<div>
{% for op, t in templates %}
<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="button" class="btn btn-sm btn-outline-primary rm-tpl-gen" data-seed="{{ t.seed }}"
data-confirm="{{ __('Preencher o editor com o template padrão (+ campos obrigatórios do seu Redmine)? O conteúdo atual do editor será substituído — nada é salvo até você clicar em Salvar.', 'redmine') }}">
<i class="ti ti-wand me-1"></i>{{ __('Gerar template', 'redmine') }}
</button>
</span>
</div>
{% if field_dicts[op] is defined and field_dicts[op] is not empty %}
<div class="px-3 py-1 border-bottom">
<a class="small" data-bs-toggle="collapse" href="#rmdict-{{ op }}" role="button">
<i class="ti ti-book me-1"></i>{{ __('Campos que o Redmine espera — o "PARA" (clique num campo para adicioná-lo)', 'redmine') }}
</a>
<div class="collapse" id="rmdict-{{ op }}">
<table class="table table-sm table-hover mb-1 mt-1" style="font-size:.75rem">
<tbody>
{% for f in field_dicts[op] %}
<tr class="rm-dict-row" data-path="{{ f.path }}" title="{{ __('Clique para adicionar este campo ao template', 'redmine') }}">
<td style="white-space:nowrap">
<code>{{ f.path }}</code>
{% if f.required %}<span class="badge bg-red-lt text-red ms-1">{{ __('obrigatório', 'redmine') }}</span>{% endif %}
</td>
<td>
{{ f.desc }}
{% if f.values is not empty %}
<div class="text-muted" style="font-size:.7rem">{{ f.values|join(' · ') }}</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<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="templates_json[{{ op }}]" 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">
<label class="form-check m-0">
<input type="checkbox" class="form-check-input" name="templates_active[{{ op }}]" value="1" {{ t.is_active ? 'checked' : '' }}>
<span class="form-check-label">{{ __('Ativo (salvo pelo botão Salvar no fim da página)', 'redmine') }}</span>
</label>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="d-flex justify-content-end mb-3"> <div class="d-flex justify-content-end mb-3">
<button type="submit" name="update" value="1" class="btn btn-primary"> <button type="submit" name="update" value="1" class="btn btn-primary">
<i class="ti ti-device-floppy me-1"></i>{{ _x('button', 'Save') }} <i class="ti ti-device-floppy me-1"></i>{{ _x('button', 'Save') }}
@ -141,122 +250,6 @@
</div> </div>
</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>
{# Variáveis do GLPI como AUTOCOMPLETE nos campos de valor #}
<datalist id="rm-glpi-vars">
{% for v in palette_vars %}
<option value="{{ v.tag }}">{{ v.group }}{{ v.desc }}</option>
{% endfor %}
</datalist>
{# Referência opcional (colapsada) — descoberta das tags para remapeamentos #}
<div class="mb-3">
<a class="small" data-bs-toggle="collapse" href="#rm-glpi-vars-panel" role="button">
<i class="ti ti-variable me-1"></i>{{ __('Variáveis do GLPI disponíveis — o "DE" (referência; nos campos de valor há autocomplete)', 'redmine') }}
</a>
<div class="collapse" id="rm-glpi-vars-panel">
<div class="card card-body p-2 mt-1" style="max-height:300px;overflow-y:auto;columns:2">
{% set lastgroup = '' %}
{% for v in palette_vars %}
{% if v.group != lastgroup %}
{% set lastgroup = v.group %}
<div class="mt-1 mb-1 text-uppercase text-muted" style="font-size:.7rem;break-inside:avoid">{{ v.group }}</div>
{% endif %}
<div class="rm-var px-1 rounded" data-tag="{{ v.tag }}" title="{{ v.desc }}" style="break-inside:avoid">
<code style="font-size:.75rem">{{ v.tag }}</code>
</div>
{% endfor %}
</div>
</div>
</div>
{# ---- Editores por operação (largura total) ---- #}
<div>
{% 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>
{% if field_dicts[op] is defined and field_dicts[op] is not empty %}
<div class="px-3 py-1 border-bottom">
<a class="small" data-bs-toggle="collapse" href="#rmdict-{{ op }}" role="button">
<i class="ti ti-book me-1"></i>{{ __('Campos que o Redmine espera — o "PARA" (clique num campo para adicioná-lo)', 'redmine') }}
</a>
<div class="collapse" id="rmdict-{{ op }}">
<table class="table table-sm table-hover mb-1 mt-1" style="font-size:.75rem">
<tbody>
{% for f in field_dicts[op] %}
<tr class="rm-dict-row" data-path="{{ f.path }}" title="{{ __('Clique para adicionar este campo ao template', 'redmine') }}">
<td style="white-space:nowrap">
<code>{{ f.path }}</code>
{% if f.required %}<span class="badge bg-red-lt text-red ms-1">{{ __('obrigatório', 'redmine') }}</span>{% endif %}
</td>
<td>
{{ f.desc }}
{% if f.values is not empty %}
<div class="text-muted" style="font-size:.7rem">{{ f.values|join(' · ') }}</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<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>
{% verbatim %} {% verbatim %}
<script> <script>
(function () { (function () {
@ -376,6 +369,21 @@
setMode(); setMode();
}); });
addBtn.addEventListener('click', function () { addRow('', ''); }); addBtn.addEventListener('click', function () { addRow('', ''); });
// Gerar template: preenche o editor localmente (nada é salvo até o Salvar único)
var genBtn = card.querySelector('.rm-tpl-gen');
if (genBtn) {
genBtn.addEventListener('click', function () {
if (!confirm(genBtn.dataset.confirm || 'Substituir o conteúdo do editor pelo template padrão?')) { return; }
try {
ta.value = JSON.stringify(JSON.parse(genBtn.dataset.seed || '{}'), null, 2);
} catch (e) {
ta.value = genBtn.dataset.seed || '';
}
rawMode = false;
renderLines();
setMode();
});
}
// Dicionário "PARA": clique num campo adiciona a linha com o caminho pronto // Dicionário "PARA": clique num campo adiciona a linha com o caminho pronto
card.querySelectorAll('.rm-dict-row').forEach(function (r) { card.querySelectorAll('.rm-dict-row').forEach(function (r) {
r.style.cursor = 'pointer'; r.style.cursor = 'pointer';