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 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: ` +
+
+

{{ props.label }}

+ @if (!props['maxItems'] || field.fieldGroup!.length < props['maxItems']) { + + } +
+ + @for (f of field.fieldGroup; track f.id; let i = $index) { +
+
+ {{ props['itemLabel'] || 'Item' }} {{ i + 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