From 009f61de3deb134d5c3a5ff25a8574c2038be3af Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Thu, 11 Jun 2026 20:59:02 -0400 Subject: [PATCH] fix tabulator issue --- .claude/settings.local.json | 4 +- public/js/app.js | 97 +++++++++++++++++++++---------------- 2 files changed, 58 insertions(+), 43 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 26764f5..7ac8693 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,9 @@ "PowerShell(Get-ChildItem -Path \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" -Recurse -Directory -ErrorAction SilentlyContinue | Select-Object -First 30 | ForEach-Object { $_.FullName })", "Skill(graphify)", "PowerShell(python -c $script)", - "PowerShell(python .graphify_query.py)" + "PowerShell(python .graphify_query.py)", + "Bash(docker compose *)", + "Bash(node --check public/js/app.js)" ] } } diff --git a/public/js/app.js b/public/js/app.js index 8659bcc..ffd4f1f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -55,6 +55,51 @@ function _tabulatorPersistenceKey(key) { return 'ct.tabulator.' + key; } +// Builds a stable Tabulator field name for a dynamic attribute column, derived from the +// attribute's name rather than its position. This keeps persisted column order, width, +// visibility, and filters tied to the same attribute across page loads, even when the +// set/order of attributes discovered from the result rows changes. +function _attributeFieldKey(name, usedFields) { + let slug = String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); + if (slug === '') { + slug = 'field'; + } + + const base = 'attr__' + slug; + let key = base; + let suffix = 2; + while (usedFields.has(key)) { + key = base + '_' + suffix; + suffix++; + } + + usedFields.add(key); + return key; +} + +// Discovers the unique set of dynamic attributes across a list of rows, assigning each +// a stable `field` name (see _attributeFieldKey) for use as a Tabulator column field. +function _discoverAttributeColumns(rows, typeAttributesField, normalizeFn) { + const attributes = []; + const usedFields = new Set(); + + rows.forEach((row) => { + normalizeFn(row[typeAttributesField] || []).forEach((attr) => { + if (!attributes.some((existing) => existing.name === attr.name)) { + attributes.push({ ...attr, field: _attributeFieldKey(attr.name, usedFields) }); + } + }); + + Object.keys(row.attribute_values || {}).forEach((name) => { + if (!attributes.some((existing) => existing.name === name)) { + attributes.push({ name: name, type: 'text', order: attributes.length + 1, field: _attributeFieldKey(name, usedFields) }); + } + }); + }); + + return attributes; +} + function _tabulatorBaseOptions(persistenceKey) { return { persistence: { @@ -528,23 +573,7 @@ window.campaignTable = function () { }, jobAttributeColumnsForRows(rows) { - const attributes = []; - - rows.forEach((row) => { - this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => { - if (!attributes.some((existing) => existing.name === attr.name)) { - attributes.push(attr); - } - }); - - Object.keys(row.attribute_values || {}).forEach((name) => { - if (!attributes.some((existing) => existing.name === name)) { - attributes.push({ name: name, type: 'text', order: attributes.length + 1 }); - } - }); - }); - - return attributes; + return _discoverAttributeColumns(rows, 'job_type_attributes', this.normalizeAttributes); }, formatJobRows(rows, attributes) { @@ -560,8 +589,8 @@ window.campaignTable = function () { edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit', }; - attributes.forEach((attr, index) => { - tableRow['job_attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); + attributes.forEach((attr) => { + tableRow[attr.field] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); }); return tableRow; @@ -586,10 +615,10 @@ window.campaignTable = function () { { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' }, ]; - attributes.forEach((attr, index) => { + attributes.forEach((attr) => { columns.push({ title: attr.name, - field: 'job_attr_' + index, + field: attr.field, minWidth: 150, headerFilter: 'input', formatter: function (cell) { @@ -1440,23 +1469,7 @@ window.jobTable = function () { }, attributeColumnsForRows(rows) { - const attributes = []; - - rows.forEach((row) => { - this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => { - if (!attributes.some((existing) => existing.name === attr.name)) { - attributes.push(attr); - } - }); - - Object.keys(row.attribute_values || {}).forEach((name) => { - if (!attributes.some((existing) => existing.name === name)) { - attributes.push({ name: name, type: 'text', order: attributes.length + 1 }); - } - }); - }); - - return attributes; + return _discoverAttributeColumns(rows, 'job_type_attributes', this.normalizeAttributes); }, normalizeAttributes(attributes) { @@ -1480,8 +1493,8 @@ window.jobTable = function () { updated_at: row.updated_at || '', }; - attributes.forEach((attr, index) => { - tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); + attributes.forEach((attr) => { + tableRow[attr.field] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); }); return tableRow; @@ -1520,10 +1533,10 @@ window.jobTable = function () { { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' }, ]; - attributes.forEach((attr, index) => { + attributes.forEach((attr) => { columns.push({ title: attr.name, - field: 'attr_' + index, + field: attr.field, minWidth: 150, headerFilter: 'input', formatter: function (cell) {