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:
Gemini 2026-07-02 16:04:22 -03:00
parent 7ac1f8ac44
commit 1053b9f506
7 changed files with 427 additions and 1 deletions

View file

@ -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. 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 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). 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] ## [1.6.0]

View file

@ -26,6 +26,43 @@ 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

@ -51,6 +51,24 @@ if (isset($_POST["action"])) {
Session::addMessageAfterRedirect(__('Entidade vinculada com sucesso!', 'redmine')); Session::addMessageAfterRedirect(__('Entidade vinculada com sucesso!', 'redmine'));
} else { } else {
// CREATE OPERATION // 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 = [ $payload = [
'name' => $_POST['name'], 'name' => $_POST['name'],
'identifier' => $_POST['identifier'], 'identifier' => $_POST['identifier'],
@ -93,6 +111,8 @@ if (isset($_POST["action"])) {
} }
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload); $new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload);
} // fim do motor 1.x
if ($new_redmine_id) { if ($new_redmine_id) {
$DB->insert('glpi_plugin_redmine_entities', [ $DB->insert('glpi_plugin_redmine_entities', [
'entities_id' => $entities_id, 'entities_id' => $entities_id,

View file

@ -56,6 +56,24 @@ if (isset($_POST["action"])) {
Session::addMessageAfterRedirect("Projeto vinculado com sucesso!"); Session::addMessageAfterRedirect("Projeto vinculado com sucesso!");
} else { } else {
// CREATE OPERATION // 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 = [ $payload = [
'name' => $_POST['name'], 'name' => $_POST['name'],
'identifier' => $_POST['identifier'], 'identifier' => $_POST['identifier'],
@ -100,6 +118,7 @@ if (isset($_POST["action"])) {
} }
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload); $new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload);
} // fim do motor 1.x
if ($new_redmine_id) { if ($new_redmine_id) {
global $DB; global $DB;

View file

@ -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', [ \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__), 'action_url' => Toolbox::getItemTypeFormURL(__CLASS__),
'csrf_token_value' => Session::getNewCSRFToken(), 'csrf_token_value' => Session::getNewCSRFToken(),
'redmine_url' => $config['redmine_url'] ?? '', 'redmine_url' => $config['redmine_url'] ?? '',

View file

@ -34,6 +34,18 @@ class PluginRedmineTemplateengine {
return count($it) > 0 ? $it->current() : null; 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. * Grava (upsert) o template de uma operação.
*/ */
@ -129,10 +141,78 @@ class PluginRedmineTemplateengine {
return $template; 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
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/**
* 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. * Monta o contexto de variáveis (a "paleta") para o render.
*/ */

View file

@ -140,3 +140,223 @@
</form> </form>
</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>
<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, '&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" 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">&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('', ''); });
form.addEventListener('submit', function () { syncToTextarea(); });
renderLines();
setMode();
});
})();
</script>
{% endverbatim %}