Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ The `.github/skills/` directory contains domain-specific knowledge files:
| `project-architecture.md` | Understanding how components connect and navigating the codebase |
| `dashboard.md` | Dashboard pages, Chart.js graphs, chart types, graph data/creation modules |
| `js-bundling.md` | How JS/CSS is bundled and embedded into the HTML (no Node.js bundler) |
| `js-feature-patterns.md` | **End-to-end patterns for adding new JS features**: custom widget checklist, GridStack item lifecycle, undo/redo snapshot pattern, localStorage-only keys |
| `conventions-and-gotchas.md` | Edge cases, run identity, offline mode, custom DBs, server auth model |
| `coding-style.md` | Python/JS/CSS style conventions |
| `workflows.md` | CLI usage, running tests, server mode, docs site |
Expand Down
219 changes: 219 additions & 0 deletions .github/skills/js-feature-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
---
description: Use when adding a new feature to the JavaScript front-end: new widget types, new GridStack tiles, new localStorage-persisted data, or new modal dialogs. Contains the complete end-to-end checklist and all architectural patterns needed to add features without re-exploring the codebase from scratch.
---

# JavaScript Feature Patterns

This skill covers the recurring patterns used when adding new features to the dashboard front-end. Reading this file (plus `js-patterns.md` for structure and `js-coding-standards.md` for 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`) and **link widget** (`linkwidgets.js`).

### 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.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 preserved-keys exclusion in `merge_deep()`:

```js
// before:
if (key !== "layouts" && key !== "libraries" && key !== "theme" && key !== "filterProfiles" && key !== "statWidgets" && key !== "customSections" && ...
// after — add your key:
&& key !== "<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 whitelisted in `merge_deep()` in `localstorage.js` or they will be silently dropped on every page load. Current whitelist:

| Key | Module that owns it |
|---|---|
| `layouts` | `layout.js` |
| `libraries` | keyword library toggles |
| `theme` | theme switcher |
| `filterProfiles` | `filter.js` |
| `statWidgets` | `statwidgets.js` |
| `linkWidgets` | `linkwidgets.js` |
| `customSections` | `customsections.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.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 |
|---|---|
| `js/<name>widgets.js` | **Create** — full widget module |
| `js/layout.js` | Import + add to snapshots + render calls + setup call |
| `js/localstorage.js` | (1) Add key to `merge_deep` whitelist AND (2) add `data-gs-id` prefix to `merge_layout` allowlist — **both are required** or positions reset on refresh |
| `css/components.css` | Add all widget CSS classes |
| `templates/dashboard.html` | Add modal HTML |
5 changes: 4 additions & 1 deletion .github/skills/js-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ js/
menu.js — tab/page switching
eventlisteners.js — wires all event listeners on DOMContentLoaded
statwidgets.js — custom stat widget CRUD, rendering, and modal wiring
linkwidgets.js — custom link widget CRUD, rendering, and modal wiring
variables/
data.js — decodes the base64/zlib-compressed data payload
globals.js — shared mutable state (filteredRuns, filteredSuites, etc.)
Expand Down Expand Up @@ -56,7 +57,7 @@ js/

### Settings live in `js/variables/settings.js`
- The `settings` object is the single source of truth for user preferences. It is loaded from localStorage at startup and deep-merged with defaults.
- Persisted keys: `layouts`, `libraries`, `theme`, `filterProfiles`, `statWidgets`.
- Persisted keys: `layouts`, `libraries`, `theme`, `filterProfiles`, `statWidgets`, `linkWidgets`, `customSections`.

### Module-local constants stay in their own file
- A `const` that is only used within one file stays in that file (e.g. `TIME_PROPS` in `statwidgets.js`).
Expand Down Expand Up @@ -97,6 +98,8 @@ Because all modules are concatenated into one `<script>`:
- Grid items are added with `gridStack.makeWidget(el)`.
- `gridEditMode` (in `globals.js`) tracks whether the layout is in edit/customize mode.
- Custom stat widgets are added to grids via `render_custom_stat_widgets()` and `render_add_stat_widget_tile()` in `statwidgets.js`.
- Custom link widgets follow the identical pattern via `linkwidgets.js`.
- For the complete end-to-end checklist for adding a new widget type, see `js-feature-patterns.md`.

---

Expand Down
118 changes: 118 additions & 0 deletions robotframework_dashboard/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,14 @@
z-index: 1;
}

/* Delete button on custom link widgets — pinned to top-right, out of flex flow */
.delete-custom-link-widget {
position: absolute;
top: 5px;
right: 5px;
z-index: 1;
}

/* Compact flex layout for custom stat widgets — works at any GridStack size */
.stat-widget-inner {
position: relative;
Expand Down Expand Up @@ -319,6 +327,116 @@
.grid-stack-item-content.red-bg { background-color: rgba(206, 62, 1, 0.2); }
.grid-stack-item-content.yellow-bg { background-color: rgba(254, 216, 79, 0.2); }

/* ─── Custom link widget ─────────────────────────────────────────────────── */

/* Add link widget tile — same visual language as add-stat-widget-tile */
.add-link-widget-tile {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--bs-border-color, rgba(128,128,128,0.4));
border-radius: 8px;
transition: border-color 0.15s, background-color 0.15s;
}

.add-link-widget-tile:hover {
border-color: var(--color-text);
background-color: rgba(128,128,128,0.08);
}

.add-link-widget-tile-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
transition: opacity 0.15s;
user-select: none;
}

.add-link-widget-plus {
font-size: 2rem;
font-weight: 300;
line-height: 1;
}

.add-link-widget-tile-label {
font-size: 0.8rem;
font-weight: 500;
}

/* Inner layout — mirrors stat-widget-inner */
.link-widget-inner {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.25rem;
height: 100%;
text-align: center;
overflow: hidden;
}

/* Label — main large text, like .stat-value */
.link-widget-label {
margin: 0;
font-weight: 600;
font-size: 1.5rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: calc(100% - 20px); /* leave room so text doesn't run under delete button */
}

/* URL preview — small muted text below the label */
.link-widget-url {
font-size: 0.72rem;
opacity: 0.55;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: calc(100% - 20px);
}

/* Clickable in non-edit mode */
.link-widget-clickable {
cursor: pointer;
}

/* Same padding as stat widget tiles */
.grid-stack-item-content:has(.link-widget-inner) {
padding: 8px 12px;
}

/* h=1 compact row — mirrors stat widget compact layout */
.grid-stack-item[gs-h="1"] .link-widget-inner,
.grid-stack-item[data-gs-h="1"] .link-widget-inner {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}

.grid-stack-item[gs-h="1"] .link-widget-label,
.grid-stack-item[data-gs-h="1"] .link-widget-label {
flex: 1 1 0;
font-size: 1.1rem;
text-align: left;
max-width: calc(60% - 10px);
}

.grid-stack-item[gs-h="1"] .link-widget-url,
.grid-stack-item[data-gs-h="1"] .link-widget-url {
flex-shrink: 0;
font-size: 0.72rem;
text-align: right;
max-width: calc(40% - 30px); /* keep clear of the absolute delete button */
}

/* ─────────────────────────────────────────────────────────────────────────── */

.fullscreen .stat-value {
font-size: 4rem;
}
Expand Down
Loading
Loading