PageSourceSearch

https://healthylifenetwork.com.au/assets/controllers/spreadsheet-editor_controller-2TP2kXy.js

js healthylifenetwork.com.au collected 2026-10-02 16:03:14 UTC 15,124 bytes, 363 lines download raw bytes

1import { Controller } from '@hotwired/stimulus';
2import jspreadsheet from 'jspreadsheet-ce';
3
4// NOTE: no formula engine is wired. jspreadsheet-ce's built-in evaluator and the
5// @jspreadsheet/formula extension both compute via `new Function()`, which the
6// prod CSP (script-src without 'unsafe-eval') blocks — so we run with
7// parseFormulas:false (see _buildSpreadsheet). See EVO-BUG-6974.
8
9export default class extends Controller {
10    static targets = ['container', 'statusBar', 'sheetTabs'];
11    static values = {
12        sheets:    { type: String, default: '[]' },
13        saveUrl:   String,
14        csrfToken: String,
15        readOnly:  { type: Boolean, default: false },
16    };
17
18    connect() {
19        this._worksheets = [];
20        this._activeSheet = 0;
21        this._saveTimer   = null;
22        this._dirty       = false;
23
24        this._loadSheets();
25        this._bindKeyboard();
26    }
27
28    disconnect() {
29        document.removeEventListener('keydown', this._keyHandler);
30        clearTimeout(this._saveTimer);
31        if (this._worksheets.length > 0) {
32            try { jspreadsheet.destroy(this.containerTarget); } catch (_) {}
33        }
34    }
35
36    // ── Init ──────────────────────────────────────────────────────────────────
37
38    _loadSheets() {
39        let sheets = [];
40        try {
41            const parsed = JSON.parse(this.sheetsValue);
42            sheets = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.sheets) ? parsed.sheets : []);
43        } catch (_) {}
44        if (sheets.length === 0) {
45            sheets = [{ name: 'Sheet1', data: this._emptyData(20, 10) }];
46        }
47        this._buildSpreadsheet(sheets);
48    }
49
50    _emptyData(rows, cols) {
51        return Array.from({ length: rows }, () => Array(cols).fill(''));
52    }
53
54    _buildSpreadsheet(sheets) {
55        if (!this.hasContainerTarget) return;
56
57        try { jspreadsheet.destroy(this.containerTarget); } catch (_) {}
58        this.containerTarget.innerHTML = '';
59
60        const worksheetConfigs = sheets.map((sheet) => {
61            const data = Array.isArray(sheet.data) && sheet.data.length > 0
62                ? sheet.data
63                : this._emptyData(20, 10);
64            const config = {
65                data,
66                worksheetName:  sheet.name || 'Sheet1',
67                minDimensions:  [10, 20],
68                tableOverflow:  true,
69                tableWidth:     '100%',
70                tableHeight:    this._sheetHeight(),
71                editable:       !this.readOnlyValue,
72                columnResize:   true,
73                rowResize:      true,
74                allowComments:  true,
75                search:         false,
76                pagination:     false,
77            };
78            if (Array.isArray(sheet.columns) && sheet.columns.length > 0) {
79                config.columns = sheet.columns;
80            }
81            if (sheet.rows && Object.keys(sheet.rows).length > 0) {
82                config.rows = sheet.rows;
83            }
84            if (sheet.mergeCells && Object.keys(sheet.mergeCells).length > 0) {
85                config.mergeCells = sheet.mergeCells;
86            }
87            if (sheet.style && Object.keys(sheet.style).length > 0) {
88                config.style = sheet.style;
89            }
90            if (sheet.freezeColumns > 0) {
91                config.freezeColumns = sheet.freezeColumns;
92            }
93            return config;
94        });
95
96        this._worksheets = jspreadsheet(this.containerTarget, {
97            worksheets:     worksheetConfigs,
98            // Formula evaluation is OFF: jspreadsheet-ce's internal executor AND
99            // @jspreadsheet/formula both compute via `new Function()`, which our
100            // prod CSP (script-src without 'unsafe-eval') blocks — throwing an
101            // uncaught EvalError from createWorksheets on any '=...' cell and
102            // aborting the whole render (EVO-BUG-6974). With it off, '=' cells
103            // display their literal formula text instead of crashing the page.
104            parseFormulas:  false,
105            toolbar:        !this.readOnlyValue,
106            onchange:       () => this._scheduleSave(),
107            oninsertrow:    () => this._scheduleSave(),
108            ondeleterow:    () => this._scheduleSave(),
109            oninsertcolumn: () => this._scheduleSave(),
110            ondeletecolumn: () => this._scheduleSave(),
111            onmoverow:      () => this._scheduleSave(),
112            onmovecolumn:   () => this._scheduleSave(),
113            onresizecolumn: () => this._scheduleSave(),
114            onresizerow:    () => this._scheduleSave(),
115            oncomments:     () => this._scheduleSave(),
116        });
117
118        // Comments must be applied post-init via setComments (not an init-time option)
119        sheets.forEach((sheet, i) => {
120            if (sheet.comments && Object.keys(sheet.comments).length > 0) {
121                try { this._worksheets[i].setComments(sheet.comments); } catch (_) {}
122            }
123        });
124
125        // jspreadsheet renders its own native worksheet tab strip (jsuites
126        // .jtabs-headers). We render a branded bar into the sheetTabs target, so
127        // hide the native one to avoid a confusing duplicate row of tabs.
128        this.containerTarget.querySelectorAll('.jtabs-headers').forEach(el => { el.style.display = 'none'; });
129
130        this._syncTabBar(sheets);
131    }
132
133    _sheetHeight() {
134        return 'calc(100vh - 280px)';
135    }
136
137    // ── Tab bar ───────────────────────────────────────────────────────────────
138
139    _syncTabBar(sheets) {
140        if (!this.hasSheetTabsTarget) return;
141        this.sheetTabsTarget.innerHTML = '';
142        const multiSheet = sheets.length > 1;
143        sheets.forEach((s, i) => {
144            const btn = document.createElement('button');
145            btn.type = 'button';
146            btn.className = 'flex items-center gap-1 px-3 py-1 text-sm border-r border-base-300 hover:bg-base-200 transition-colors';
147            btn.dataset.sheetIndex = i;
148
149            const label = document.createElement('span');
150            label.textContent = s.name || `Sheet${i + 1}`;
151            btn.appendChild(label);
152
153            if (multiSheet && !this.readOnlyValue) {
154                const del = document.createElement('span');
155                del.textContent = '×';
156                del.className = 'text-base-content/30 hover:text-error text-xs ml-0.5 leading-none';
157                del.title = 'Delete sheet';
158                del.addEventListener('click', e => { e.stopPropagation(); this._deleteSheet(i); });
159                btn.appendChild(del);
160            }
161
162            btn.addEventListener('click', () => this._showSheet(i));
163            if (!this.readOnlyValue) {
164                btn.addEventListener('dblclick', e => { e.stopPropagation(); this._startRenameTab(label, i); });
165            }
166            this.sheetTabsTarget.appendChild(btn);
167        });
168        this._highlightTab(this._activeSheet);
169    }
170
171    // ── Sheet switching ───────────────────────────────────────────────────────
172
173    _showSheet(index) {
174        this._activeSheet = index;
175        try {
176            const ws = this._worksheets[index];
177            // openWorksheet is a WORKSHEET method (internally it calls
178            // this.parent.element.tabs.open). Calling it on ws.parent is
179            // undefined and throws — silently swallowed here — so the tab never
180            // switches. Call it on the worksheet instance itself.
181            if (ws && typeof ws.openWorksheet === 'function') {
182                ws.openWorksheet(index);
183            }
184        } catch (_) {}
185        this._highlightTab(index);
186    }
187
188    _highlightTab(index) {
189        if (!this.hasSheetTabsTarget) return;
190        this.sheetTabsTarget.querySelectorAll('button').forEach((btn, i) => {
191            btn.classList.toggle('bg-base-100', i === index);
192            btn.classList.toggle('font-medium', i === index);
193            btn.classList.toggle('border-b-2', i === index);
194            btn.classList.toggle('border-primary', i === index);
195        });
196    }
197
198    // ── Sheet rename (double-click tab label) ─────────────────────────────────
199
200    _startRenameTab(labelEl, index) {
201        const currentName = labelEl.textContent;
202        const input = document.createElement('input');
203        input.type = 'text';
204        input.value = currentName;
205        input.className = 'px-1 text-sm bg-base-100 border border-primary rounded outline-none w-24';
206        labelEl.replaceWith(input);
207        input.focus();
208        input.select();
209
210        const commit = () => {
211            const newName = input.value.trim() || currentName;
212            this._renameSheet(index, newName);
213        };
214        input.addEventListener('blur', commit);
215        input.addEventListener('keydown', e => {
216            if (e.key === 'Enter')  { e.preventDefault(); input.blur(); }
217            if (e.key === 'Escape') { input.value = currentName; input.blur(); }
218        });
219    }
220
221    _renameSheet(index, newName) {
222        const sheets = this._collectSheets();
223        sheets[index].name = newName;
224        const active = this._activeSheet;
225        this._buildSpreadsheet(sheets);
226        this._showSheet(active);
227        this._scheduleSave();
228    }
229
230    // ── Sheet delete ──────────────────────────────────────────────────────────
231
232    async _deleteSheet(index) {
233        if (this._worksheets.length <= 1) return;
234        const sheetName = this._worksheets[index]?.options?.worksheetName || `Sheet${index + 1}`;
235        const confirmed = await (window.eiqConfirm?.(`Delete sheet "${sheetName}"?`) ?? Promise.resolve(true));
236        if (!confirmed) return;
237        const sheets = this._collectSheets();
238        sheets.splice(index, 1);
239        const newActive = Math.min(this._activeSheet, sheets.length - 1);
240        this._buildSpreadsheet(sheets);
241        this._activeSheet = newActive;
242        this._showSheet(newActive);
243        this._scheduleSave();
244    }
245
246    // ── Add sheet ─────────────────────────────────────────────────────────────
247
248    addSheet() {
249        const existing = this._collectSheets();
250        const name = `Sheet${existing.length + 1}`;
251        existing.push({ name, data: this._emptyData(20, 10) });
252        this._buildSpreadsheet(existing);
253        this._showSheet(existing.length - 1);
254        this._scheduleSave();
255    }
256
257    // ── CSV download ──────────────────────────────────────────────────────────
258
259    downloadCsv() {
260        const ws = this._worksheets[this._activeSheet];
261        if (ws?.download) {
262            ws.download();
263        }
264    }
265
266    // ── Persistence ───────────────────────────────────────────────────────────
267
268    _scheduleSave() {
269        this._dirty = true;
270        this._setStatus('Unsaved changes…', 'warning');
271        clearTimeout(this._saveTimer);
272        this._saveTimer = setTimeout(() => this.save(), 1500);
273    }
274
275    async save() {
276        if (!this.saveUrlValue) return;
277        clearTimeout(this._saveTimer);
278
279        const sheets = this._collectSheets();
280        this._setStatus('Saving…', 'info');
281
282        try {
283            const resp = await fetch(this.saveUrlValue, {
284                method:  'POST',
285                headers: {
286                    'Content-Type': 'application/json',
287                    'X-Requested-With': 'XMLHttpRequest',
288                },
289                body: JSON.stringify({
290                    _token:  this.csrfTokenValue,
291                    content: JSON.stringify({ sheets }),
292                }),
293            });
294
295            if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
296            this._dirty = false;
297            this._setStatus('Saved', 'success');
298            setTimeout(() => this._setStatus('', ''), 2000);
299        } catch (e) {
300            console.error('[SpreadsheetEditor] save failed', e);
301            this._setStatus('Save failed', 'error');
302        }
303    }
304
305    _collectSheets() {
306        return this._worksheets.map((ws, i) => {
307            const sheet = {
308                name: ws.options?.worksheetName || `Sheet${i + 1}`,
309                data: ws.getData ? ws.getData() : [],
310            };
311            // Column widths
312            if (Array.isArray(ws.options?.columns)) {
313                sheet.columns = ws.options.columns.map(c => ({ width: c.width ?? null }));
314            }
315            // Row heights
316            if (ws.options?.rows && Object.keys(ws.options.rows).length > 0) {
317                sheet.rows = ws.options.rows;
318            }
319            // Merged cells
320            if (ws.options?.mergeCells && Object.keys(ws.options.mergeCells).length > 0) {
321                sheet.mergeCells = ws.options.mergeCells;
322            }
323            // Cell styles
324            if (typeof ws.getStyle === 'function') {
325                const style = ws.getStyle();
326                if (style && Object.keys(style).length > 0) {
327                    sheet.style = style;
328                }
329            }
330            // Freeze columns
331            if (ws.options?.freezeColumns > 0) {
332                sheet.freezeColumns = ws.options.freezeColumns;
333            }
334            // Cell comments
335            if (typeof ws.getComments === 'function') {
336                const comments = ws.getComments();
337                if (comments && Object.keys(comments).length > 0) {
338                    sheet.comments = comments;
339                }
340            }
341            return sheet;
342        });
343    }
344
345    _setStatus(msg, level) {
346        if (!this.hasStatusBarTarget) return;
347        const map = { success: 'text-success', warning: 'text-warning', error: 'text-error', info: 'text-base-content/60', '': '' };
348        this.statusBarTarget.textContent = msg;
349        this.statusBarTarget.className   = `text-xs px-3 ${map[level] ?? ''}`;
350    }
351
352    // ── Keyboard shortcuts ────────────────────────────────────────────────────
353
354    _bindKeyboard() {
355        this._keyHandler = (e) => {
356            if ((e.ctrlKey || e.metaKey) && e.key === 's') {
357                e.preventDefault();
358                this.save();
359            }
360        };
361        document.addEventListener('keydown', this._keyHandler);
362    }
363}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.