From c815906c18d3d4bac47bbf3d34c9c7540743db62 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Mon, 9 Mar 2026 14:00:09 +0000 Subject: [PATCH 1/2] feat: migrate chart-edit config to Formly dynamic forms Replace manual chart configuration template with @ngx-formly/core driven forms. Adds custom field types (repeat-section, color-picker, color-palette, checkbox, expansion-panel wrapper) and a JSON-schema-based field builder for chart options. Co-Authored-By: Claude Opus 4.6 --- frontend/package.json | 2 + .../chart-edit/chart-edit.component.css | 353 +++--------- .../chart-edit/chart-edit.component.html | 409 +------------ .../chart-edit/chart-edit.component.spec.ts | 25 +- .../charts/chart-edit/chart-edit.component.ts | 542 +++++------------- .../src/app/formly/chart-options-fields.ts | 406 +++++++++++++ .../src/app/formly/chart-options.schema.ts | 252 ++++++++ frontend/src/app/formly/checkbox.type.ts | 15 + frontend/src/app/formly/color-palette.type.ts | 100 ++++ frontend/src/app/formly/color-picker.type.ts | 103 ++++ .../src/app/formly/expansion-panel.wrapper.ts | 19 + frontend/src/app/formly/formly-config.ts | 27 + .../app/formly/palette-color-input.type.ts | 52 ++ .../src/app/formly/repeat-section.type.ts | 102 ++++ frontend/src/main.ts | 2 + frontend/yarn.lock | 26 + 16 files changed, 1375 insertions(+), 1060 deletions(-) create mode 100644 frontend/src/app/formly/chart-options-fields.ts create mode 100644 frontend/src/app/formly/chart-options.schema.ts create mode 100644 frontend/src/app/formly/checkbox.type.ts create mode 100644 frontend/src/app/formly/color-palette.type.ts create mode 100644 frontend/src/app/formly/color-picker.type.ts create mode 100644 frontend/src/app/formly/expansion-panel.wrapper.ts create mode 100644 frontend/src/app/formly/formly-config.ts create mode 100644 frontend/src/app/formly/palette-color-input.type.ts create mode 100644 frontend/src/app/formly/repeat-section.type.ts diff --git a/frontend/package.json b/frontend/package.json index 10c6212b8..704883c2f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,8 @@ "@jsonurl/jsonurl": "^1.1.8", "@material-symbols/font-400": "^0.40.2", "@ngstack/code-editor": "^9.0.0", + "@ngx-formly/core": "^7.1.0", + "@ngx-formly/material": "^7.1.0", "@sentry-internal/rrweb": "^2.31.0", "@sentry/angular": "^10.33.0", "@stripe/stripe-js": "^5.3.0", diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css index eab06f80c..2b1c38750 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css @@ -308,17 +308,104 @@ height: 100%; } -.chart-config { +/* Formly layout classes */ +:host ::ng-deep .chart-config { display: flex; gap: 16px; flex-wrap: wrap; } -.chart-config-field { +:host ::ng-deep .chart-config-field { flex: 1; min-width: 150px; } +:host ::ng-deep .series-section { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +:host ::ng-deep .series-fields { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +:host ::ng-deep .series-fields-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +:host ::ng-deep .series-field { + flex: 1; + min-width: 120px; +} + +:host ::ng-deep .series-field--small { + max-width: 100px; + min-width: 80px; +} + +:host ::ng-deep .series-options { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; + margin-top: 4px; +} + +:host ::ng-deep .series-mode-field { + min-width: 160px; + font-size: 13px; +} + +:host ::ng-deep .series-column-config { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +:host ::ng-deep .option-group { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; +} + +:host ::ng-deep .option-field { + min-width: 120px; +} + +:host ::ng-deep .inline-fields { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +:host ::ng-deep .inline-fields .option-field { + flex: 1; +} + +:host ::ng-deep .axis-label { + margin: 8px 0 0; + font-size: 13px; + font-weight: 500; + color: rgba(0, 0, 0, 0.6); +} + +@media (prefers-color-scheme: dark) { + :host ::ng-deep .axis-label { + color: rgba(255, 255, 255, 0.6); + } +} + +:host ::ng-deep .advanced-options { + margin-top: 8px; +} + .chart-container { min-height: 250px; padding: 16px; @@ -433,265 +520,3 @@ vertical-align: middle; margin-right: 4px; } - -/* Series section */ -.series-section { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 8px; -} - -.series-section .section-header h4 { - margin: 0; - font-size: 14px; - font-weight: 500; -} - -.add-series-button { - font-size: 13px; -} - -.add-series-button mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -.series-card { - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 4px; - padding: 12px; -} - -@media (prefers-color-scheme: dark) { - .series-card { - border-color: rgba(255, 255, 255, 0.12); - } -} - -.series-card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.series-number { - font-size: 12px; - font-weight: 500; - text-transform: uppercase; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .series-number { - color: rgba(255, 255, 255, 0.54); - } -} - -.remove-series-button { - width: 28px !important; - height: 28px !important; - line-height: 28px !important; -} - -.remove-series-button mat-icon { - font-size: 16px; - width: 16px; - height: 16px; -} - -.series-fields { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.series-field { - flex: 1; - min-width: 120px; -} - -/* Color picker */ -.series-color-picker { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 80px; -} - -.color-picker-label { - font-size: 12px; - color: rgba(0, 0, 0, 0.6); -} - -@media (prefers-color-scheme: dark) { - .color-picker-label { - color: rgba(255, 255, 255, 0.6); - } -} - -.color-picker-row { - display: flex; - align-items: center; - gap: 6px; -} - -.color-input { - width: 36px; - height: 36px; - padding: 2px; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 4px; - cursor: pointer; - background: none; -} - -@media (prefers-color-scheme: dark) { - .color-input { - border-color: rgba(255, 255, 255, 0.12); - } -} - -.color-input::-webkit-color-swatch-wrapper { - padding: 2px; -} - -.color-input::-webkit-color-swatch { - border: none; - border-radius: 2px; -} - -.color-input::-moz-color-swatch { - border: none; - border-radius: 2px; -} - -.color-reset-button { - width: 24px !important; - height: 24px !important; - line-height: 24px !important; -} - -.color-reset-button mat-icon { - font-size: 14px; - width: 14px; - height: 14px; -} - -.color-auto-label { - font-size: 12px; - color: rgba(0, 0, 0, 0.38); -} - -@media (prefers-color-scheme: dark) { - .color-auto-label { - color: rgba(255, 255, 255, 0.38); - } -} - -/* Palette colors */ -.palette-colors { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.palette-color-item { - display: flex; - align-items: center; - gap: 2px; -} - -.color-remove-button { - width: 24px !important; - height: 24px !important; - line-height: 24px !important; -} - -.color-remove-button mat-icon { - font-size: 14px; - width: 14px; - height: 14px; -} - -.series-mode-field { - min-width: 160px; - font-size: 13px; -} - -.series-column-config { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.palette-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 4px; -} - -.add-color-button { - font-size: 13px; -} - -.add-color-button mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -.series-field--small { - max-width: 100px; - min-width: 80px; -} - -.series-options { - display: flex; - gap: 16px; - align-items: center; - flex-wrap: wrap; - margin-top: 4px; -} - -/* Advanced options accordion */ -.advanced-options { - margin-top: 8px; -} - -.option-group { - display: flex; - flex-direction: column; - gap: 12px; - padding: 4px 0; -} - -.option-field { - min-width: 120px; -} - -.inline-fields { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.inline-fields .option-field { - flex: 1; -} - -.axis-label { - margin: 8px 0 0; - font-size: 13px; - font-weight: 500; - color: rgba(0, 0, 0, 0.6); -} - -@media (prefers-color-scheme: dark) { - .axis-label { - color: rgba(255, 255, 255, 0.6); - } -} diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html index c7d0d464b..a273eb320 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html @@ -46,7 +46,7 @@

{{ isEditMode() ? 'Edit panel' : 'Create panel' }}

-
- -
- - Chart type - - @for (type of chartTypes; track type.value) { - {{ type.label }} - } - - - - - Label column - - @for (col of resultColumns(); track col) { - {{ col }} - } - - - - @if (showLabelTypeOption()) { - - Label type - - @for (type of labelTypes; track type.value) { - {{ type.label }} - } - - - } -
- - -
-
-

Data series

- @if (!isPieType()) { - - Series mode - - Manual - Series from column - - - } -
- - @if (seriesMode() === 'column' && !isPieType()) { -
- - Series column - - @for (col of resultColumns(); track col) { - {{ col }} - } - - Categorical column to split into datasets - - - - Value column - - @for (col of resultColumns(); track col) { - {{ col }} - } - - Numeric column to chart - -
- } - - @if (seriesMode() === 'manual' || isPieType()) { - @if (!isPieType() || seriesList().length === 0) { - - } - - @for (series of seriesList(); track $index; let i = $index) { -
-
- Series {{ i + 1 }} - @if (seriesList().length > 1) { - - } -
- -
- - Value column - - @for (col of resultColumns(); track col) { - {{ col }} - } - - - - - Label - - - - @if (!isPieType()) { -
- Color -
- - @if (series.color) { - - } - @if (!series.color) { - Auto - } -
-
- } - - @if (!isPieType() && chartType() !== 'bar') { - - Point style - - @for (ps of pointStyles; track ps.value) { - {{ ps.label }} - } - - - } -
- - @if (!isPieType()) { -
- @if (chartType() === 'line' || series.type === 'line') { - - Fill area - - } - - @if (chartType() === 'line' || series.type === 'line') { - - Tension - - - } - - @if (seriesList().length > 1) { - - Type override - - Default - Bar - Line - - - } -
- } -
- } - } -
+ + - - - - - - - Display options - - -
- @if (!isPieType()) { - - Stacked - - } - - @if (chartType() === 'bar') { - - Horizontal - - } - - - Show data labels - - - - Show legend - - - @if (legendShow()) { - - Legend position - - @for (pos of legendPositions; track pos.value) { - {{ pos.label }} - } - - - } -
-
- - - - - Units & formatting - - -
- - Unit mode - - @for (mode of unitModes; track mode.value) { - {{ mode.label }} - } - - - - @if (unitMode() === 'custom') { -
- - Unit text - - - - - Unit position - - Prefix ($100) - Suffix (100ms) - - -
- } - - @if (unitMode() === 'convert') { - - Source unit - - @for (group of convertUnitPresets; track group.group) { - - @for (unit of group.units; track unit.value) { - {{ unit.label }} - } - - } - - Values auto-convert to the best readable unit - - } - - @if (unitMode() !== 'convert') { -
- - Decimal places - - -
- - - Thousands separator - - - - Compact notation (1K, 1M, 1B) - - } -
-
- - - @if (!isPieType()) { - - - Axis configuration - - -
-

Y-Axis

-
- - Title - - - - - Scale type - - @for (st of scaleTypes; track st.value) { - {{ st.label }} - } - - -
- -
- - Min - - - - - Max - - -
- - - Begin at zero - - -

X-Axis

- - Title - - -
-
- } - - - - - Data options - - -
-
- - Sort by - - @for (opt of sortOptions; track opt.value) { - {{ opt.label }} - } - - - - - Limit - - -
-
-
- - - - - Custom color palette - - -
-
- @for (color of colorPalette(); track $index; let i = $index) { -
- - -
- } -
-
- - @if (colorPalette().length === 0) { - - } -
-
-
-
} @@ -598,7 +207,7 @@

Query results ({{ testResults().length }} rows)

@if (!loading()) {
Back - +
+ } + +
+ + @if (field.fieldGroup!.length === 0) { + + } +
+ + `, + styles: [ + ` + .option-group { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; + } + .palette-colors { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .palette-color-item { + display: flex; + align-items: center; + gap: 2px; + } + .color-remove-button { + width: 24px !important; + height: 24px !important; + line-height: 24px !important; + } + .color-remove-button mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + .palette-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; + } + .add-color-button { + font-size: 13px; + } + .add-color-button mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + `, + ], + imports: [FormlyModule, MatButtonModule, MatIconModule, MatTooltipModule], +}) +export class ColorPaletteType extends FieldArrayType { + loadDefaults(): void { + const defaults = [ + 'rgba(99, 102, 241, 0.7)', + 'rgba(16, 185, 129, 0.7)', + 'rgba(245, 158, 11, 0.7)', + 'rgba(239, 68, 68, 0.7)', + 'rgba(139, 92, 246, 0.7)', + 'rgba(236, 72, 153, 0.7)', + 'rgba(14, 165, 233, 0.7)', + 'rgba(20, 184, 166, 0.7)', + 'rgba(251, 146, 60, 0.7)', + 'rgba(168, 85, 247, 0.7)', + ]; + const formArray = this.formControl; + while (formArray.length) { + this.remove(0); + } + defaults.forEach(() => this.add()); + formArray.patchValue(defaults); + } +} diff --git a/frontend/src/app/formly/color-picker.type.ts b/frontend/src/app/formly/color-picker.type.ts new file mode 100644 index 000000000..30425ef1d --- /dev/null +++ b/frontend/src/app/formly/color-picker.type.ts @@ -0,0 +1,103 @@ +import { Component } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FieldType, FieldTypeConfig } from '@ngx-formly/core'; + +@Component({ + selector: 'formly-color-picker', + template: ` +
+ {{ props.label || 'Color' }} +
+ + @if (formControl.value) { + + } + @if (!formControl.value) { + Auto + } +
+
+ `, + styles: [ + ` + .series-color-picker { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 80px; + } + .color-picker-label { + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + } + @media (prefers-color-scheme: dark) { + .color-picker-label { + color: rgba(255, 255, 255, 0.6); + } + } + .color-picker-row { + display: flex; + align-items: center; + gap: 6px; + } + .color-input { + width: 36px; + height: 36px; + padding: 2px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + cursor: pointer; + background: none; + } + @media (prefers-color-scheme: dark) { + .color-input { + border-color: rgba(255, 255, 255, 0.12); + } + } + .color-input::-webkit-color-swatch-wrapper { + padding: 2px; + } + .color-input::-webkit-color-swatch { + border: none; + border-radius: 2px; + } + .color-reset-btn { + width: 24px; + height: 24px; + border: none; + background: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + } + .color-reset-x { + font-size: 16px; + color: rgba(0, 0, 0, 0.54); + } + @media (prefers-color-scheme: dark) { + .color-reset-x { + color: rgba(255, 255, 255, 0.54); + } + } + .color-auto-label { + font-size: 12px; + color: rgba(0, 0, 0, 0.38); + } + @media (prefers-color-scheme: dark) { + .color-auto-label { + color: rgba(255, 255, 255, 0.38); + } + } + `, + ], + imports: [ReactiveFormsModule], +}) +export class ColorPickerType extends FieldType {} diff --git a/frontend/src/app/formly/expansion-panel.wrapper.ts b/frontend/src/app/formly/expansion-panel.wrapper.ts new file mode 100644 index 000000000..b7c3db5d2 --- /dev/null +++ b/frontend/src/app/formly/expansion-panel.wrapper.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { FieldWrapper, FormlyModule } from '@ngx-formly/core'; + +@Component({ + selector: 'formly-expansion-panel', + template: ` + + + {{ props.label }} + +
+ +
+
+ `, + imports: [MatExpansionModule, FormlyModule], +}) +export class ExpansionPanelWrapper extends FieldWrapper {} diff --git a/frontend/src/app/formly/formly-config.ts b/frontend/src/app/formly/formly-config.ts new file mode 100644 index 000000000..9771c1974 --- /dev/null +++ b/frontend/src/app/formly/formly-config.ts @@ -0,0 +1,27 @@ +import { FormlyModule } from '@ngx-formly/core'; +import { FormlyMaterialModule } from '@ngx-formly/material'; +import { FormlyMatSelectModule } from '@ngx-formly/material/select'; +import { FormlyMatToggleModule } from '@ngx-formly/material/toggle'; +import { CheckboxType } from './checkbox.type'; +import { ColorPaletteType } from './color-palette.type'; +import { ColorPickerType } from './color-picker.type'; +import { ExpansionPanelWrapper } from './expansion-panel.wrapper'; +import { PaletteColorInputType } from './palette-color-input.type'; +import { RepeatSectionType } from './repeat-section.type'; + +export const FORMLY_IMPORTS = [ + FormlyMaterialModule, + FormlyMatSelectModule, + FormlyMatToggleModule, + FormlyModule.forRoot({ + types: [ + { name: 'checkbox', component: CheckboxType, wrappers: [] }, + { name: 'repeat', component: RepeatSectionType }, + { name: 'color-picker', component: ColorPickerType }, + { name: 'color-palette', component: ColorPaletteType }, + { name: 'palette-color-input', component: PaletteColorInputType }, + ], + wrappers: [{ name: 'expansion-panel', component: ExpansionPanelWrapper }], + validationMessages: [{ name: 'required', message: 'This field is required' }], + }), +]; diff --git a/frontend/src/app/formly/palette-color-input.type.ts b/frontend/src/app/formly/palette-color-input.type.ts new file mode 100644 index 000000000..b0ff39fc0 --- /dev/null +++ b/frontend/src/app/formly/palette-color-input.type.ts @@ -0,0 +1,52 @@ +import { Component } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FieldType, FieldTypeConfig } from '@ngx-formly/core'; + +@Component({ + selector: 'formly-palette-color-input', + template: ` + + `, + styles: [ + ` + .color-input { + width: 36px; + height: 36px; + padding: 2px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + cursor: pointer; + background: none; + } + @media (prefers-color-scheme: dark) { + .color-input { + border-color: rgba(255, 255, 255, 0.12); + } + } + .color-input::-webkit-color-swatch-wrapper { + padding: 2px; + } + .color-input::-webkit-color-swatch { + border: none; + border-radius: 2px; + } + `, + ], + imports: [ReactiveFormsModule], +}) +export class PaletteColorInputType extends FieldType { + toHex(color: string | undefined): string { + if (!color) return '#6366f1'; + if (color.startsWith('#')) return color.substring(0, 7); + const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!match) return '#000000'; + const r = parseInt(match[1], 10).toString(16).padStart(2, '0'); + const g = parseInt(match[2], 10).toString(16).padStart(2, '0'); + const b = parseInt(match[3], 10).toString(16).padStart(2, '0'); + return `#${r}${g}${b}`; + } +} diff --git a/frontend/src/app/formly/repeat-section.type.ts b/frontend/src/app/formly/repeat-section.type.ts new file mode 100644 index 000000000..37dc77158 --- /dev/null +++ b/frontend/src/app/formly/repeat-section.type.ts @@ -0,0 +1,102 @@ +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { FieldArrayType, FormlyModule } from '@ngx-formly/core'; + +@Component({ + selector: 'formly-repeat-section', + template: ` +
+
+

{{ props.label }}

+ @if (!props['maxItems'] || field.fieldGroup!.length < props['maxItems']) { + + } +
+ + @for (f of field.fieldGroup; track $index) { +
+
+ {{ props['itemLabel'] || 'Item' }} {{ $index + 1 }} + @if (field.fieldGroup!.length > 1) { + + } +
+ +
+ } +
+ `, + styles: [ + ` + .series-section { + display: flex; + flex-direction: column; + gap: 12px; + } + .section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + } + .section-header h4 { + margin: 0; + font-size: 14px; + font-weight: 500; + } + .add-series-button { + font-size: 13px; + } + .add-series-button mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + .series-card { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 12px; + } + @media (prefers-color-scheme: dark) { + .series-card { + border-color: rgba(255, 255, 255, 0.12); + } + } + .series-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + .series-number { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.54); + } + @media (prefers-color-scheme: dark) { + .series-number { + color: rgba(255, 255, 255, 0.54); + } + } + .remove-series-button { + width: 28px !important; + height: 28px !important; + line-height: 28px !important; + } + .remove-series-button mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + `, + ], + imports: [FormlyModule, MatButtonModule, MatIconModule, MatTooltipModule], +}) +export class RepeatSectionType extends FieldArrayType {} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 771da16b0..bd8d6141b 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -19,6 +19,7 @@ import { MarkdownModule, provideMarkdown } from 'ngx-markdown'; import { NgxStripeModule } from 'ngx-stripe'; import { AppComponent } from './app/app.component'; import { AppRoutingModule } from './app/app-routing.module'; +import { FORMLY_IMPORTS } from './app/formly/formly-config'; import { ConfigModule } from './app/modules/config.module'; import { ConnectionsService } from './app/services/connections.service'; import { NotificationsService } from './app/services/notifications.service'; @@ -97,6 +98,7 @@ bootstrapApplication(AppComponent, { Angulartics2Module.forRoot(), ClipboardModule, DragDropModule, + ...FORMLY_IMPORTS, MarkdownModule.forRoot(), // ...saasExtraModules, NgxThemeModule.forRoot(colorConfig, { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 5766736e3..b233219d8 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3963,6 +3963,30 @@ __metadata: languageName: node linkType: hard +"@ngx-formly/core@npm:^7.1.0": + version: 7.1.0 + resolution: "@ngx-formly/core@npm:7.1.0" + dependencies: + tslib: ^2.0.0 + peerDependencies: + "@angular/forms": ">=13.2.0" + rxjs: ^6.5.3 || ^7.0.0 + checksum: 7e8051edd80dcf1de39505f07ea2fcc1821484d5393eefbef935051d03eb943ca710c2acf4404c841f54ddbf38ffea9a38eaed142a3114f834c5df0fa4d94671 + languageName: node + linkType: hard + +"@ngx-formly/material@npm:^7.1.0": + version: 7.1.0 + resolution: "@ngx-formly/material@npm:7.1.0" + dependencies: + tslib: ^2.0.0 + peerDependencies: + "@angular/material": ">=16.0.0" + "@ngx-formly/core": 7.1.0 + checksum: fe5f9c683d37f60406453ebc60a247756d9e601d90e98d1af6e54e8fb0e49d29e4a49b90846093f02594aa1c7ebd8f021496e985c4720d7943eb6add818ffad0 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -12975,6 +12999,8 @@ __metadata: "@jsonurl/jsonurl": ^1.1.8 "@material-symbols/font-400": ^0.40.2 "@ngstack/code-editor": ^9.0.0 + "@ngx-formly/core": ^7.1.0 + "@ngx-formly/material": ^7.1.0 "@sentry-internal/rrweb": ^2.16.0 "@sentry/angular": ^10.33.0 "@storybook/angular": ^10.2.14 From f1a584d7775472ed557441367cf5223ded68b6f0 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Mon, 9 Mar 2026 15:29:04 +0000 Subject: [PATCH 2/2] fix: resolve reactivity bugs and simplify formly chart config - Fix chartType/hasChartData computeds not reacting to model changes (missing _modelVersion signal dependency) - Fix wrong parent chain depth in series field hide expressions (replaced fragile parent traversal with getRootModel helper) - Deep-clone seriesList/colorPalette in onModelChange to avoid shared references with Formly internals - Remove redundant testResults() dependency from currentWidgetOptions - Consolidate double _modelVersion bumps in testQuery - Memoize columnOptions to avoid repeated allocations per expression cycle - Use DEFAULT_COLOR_PALETTE from shared helper instead of duplicate - Remove unused imports (MatExpansionModule, MatSelectModule, DEFAULT_COLOR_PALETTE) - Use stable track by f.id instead of $index in repeat types Co-Authored-By: Claude Opus 4.6 --- .../charts/chart-edit/chart-edit.component.ts | 33 ++++++++--------- .../src/app/formly/chart-options-fields.ts | 37 +++++++++++++------ frontend/src/app/formly/color-palette.type.ts | 19 ++-------- .../src/app/formly/repeat-section.type.ts | 6 +-- 4 files changed, 49 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts b/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts index 73927988d..dbd37ee2e 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts @@ -3,12 +3,10 @@ import { Component, computed, effect, inject, OnInit, signal } from '@angular/co import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatExpansionModule } from '@angular/material/expansion'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSelectModule } from '@angular/material/select'; import { MatTableModule } from '@angular/material/table'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Title } from '@angular/platform-browser'; @@ -22,7 +20,6 @@ import { ChartOptionsModel, DEFAULT_CHART_OPTIONS_MODEL, } from 'src/app/formly/chart-options-fields'; -import { DEFAULT_COLOR_PALETTE } from 'src/app/lib/chart-config.helper'; import { ChartAxisConfig, ChartLegendConfig, @@ -32,7 +29,6 @@ import { ChartUnitConfig, ChartWidgetOptions, GeneratedPanelWithPosition, - TestQueryResult, } from 'src/app/models/saved-query'; import { ConnectionsService } from 'src/app/services/connections.service'; import { DashboardsService } from 'src/app/services/dashboards.service'; @@ -52,11 +48,9 @@ import { ChartPreviewComponent } from '../chart-preview/chart-preview.component' ReactiveFormsModule, RouterModule, MatButtonModule, - MatExpansionModule, MatIconModule, MatInputModule, MatFormFieldModule, - MatSelectModule, MatTableModule, MatTooltipModule, MatProgressSpinnerModule, @@ -115,6 +109,7 @@ export class ChartEditComponent implements OnInit { protected canTest = computed(() => !!this.queryText().trim() && !this.testing()); protected hasChartData = computed(() => { + this._modelVersion(); const model = this.chartModel; const hasLabel = !!model.labelColumn; if (model.seriesMode === 'column') { @@ -125,8 +120,6 @@ export class ChartEditComponent implements OnInit { }); protected currentWidgetOptions = computed(() => { - // Touch testResults to trigger recompute when model changes via formly - this.testResults(); this._modelVersion(); const model = this.chartModel; @@ -189,7 +182,10 @@ export class ChartEditComponent implements OnInit { return options; }); - protected chartType = computed(() => this.chartModel.chartType as ChartType); + protected chartType = computed(() => { + this._modelVersion(); + return this.chartModel.chartType as ChartType; + }); // Bumped on every formly model change to trigger computed signal recalculation private _modelVersion = signal(0); @@ -235,7 +231,11 @@ export class ChartEditComponent implements OnInit { } onModelChange(model: ChartOptionsModel): void { - this.chartModel = { ...model }; + this.chartModel = { + ...model, + seriesList: model.seriesList.map((s) => ({ ...s })), + colorPalette: [...model.colorPalette], + }; this._modelVersion.update((v) => v + 1); } @@ -291,16 +291,15 @@ export class ChartEditComponent implements OnInit { this.resultColumns.set(result.data.length > 0 ? Object.keys(result.data[0]) : []); this.showResults.set(true); + const updates: Partial = {}; if (this.resultColumns().length > 0 && !this.chartModel.labelColumn) { - this.chartModel = { ...this.chartModel, labelColumn: this.resultColumns()[0] }; - this._modelVersion.update((v) => v + 1); + updates.labelColumn = this.resultColumns()[0]; } - // Auto-add first series if none exist if (this.chartModel.seriesList.length === 0 && this.resultColumns().length > 1) { - this.chartModel = { - ...this.chartModel, - seriesList: [{ value_column: this.resultColumns()[1] }], - }; + updates.seriesList = [{ value_column: this.resultColumns()[1] }]; + } + if (Object.keys(updates).length > 0) { + this.chartModel = { ...this.chartModel, ...updates }; this._modelVersion.update((v) => v + 1); } } diff --git a/frontend/src/app/formly/chart-options-fields.ts b/frontend/src/app/formly/chart-options-fields.ts index 00880877c..79d302e64 100644 --- a/frontend/src/app/formly/chart-options-fields.ts +++ b/frontend/src/app/formly/chart-options-fields.ts @@ -150,12 +150,27 @@ function isPieType(model: ChartOptionsModel): boolean { return ['pie', 'doughnut', 'polarArea'].includes(model.chartType); } +function getRootModel(field: FormlyFieldConfig): ChartOptionsModel { + let f = field; + while (f.parent) f = f.parent; + return f.model as ChartOptionsModel; +} + // --------------------------------------------------------------------------- // Layout builder // --------------------------------------------------------------------------- export function buildChartOptionsFields(resultColumns: Signal): FormlyFieldConfig[] { - const columnOptions = () => resultColumns().map((c) => ({ value: c, label: c })); + let _cachedCols: string[] = []; + let _cachedOptions: { value: string; label: string }[] = []; + const columnOptions = () => { + const cols = resultColumns(); + if (cols !== _cachedCols) { + _cachedCols = cols; + _cachedOptions = cols.map((c) => ({ value: c, label: c })); + } + return _cachedOptions; + }; return [ // Basic chart config @@ -238,15 +253,15 @@ export function buildChartOptionsFields(resultColumns: Signal): Formly key: 'color', type: 'color-picker', expressions: { - hide: (field) => isPieType(field.parent?.parent?.parent?.model), + hide: (field) => isPieType(getRootModel(field)), }, }, ssf('point_style', { className: 'series-field', expressions: { hide: (field) => { - const root = field.parent?.parent?.parent?.model; - return isPieType(root) || root?.chartType === 'bar'; + const root = getRootModel(field); + return isPieType(root) || root.chartType === 'bar'; }, }, }), @@ -254,16 +269,16 @@ export function buildChartOptionsFields(resultColumns: Signal): Formly }, { fieldGroupClassName: 'series-options', - expressions: { hide: (field) => isPieType(field.parent?.parent?.model) }, + expressions: { hide: (field) => isPieType(getRootModel(field)) }, fieldGroup: [ ssf('fill', { type: 'checkbox', className: 'series-option-checkbox', expressions: { hide: (field) => { - const root = field.parent?.parent?.parent?.parent?.model; + const root = getRootModel(field); const series = field.parent?.parent?.model; - return root?.chartType !== 'line' && series?.type !== 'line'; + return root.chartType !== 'line' && series?.type !== 'line'; }, }, }), @@ -272,9 +287,9 @@ export function buildChartOptionsFields(resultColumns: Signal): Formly props: { step: 0.1 }, expressions: { hide: (field) => { - const root = field.parent?.parent?.parent?.parent?.model; + const root = getRootModel(field); const series = field.parent?.parent?.model; - return root?.chartType !== 'line' && series?.type !== 'line'; + return root.chartType !== 'line' && series?.type !== 'line'; }, }, }), @@ -282,8 +297,8 @@ export function buildChartOptionsFields(resultColumns: Signal): Formly className: 'series-field', expressions: { hide: (field) => { - const root = field.parent?.parent?.parent?.parent?.model; - return (root?.seriesList?.length || 0) <= 1; + const root = getRootModel(field); + return (root.seriesList?.length || 0) <= 1; }, }, }), diff --git a/frontend/src/app/formly/color-palette.type.ts b/frontend/src/app/formly/color-palette.type.ts index f39dd8691..ac71bf58d 100644 --- a/frontend/src/app/formly/color-palette.type.ts +++ b/frontend/src/app/formly/color-palette.type.ts @@ -3,13 +3,14 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { FieldArrayType, FormlyModule } from '@ngx-formly/core'; +import { DEFAULT_COLOR_PALETTE } from 'src/app/lib/chart-config.helper'; @Component({ selector: 'formly-color-palette', template: `
- @for (f of field.fieldGroup; track $index) { + @for (f of field.fieldGroup; track f.id) {
- @for (f of field.fieldGroup; track $index) { + @for (f of field.fieldGroup; track f.id; let i = $index) {
- {{ props['itemLabel'] || 'Item' }} {{ $index + 1 }} + {{ props['itemLabel'] || 'Item' }} {{ i + 1 }} @if (field.fieldGroup!.length > 1) { - }