Redmine/templates/config_form.html.twig
Gemini 7bf8cdab4e feat(2.0): Fase 3 — testador de templates (preview + dry-run com tradução de 422)
- ajax/template_test.php: action=preview (render com sampleContext) e
  action=dryrun (cria+apaga objeto de teste no Redmine, traduz erros 422
  em dicas acionáveis; issue temporária para log_time/update_status)
- RedmineApi::rawRequest (código+corpo crus) e TemplateEngine::sampleContext
- UI: botão "Testar" + painel por card (preview sempre; teste real opt-in
  com seletor de projeto alvo), via fetch com X-Glpi-Csrf-Token
- validado E2E por HTTP: preview, dry-run cria/deleta (204), render inválido
  capturado, tradução de 422; sem resíduo de teste no Redmine

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:58:17 -03:00

472 lines
25 KiB
Twig

{% import 'components/form/fields_macros.html.twig' as fields %}
<form method="post" action="{{ action_url }}">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="id" value="1">
{# --- Conexão --- #}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="card-title mb-0">
<i class="ti ti-plug-connected me-1"></i>{{ __('Conexão com o Redmine', 'redmine') }}
</h4>
<button type="submit" name="sync" value="1" class="btn btn-sm btn-outline-secondary">
<i class="ti ti-refresh me-1"></i>{{ __('Sincronizar Metadados', 'redmine') }}
</button>
</div>
<div class="card-body">
<div class="row">
{{ fields.textField('redmine_url', redmine_url, __('URL do Redmine', 'redmine'), {
'helper': 'https://redmine.exemplo.com.br'
}) }}
{{ fields.passwordField('api_key', '', __('Chave de API (Admin)', 'redmine'), {
'helper': has_key
? __('Deixe em branco para manter a chave atual', 'redmine')
: __('Chave de API de um usuário administrador do Redmine', 'redmine')
}) }}
</div>
</div>
</div>
{# --- Valores padrão da issue --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-settings me-1"></i>{{ __('Valores padrão da tarefa (issue) Redmine', 'redmine') }}</h4>
</div>
<div class="card-body">
{% if not has_metadata %}
<div class="alert alert-warning">{{ __('Salve a URL e a chave e clique em "Sincronizar Metadados" para carregar as opções.', 'redmine') }}</div>
{% endif %}
<div class="row">
{{ fields.dropdownArrayField('default_tracker_id', default_tracker_id, tracker_options, __('Tipo (Tracker) padrão', 'redmine'), {'display_emptychoice': true}) }}
{{ fields.dropdownArrayField('default_priority_id', default_priority_id, priority_options, __('Prioridade padrão', 'redmine'), {'display_emptychoice': true}) }}
{{ fields.dropdownArrayField('default_activity_id', default_activity_id, activity_options, __('Atividade padrão (lançamento de tempo)', 'redmine'), {'display_emptychoice': true}) }}
</div>
</div>
</div>
{# --- Autores e observadores --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-users me-1"></i>{{ __('Autores e observadores', 'redmine') }}</h4>
</div>
<div class="card-body">
<div class="alert alert-info">
{{ __('O autor (técnico atribuído) é adicionado automaticamente como membro do projeto Redmine com o papel abaixo, para permitir a impersonation.', 'redmine') }}
</div>
<div class="row">
{{ fields.dropdownArrayField('default_role_id', default_role_id, role_options, __('Papel padrão para membros (auto-membership)', 'redmine'), {'display_emptychoice': true}) }}
{{ fields.dropdownArrayField('default_watcher_ids', '', redmine_user_options, __('Observadores padrão', 'redmine'), {
'multiple': true,
'values': default_watcher_ids
}) }}
</div>
</div>
</div>
{# --- Mapa de status --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-arrows-exchange me-1"></i>{{ __('Mapa de status: Chamado GLPI → Issue Redmine', 'redmine') }}</h4>
</div>
<div class="card-body">
<div class="row">
{% for sid, slabel in glpi_ticket_statuses %}
{{ fields.dropdownArrayField('status_map[' ~ sid ~ ']', attribute(status_map, sid)|default(''), status_options, slabel, {'display_emptychoice': true}) }}
{% endfor %}
</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>
<div id="rm-tpl-meta" class="d-none" data-testurl="{{ ajax_test_url }}"></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" data-op="{{ op }}">
<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>
<button type="button" class="btn btn-sm btn-outline-success rm-tpl-test">
<i class="ti ti-flask me-1"></i>{{ __('Testar', '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>
{# Painel do testador (Fase 3): preview + teste real opcional #}
<div class="rm-tpl-testpanel d-none border-top mt-2 pt-2">
<div class="d-flex gap-2 align-items-center mb-2 flex-wrap">
{% if op != 'create_project' %}
<select class="form-select form-select-sm rm-test-target" style="max-width:320px">
<option value="">{{ __('Projeto Redmine alvo para o teste real...', 'redmine') }}</option>
{% for pid, pname in test_project_options %}
<option value="{{ pid }}">{{ pname }} (#{{ pid }})</option>
{% endfor %}
</select>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-warning rm-test-run">
<i class="ti ti-player-play me-1"></i>{{ __('Teste real (cria e apaga um objeto de teste no Redmine)', 'redmine') }}
</button>
</div>
<pre class="rm-test-out p-2 rounded border" style="font-size:.75rem;max-height:320px;overflow:auto;white-space:pre-wrap"></pre>
</div>
</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">
<button type="submit" name="update" value="1" class="btn btn-primary">
<i class="ti ti-device-floppy me-1"></i>{{ _x('button', 'Save') }}
</button>
</div>
</form>
{# --- Mapeamento de usuários (overrides) — fora do form principal --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-user-cog me-1"></i>{{ __('Mapeamento de usuários (GLPI → Redmine)', 'redmine') }}</h4>
</div>
<div class="card-body">
{% if user_cache_count|default(0) == 0 %}
<div class="alert alert-warning">
<i class="ti ti-alert-triangle me-1"></i>
{{ __('Cache de usuários Redmine VAZIO — os lançamentos sairão como admin. Clique em "Sincronizar Metadados".', 'redmine') }}
</div>
{% endif %}
<div class="alert alert-info">
{{ __('Usuários com o mesmo e-mail nos dois sistemas são associados automaticamente. Use a tabela abaixo apenas para exceções (e-mails diferentes).', 'redmine') }}
<br><small>{{ __('Usuários Redmine em cache:', 'redmine') }} <strong>{{ user_cache_count|default(0) }}</strong></small>
</div>
<table class="table table-sm align-middle">
<thead><tr>
<th>{{ __('Usuário GLPI', 'redmine') }}</th>
<th>{{ __('Usuário Redmine', 'redmine') }}</th>
<th class="text-end">{{ _x('button', 'Delete') }}</th>
</tr></thead>
<tbody>
{% for m in usermap %}
<tr>
<td>{{ m.glpi_name }}</td>
<td>{{ m.redmine_name }}</td>
<td class="text-end">
<form method="post" action="{{ action_url }}" class="d-inline">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="del_usermap" value="{{ m.id }}">
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="ti ti-trash"></i></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-muted">{{ __('Nenhum override cadastrado.', 'redmine') }}</td></tr>
{% endfor %}
</tbody>
</table>
<form method="post" action="{{ action_url }}" class="mt-3">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="add_usermap" value="1">
<div class="row">
{{ fields.dropdownField('User', 'override_users_id', 0, __('Usuário GLPI', 'redmine'), {'display_emptychoice': true}) }}
{{ fields.dropdownArrayField('override_redmine_user_id', '', redmine_user_options, __('Usuário Redmine', 'redmine'), {'display_emptychoice': true}) }}
</div>
<div class="text-end">
<button type="submit" class="btn btn-outline-primary"><i class="ti ti-plus me-1"></i>{{ _x('button', 'Add') }}</button>
</div>
</form>
</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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
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" list="rm-glpi-vars" style="font-family:monospace;font-size:.78rem" placeholder="valor fixo ou variável do GLPI (autocomplete)" value="' + esc(v) + '">' +
'<button type="button" class="btn btn-sm btn-outline-danger rm-del">&times;</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('', ''); });
// 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();
});
}
// Testador (Fase 3): preview do render + teste real opcional
var testBtn = card.querySelector('.rm-tpl-test');
var panel = card.querySelector('.rm-tpl-testpanel');
var out = card.querySelector('.rm-test-out');
var runBtn = card.querySelector('.rm-test-run');
var meta = document.getElementById('rm-tpl-meta');
function csrfToken() {
var m = document.querySelector('meta[property="glpi:csrf_token"]');
return m ? m.getAttribute('content') : '';
}
function callTest(action) {
syncToTextarea();
var body = new URLSearchParams({
action: action,
operation: card.dataset.op || '',
template_json: ta.value
});
var tgt = card.querySelector('.rm-test-target');
if (tgt && tgt.value) { body.append('target_project', tgt.value); }
panel.classList.remove('d-none');
out.textContent = '... executando (' + action + ') ...';
fetch(meta.dataset.testurl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'X-Glpi-Csrf-Token': csrfToken()
},
body: body.toString()
}).then(function (r) { return r.json(); }).then(function (d) {
var txt = '';
if (d.note) { txt += d.note + '\n\n'; }
if (d.message) { txt += d.message + '\n'; }
if (d.errors && d.errors.length) { txt += '\nDicas:\n - ' + d.errors.join('\n - ') + '\n'; }
if (d.payload) { txt += JSON.stringify(d.payload, null, 2); }
if (d.sent) { txt += '\n\nPayload enviado:\n' + JSON.stringify(d.sent, null, 2); }
out.textContent = (d.success ? '✅ ' : '❌ ') + txt;
}).catch(function (e) {
out.textContent = '❌ Falha na chamada de teste: ' + e;
});
}
if (testBtn) { testBtn.addEventListener('click', function () { callTest('preview'); }); }
if (runBtn) { runBtn.addEventListener('click', function () { callTest('dryrun'); }); }
// Dicionário "PARA": clique num campo adiciona a linha com o caminho pronto
card.querySelectorAll('.rm-dict-row').forEach(function (r) {
r.style.cursor = 'pointer';
r.addEventListener('click', function () {
if (rawMode) { alert('Volte para "Ver campos" para adicionar pela lista.'); return; }
addRow(r.dataset.path, '');
var rows = linesBox.querySelectorAll('.rm-tpl-row');
var last = rows[rows.length - 1];
if (last) { last.querySelector('.rm-v').focus(); }
});
});
form.addEventListener('submit', function () { syncToTextarea(); });
renderLines();
setMode();
});
})();
</script>
{% endverbatim %}