Skip to content

feat: add TemplateBuilder component system#121

Open
roncodes wants to merge 30 commits intomainfrom
feature/template-builder-component
Open

feat: add TemplateBuilder component system#121
roncodes wants to merge 30 commits intomainfrom
feature/template-builder-component

Conversation

@roncodes
Copy link
Member

@roncodes roncodes commented Mar 3, 2026

Template Builder Component System

Implements a full-featured, reusable template builder component for designing invoice templates, shipping label templates, report templates, receipt templates, and other document types.

Companion PR: fleetbase/core-api#198


Components

Component Description
TemplateBuilder Top-level orchestrator — composes all panels and manages shared state
TemplateBuilder::Canvas Pixel-perfect free-canvas with interact.js drag/resize and grid snapping
TemplateBuilder::ElementRenderer Renders all element types: text, image, table, line, shape, qr_code, barcode
TemplateBuilder::Toolbar Element type picker, zoom controls (25%–300%), undo/redo, save/preview
TemplateBuilder::LayersPanel Element tree with visibility toggle, z-order reorder, delete
TemplateBuilder::PropertiesPanel Contextual properties editor: Position, Size, Typography, Appearance, Border, Effects
TemplateBuilder::PropertiesPanel::Section Collapsible section wrapper for the properties panel
TemplateBuilder::PropertiesPanel::Field Labelled field row helper
TemplateBuilder::VariablePicker Modal for browsing context schema variables and composing formulas

Key Features

  • Free-canvas drag and resize via interact.js with 5px grid snapping and rotation support
  • 50-step undo/redo history
  • Variable syntax: {invoice.total}, {company.name}, {order.tracking_number}
  • Formula syntax: [{ {invoice.subtotal} * 1.1 }]
  • Iteration syntax: {{#each orders}}...{{/each}} with {this.property}, {loop.index}
  • Three-tier context: global/ambient → primary subject → query collections
  • Variable picker with search, namespace grouping, type icons, and inline formula editor
  • Full dark mode support via Tailwind + data-theme=dark
  • Paper size and orientation support (A4, Letter, Legal, custom dimensions)
  • Element types: text, image, table, line, shape, qr_code, barcode

Usage

<TemplateBuilder
    @template={{this.template}}
    @contextSchemas={{this.contextSchemas}}
    @isSaving={{this.isSaving}}
    @onSave={{this.saveTemplate}}
    @onPreview={{this.previewTemplate}}
/>

Dependencies Added

  • interactjs ^1.10.27 — drag, resize, and snap behaviour for canvas elements

Implements a full-featured, reusable template builder component for designing
invoice templates, shipping label templates, report templates, receipt templates,
and other document types.

## Components

- `TemplateBuilder` — top-level orchestrator, composes all panels
- `TemplateBuilder::Canvas` — pixel-perfect free-canvas with interact.js drag/resize
- `TemplateBuilder::ElementRenderer` — renders all element types (text, image, table, line, shape, qr_code, barcode)
- `TemplateBuilder::Toolbar` — element type picker, zoom controls, undo/redo, save/preview
- `TemplateBuilder::LayersPanel` — element tree with visibility toggle, reorder, delete
- `TemplateBuilder::PropertiesPanel` — contextual properties editor with Position, Size, Typography, Appearance, Border, Effects sections
- `TemplateBuilder::PropertiesPanel::Section` — collapsible section wrapper
- `TemplateBuilder::PropertiesPanel::Field` — labelled field row
- `TemplateBuilder::VariablePicker` — modal for browsing context schema variables and composing formulas

## Key Features

- Free-canvas drag and resize via interact.js with grid snapping
- 50-step undo/redo history
- Variable syntax: `{invoice.total}`, `{company.name}`
- Formula syntax: `[{ {invoice.subtotal} * 1.1 }]`
- Iteration syntax: `{{#each orders}}...{{/each}}`
- Three-tier context: global/ambient → primary subject → query collections
- Variable picker with search, namespace grouping, type icons, and formula editor
- Full dark mode support via Tailwind + data-theme='dark'
- Paper size and orientation support (A4, Letter, Legal, custom)
- Element types: text, image, table, line, shape, qr_code, barcode

## Dependencies

- interactjs ^1.10.27 (drag/resize)

Refs: fleetbase/core-api#198
roncodes pushed a commit to fleetbase/fleetbase that referenced this pull request Mar 3, 2026
Adds two Ember Data models to the console app to support the new
template builder system introduced in fleetbase/core-api#198 and
fleetbase/ember-ui#121.

## Models

### template
- Full attribute set matching the backend Template model
- Computed: isDraft, isPublished, contextTypeLabel, dimensionLabel
- hasMany: template-query (inverse)
- Supports: name, description, context_type, paper_size, orientation,
  width, height, unit, background_color, content (array), is_default, status

### template-query
- Full attribute set matching the backend TemplateQuery model
- Computed: variableName, variableToken, resourceTypeLabel
- belongsTo: template (inverse)
- Supports: name, label, description, resource_type, filters (object),
  sort_by, sort_direction, limit

Both models follow the existing Fleetbase model conventions:
- @ember-data/model with attr/belongsTo/hasMany decorators
- Standard date computed properties (updatedAgo, updatedAt, createdAt, etc.)
- date-fns for date formatting

Refs: fleetbase/core-api#198, fleetbase/ember-ui#121
Ronald A Richardson and others added 27 commits March 3, 2026 06:40
…cycle hook collision

Glimmer components throw immediately in debug mode if a method named
'didInsertElement' or 'willDestroyElement' is defined on the class,
even when decorated with @action and intended as a modifier callback.

Renamed:
  didInsertElement  → setupElement
  willDestroyElement → teardownElement

Updated canvas.hbs to pass the renamed actions via fn helper.

Fixes: Uncaught Error: You attempted to define the 'didInsertElement'
method on a Glimmer Component, but that lifecycle hook does not exist...
JSON.stringify() cannot serialise Ember Data model instances because
their attributes are tracked prototype getters, not plain enumerable
own properties — resulting in an empty {} clone and a broken editor.

Fix: detect Ember Data models via eachAttribute() and extract each
attribute value explicitly before JSON round-tripping. Plain objects
continue to use the existing JSON.parse(JSON.stringify()) path.

Fixes: TemplateBuilder crashes when @template is an Ember Data model.
…contamination

Two bugs fixed:

1. layers-panel.js — isSelected/isVisible/isRenaming/elementIcon/elementLabel
   called as Glimmer helpers via (this.method arg) in HBS. Plain methods are
   invoked with this=undefined in that context. Fix: decorate all five with
   @action so they are bound to the component instance.

2. template-builder.js — _updateTemplate used spread { ...this.template }
   which can carry Ember tracked tags into the new plain object. When Glimmer
   detects a tracked write during a render pass it throws a setter assertion.
   Fix: use Object.assign then JSON.parse(JSON.stringify()) to guarantee a
   clean POJO with no tracking baggage.
Two bugs fixed:

1. canvas.js — isSelected and elementStyle called as Glimmer helpers via
   @isSelected={{this.isSelected element}} in HBS. Without @action the
   methods are invoked with this=undefined. Fix: add @action decorator to
   both isSelected and elementStyle.

2. template-builder.js — _updateTemplate writes to this._template (a
   @Tracked property) synchronously inside actions that can fire while a
   render pass is still in progress (e.g. addElement from toolbar click).
   Glimmer throws a setter assertion when a tracked value is modified after
   it was consumed during the current render transaction.
   Fix: defer the this._template write using schedule('afterRender') so it
   always happens after the current render pass completes cleanly.
…next()

Full audit of all template-builder HBS files for (this.method arg) helper
invocations — any plain method called this way is invoked with this=undefined
in Glimmer. Fixed by adding @action decorator to bind them to the instance.

Files fixed:
- properties-panel.js: isSectionOpen (called as @isopen={{this.isSectionOpen ...}})
- variable-picker.js:  isExpanded (called as {{if (this.isExpanded ...) ...}})

Also: switch _updateTemplate from schedule('afterRender') to next() from
@ember/runloop. schedule('afterRender') still fires within the same runloop
flush and can still trigger the setter-during-render assertion. next() defers
to the next runloop tick entirely, which is the correct Ember idiom for
deferring a tracked write past the current render transaction.
All .tb-input elements (text, number, select) now share an explicit
height: 26px with padding: 0 6px and box-sizing: border-box.

- appearance: none applied globally to strip browser-default sizing
- select.tb-input restores appearance: auto to keep the native dropdown arrow
- input[type=number] restores inner-spin-button with height: 24px so the
  spinners stay compact and don't overflow the 26px container
- Fixes number inputs rendering taller than select inputs in the properties
  panel (visible in screenshot: X/Y/Width/Height/Size vs Font Family/Weight)
The canvas component was checking window.interact (UMD global) which is
never set in an Ember build — causing drag/resize to silently no-op.

Replace with a proper ES6 import:
  import interact from 'interactjs';

interactjs is already declared in package.json so no new dependency is
needed. The graceful-degrade guard is also removed since the import will
throw at build time if the package is missing, which is the correct
failure mode for a hard dependency.
CSS fixes (template-builder.css):
- Explicitly re-declare font-size (11px), color (#374151) and border-radius (4px)
  on the [type='number'] and select.tb-input overrides so browser UA stylesheets
  cannot override these values after -webkit-appearance resets them.
- Add textarea.tb-input variant with auto height and correct padding.
- Align border colour to #d1d5db (gray-300) across all input types for visual
  consistency with the existing select inputs.
- Dark-mode colour updated to #d1d5db for all input variants.

Canvas drag/resize fixes (canvas.js + element-renderer.js):
- Switch element positioning from left/top to transform: translate(x, y) only.
  Having both left/top and a transform offset caused elements to jump on the
  first drag and made interact.js misidentify drag gestures as resize gestures.
- element-renderer.js: wrapperStyle now emits left:0; top:0. handleInsert seeds
  el.dataset.x/y from stored values and sets the initial transform so the element
  renders at the correct position without waiting for an interact.js event.
- canvas.js: resize edges now use CSS selector strings (.tb-handle-nw etc.) so
  resize gestures are restricted to the four corner handle elements. Any drag on
  the element body is therefore unambiguously a move, not a resize.
- Add moveElement action (template-builder.js) that mutates element position
  in-place without replacing the content array, preventing Glimmer from
  scheduling a re-render after every drag/resize end event. This keeps interact.js
  instances alive across gestures.
- Wire @onMoveElement to the canvas in template-builder.hbs.
- Replace Object-based _interactables map with a proper Map for cleaner
  lifecycle management.
- Add stale-interactable cleanup in setupElement for safety on re-renders.
…anvas.js

Babel's class-fields transform rejects .bind(this) chained onto a method
shorthand (e.g. end(event) { ... }.bind(this)) inside an object literal that
lives inside a class method body. The fix is to use arrow functions for all
interact.js listener callbacks so that `this` is captured lexically without
needing .bind().
…o template builder

Paper size & orientation (template-builder.js):
- _updateTemplate now calls _dimensionsForPaperSize() whenever paper_size or
  orientation changes, writing the resolved width/height/unit back onto the
  template object so the canvas reacts immediately.
- _dimensionsForPaperSize() contains the canonical mm dimensions for A4, A3,
  A5, Letter and Legal; landscape simply swaps width and height.
- Custom paper size leaves width/height unchanged (user sets them manually).

Image element (properties-panel.hbs + properties-panel.js):
- Replaced the raw Source URL text input with a three-state UI:
    empty  → dashed upload dropzone + 'or use variable' button
    variable → blue badge showing the token with edit/clear buttons
    uploaded → filename badge with replace-upload and clear buttons
- Upload is handled via fetch.uploadFile.perform() (same pattern as
  driver/form.js and custom-field/input.js). The uploaded URL is stored on
  the element's src property; only the filename is shown in the UI.
- Variable tokens (containing '{') continue to work via the variable picker.
- isUploadingImage tracked state drives the 'Uploading...' placeholder.

Table element (properties-panel.hbs + properties-panel.js):
- Added three new collapsible sections for table elements:
    Columns  – add/remove columns; each column has a Label and a data Key.
               Renaming a key migrates existing row data to the new key.
               Removing a column deletes its key from all rows.
    Data Source – toggle between Variable and Manual modes.
               Variable: a text field (+ variable picker) for a data_source
               token that resolves to an array of objects at render time.
               Manual: an inline row editor where each row shows one input
               per column; rows can be added and removed.
    Table Style – header background/text colour, border colour, cell padding,
               and alternating row colour.
- tableColumns / tableRows getters read directly from the element object.
- tableDataMode getter derives the current mode from whether data_source is set.
… bugs

Root cause analysis
-------------------

1. Elements become non-interactive after first drag/resize
   moveElement() previously wrote  to keep the
   properties panel in sync. Writing to any @Tracked property triggers a
   Glimmer re-render. Re-rendering causes the {{#if @isSelected}} block in
   element-renderer.hbs to insert/remove the .tb-handle-* divs, and in some
   cases destroys and recreates the element wrapper DOM node entirely. When
   the DOM node is recreated, interact.js loses its internal pointer state
   and the element becomes permanently unresponsive until the page reloads.

   Fix: remove the selectedElement write from moveElement(). The properties
   panel will reflect updated values the next time the user clicks the element
   (selectElement is called, which sets selectedElement to the same mutated
   object). No re-render occurs during drag/resize.

2. Mouse snaps to element center during drag
   interact.modifiers.snap() was configured with:
     relativePoints: [{ x: 0, y: 0 }]
   This tells interact.js to snap the element's top-left corner to the grid,
   not the pointer. Because the pointer is typically somewhere in the middle
   of the element, interact.js compensates by jumping the element so its
   top-left lands on the nearest grid point — which feels like the pointer
   is being pulled to the element's origin (center-snap).

   Fix: remove the snap modifier entirely. Free movement is smooth and
   accurate. Grid snapping can be re-added later using offset:'startCoords'
   which snaps relative to where the drag started, not the element origin.

3. Choppy movement
   The snap modifier (relativePoints) combined with restrict (parent bounding
   box) ran two constraint passes per pointer event, causing visible jank.

   Fix: both modifiers removed from draggable. Only restrictSize (minimum
   element dimensions) is kept on resizable, as it has no pointer-position
   side effects.

4. Zoom read at event time
   The previous implementation captured zoom at interactable-creation time
   via a closure. If the user changed zoom after an element was placed, the
   drag deltas would be scaled by the wrong factor.

   Fix: zoom is now read from this.args.zoom inside each event handler via
   a getZoom() closure, so it always reflects the current zoom level.
…on addElement; add canvas boundary clamping

Root cause of persistent drag/resize breakage
----------------------------------------------
The previous implementation stored the entire template (including content
array) in a single @Tracked _template object. Every mutation went through
_updateTemplate(), which did:

  JSON.parse(JSON.stringify(merged))

This deep-clone creates brand-new JS object references for EVERY element in
the content array, even elements that were not changed. Glimmer's {{#each}}
loop compares item references to decide whether to reuse or recreate a
component. Because every object was a new reference after every operation
(addElement, updateElement, deleteElement, reorderElement), Glimmer destroyed
and recreated EVERY ElementRenderer component on every single mutation. This
fired will-destroy (teardownElement -> interactable.unset()) followed by
did-insert (setupElement -> new interactable) for all elements — not just the
one that changed.

Fix: split state into @Tracked _meta (non-content template fields) and
@Tracked _content (element array). The invariant is that existing element
objects keep their JS identity across renders:

  addElement    -> push new object onto _content; existing objects unchanged;
                   Glimmer creates exactly one new ElementRenderer
  updateElement -> Object.assign onto the existing object (preserves identity);
                   replace _content with [..._content] (same refs) to notify
                   Glimmer; {{#each}} reuses all existing components
  moveElement   -> Object.assign only; no _content replacement; zero re-renders
  deleteElement -> filter to new array without target; only that component is
                   destroyed
  reorderElement-> mutate z_index in-place; replace array ref for reactivity
  undo/redo     -> restore from deep-cloned snapshot; full re-render acceptable
                   since it is an explicit user action

Canvas boundary clamping
------------------------
Added position and size clamping in both the drag.move and resize.move
handlers in canvas.js. Elements are now constrained to [0, canvasWidth - elW]
on the x axis and [0, canvasHeight - elH] on the y axis. Canvas dimensions
are read at event time (getCanvasDims()) so paper-size changes are reflected
without recreating interactables.
Toggle stuck on manual — root cause
-------------------------------------
The previous tableDataMode getter used:
  return this.element?.data_source ? 'variable' : 'manual';

When setTableDataMode('variable') was called it wrote { data_source: '' }.
An empty string is falsy in JS, so tableDataMode immediately evaluated back
to 'manual' — making the toggle appear stuck.

Fix: store the mode explicitly as data_source_mode on the element object.
tableDataMode now reads element.data_source_mode ?? 'manual'. The mode is
independent of whether the variable/query fields have been filled in yet.

Query data source mode
-----------------------
Added a third 'Query' option to the toggle. When selected, the UI shows:

  Endpoint        — the API path to call at render time (e.g. /api/v1/orders)
  Response Path   — dot-path to unwrap the array from the JSON envelope
                    (e.g. 'data' for { "data": [...] })
  Query Parameters — key/value pairs appended as query string; values support
                    {variable} syntax for dynamic filters

setTableDataMode clears the fields for the previous mode when switching:
  -> manual   clears data_source, query_endpoint, query_params, query_response_path
  -> variable clears query_endpoint, query_params, query_response_path
  -> query    clears data_source; seeds query_params: [] if not present
…dialog, and variable picker integration

- Remove incorrect 'Query' toggle from table data source (was raw endpoint input)
- Add TemplateBuilder::QueriesPanel component: lists queries per template, add/edit/delete via fetch service
- Add TemplateBuilder::QueryForm dialog: resource type picker, condition builder (field/operator/value), sort directives, limit, eager-load (with)
- Add Layers/Queries tab switcher to left panel
- Inject saved query variable tokens as a 'Queries' section in the variable picker
- Table data source now uses Variable | Manual toggle; variable tokens from queries are inserted via the variable picker
- Seed _queries from args.template.queries in constructor
- Include queries array in template getter (save payload)
- QueryForm: remove all API calls — validate and return data to parent only
- QueriesPanel: receive @queries from parent, no API fetch, all CRUD via @onQueriesChange
- template-builder.hbs: pass @queries to QueriesPanel instead of @templateUuid
- Queries created before template is saved get a temp _new_ UUID; backend strips these on upsert
- On save, backend upserts all queries in one request alongside the template record
…ponents

Ember addons require a re-export shim in app/components/ for each component
defined in addon/components/. Without these files Glimmer cannot resolve
TemplateBuilder::QueriesPanel or TemplateBuilder::QueryForm at runtime.
…-derive, template-builder service

- canvas.js: use @Tracked selectedUuid for proper Glimmer reactivity on selection;
  add interact.js tap listener to ensure selectElement fires even when interact
  intercepts pointer events; pass @rotation and @isSelected as explicit primitive
  args to ElementRenderer so Glimmer tracks changes correctly
- canvas.hbs: pass @isSelected={{eq element.uuid this.selectedUuid}} and @rotation={{element.rotation}}
  as explicit primitive arguments so did-update fires correctly on rotation change
- element-renderer.hbs: use @rotation in did-update instead of @element.rotation
- element-renderer.js: _applyTransform reads @rotation arg directly
- query-form.js: inject service:template-builder for resource types; fix variable_name
  auto-derive; add @resourceTypes arg override support
- query-form.hbs: section spacing, filter condition row layout fix, sort row layout fix
- services/template-builder.js: new service for extension resource type registration
- app/services/template-builder.js: re-export shim
…ression

- canvas.js: remove side-effect from selectedUuid getter — writing @Tracked
  inside a getter causes Glimmer's infinite render loop assertion, which
  prevented the canvas from rendering any elements at all
- toolbar.hbs: remove rotate buttons from center section (duplicate); move
  them to the right section before undo/redo, which is where the user expects
  them (left side of Preview/Save action buttons)
… and resource type namespaces

- Fix undo/redo buttons permanently disabled: _undoStack and _redoStack were
  plain class fields (not @Tracked), so canUndo/canRedo getters never
  re-evaluated when items were pushed/popped. Changed to @Tracked and replaced
  all array mutations (.push/.pop/.shift) with immutable array operations
  (spread + reassignment) so Glimmer detects the changes.

- Fix rotation not reflecting until element is dragged: added imperative DOM
  update in updateElement() when changes.rotation is defined. Reads the
  current x/y from data-element-uuid dataset attributes (maintained by
  interact.js) and applies the full translate+rotate transform immediately,
  matching the pattern used by interact.js for drag/resize.

- Fix query form resource types: updated default resource types from incorrect
  Fleetbase\Models\* namespace to correct Fleetbase\FleetOps\Models\*
  namespace. Removed Payload, Entity, TrackingStatus, Zone, ServiceArea, and
  Route (not valid query targets). Added Issue, FuelReport, and PurchaseRate.
… canvas

Root cause: updateElement and reorderElement were mutating element objects
in-place (Object.assign / direct property assignment). Glimmer's reactivity
system tracks argument changes by reference — when @element is the same
object reference, Glimmer considers it unchanged and does NOT re-render the
ElementRenderer component. As a result, getters like textContent, textStyle,
wrapperStyle, and wrapperStyle never re-evaluated after a property update,
making all properties panel changes invisible on the canvas.

Fix: replace the element object with a new spread copy in both updateElement
and reorderElement. This gives @element a new reference, which Glimmer detects
and uses to re-render the affected ElementRenderer. The interact.js instance
is torn down (will-destroy) and immediately re-created (did-insert) with the
new object — setupElement handles stale interactables by uuid, so this is
seamless. The element's current position (x/y from moveElement's in-place
mutations) is preserved in the spread copy, so handleInsert correctly seeds
the data attributes for interact.js.
The previous fix (replacing element objects in updateElement) caused
interact.js to be destroyed and recreated on every property change because
Glimmer was tracking {{#each}} by array index — a new object reference at
the same index meant destroy-old + create-new for the ElementRenderer.

Root cause of drag/resize breaking:
- Without a key, Glimmer destroys/recreates ElementRenderer on every
  updateElement call, tearing down the interact.js instance each time.
- The new instance was set up correctly, but the stale closure in
  _setupInteract still read element.rotation from the old captured object.

Fix (three-part):

1. canvas.hbs: Add key="uuid" to the {{#each}} loop.
   Glimmer now tracks each ElementRenderer by uuid. When updateElement
   replaces the element object (same uuid, new reference), Glimmer REUSES
   the existing component instance and DOM node — interact.js stays alive —
   but passes the new @element, causing the template to re-render and all
   getters (textContent, textStyle, wrapperStyle, etc.) to re-evaluate.

2. canvas.js: Read rotation from el.dataset.rotation in applyTransform.
   The _setupInteract closure captured element by reference at setup time.
   With key="uuid", setupElement is not called again on property updates,
   so the closure would read stale rotation from the old object. Reading
   from el.dataset.rotation (a live DOM attribute) avoids this.

3. element-renderer.js: Write el.dataset.rotation in _applyTransform.
   Keeps the DOM attribute in sync whenever rotation changes (via
   handleInsert on first render and handleUpdate on subsequent changes),
   so the canvas closure always reads the current value.
…derer

Each component now owns exactly its own responsibilities:

ElementRenderer
  - Imports interact.js and sets up its own interactable in did-insert
  - Tears it down in will-destroy (no parent involvement)
  - Emits @onmove(uuid, {x,y}) when a drag ends
  - Emits @onresize(uuid, {x,y,width,height}) when a resize ends
  - Emits @onselect(element) when tapped
  - Reads @canvasWidth/@canvasHeight for boundary clamping
  - Reads @zoom at event time (no closure staleness)
  - Reads rotation from el.dataset.rotation (written by _applyTransform)
    so the applyTransform closure never holds a stale element reference

Canvas
  - Removed: interact.js import, _interactables map, setupElement,
    teardownElement, _setupInteract, didInsertCanvas, willDestroyCanvas
  - Owns only: canvas dimensions/style, selectedUuid tracking,
    handleSelectElement, handleDeselectAll
  - Passes @canvasWidth/@canvasHeight/@zoom to each ElementRenderer
  - Forwards @onMoveElement → @onmove, @onResizeElement → @onresize

TemplateBuilder
  - Added resizeElement action (separate from moveElement)
  - moveElement: drag-end only, syncs {x,y}
  - resizeElement: resize-end only, syncs {x,y,width,height}
  - Both are silent in-place mutations (no re-render, no undo entry)
  - Removed @onUpdateElement from canvas wiring (canvas never needed it)

This eliminates the entire class of bugs caused by the parent managing
child DOM lifecycle: no more interact.js destruction on property updates,
no stale closure references, no need for data-attribute workarounds.
…dex change

Bug 1 — Selection not working
  Root cause: canvas.js was tracking _selectedUuid locally. When the layers
  panel selected an element, the parent's selectedElement updated but the
  canvas's _selectedUuid never changed, so @isSelected was always false.
  Additionally, the canvas's {{on "click" handleDeselectAll}} was firing even
  when a child element was tapped (interact.js tap does not stop the native
  click from bubbling), immediately clearing the selection.

  Fix:
  - canvas.js: removed local _selectedUuid state entirely. selectedUuid is
    now a getter derived from @selectedElement (the parent's source of truth).
    This means any selection source (canvas tap, layers panel, keyboard) is
    automatically reflected in the canvas highlight.
  - canvas.js: handleDeselectAll now guards with
    event.target !== event.currentTarget so it only fires when the canvas
    background itself is clicked, not when a child element is tapped.

Bug 2 — z-index change repositions all elements to 0,0
  Root cause: reorderElement replaces element objects with new spread copies.
  Glimmer re-renders the ElementRenderer's style attribute (wrapperStyle).
  wrapperStyle intentionally omits the CSS transform (which is managed
  imperatively by interact.js). When Glimmer writes the new style attribute,
  it clears the transform, snapping every element to 0,0.

  Fix:
  - element-renderer.hbs: added {{did-update this.handleUpdate @element}}.
  - element-renderer.js: handleUpdate re-applies _applyTransform(el) after
    Glimmer updates the style attribute, restoring the correct position.

Bonus fix — reorderElement now syncs selectedElement to the new object so
the properties panel reflects the updated z_index immediately.
…ove namespace display

- toolbar: change undo icon from arrow-rotate-left to step-backward,
  redo icon from arrow-rotate-right to step-forward

- query-form: remove the PHP namespace confirmation block shown below
  the resource type grid after a type is selected — unnecessary detail
  that adds visual noise for users

- CSS: add .tb-query-form scoped overrides so input[type=text] and
  select inside the query dialog share the same height (30px),
  border-radius (6px), font-size (12px), and padding as each other,
  eliminating the mismatch between text inputs and select inputs
…onsole errors

- Split query-form state into individual @Tracked fields (label, variableName,
  description, modelType, limit, withRelations) plus separate @Tracked
  conditions and sort arrays. Previously, every keystroke replaced the entire
  form object, causing Glimmer to destroy and recreate all input DOM elements
  in the {{#each}} loops — stealing focus from the active input.

- conditions and sort now use in-place Object.assign mutation + array reference
  bump (this.conditions = [...this.conditions]). Glimmer re-evaluates the list
  but reuses existing DOM nodes (same array indices), so the focused input keeps
  focus across keystrokes.

- Updated query-form.hbs to bind to individual tracked properties and dedicated
  action handlers (updateLabel, updateVariableName, updateDescription,
  updateLimit) instead of the generic updateField dispatcher.

- Added addon/helpers/string-starts-with.js + app/helpers/string-starts-with.js
  to fix the 'Attempted to resolve string-starts-with' crash that occurred when
  queries-panel.hbs rendered after a successful Create Query. The helper was
  referenced but never registered in this addon.
roncodes added 2 commits March 4, 2026 03:08
- toolbar.hbs: render a close/back button on the left side of the toolbar
  when @onclose is provided; separated from element-type buttons by a
  vertical divider. Defaults to a chevron-left icon; @closeIcon and
  @closeLabel args allow customisation.

- toolbar.js: add close() @action that calls @onclose if present; update
  JSDoc to document @onclose, @closeIcon, @closeLabel args.

- template-builder.hbs: forward @onclose, @closeIcon, @closeLabel from
  the root component down to <TemplateBuilder::Toolbar>.

- template-builder.js: document the three new optional args in the class
  JSDoc block.
The contextSchemas getter now returns [] if the passed value is not an
array, preventing 'filteredSchemas.some is not a function' crashes when
the consumer passes an API response object instead of a normalised array.

hasResults also guards each schema's variables with ?? [] for safety.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant