- name
- js-features
- description
- End-to-end checklist for adding a front-end feature — new widget types, GridStack tiles, localStorage-persisted data (merge_deep/merge_layout whitelists), undo/redo snapshots, modal dialogs, and the layout-user-action event. Use when implementing a new dashboard feature rather than fixing an existing one.
# JavaScript Feature Patterns
This skill covers the recurring patterns used when adding new features to the dashboard front-end. Reading this file (plus the `coding-standards` skill for structure and style rules) is sufficient to implement most features without additional codebase exploration.
---
## 1. The "Custom Widget" Pattern (GridStack tile + localStorage + modal)
Every custom widget type in the dashboard follows the same 6-part pattern. Existing examples: **stat widget** (`statwidgets.js`), **link widget** (`linkwidgets.js`) and **custom graph** (`customgraphs/widgets.js` + `customgraphs/builder.js`; delegated button listener instead of per-render wiring).
> The add buttons now live in the section headers (`.add-<name>-header`, toggled in `setup_section_order`, opened from the delegated click handler in `setup_dashboard_section_layout_buttons`), not in add-tiles inside the grid. Chart instances of widgets with generated ids must not be kept on `window[canvasId]`: that resolves to the canvas element itself (named element access) — custom graphs keep theirs in a `Map`.
### Part 1 — New JS module (`js/<name>widgets.js`)
Must contain:
| Function | Description |
|---|---|
| `render_custom_<name>_widgets(gridStack, sectionKey, editMode)` | Filters `settings.<name>Widgets` by `section === sectionKey`, creates `grid-stack-item` elements with `data-gs-id="custom<Name>Widget-<id>"`, calls `gridStack.makeWidget(item)`. Reads saved positions from `settings.layouts[grid<Section>]`. |
| `build_<name>_widget_html(widget, editMode)` | Returns the inner HTML string for one widget card. Includes a delete button only when `editMode === true`. |
| `add_custom_<name>_widget(section, ...fields)` | Generates a random ID with `generate_id()`, builds the widget object, appends to `settings.<name>Widgets`, calls `set_local_storage_item('<name>Widgets', list)`. Returns the widget object. |
| `remove_custom_<name>_widget(id)` | Filters `settings.<name>Widgets` to exclude `id`, persists. |
| `render_add_<name>_widget_tile(gridStack, sectionKey)` | Adds a 2×2 non-resizable dashed-border "add" tile. Uses `data-gs-id="add<Name>WidgetTile-<section>"`. Click opens the modal. |
| `wire_<name>_delete_buttons(gridStack, sectionKey)` | After `render_custom_<name>_widgets` in edit mode, wires `click` on all `.delete-custom-<name>-widget` buttons. |
| `handle_delete_<name>_widget(id, gridStack)` | Calls `gridStack.removeWidget(el)`, then `remove_custom_<name>_widget(id)`, then `document.dispatchEvent(new CustomEvent("layout-user-action"))`. |
| `setup_add_<name>_widget_modal()` | Populates all modal form elements, wires the confirm button (reads modal state → calls `add_custom_<name>_widget` → calls `grid.makeWidget` live → dispatches `layout-user-action` → closes modal). Called **once** from `setup_dashboard_section_layout_buttons()` in `layout.js`. |
| `open_add_<name>_widget_modal(sectionKey)` | Stores `sectionKey` in `modal.dataset.pendingSection` then calls `bootstrap.Modal.getOrCreateInstance(modal).show()`. |
**Import the new module from `layout.js`** — the bundler discovers it via the import graph from `main.js`.
### Part 2 — CSS (`css/components/03-widgets.css`)
Required CSS classes:
| Class | Description |
|---|---|
| `.add-<name>-widget-tile` | Dashed-border clickable tile (copy from `.add-stat-widget-tile`). |
| `.add-<name>-widget-tile-inner` / `.add-<name>-widget-plus` / `.add-<name>-widget-tile-label` | Inner content layout (copy from stat equivalents). |
| `.<name>-widget-inner` | `position: relative; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 0.25rem; height: 100%; overflow: hidden;` |
| `.<name>-widget-title` (or label) | `margin: 0; font-weight: 600; font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: calc(100% - 20px);` |
| `.delete-custom-<name>-widget` | `position: absolute; top: 5px; right: 5px; z-index: 1;` |
| `.grid-stack-item-content:has(.<name>-widget-inner)` | `padding: 8px 12px;` (same as stat widget) |
| h=1 compact row rules | Mirror the `gs-h="1"` and `data-gs-h="1"` row-layout overrides from stat widget. |
### Part 3 — localStorage whitelist (`js/localstorage.js`)
Add the new key name to the `LOCAL_ONLY_SETTINGS` set that `merge_deep()` checks:
```js
const LOCAL_ONLY_SETTINGS = new Set([
"layouts", "libraries", "theme", "filterProfiles", "statWidgets", "customSections", "linkWidgets", "customGraphs",
"<name>Widgets",
]);
```
**Also** add the `data-gs-id` prefix to the `merge_layout()` allowlist in the same file, or saved widget positions will be silently dropped on every page load:
```js
// In merge_layout(), inside the arr.filter(...) call:
(typeof item.id === 'string' && item.id.startsWith('custom<Name>Widget-')) ||
```
Without this second whitelist entry, `merge_layout` strips any layout position whose ID doesn't match a known graph — so widgets always revert to their default GridStack position after refresh.
### Part 4 — Undo/Redo snapshots (`js/layout.js`)
Add `<name>Widgets` to **all three** snapshot/restore functions:
```js
// capture_settings_snapshot()
<name>Widgets: JSON.parse(JSON.stringify(settings.<name>Widgets || [])),
// capture_dom_snapshot()
<name>Widgets: JSON.parse(JSON.stringify(settings.<name>Widgets || [])),
// apply_layout_snapshot(snapshot)
set_local_storage_item('<name>Widgets', JSON.parse(JSON.stringify(snapshot.<name>Widgets || [])));
```
Missing any one of these three causes widgets to be lost or not properly undone.
### Part 5 — Render calls (`js/layout.js` → `setup_graph_order`)
In the `setup_graph_order` function, immediately after the stat-widget block:
```js
// Render custom <name> widgets for this section
render_custom_<name>_widgets(window[grid], sectionKey, gridEditMode);
if (gridEditMode) {
wire_<name>_delete_buttons(window[grid], sectionKey);
render_add_<name>_widget_tile(window[grid], sectionKey);
}
```
This block runs for all sections **except "Compare"**. Unified section uses `sectionKey = "unified"` and `window[grid] = window.gridUnified`.
### Part 6 — Modal HTML (`templates/dashboard.html`)
Add the modal `<div>` in the `<!-- modals -->` area, before `<!-- placeholder_javascript -->`. Use Bootstrap 5 modal structure (same as `#addStatWidgetModal`). Wire it via `setup_add_<name>_widget_modal()` called in `setup_dashboard_section_layout_buttons()`.
---
## 2. Section Key → GridStack Instance Mapping
The `sectionKey` (lowercase string) maps to:
| `sectionKey` | `window[grid]` | Layout key |
|---|---|---|
| `"run"` | `window.gridRun` | `settings.layouts.gridRun` |
| `"suite"` | `window.gridSuite` | `settings.layouts.gridSuite` |
| `"test"` | `window.gridTest` | `settings.layouts.gridTest` |
| `"keyword"` | `window.gridKeyword` | `settings.layouts.gridKeyword` |
| `"unified"` | `window.gridUnified` | `settings.layouts.gridUnified` |
| `"compare"` | (excluded from custom widgets) | `settings.layouts.gridCompare` |
The `window[grid]` pattern uses `"grid" + section.charAt(0).toUpperCase() + section.slice(1)`.
---
## 3. GridStack Item Lifecycle
### Creating an item
```js
const item = document.createElement('div');
item.classList.add('grid-stack-item');
item.setAttribute('gs-w', w);
item.setAttribute('gs-h', h);
item.setAttribute('gs-min-w', 1); item.setAttribute('gs-min-h', 1);
item.setAttribute('gs-max-w', 12); item.setAttribute('gs-max-h', 12);
item.setAttribute('data-gs-id', 'myWidget-<id>'); // must be unique
item.innerHTML = `<div class="grid-stack-item-content">${innerHtml}</div>`;
gridStack.makeWidget(item);
```
### Restoring saved position
Before `makeWidget`, check `settings.layouts[gridId]` (a JSON string), parse it, find the entry whose `.id` matches `data-gs-id`, and set `gs-x`/`gs-y`/`gs-w`/`gs-h` from the saved entry.
### Removing an item
```js
const el = gridStack.el.querySelector('[data-gs-id="myWidget-<id>"]');
if (el) gridStack.removeWidget(el);
```
### Saving layout (automatic)
`capture_dom_snapshot()` in `layout.js` reads `gridStack.engine.nodes` for all grids and builds a JSON string of `[{id, x, y, w, h}]` entries. This is called automatically on every drag/resize (`dragstop`/`resizestop`) and on every `layout-user-action` event.
---
## 4. The `layout-user-action` Event
Dispatching `document.dispatchEvent(new CustomEvent("layout-user-action"))` after any state-changing operation:
- Takes a DOM snapshot and pushes it onto the undo history stack.
- Is the only mechanism that captures widget add/delete in undo history.
- Must be dispatched after **both** the DOM change and the localStorage change are complete.
---
## 5. localStorage-Only Keys Pattern
Some keys only exist in localStorage (not in the `settings` defaults object). They must be listed in `LOCAL_ONLY_SETTINGS` in `localstorage.js` or `merge_deep()` silently drops them on every page load. Current list:
| Key | Module that owns it |
|---|---|
| `layouts` | `layout.js` |
| `libraries` | keyword library toggles |
| `theme` | theme switcher |
| `filterProfiles` | `filter/profiles.js` |
| `statWidgets` | `statwidgets.js` |
| `linkWidgets` | `linkwidgets.js` |
| `customSections` | `customsections.js` |
| `customGraphs` | `customgraphs/widgets.js` |
**Rule**: Any new key you persist with `set_local_storage_item` that is not in `settings` defaults must be added to this whitelist, or it will be deleted by `merge_deep` on the next page load.
---
## 6. Adding a New Settings Toggle / Persisted Value
If the new value *is* in `settings` defaults (not localStorage-only):
1. Add it to the `settings` object in `js/variables/settings.js`.
2. Persist changes via `set_local_storage_item('path.to.key', value)`.
3. No whitelist change needed — it's in the defaults, so `merge_deep` handles it.
---
## 7. Color Pickers (Reusing Stat Widget Colors)
The `STAT_WIDGET_COLORS` and `STAT_WIDGET_BG_COLORS` arrays from `js/variables/statwidgetdefs.js` can be reused by any widget type. Use `fill_color_picker(pickerEl, colors, defaultValue)` (or a local copy of it) to populate the buttons:
```js
import { STAT_WIDGET_COLORS, STAT_WIDGET_BG_COLORS } from './variables/statwidgetdefs.js';
```
The color CSS classes they reference (`.white-text`, `.blue-text`, `.blue-bg`, etc.) are defined in `css/components/03-widgets.css`, `css/components/04-status-colors.css` and `css/base.css`.
---
## 8. The `gridEditMode` Guard
`gridEditMode` (in `js/variables/globals.js`) is `true` while the user is in Customize View mode. Use it to suppress interactive behaviour (e.g. link navigation) in edit mode:
```js
if (gridEditMode) return;
```
Do not use an `<a>` tag as the outermost clickable wrapper for GridStack items — it interferes with drag. Use a `<div>` with a `data-href` attribute and a `click` event listener that checks `gridEditMode` before navigating.
---
## 9. Quick-Reference: Files to Touch for a New Widget Type
| File | Change |
|---|---|
Auf GitHub ansehen