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.
|
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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,7 +118,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) {
|
||||||
global $DB;
|
global $DB;
|
||||||
$DB->insert('glpi_plugin_redmine_projects', [
|
$DB->insert('glpi_plugin_redmine_projects', [
|
||||||
|
|
|
||||||
|
|
@ -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'] ?? '',
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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, '&').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