diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d5486..417d675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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_` dos custom fields). ## [1.6.0] diff --git a/front/config.form.php b/front/config.form.php index 74a18f1..d5d7494 100644 --- a/front/config.form.php +++ b/front/config.form.php @@ -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); diff --git a/front/entity.form.php b/front/entity.form.php index 8b0afe5..51af766 100644 --- a/front/entity.form.php +++ b/front/entity.form.php @@ -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, diff --git a/front/project.form.php b/front/project.form.php index b7446fa..bef29f3 100644 --- a/front/project.form.php +++ b/front/project.form.php @@ -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,7 +118,8 @@ if (isset($_POST["action"])) { } $new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload); - + } // fim do motor 1.x + if ($new_redmine_id) { global $DB; $DB->insert('glpi_plugin_redmine_projects', [ diff --git a/inc/config.class.php b/inc/config.class.php index 2f4f28b..4dd04af 100644 --- a/inc/config.class.php +++ b/inc/config.class.php @@ -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'] ?? '', diff --git a/inc/templateengine.class.php b/inc/templateengine.class.php index 3ad53ab..d5c5b8f 100644 --- a/inc/templateengine.class.php +++ b/inc/templateengine.class.php @@ -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_', '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. */ diff --git a/templates/config_form.html.twig b/templates/config_form.html.twig index a22f2dc..589cb78 100644 --- a/templates/config_form.html.twig +++ b/templates/config_form.html.twig @@ -140,3 +140,223 @@ + +{# ==================================================================== + Templates de Payload (motor 2.0) — editor linha-a-linha + paleta + ==================================================================== #} +
+
+

{{ __('Templates de Payload (avançado)', 'redmine') }}

+
+
+
+ {{ __('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') }} +
+ +
+ {# ---- Paleta ---- #} +
+
+
{{ __('Variáveis (clique para copiar)', 'redmine') }}
+
+ {% set lastgroup = '' %} + {% for v in palette_vars %} + {% if v.group != lastgroup %} + {% set lastgroup = v.group %} +
{{ v.group }}
+ {% endif %} +
+ {{ v.tag }} + +
+ {% endfor %} + {% if palette_ref is not empty %} +
+
{{ __('Referência do seu Redmine', 'redmine') }}
+ {% for glabel, items in palette_ref %} +
{{ glabel }}
+ {% for item in items %} +
{{ item }}
+ {% endfor %} + {% endfor %} + {% endif %} +
+
+
+ + {# ---- Editores por operação ---- #} +
+ {% for op, t in templates %} +
+ + +
+
+ + {{ t.label }} + {{ op }} + {% if t.is_active %} + {{ __('ativo', 'redmine') }} + {% else %} + {{ __('inativo (usa o padrão)', 'redmine') }} + {% endif %} + + + + + +
+
+
+ + +
+ +
+
+ {% endfor %} +
+
+
+
+ +{% verbatim %} + +{% endverbatim %}