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' }}
-
}
@@ -598,7 +207,7 @@ Query results ({{ testResults().length }} rows)
@if (!loading()) {
Back
-
diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts b/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts
index 994f01bfa..10e9a4ae1 100644
--- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts
+++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts
@@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { NO_ERRORS_SCHEMA, Signal, WritableSignal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormGroup } from '@angular/forms';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, Router } from '@angular/router';
@@ -9,7 +10,8 @@ import { RouterTestingModule } from '@angular/router/testing';
import { CodeEditorModule } from '@ngstack/code-editor';
import { Angulartics2Module } from 'angulartics2';
import { of } from 'rxjs';
-import { ChartSeriesConfig, ChartType, SavedQuery } from 'src/app/models/saved-query';
+import { ChartOptionsModel } from 'src/app/formly/chart-options-fields';
+import { SavedQuery } from 'src/app/models/saved-query';
import { ConnectionsService } from 'src/app/services/connections.service';
import { SavedQueriesService } from 'src/app/services/saved-queries.service';
import { UiSettingsService } from 'src/app/services/ui-settings.service';
@@ -26,9 +28,8 @@ type ChartEditComponentTestable = ChartEditComponent & {
testResults: WritableSignal[]>;
resultColumns: WritableSignal;
executionTime: WritableSignal;
- labelColumn: WritableSignal;
- seriesList: WritableSignal;
- chartType: WritableSignal;
+ chartModel: ChartOptionsModel;
+ chartForm: FormGroup;
canSave: Signal;
canTest: Signal;
hasChartData: Signal;
@@ -134,7 +135,7 @@ describe('ChartEditComponent', () => {
it('should have correct default chart type', () => {
const testable = component as ChartEditComponentTestable;
- expect(testable.chartType()).toBe('bar');
+ expect(testable.chartModel.chartType).toBe('bar');
});
describe('canSave computed', () => {
@@ -214,8 +215,8 @@ describe('ChartEditComponent', () => {
const testable = component as ChartEditComponentTestable;
testable.queryText.set('SELECT * FROM users');
await component.testQuery();
- expect(testable.labelColumn()).toBe('name');
- expect(testable.seriesList()).toEqual([{ value_column: 'count' }]);
+ expect(testable.chartModel.labelColumn).toBe('name');
+ expect(testable.chartModel.seriesList).toEqual([{ value_column: 'count' }]);
});
});
@@ -263,16 +264,18 @@ describe('ChartEditComponent', () => {
it('should return false when no series configured', () => {
const testable = component as ChartEditComponentTestable;
testable.testResults.set([{ name: 'John' }]);
- testable.labelColumn.set('name');
- testable.seriesList.set([]);
+ testable.chartModel = { ...testable.chartModel, labelColumn: 'name', seriesList: [] };
expect(testable.hasChartData()).toBe(false);
});
it('should return true when results, label and series are set', () => {
const testable = component as ChartEditComponentTestable;
testable.testResults.set([{ name: 'John', count: 10 }]);
- testable.labelColumn.set('name');
- testable.seriesList.set([{ value_column: 'count' }]);
+ testable.chartModel = {
+ ...testable.chartModel,
+ labelColumn: 'name',
+ seriesList: [{ value_column: 'count' }],
+ };
expect(testable.hasChartData()).toBe(true);
});
});
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 f31a4ecde..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
@@ -1,23 +1,25 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, inject, OnInit, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
-import { FormsModule } from '@angular/forms';
+import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
-import { MatCheckboxModule } from '@angular/material/checkbox';
-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';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { CodeEditorModule } from '@ngstack/code-editor';
+import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
import { Angulartics2 } from 'angulartics2';
import posthog from 'posthog-js';
-import { DEFAULT_COLOR_PALETTE } from 'src/app/lib/chart-config.helper';
+import {
+ buildChartOptionsFields,
+ ChartOptionsModel,
+ DEFAULT_CHART_OPTIONS_MODEL,
+} from 'src/app/formly/chart-options-fields';
import {
ChartAxisConfig,
ChartLegendConfig,
@@ -27,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';
@@ -44,18 +45,17 @@ import { ChartPreviewComponent } from '../chart-preview/chart-preview.component'
imports: [
CommonModule,
FormsModule,
+ ReactiveFormsModule,
RouterModule,
MatButtonModule,
- MatCheckboxModule,
- MatExpansionModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
- MatSelectModule,
MatTableModule,
MatTooltipModule,
MatProgressSpinnerModule,
CodeEditorModule,
+ FormlyModule,
ChartPreviewComponent,
AlertComponent,
DashboardsSidebarComponent,
@@ -78,54 +78,6 @@ export class ChartEditComponent implements OnInit {
protected executionTime = signal(null);
protected showResults = signal(false);
- // Basic chart config
- protected chartType = signal('bar');
- protected labelColumn = signal('');
- protected labelType = signal<'values' | 'datetime'>('values');
-
- // Series config
- protected seriesMode = signal<'manual' | 'column'>('manual');
- protected seriesList = signal([]);
- protected seriesColumn = signal('');
- protected seriesValueColumn = signal('');
-
- // Display options
- protected stacked = signal(false);
- protected horizontal = signal(false);
- protected showDataLabels = signal(false);
-
- // Legend
- protected legendShow = signal(true);
- protected legendPosition = signal<'top' | 'bottom' | 'left' | 'right'>('top');
-
- // Units
- protected unitMode = signal<'none' | 'custom' | 'convert'>('none');
- protected unitsText = signal('');
- protected unitsPosition = signal<'prefix' | 'suffix'>('suffix');
- protected convertUnit = signal('');
-
- // Number format
- protected decimalPlaces = signal(null);
- protected thousandsSeparator = signal(true);
- protected compact = signal(false);
-
- // Y-axis
- protected yAxisTitle = signal('');
- protected yAxisMin = signal(null);
- protected yAxisMax = signal(null);
- protected yAxisBeginAtZero = signal(true);
- protected yAxisScaleType = signal<'linear' | 'logarithmic'>('linear');
-
- // X-axis
- protected xAxisTitle = signal('');
-
- // Sort & limit
- protected sortBy = signal<'label_asc' | 'label_desc' | 'value_asc' | 'value_desc' | 'none'>('none');
- protected limit = signal(null);
-
- // Color palette
- protected colorPalette = signal([]);
-
// AI generation
protected aiDescription = signal('');
protected aiGenerating = signal(false);
@@ -133,150 +85,10 @@ export class ChartEditComponent implements OnInit {
protected manualExpanded = signal(false);
protected canGenerate = computed(() => !!this.aiDescription().trim() && !this.aiGenerating());
- public chartTypes: { value: ChartType; label: string }[] = [
- { value: 'bar', label: 'Bar Chart' },
- { value: 'line', label: 'Line Chart' },
- { value: 'pie', label: 'Pie Chart' },
- { value: 'doughnut', label: 'Doughnut Chart' },
- { value: 'polarArea', label: 'Polar Area Chart' },
- ];
-
- public labelTypes: { value: 'values' | 'datetime'; label: string }[] = [
- { value: 'values', label: 'Values' },
- { value: 'datetime', label: 'Datetime' },
- ];
-
- public legendPositions: { value: string; label: string }[] = [
- { value: 'top', label: 'Top' },
- { value: 'bottom', label: 'Bottom' },
- { value: 'left', label: 'Left' },
- { value: 'right', label: 'Right' },
- ];
-
- public sortOptions: { value: string; label: string }[] = [
- { value: 'none', label: 'None' },
- { value: 'label_asc', label: 'Label (A-Z)' },
- { value: 'label_desc', label: 'Label (Z-A)' },
- { value: 'value_asc', label: 'Value (Low-High)' },
- { value: 'value_desc', label: 'Value (High-Low)' },
- ];
-
- public scaleTypes: { value: string; label: string }[] = [
- { value: 'linear', label: 'Linear' },
- { value: 'logarithmic', label: 'Logarithmic' },
- ];
-
- public pointStyles: { value: string; label: string }[] = [
- { value: 'circle', label: 'Circle' },
- { value: 'rect', label: 'Rectangle' },
- { value: 'triangle', label: 'Triangle' },
- { value: 'cross', label: 'Cross' },
- { value: 'none', label: 'None' },
- ];
-
- public unitModes: { value: string; label: string }[] = [
- { value: 'none', label: 'None' },
- { value: 'custom', label: 'Custom text' },
- { value: 'convert', label: 'Auto-convert' },
- ];
-
- public convertUnitPresets: { group: string; units: { value: string; label: string }[] }[] = [
- {
- group: 'Time',
- units: [
- { value: 'ms', label: 'Milliseconds (ms)' },
- { value: 's', label: 'Seconds (s)' },
- { value: 'min', label: 'Minutes (min)' },
- { value: 'h', label: 'Hours (h)' },
- { value: 'd', label: 'Days (d)' },
- ],
- },
- {
- group: 'Data',
- units: [
- { value: 'B', label: 'Bytes (B)' },
- { value: 'KB', label: 'Kilobytes (KB)' },
- { value: 'MB', label: 'Megabytes (MB)' },
- { value: 'GB', label: 'Gigabytes (GB)' },
- { value: 'TB', label: 'Terabytes (TB)' },
- ],
- },
- {
- group: 'Length',
- units: [
- { value: 'mm', label: 'Millimeters (mm)' },
- { value: 'cm', label: 'Centimeters (cm)' },
- { value: 'm', label: 'Meters (m)' },
- { value: 'km', label: 'Kilometers (km)' },
- { value: 'in', label: 'Inches (in)' },
- { value: 'ft', label: 'Feet (ft)' },
- { value: 'mi', label: 'Miles (mi)' },
- ],
- },
- {
- group: 'Mass',
- units: [
- { value: 'mg', label: 'Milligrams (mg)' },
- { value: 'g', label: 'Grams (g)' },
- { value: 'kg', label: 'Kilograms (kg)' },
- { value: 'oz', label: 'Ounces (oz)' },
- { value: 'lb', label: 'Pounds (lb)' },
- ],
- },
- {
- group: 'Temperature',
- units: [
- { value: 'C', label: 'Celsius (C)' },
- { value: 'F', label: 'Fahrenheit (F)' },
- { value: 'K', label: 'Kelvin (K)' },
- ],
- },
- {
- group: 'Frequency',
- units: [
- { value: 'Hz', label: 'Hertz (Hz)' },
- { value: 'kHz', label: 'Kilohertz (kHz)' },
- { value: 'MHz', label: 'Megahertz (MHz)' },
- { value: 'GHz', label: 'Gigahertz (GHz)' },
- ],
- },
- {
- group: 'Power',
- units: [
- { value: 'W', label: 'Watts (W)' },
- { value: 'kW', label: 'Kilowatts (kW)' },
- { value: 'MW', label: 'Megawatts (MW)' },
- ],
- },
- {
- group: 'Energy',
- units: [
- { value: 'J', label: 'Joules (J)' },
- { value: 'Wh', label: 'Watt-hours (Wh)' },
- { value: 'kWh', label: 'Kilowatt-hours (kWh)' },
- ],
- },
- {
- group: 'Pressure',
- units: [
- { value: 'Pa', label: 'Pascals (Pa)' },
- { value: 'bar', label: 'Bar' },
- { value: 'psi', label: 'PSI' },
- { value: 'atm', label: 'Atmospheres (atm)' },
- ],
- },
- {
- group: 'Volume',
- units: [
- { value: 'mL', label: 'Milliliters (mL)' },
- { value: 'L', label: 'Liters (L)' },
- { value: 'gal', label: 'Gallons (gal)' },
- ],
- },
- ];
-
- protected showLabelTypeOption = computed(() => ['bar', 'line'].includes(this.chartType()));
- protected isPieType = computed(() => ['pie', 'doughnut', 'polarArea'].includes(this.chartType()));
+ // Formly form
+ protected chartForm = new FormGroup({});
+ protected chartModel: ChartOptionsModel = { ...DEFAULT_CHART_OPTIONS_MODEL };
+ protected chartFields: FormlyFieldConfig[] = [];
protected codeModel = signal({
language: 'sql',
@@ -297,74 +109,87 @@ export class ChartEditComponent implements OnInit {
protected canTest = computed(() => !!this.queryText().trim() && !this.testing());
protected hasChartData = computed(() => {
- const hasLabel = !!this.labelColumn();
- if (this.seriesMode() === 'column') {
- return this.testResults().length > 0 && hasLabel && !!this.seriesColumn() && !!this.seriesValueColumn();
+ this._modelVersion();
+ const model = this.chartModel;
+ const hasLabel = !!model.labelColumn;
+ if (model.seriesMode === 'column') {
+ return this.testResults().length > 0 && hasLabel && !!model.seriesColumn && !!model.seriesValueColumn;
}
- const hasSeries = this.seriesList().length > 0 && this.seriesList().some((s) => !!s.value_column);
+ const hasSeries = model.seriesList.length > 0 && model.seriesList.some((s) => !!s.value_column);
return this.testResults().length > 0 && hasLabel && hasSeries;
});
protected currentWidgetOptions = computed(() => {
+ this._modelVersion();
+
+ const model = this.chartModel;
const options: ChartWidgetOptions = {
- label_column: this.labelColumn(),
- label_type: this.labelType(),
+ label_column: model.labelColumn,
+ label_type: model.labelType as 'values' | 'datetime',
};
- if (this.seriesMode() === 'column' && this.seriesColumn()) {
- options.series_column = this.seriesColumn();
- options.value_column = this.seriesValueColumn();
+ if (model.seriesMode === 'column' && model.seriesColumn) {
+ options.series_column = model.seriesColumn;
+ options.value_column = model.seriesValueColumn;
} else {
- const series = this.seriesList();
+ const series = model.seriesList;
if (series.length > 0) {
- options.series = series;
+ options.series = series as ChartSeriesConfig[];
}
}
- if (this.stacked()) options.stacked = true;
- if (this.horizontal()) options.horizontal = true;
- if (this.showDataLabels()) options.show_data_labels = true;
+ if (model.stacked) options.stacked = true;
+ if (model.horizontal) options.horizontal = true;
+ if (model.showDataLabels) options.show_data_labels = true;
- if (!this.legendShow() || this.legendPosition() !== 'top') {
+ if (!model.legendShow || model.legendPosition !== 'top') {
options.legend = {
- show: this.legendShow(),
- position: this.legendPosition(),
+ show: model.legendShow,
+ position: model.legendPosition as ChartLegendConfig['position'],
};
}
- if (this.unitMode() === 'custom' && this.unitsText()) {
- options.units = { text: this.unitsText(), position: this.unitsPosition() };
- } else if (this.unitMode() === 'convert' && this.convertUnit()) {
- options.units = { convert_unit: this.convertUnit() };
+ if (model.unitMode === 'custom' && model.unitsText) {
+ options.units = { text: model.unitsText, position: model.unitsPosition as ChartUnitConfig['position'] };
+ } else if (model.unitMode === 'convert' && model.convertUnit) {
+ options.units = { convert_unit: model.convertUnit };
}
const numberFormat: ChartNumberFormatConfig = {};
- if (this.decimalPlaces() !== null) numberFormat.decimal_places = this.decimalPlaces()!;
- if (!this.thousandsSeparator()) numberFormat.thousands_separator = false;
- if (this.compact()) numberFormat.compact = true;
+ if (model.decimalPlaces !== null) numberFormat.decimal_places = model.decimalPlaces;
+ if (!model.thousandsSeparator) numberFormat.thousands_separator = false;
+ if (model.compact) numberFormat.compact = true;
if (Object.keys(numberFormat).length > 0) options.number_format = numberFormat;
const yAxis: ChartAxisConfig = {};
- if (this.yAxisTitle()) yAxis.title = this.yAxisTitle();
- if (this.yAxisMin() !== null) yAxis.min = this.yAxisMin()!;
- if (this.yAxisMax() !== null) yAxis.max = this.yAxisMax()!;
- if (!this.yAxisBeginAtZero()) yAxis.begin_at_zero = false;
- if (this.yAxisScaleType() !== 'linear') yAxis.scale_type = this.yAxisScaleType();
+ if (model.yAxisTitle) yAxis.title = model.yAxisTitle;
+ if (model.yAxisMin !== null) yAxis.min = model.yAxisMin;
+ if (model.yAxisMax !== null) yAxis.max = model.yAxisMax;
+ if (!model.yAxisBeginAtZero) yAxis.begin_at_zero = false;
+ if (model.yAxisScaleType !== 'linear') yAxis.scale_type = model.yAxisScaleType as ChartAxisConfig['scale_type'];
if (Object.keys(yAxis).length > 0) options.y_axis = yAxis;
const xAxis: ChartAxisConfig = {};
- if (this.xAxisTitle()) xAxis.title = this.xAxisTitle();
+ if (model.xAxisTitle) xAxis.title = model.xAxisTitle;
if (Object.keys(xAxis).length > 0) options.x_axis = xAxis;
- if (this.sortBy() !== 'none') options.sort_by = this.sortBy();
- if (this.limit() !== null && this.limit()! > 0) options.limit = this.limit()!;
+ if (model.sortBy !== 'none') options.sort_by = model.sortBy as ChartWidgetOptions['sort_by'];
+ if (model.limit !== null && model.limit > 0) options.limit = model.limit;
- const palette = this.colorPalette();
+ const palette = model.colorPalette;
if (palette.length > 0) options.color_palette = palette;
return options;
});
+ 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);
+
private _savedQueries = inject(SavedQueriesService);
private _connections = inject(ConnectionsService);
private _dashboards = inject(DashboardsService);
@@ -391,6 +216,9 @@ export class ChartEditComponent implements OnInit {
this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs';
+ // Build formly fields with dynamic column options
+ this.chartFields = buildChartOptionsFields(this.resultColumns);
+
if (this.isEditMode()) {
this.manualExpanded.set(true);
this.aiExpanded.set(false);
@@ -402,6 +230,15 @@ export class ChartEditComponent implements OnInit {
this._loadAiPrerequisites();
}
+ onModelChange(model: ChartOptionsModel): void {
+ this.chartModel = {
+ ...model,
+ seriesList: model.seriesList.map((s) => ({ ...s })),
+ colorPalette: [...model.colorPalette],
+ };
+ this._modelVersion.update((v) => v + 1);
+ }
+
async loadSavedQuery(): Promise {
this.loading.set(true);
try {
@@ -412,86 +249,19 @@ export class ChartEditComponent implements OnInit {
this.queryDescription.set(query.description || '');
this.queryText.set(query.query_text);
+ const newModel: ChartOptionsModel = { ...DEFAULT_CHART_OPTIONS_MODEL };
+
if (query.chart_type) {
- this.chartType.set(query.chart_type);
+ newModel.chartType = query.chart_type;
}
if (query.widget_options) {
const opts = query.widget_options as Partial;
-
- if (opts.label_column) this.labelColumn.set(opts.label_column);
- if (opts.label_type) this.labelType.set(opts.label_type);
-
- // Load series
- if (opts.series_column) {
- this.seriesMode.set('column');
- this.seriesColumn.set(opts.series_column);
- if (opts.value_column) this.seriesValueColumn.set(opts.value_column);
- } else if (opts.series?.length) {
- this.seriesList.set([...opts.series]);
- } else if (opts.value_column) {
- // Legacy: convert single value_column to series
- this.seriesList.set([{ value_column: opts.value_column }]);
- }
-
- // Display options
- if (opts.stacked) this.stacked.set(true);
- if (opts.horizontal) this.horizontal.set(true);
- if (opts.show_data_labels) this.showDataLabels.set(true);
-
- // Legend
- if (opts.legend) {
- if (opts.legend.show !== undefined) this.legendShow.set(opts.legend.show);
- if (opts.legend.position) this.legendPosition.set(opts.legend.position);
- }
-
- // Units
- if (opts.units) {
- if (opts.units.convert_unit) {
- this.unitMode.set('convert');
- this.convertUnit.set(opts.units.convert_unit);
- } else if (opts.units.text) {
- this.unitMode.set('custom');
- this.unitsText.set(opts.units.text);
- if (opts.units.position) this.unitsPosition.set(opts.units.position);
- }
- }
-
- // Number format
- if (opts.number_format) {
- if (opts.number_format.decimal_places !== undefined) {
- this.decimalPlaces.set(opts.number_format.decimal_places);
- }
- if (opts.number_format.thousands_separator !== undefined) {
- this.thousandsSeparator.set(opts.number_format.thousands_separator);
- }
- if (opts.number_format.compact) this.compact.set(true);
- }
-
- // Y-axis
- if (opts.y_axis) {
- if (opts.y_axis.title) this.yAxisTitle.set(opts.y_axis.title);
- if (opts.y_axis.min !== undefined) this.yAxisMin.set(opts.y_axis.min);
- if (opts.y_axis.max !== undefined) this.yAxisMax.set(opts.y_axis.max);
- if (opts.y_axis.begin_at_zero !== undefined) this.yAxisBeginAtZero.set(opts.y_axis.begin_at_zero);
- if (opts.y_axis.scale_type) this.yAxisScaleType.set(opts.y_axis.scale_type);
- }
-
- // X-axis
- if (opts.x_axis) {
- if (opts.x_axis.title) this.xAxisTitle.set(opts.x_axis.title);
- }
-
- // Sort & limit
- if (opts.sort_by) this.sortBy.set(opts.sort_by);
- if (opts.limit) this.limit.set(opts.limit);
-
- // Color palette
- if (opts.color_palette?.length) {
- this.colorPalette.set([...opts.color_palette]);
- }
+ this._applyWidgetOptionsToModel(opts, newModel);
}
+ this.chartModel = newModel;
+ this._modelVersion.update((v) => v + 1);
this.codeModel.set({ language: 'sql', uri: 'query.sql', value: query.query_text });
this.testQuery();
}
@@ -521,12 +291,16 @@ export class ChartEditComponent implements OnInit {
this.resultColumns.set(result.data.length > 0 ? Object.keys(result.data[0]) : []);
this.showResults.set(true);
- if (this.resultColumns().length > 0 && !this.labelColumn()) {
- this.labelColumn.set(this.resultColumns()[0]);
+ const updates: Partial = {};
+ if (this.resultColumns().length > 0 && !this.chartModel.labelColumn) {
+ updates.labelColumn = this.resultColumns()[0];
+ }
+ if (this.chartModel.seriesList.length === 0 && this.resultColumns().length > 1) {
+ updates.seriesList = [{ value_column: this.resultColumns()[1] }];
}
- // Auto-add first series if none exist
- if (this.seriesList().length === 0 && this.resultColumns().length > 1) {
- this.seriesList.set([{ value_column: this.resultColumns()[1] }]);
+ if (Object.keys(updates).length > 0) {
+ this.chartModel = { ...this.chartModel, ...updates };
+ this._modelVersion.update((v) => v + 1);
}
}
} finally {
@@ -539,56 +313,6 @@ export class ChartEditComponent implements OnInit {
posthog.capture('Charts: test query executed');
}
- addSeries(): void {
- const cols = this.resultColumns();
- const usedCols = new Set(this.seriesList().map((s) => s.value_column));
- const nextCol = cols.find((c) => c !== this.labelColumn() && !usedCols.has(c)) || cols[0] || '';
- this.seriesList.update((list) => [...list, { value_column: nextCol }]);
- }
-
- removeSeries(index: number): void {
- this.seriesList.update((list) => list.filter((_, i) => i !== index));
- }
-
- updateSeries(index: number, field: keyof ChartSeriesConfig, value: unknown): void {
- this.seriesList.update((list) => {
- const updated = [...list];
- updated[index] = { ...updated[index], [field]: value };
- return updated;
- });
- }
-
- 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]).toString(16).padStart(2, '0');
- const g = parseInt(match[2]).toString(16).padStart(2, '0');
- const b = parseInt(match[3]).toString(16).padStart(2, '0');
- return `#${r}${g}${b}`;
- }
-
- addPaletteColor(): void {
- this.colorPalette.update((list) => [...list, '#6366f1']);
- }
-
- removePaletteColor(index: number): void {
- this.colorPalette.update((list) => list.filter((_, i) => i !== index));
- }
-
- updatePaletteColor(index: number, value: string): void {
- this.colorPalette.update((list) => {
- const updated = [...list];
- updated[index] = value;
- return updated;
- });
- }
-
- initializeDefaultPalette(): void {
- this.colorPalette.set([...DEFAULT_COLOR_PALETTE]);
- }
-
async saveQuery(): Promise {
if (!this.queryName().trim() || !this.queryText().trim()) {
return;
@@ -603,7 +327,7 @@ export class ChartEditComponent implements OnInit {
description: this.queryDescription() || undefined,
query_text: this.queryText(),
widget_type: 'chart' as const,
- chart_type: this.chartType(),
+ chart_type: this.chartModel.chartType as ChartType,
widget_options: widgetOptions as unknown as Record,
};
@@ -655,6 +379,8 @@ export class ChartEditComponent implements OnInit {
}
private _applyAiResponse(result: GeneratedPanelWithPosition): void {
+ const newModel: ChartOptionsModel = { ...this.chartModel };
+
if (result.name) this.queryName.set(result.name);
if (result.description) this.queryDescription.set(result.description);
if (result.query_text) {
@@ -665,52 +391,97 @@ export class ChartEditComponent implements OnInit {
if (result.chart_type) {
const validTypes: ChartType[] = ['bar', 'line', 'pie', 'doughnut', 'polarArea'];
if (validTypes.includes(result.chart_type as ChartType)) {
- this.chartType.set(result.chart_type as ChartType);
+ newModel.chartType = result.chart_type;
}
}
if (result.widget_options) {
const opts = result.widget_options as Partial;
+ this._applyWidgetOptionsToModel(opts, newModel);
+ }
- if (opts.label_column) this.labelColumn.set(opts.label_column);
- if (opts.label_type) this.labelType.set(opts.label_type);
-
- // Series
- if (opts.series_column) {
- this.seriesMode.set('column');
- this.seriesColumn.set(opts.series_column);
- if (opts.value_column) this.seriesValueColumn.set(opts.value_column);
- } else if (opts.series?.length) {
- this.seriesMode.set('manual');
- this.seriesList.set([...opts.series]);
- } else if (opts.value_column) {
- this.seriesMode.set('manual');
- this.seriesList.set([{ value_column: opts.value_column }]);
- }
+ this.chartModel = newModel;
+ this._modelVersion.update((v) => v + 1);
+ }
+
+ private _applyWidgetOptionsToModel(opts: Partial, model: ChartOptionsModel): void {
+ if (opts.label_column) model.labelColumn = opts.label_column;
+ if (opts.label_type) model.labelType = opts.label_type;
+
+ // Series
+ if (opts.series_column) {
+ model.seriesMode = 'column';
+ model.seriesColumn = opts.series_column;
+ if (opts.value_column) model.seriesValueColumn = opts.value_column;
+ } else if (opts.series?.length) {
+ model.seriesMode = 'manual';
+ model.seriesList = [...opts.series];
+ } else if (opts.value_column) {
+ model.seriesMode = 'manual';
+ model.seriesList = [{ value_column: opts.value_column }];
+ }
- // Display options
- if (opts.stacked) this.stacked.set(true);
- if (opts.horizontal) this.horizontal.set(true);
- if (opts.show_data_labels) this.showDataLabels.set(true);
+ // Display options
+ if (opts.stacked) model.stacked = true;
+ if (opts.horizontal) model.horizontal = true;
+ if (opts.show_data_labels) model.showDataLabels = true;
- // Legend
- if (opts.legend) {
- if (opts.legend.show !== undefined) this.legendShow.set(opts.legend.show);
- if (opts.legend.position) this.legendPosition.set(opts.legend.position);
+ // Legend
+ if (opts.legend) {
+ if (opts.legend.show !== undefined) model.legendShow = opts.legend.show;
+ if (opts.legend.position) model.legendPosition = opts.legend.position;
+ }
+
+ // Units
+ if (opts.units) {
+ if (opts.units.convert_unit) {
+ model.unitMode = 'convert';
+ model.convertUnit = opts.units.convert_unit;
+ } else if (opts.units.text) {
+ model.unitMode = 'custom';
+ model.unitsText = opts.units.text;
+ if (opts.units.position) model.unitsPosition = opts.units.position;
}
+ }
- // Color palette
- if (opts.color_palette?.length) {
- this.colorPalette.set([...opts.color_palette]);
+ // Number format
+ if (opts.number_format) {
+ if (opts.number_format.decimal_places !== undefined) {
+ model.decimalPlaces = opts.number_format.decimal_places;
}
+ if (opts.number_format.thousands_separator !== undefined) {
+ model.thousandsSeparator = opts.number_format.thousands_separator;
+ }
+ if (opts.number_format.compact) model.compact = true;
+ }
+
+ // Y-axis
+ if (opts.y_axis) {
+ if (opts.y_axis.title) model.yAxisTitle = opts.y_axis.title;
+ if (opts.y_axis.min !== undefined) model.yAxisMin = opts.y_axis.min;
+ if (opts.y_axis.max !== undefined) model.yAxisMax = opts.y_axis.max;
+ if (opts.y_axis.begin_at_zero !== undefined) model.yAxisBeginAtZero = opts.y_axis.begin_at_zero;
+ if (opts.y_axis.scale_type) model.yAxisScaleType = opts.y_axis.scale_type;
+ }
+
+ // X-axis
+ if (opts.x_axis) {
+ if (opts.x_axis.title) model.xAxisTitle = opts.x_axis.title;
+ }
+
+ // Sort & limit
+ if (opts.sort_by) model.sortBy = opts.sort_by;
+ if (opts.limit) model.limit = opts.limit;
+
+ // Color palette
+ if (opts.color_palette?.length) {
+ model.colorPalette = [...opts.color_palette];
}
}
private _loadAiPrerequisites(): void {
const connectionId = this.connectionId();
if (!connectionId) return;
-
- // Trigger dashboards loading; the effect in constructor picks up the result
this._dashboards.setActiveConnection(connectionId);
}
}
diff --git a/frontend/src/app/formly/chart-options-fields.ts b/frontend/src/app/formly/chart-options-fields.ts
new file mode 100644
index 000000000..79d302e64
--- /dev/null
+++ b/frontend/src/app/formly/chart-options-fields.ts
@@ -0,0 +1,421 @@
+import { Signal } from '@angular/core';
+import { FormlyFieldConfig } from '@ngx-formly/core';
+import { JSONSchema7 } from 'json-schema';
+import { CHART_OPTIONS_SCHEMA, CONVERT_UNIT_OPTIONS } from './chart-options.schema';
+
+export interface ChartOptionsModel {
+ chartType: string;
+ labelColumn: string;
+ labelType: string;
+ seriesMode: string;
+ seriesColumn: string;
+ seriesValueColumn: string;
+ seriesList: {
+ value_column: string;
+ label?: string;
+ color?: string;
+ point_style?: string;
+ fill?: boolean;
+ tension?: number;
+ type?: string;
+ }[];
+ stacked: boolean;
+ horizontal: boolean;
+ showDataLabels: boolean;
+ legendShow: boolean;
+ legendPosition: string;
+ unitMode: string;
+ unitsText: string;
+ unitsPosition: string;
+ convertUnit: string;
+ decimalPlaces: number | null;
+ thousandsSeparator: boolean;
+ compact: boolean;
+ yAxisTitle: string;
+ yAxisMin: number | null;
+ yAxisMax: number | null;
+ yAxisBeginAtZero: boolean;
+ yAxisScaleType: string;
+ xAxisTitle: string;
+ sortBy: string;
+ limit: number | null;
+ colorPalette: string[];
+}
+
+export const DEFAULT_CHART_OPTIONS_MODEL: ChartOptionsModel = {
+ chartType: 'bar',
+ labelColumn: '',
+ labelType: 'values',
+ seriesMode: 'manual',
+ seriesColumn: '',
+ seriesValueColumn: '',
+ seriesList: [],
+ stacked: false,
+ horizontal: false,
+ showDataLabels: false,
+ legendShow: true,
+ legendPosition: 'top',
+ unitMode: 'none',
+ unitsText: '',
+ unitsPosition: 'suffix',
+ convertUnit: '',
+ decimalPlaces: null,
+ thousandsSeparator: true,
+ compact: false,
+ yAxisTitle: '',
+ yAxisMin: null,
+ yAxisMax: null,
+ yAxisBeginAtZero: true,
+ yAxisScaleType: 'linear',
+ xAxisTitle: '',
+ sortBy: 'none',
+ limit: null,
+ colorPalette: [],
+};
+
+// ---------------------------------------------------------------------------
+// Schema-driven field factory
+// ---------------------------------------------------------------------------
+
+function resolveOptions(prop: JSONSchema7): { value: string | number | boolean; label: string }[] | undefined {
+ type SchemaConst = JSONSchema7 & { const: string };
+ if (prop.oneOf) {
+ return (prop.oneOf as SchemaConst[]).map((o) => ({
+ value: o.const,
+ label: o.title || String(o.const),
+ }));
+ }
+ if (prop.enum) {
+ return prop.enum.map((v) => ({ value: v as string | number | boolean, label: String(v) }));
+ }
+ return undefined;
+}
+
+function resolveType(prop: JSONSchema7): string {
+ if (prop.oneOf || prop.enum) return 'select';
+ if (prop.type === 'boolean') return 'checkbox';
+ if (prop.type === 'number' || (Array.isArray(prop.type) && prop.type.includes('number'))) return 'input';
+ return 'input';
+}
+
+/** Create a FormlyFieldConfig from a JSON Schema property + UI overrides. */
+function createFieldFactory(schemaProps: Record) {
+ return (key: string, ui: Partial = {}): FormlyFieldConfig => {
+ const prop = schemaProps[key];
+ if (!prop) return { key, ...ui };
+
+ const { props: uiProps, type: uiType, ...uiRest } = ui;
+
+ const autoProps: Record = {};
+ if (prop.title) autoProps.label = prop.title;
+
+ const options = resolveOptions(prop);
+ if (options) autoProps.options = options;
+
+ const type = uiType ?? resolveType(prop);
+
+ if (type === 'input' && (prop.type === 'number' || (Array.isArray(prop.type) && prop.type.includes('number')))) {
+ autoProps.type = 'number';
+ }
+
+ if (prop.minimum !== undefined) autoProps.min = prop.minimum;
+ if (prop.maximum !== undefined) autoProps.max = prop.maximum;
+
+ if (type === 'input' || type === 'select') {
+ autoProps.appearance = 'outline';
+ }
+
+ return {
+ ...uiRest,
+ key,
+ type,
+ props: { ...autoProps, ...uiProps },
+ };
+ };
+}
+
+const SCHEMA_PROPS = CHART_OPTIONS_SCHEMA.properties as Record;
+const SERIES_ITEM_PROPS = (SCHEMA_PROPS.seriesList as JSONSchema7 & { items: JSONSchema7 }).items.properties as Record<
+ string,
+ JSONSchema7
+>;
+
+/** Field builder for root-level schema properties */
+const sf = createFieldFactory(SCHEMA_PROPS);
+
+/** Field builder for series-item schema properties */
+const ssf = createFieldFactory(SERIES_ITEM_PROPS);
+
+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[] {
+ 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
+ {
+ fieldGroupClassName: 'chart-config',
+ fieldGroup: [
+ sf('chartType', {
+ className: 'chart-config-field',
+ props: { required: true, attributes: { 'data-testid': 'chart-type-select' } },
+ }),
+ sf('labelColumn', {
+ className: 'chart-config-field',
+ props: { required: true, attributes: { 'data-testid': 'label-column-select' } },
+ expressions: { 'props.options': () => columnOptions() },
+ }),
+ sf('labelType', {
+ className: 'chart-config-field',
+ props: { attributes: { 'data-testid': 'label-type-select' } },
+ expressions: { hide: (field) => !['bar', 'line'].includes(field.model.chartType) },
+ }),
+ ],
+ },
+
+ // Series section
+ {
+ fieldGroupClassName: 'series-section',
+ fieldGroup: [
+ sf('seriesMode', {
+ className: 'series-mode-field',
+ expressions: { hide: (field) => isPieType(field.model) },
+ }),
+
+ // Column mode fields
+ {
+ fieldGroupClassName: 'series-column-config',
+ expressions: { hide: (field) => field.model.seriesMode !== 'column' || isPieType(field.model) },
+ fieldGroup: [
+ sf('seriesColumn', {
+ className: 'series-field',
+ props: { description: 'Categorical column to split into datasets' },
+ expressions: { 'props.options': () => columnOptions() },
+ }),
+ sf('seriesValueColumn', {
+ className: 'series-field',
+ props: { description: 'Numeric column to chart' },
+ expressions: { 'props.options': () => columnOptions() },
+ }),
+ ],
+ },
+
+ // Manual series list
+ {
+ key: 'seriesList',
+ type: 'repeat',
+ props: {
+ label: (SCHEMA_PROPS.seriesList as JSONSchema7).title,
+ addText: 'Add series',
+ itemLabel: 'Series',
+ },
+ expressions: {
+ hide: (field) => field.model.seriesMode === 'column' && !isPieType(field.model),
+ 'props.maxItems': (field) => (isPieType(field.model) ? 1 : undefined),
+ },
+ fieldArray: {
+ fieldGroupClassName: 'series-fields-group',
+ fieldGroup: [
+ {
+ fieldGroupClassName: 'series-fields',
+ fieldGroup: [
+ ssf('value_column', {
+ className: 'series-field',
+ props: { required: true },
+ expressions: { 'props.options': () => columnOptions() },
+ }),
+ ssf('label', {
+ className: 'series-field',
+ props: { placeholder: 'Auto' },
+ }),
+ {
+ key: 'color',
+ type: 'color-picker',
+ expressions: {
+ hide: (field) => isPieType(getRootModel(field)),
+ },
+ },
+ ssf('point_style', {
+ className: 'series-field',
+ expressions: {
+ hide: (field) => {
+ const root = getRootModel(field);
+ return isPieType(root) || root.chartType === 'bar';
+ },
+ },
+ }),
+ ],
+ },
+ {
+ fieldGroupClassName: 'series-options',
+ expressions: { hide: (field) => isPieType(getRootModel(field)) },
+ fieldGroup: [
+ ssf('fill', {
+ type: 'checkbox',
+ className: 'series-option-checkbox',
+ expressions: {
+ hide: (field) => {
+ const root = getRootModel(field);
+ const series = field.parent?.parent?.model;
+ return root.chartType !== 'line' && series?.type !== 'line';
+ },
+ },
+ }),
+ ssf('tension', {
+ className: 'series-field series-field--small',
+ props: { step: 0.1 },
+ expressions: {
+ hide: (field) => {
+ const root = getRootModel(field);
+ const series = field.parent?.parent?.model;
+ return root.chartType !== 'line' && series?.type !== 'line';
+ },
+ },
+ }),
+ ssf('type', {
+ className: 'series-field',
+ expressions: {
+ hide: (field) => {
+ const root = getRootModel(field);
+ return (root.seriesList?.length || 0) <= 1;
+ },
+ },
+ }),
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ },
+
+ // Display options (expansion panel)
+ {
+ wrappers: ['expansion-panel'],
+ props: { label: 'Display options' },
+ fieldGroupClassName: 'option-group',
+ fieldGroup: [
+ sf('stacked', { expressions: { hide: (field) => isPieType(field.model) } }),
+ sf('horizontal', { expressions: { hide: (field) => field.model.chartType !== 'bar' } }),
+ sf('showDataLabels'),
+ sf('legendShow'),
+ sf('legendPosition', {
+ className: 'option-field',
+ expressions: { hide: (field) => !field.model.legendShow },
+ }),
+ ],
+ },
+
+ // Units & formatting (expansion panel)
+ {
+ wrappers: ['expansion-panel'],
+ props: { label: 'Units & formatting' },
+ fieldGroupClassName: 'option-group',
+ fieldGroup: [
+ sf('unitMode', { className: 'option-field' }),
+ {
+ fieldGroupClassName: 'inline-fields',
+ expressions: { hide: (field) => field.model.unitMode !== 'custom' },
+ fieldGroup: [
+ sf('unitsText', {
+ className: 'option-field',
+ props: { placeholder: 'e.g. $, %, EUR' },
+ }),
+ sf('unitsPosition', { className: 'option-field' }),
+ ],
+ },
+ sf('convertUnit', {
+ className: 'option-field',
+ props: { options: CONVERT_UNIT_OPTIONS, description: 'Values auto-convert to the best readable unit' },
+ expressions: { hide: (field) => field.model.unitMode !== 'convert' },
+ }),
+ {
+ fieldGroupClassName: 'inline-fields',
+ expressions: { hide: (field) => field.model.unitMode === 'convert' },
+ fieldGroup: [sf('decimalPlaces', { className: 'option-field' })],
+ },
+ sf('thousandsSeparator', {
+ expressions: { hide: (field) => field.model.unitMode === 'convert' },
+ }),
+ sf('compact', {
+ expressions: { hide: (field) => field.model.unitMode === 'convert' },
+ }),
+ ],
+ },
+
+ // Axis configuration (expansion panel, non-pie only)
+ {
+ wrappers: ['expansion-panel'],
+ props: { label: 'Axis configuration' },
+ expressions: { hide: (field) => isPieType(field.model) },
+ fieldGroupClassName: 'option-group',
+ fieldGroup: [
+ { template: 'Y-Axis
' },
+ {
+ fieldGroupClassName: 'inline-fields',
+ fieldGroup: [
+ sf('yAxisTitle', { className: 'option-field' }),
+ sf('yAxisScaleType', { className: 'option-field' }),
+ ],
+ },
+ {
+ fieldGroupClassName: 'inline-fields',
+ fieldGroup: [sf('yAxisMin', { className: 'option-field' }), sf('yAxisMax', { className: 'option-field' })],
+ },
+ sf('yAxisBeginAtZero'),
+ { template: 'X-Axis
' },
+ sf('xAxisTitle', { className: 'option-field' }),
+ ],
+ },
+
+ // Data options (expansion panel)
+ {
+ wrappers: ['expansion-panel'],
+ props: { label: 'Data options' },
+ fieldGroupClassName: 'option-group',
+ fieldGroup: [
+ {
+ fieldGroupClassName: 'inline-fields',
+ fieldGroup: [
+ sf('sortBy', { className: 'option-field' }),
+ sf('limit', { className: 'option-field', props: { placeholder: 'No limit' } }),
+ ],
+ },
+ ],
+ },
+
+ // Color palette (expansion panel)
+ {
+ wrappers: ['expansion-panel'],
+ props: { label: 'Custom color palette' },
+ fieldGroup: [
+ {
+ key: 'colorPalette',
+ type: 'color-palette',
+ fieldArray: { type: 'palette-color-input' },
+ },
+ ],
+ },
+ ];
+}
diff --git a/frontend/src/app/formly/chart-options.schema.ts b/frontend/src/app/formly/chart-options.schema.ts
new file mode 100644
index 000000000..c93a1ac62
--- /dev/null
+++ b/frontend/src/app/formly/chart-options.schema.ts
@@ -0,0 +1,252 @@
+import { JSONSchema7 } from 'json-schema';
+
+export const CHART_OPTIONS_SCHEMA: JSONSchema7 = {
+ type: 'object',
+ properties: {
+ chartType: {
+ type: 'string',
+ title: 'Chart type',
+ oneOf: [
+ { const: 'bar', title: 'Bar Chart' },
+ { const: 'line', title: 'Line Chart' },
+ { const: 'pie', title: 'Pie Chart' },
+ { const: 'doughnut', title: 'Doughnut Chart' },
+ { const: 'polarArea', title: 'Polar Area Chart' },
+ ],
+ default: 'bar',
+ },
+ labelColumn: {
+ type: 'string',
+ title: 'Label column',
+ },
+ labelType: {
+ type: 'string',
+ title: 'Label type',
+ oneOf: [
+ { const: 'values', title: 'Values' },
+ { const: 'datetime', title: 'Datetime' },
+ ],
+ default: 'values',
+ },
+ seriesMode: {
+ type: 'string',
+ title: 'Series mode',
+ oneOf: [
+ { const: 'manual', title: 'Manual' },
+ { const: 'column', title: 'Series from column' },
+ ],
+ default: 'manual',
+ },
+ seriesColumn: {
+ type: 'string',
+ title: 'Series column',
+ },
+ seriesValueColumn: {
+ type: 'string',
+ title: 'Value column',
+ },
+ seriesList: {
+ type: 'array',
+ title: 'Data series',
+ items: {
+ type: 'object',
+ properties: {
+ value_column: { type: 'string', title: 'Value column' },
+ label: { type: 'string', title: 'Label' },
+ color: { type: 'string', title: 'Color' },
+ point_style: {
+ type: 'string',
+ title: 'Point style',
+ oneOf: [
+ { const: 'circle', title: 'Circle' },
+ { const: 'rect', title: 'Rectangle' },
+ { const: 'triangle', title: 'Triangle' },
+ { const: 'cross', title: 'Cross' },
+ { const: 'none', title: 'None' },
+ ],
+ default: 'circle',
+ },
+ fill: { type: 'boolean', title: 'Fill area', default: false },
+ tension: { type: 'number', title: 'Tension', minimum: 0, maximum: 1, default: 0 },
+ type: {
+ type: 'string',
+ title: 'Type override',
+ oneOf: [
+ { const: '', title: 'Default' },
+ { const: 'bar', title: 'Bar' },
+ { const: 'line', title: 'Line' },
+ ],
+ },
+ },
+ required: ['value_column'],
+ },
+ },
+ stacked: { type: 'boolean', title: 'Stacked', default: false },
+ horizontal: { type: 'boolean', title: 'Horizontal', default: false },
+ showDataLabels: { type: 'boolean', title: 'Show data labels', default: false },
+ legendShow: { type: 'boolean', title: 'Show legend', default: true },
+ legendPosition: {
+ type: 'string',
+ title: 'Legend position',
+ oneOf: [
+ { const: 'top', title: 'Top' },
+ { const: 'bottom', title: 'Bottom' },
+ { const: 'left', title: 'Left' },
+ { const: 'right', title: 'Right' },
+ ],
+ default: 'top',
+ },
+ unitMode: {
+ type: 'string',
+ title: 'Unit mode',
+ oneOf: [
+ { const: 'none', title: 'None' },
+ { const: 'custom', title: 'Custom text' },
+ { const: 'convert', title: 'Auto-convert' },
+ ],
+ default: 'none',
+ },
+ unitsText: { type: 'string', title: 'Unit text' },
+ unitsPosition: {
+ type: 'string',
+ title: 'Unit position',
+ oneOf: [
+ { const: 'prefix', title: 'Prefix ($100)' },
+ { const: 'suffix', title: 'Suffix (100ms)' },
+ ],
+ default: 'suffix',
+ },
+ convertUnit: { type: 'string', title: 'Source unit' },
+ decimalPlaces: { type: ['number', 'null'] as any, title: 'Decimal places', minimum: 0, maximum: 10 },
+ thousandsSeparator: { type: 'boolean', title: 'Thousands separator', default: true },
+ compact: { type: 'boolean', title: 'Compact notation (1K, 1M, 1B)', default: false },
+ yAxisTitle: { type: 'string', title: 'Title' },
+ yAxisMin: { type: ['number', 'null'] as any, title: 'Min' },
+ yAxisMax: { type: ['number', 'null'] as any, title: 'Max' },
+ yAxisBeginAtZero: { type: 'boolean', title: 'Begin at zero', default: true },
+ yAxisScaleType: {
+ type: 'string',
+ title: 'Scale type',
+ oneOf: [
+ { const: 'linear', title: 'Linear' },
+ { const: 'logarithmic', title: 'Logarithmic' },
+ ],
+ default: 'linear',
+ },
+ xAxisTitle: { type: 'string', title: 'Title' },
+ sortBy: {
+ type: 'string',
+ title: 'Sort by',
+ oneOf: [
+ { const: 'none', title: 'None' },
+ { const: 'label_asc', title: 'Label (A-Z)' },
+ { const: 'label_desc', title: 'Label (Z-A)' },
+ { const: 'value_asc', title: 'Value (Low-High)' },
+ { const: 'value_desc', title: 'Value (High-Low)' },
+ ],
+ default: 'none',
+ },
+ limit: { type: ['number', 'null'] as any, title: 'Limit', minimum: 1, maximum: 10000 },
+ colorPalette: {
+ type: 'array',
+ title: 'Custom color palette',
+ items: { type: 'string' },
+ },
+ },
+};
+
+/** Grouped options for convert-unit select (can't be expressed in JSON Schema) */
+export const CONVERT_UNIT_OPTIONS = [
+ {
+ label: 'Time',
+ group: [
+ { value: 'ms', label: 'Milliseconds (ms)' },
+ { value: 's', label: 'Seconds (s)' },
+ { value: 'min', label: 'Minutes (min)' },
+ { value: 'h', label: 'Hours (h)' },
+ { value: 'd', label: 'Days (d)' },
+ ],
+ },
+ {
+ label: 'Data',
+ group: [
+ { value: 'B', label: 'Bytes (B)' },
+ { value: 'KB', label: 'Kilobytes (KB)' },
+ { value: 'MB', label: 'Megabytes (MB)' },
+ { value: 'GB', label: 'Gigabytes (GB)' },
+ { value: 'TB', label: 'Terabytes (TB)' },
+ ],
+ },
+ {
+ label: 'Length',
+ group: [
+ { value: 'mm', label: 'Millimeters (mm)' },
+ { value: 'cm', label: 'Centimeters (cm)' },
+ { value: 'm', label: 'Meters (m)' },
+ { value: 'km', label: 'Kilometers (km)' },
+ { value: 'in', label: 'Inches (in)' },
+ { value: 'ft', label: 'Feet (ft)' },
+ { value: 'mi', label: 'Miles (mi)' },
+ ],
+ },
+ {
+ label: 'Mass',
+ group: [
+ { value: 'mg', label: 'Milligrams (mg)' },
+ { value: 'g', label: 'Grams (g)' },
+ { value: 'kg', label: 'Kilograms (kg)' },
+ { value: 'oz', label: 'Ounces (oz)' },
+ { value: 'lb', label: 'Pounds (lb)' },
+ ],
+ },
+ {
+ label: 'Temperature',
+ group: [
+ { value: 'C', label: 'Celsius (C)' },
+ { value: 'F', label: 'Fahrenheit (F)' },
+ { value: 'K', label: 'Kelvin (K)' },
+ ],
+ },
+ {
+ label: 'Frequency',
+ group: [
+ { value: 'Hz', label: 'Hertz (Hz)' },
+ { value: 'kHz', label: 'Kilohertz (kHz)' },
+ { value: 'MHz', label: 'Megahertz (MHz)' },
+ { value: 'GHz', label: 'Gigahertz (GHz)' },
+ ],
+ },
+ {
+ label: 'Power',
+ group: [
+ { value: 'W', label: 'Watts (W)' },
+ { value: 'kW', label: 'Kilowatts (kW)' },
+ { value: 'MW', label: 'Megawatts (MW)' },
+ ],
+ },
+ {
+ label: 'Energy',
+ group: [
+ { value: 'J', label: 'Joules (J)' },
+ { value: 'Wh', label: 'Watt-hours (Wh)' },
+ { value: 'kWh', label: 'Kilowatt-hours (kWh)' },
+ ],
+ },
+ {
+ label: 'Pressure',
+ group: [
+ { value: 'Pa', label: 'Pascals (Pa)' },
+ { value: 'bar', label: 'Bar' },
+ { value: 'psi', label: 'PSI' },
+ { value: 'atm', label: 'Atmospheres (atm)' },
+ ],
+ },
+ {
+ label: 'Volume',
+ group: [
+ { value: 'mL', label: 'Milliliters (mL)' },
+ { value: 'L', label: 'Liters (L)' },
+ { value: 'gal', label: 'Gallons (gal)' },
+ ],
+ },
+];
diff --git a/frontend/src/app/formly/checkbox.type.ts b/frontend/src/app/formly/checkbox.type.ts
new file mode 100644
index 000000000..68f08b8e6
--- /dev/null
+++ b/frontend/src/app/formly/checkbox.type.ts
@@ -0,0 +1,15 @@
+import { Component } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { FieldType, FieldTypeConfig, FormlyModule } from '@ngx-formly/core';
+
+@Component({
+ selector: 'formly-checkbox',
+ template: `
+
+ {{ props.label }}
+
+ `,
+ imports: [MatCheckboxModule, ReactiveFormsModule, FormlyModule],
+})
+export class CheckboxType extends FieldType {}
diff --git a/frontend/src/app/formly/color-palette.type.ts b/frontend/src/app/formly/color-palette.type.ts
new file mode 100644
index 000000000..ac71bf58d
--- /dev/null
+++ b/frontend/src/app/formly/color-palette.type.ts
@@ -0,0 +1,89 @@
+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';
+import { DEFAULT_COLOR_PALETTE } from 'src/app/lib/chart-config.helper';
+
+@Component({
+ selector: 'formly-color-palette',
+ template: `
+
+
+ @for (f of field.fieldGroup; track f.id) {
+
+
+
+ close
+
+
+ }
+
+
+
+ add Add color
+
+ @if (field.fieldGroup!.length === 0) {
+
+ palette Load defaults
+
+ }
+
+
+ `,
+ 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 formArray = this.formControl;
+ while (formArray.length) {
+ this.remove(0);
+ }
+ DEFAULT_COLOR_PALETTE.forEach(() => this.add());
+ formArray.patchValue(DEFAULT_COLOR_PALETTE);
+ }
+}
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..0b63b82eb
--- /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: `
+
+
+
+ @for (f of field.fieldGroup; track f.id; let i = $index) {
+
+
+
+
+ }
+
+ `,
+ 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