diff --git a/backend/src/enums/widget-type.enum.ts b/backend/src/enums/widget-type.enum.ts index 121da11c6..db1e037d9 100644 --- a/backend/src/enums/widget-type.enum.ts +++ b/backend/src/enums/widget-type.enum.ts @@ -22,4 +22,5 @@ export enum WidgetTypeEnum { Range = 'Range', Timezone = 'Timezone', S3 = 'S3', + Email = 'Email', } diff --git a/frontend/package.json b/frontend/package.json index 59ef864da..66db77136 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,8 +37,6 @@ "@sentry-internal/rrweb": "^2.31.0", "@sentry/angular": "^10.33.0", "@stripe/stripe-js": "^5.3.0", - "@types/google-one-tap": "^1.2.6", - "@types/lodash": "^4.17.13", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", "amplitude-js": "^8.21.9", @@ -52,7 +50,6 @@ "date-fns": "^4.1.0", "ipaddr.js": "^2.2.0", "json5": "^2.2.3", - "knip": "^5.79.0", "libphonenumber-js": "^1.12.9", "lodash": "^4.17.21", "lodash-es": "^4.17.23", @@ -66,7 +63,6 @@ "pluralize": "^8.0.0", "postgres-interval": "^4.0.2", "posthog-js": "^1.341.0", - "puppeteer": "^24.29.1", "rxjs": "^7.4.0", "tslib": "^2.8.1", "uuid": "^11.1.0", @@ -81,10 +77,14 @@ "@angular/language-service": "~20.3.18", "@sentry-internal/rrweb": "^2.16.0", "@storybook/angular": "^10.2.14", + "@types/google-one-tap": "^1.2.6", + "@types/lodash": "^4.17.13", "@types/node": "^22.10.2", "@vitest/browser": "^3.1.1", "jsdom": "^27.4.0", + "knip": "^5.79.0", "playwright": "^1.57.0", + "puppeteer": "^24.29.1", "storybook": "^10.2.14", "ts-node": "~10.9.2", "typescript": "~5.9.3", @@ -93,7 +93,15 @@ "resolutions": { "mermaid": "^11.10.0", "webpack": "5.104.1", - "lodash-es": "4.17.23" + "lodash-es": "4.17.23", + "path-to-regexp": "8.4.0", + "serialize-javascript": "7.0.5", + "brace-expansion": "1.1.13", + "node-forge": "1.4.0", + "dompurify": "3.3.2", + "picomatch": "4.0.4", + "tar": "7.5.11", + "rollup": "4.59.0" }, "packageManager": "yarn@1.22.22" } diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.css index a4fc2d4d9..1c787f894 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.css +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.css @@ -7,18 +7,27 @@ .filters-content { display: grid; - grid-template-columns: auto 228px 0 1fr 32px; + grid-template-columns: auto 1fr 32px; grid-column-gap: 8px; align-content: flex-start; align-items: flex-start; } .filters-select { - grid-column: 1 / span 5; + grid-column: 1 / -1; } .filter-line { - grid-column: 1 / span 4; + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + grid-column-gap: 8px; + align-items: flex-start; +} + +.filter-line__field { + grid-column: 2; + min-width: 0; } ::ng-deep .mat-dialog-container > .ng-star-inserted { @@ -46,6 +55,7 @@ } .filter-delete-button { + grid-column: 3; margin-top: 4px; } diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.html index 3f2b51b2f..a24dbd802 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.html +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.html @@ -17,122 +17,33 @@

[formControl]="fieldSearchControl"> - {{field}} + [value]="field.key" + [disabled]="isFieldAlreadyFiltered(field.key)"> + {{ field.label }} - -
- -
- + {{ getFieldLabel(value.key) }} +
+
- - - - +
- - - {{value.key}} - - - - - starts with - - - ends with - - - equal - - - contains - - - not contains - - - is empty - - - - - - - - equal - - - greater than - - - less than - - - greater than or equal - - - less than or equal - - - - - - - - + } @@ -146,6 +57,7 @@

Reset
+ {{value.key}}
relations: tableTypes[value.key] === 'foreign key' ? data.tableForeignKeys[value.key] : undefined }" [ndcDynamicOutputs]="{ - onFieldChange: { handler: updateField, args: ['$event', value.key] } + onFieldChange: { handler: updateField, args: ['$event', value.key] }, + onComparatorChange: { handler: updateComparatorFromComponent, args: ['$event', value.key] } }" >
@@ -116,7 +118,8 @@

relations: tableTypes[value.key] === 'foreign key' ? data.tableForeignKeys[value.key] : undefined }" [ndcDynamicOutputs]="{ - onFieldChange: { handler: updateField, args: ['$event', value.key] } + onFieldChange: { handler: updateField, args: ['$event', value.key] }, + onComparatorChange: { handler: updateComparatorFromComponent, args: ['$event', value.key] } }" > @@ -197,7 +200,8 @@

relations: tableTypes[value.key] === 'foreign key' ? data.tableForeignKeys[value.key] : undefined }" [ndcDynamicOutputs]="{ - onFieldChange: { handler: updateField, args: ['$event', value.key] } + onFieldChange: { handler: updateField, args: ['$event', value.key] }, + onComparatorChange: { handler: updateComparatorFromComponent, args: ['$event', value.key] } }" > @@ -259,7 +263,7 @@

(click)="removeFilters()"> Remove - - @@ -58,7 +58,7 @@
- + {{ savedFilterMap[selectedFilterSetId]?.dynamicColumn.column }} {{ {startswith: 'starts with', endswith: 'ends with', eq: 'equal', contains: 'contains', icontains: 'not contains', empty: 'is empty'}[savedFilterMap[selectedFilterSetId].dynamicColumn.operator] }} @@ -138,7 +138,8 @@ autofocus: shouldAutofocus }" [ndcDynamicOutputs]="{ - onFieldChange: { handler: updateDynamicColumnValue, args: ['$event'] } + onFieldChange: { handler: updateDynamicColumnValue, args: ['$event'] }, + onComparatorChange: { handler: updateDynamicColumnComparator, args: ['$event'] } }" >

@@ -154,7 +155,8 @@ autofocus: shouldAutofocus }" [ndcDynamicOutputs]="{ - onFieldChange: { handler: updateDynamicColumnValue, args: ['$event'] } + onFieldChange: { handler: updateDynamicColumnValue, args: ['$event'] }, + onComparatorChange: { handler: updateDynamicColumnComparator, args: ['$event'] } }" > diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts index 7f46838cd..878ebb9c3 100644 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatChipsModule } from '@angular/material/chips'; @@ -15,10 +15,11 @@ import { ActivatedRoute, Router } from '@angular/router'; import JsonURL from '@jsonurl/jsonurl'; import { Angulartics2OnModule } from 'angulartics2'; import { DynamicModule } from 'ng-dynamic-component'; +import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; import posthog from 'posthog-js'; import { PlaceholderSavedFiltersComponent } from 'src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component'; -import { filterTypes } from 'src/app/consts/filter-types'; -import { UIwidgets } from 'src/app/consts/record-edit-types'; +import { UIwidgets as FilterUIwidgets, filterTypes } from 'src/app/consts/filter-types'; +import { UIwidgets as EditUIwidgets } from 'src/app/consts/record-edit-types'; import { normalizeTableName } from 'src/app/lib/normalize'; import { TableField, TableForeignKey } from 'src/app/models/table'; import { ConnectionsService } from 'src/app/services/connections.service'; @@ -31,6 +32,7 @@ import { SavedFiltersDialogComponent } from './saved-filters-dialog/saved-filter CommonModule, FormsModule, DynamicModule, + SignalComponentIoModule, MatButtonModule, MatIconModule, MatChipsModule, @@ -55,7 +57,6 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { @Input() structure: any; @Input() tableForeignKeys: TableForeignKey[] = []; @Input() tableWidgets: any = {}; - // @Input() savedFilterData: any; @Output() filterSelected = new EventEmitter(); @Input() resetSelection: boolean = false; @@ -74,8 +75,7 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { public tableStructure: any = null; public tableRowFieldsShown: { [key: string]: any } = {}; public tableRowStructure: { [key: string]: any } = {}; - public tableWidgetsList: string[] = []; - public UIwidgets = UIwidgets; + public UIwidgets = { ...EditUIwidgets, ...FilterUIwidgets }; public displayedComparators = { eq: '=', @@ -239,8 +239,8 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { }); } - ngOnChanges() { - if (this.resetSelection) { + ngOnChanges(changes: SimpleChanges) { + if (changes.resetSelection && this.resetSelection) { this.selectedFilterSetId = null; } } @@ -269,9 +269,6 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { }, }, }); - - // No need to handle URL updates here - it's now handled in the tables.cast subscription - // when 'filters set updated' is received } setCurrentFilter(filter: any) { @@ -287,9 +284,7 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { handleDeleteFilter(filter: any) { if (filter) { this._tables.deleteSavedFilter(this.connectionID, this.selectedTableName, filter.id).subscribe({ - next: () => { - // The deletion will trigger 'delete saved filters' event which will refresh the list - }, + next: () => {}, error: (error) => { console.error('Error deleting filter:', error); }, @@ -366,7 +361,6 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { } selectFiltersSet(selectedFilterSetId: string): void { - console.log('selectFiltersSet ID:', selectedFilterSetId); if (this.selectedFilterSetId === selectedFilterSetId) { this.selectedFilterSetId = null; this.filterSelected.emit(null); @@ -394,7 +388,6 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { const queryParams = this.buildQueryParams(additionalParams); this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`], { queryParams }); - // Reset autofocus after the component has been rendered setTimeout(() => { this.shouldAutofocus = false; }, 500); @@ -445,7 +438,7 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { } isWidget(columnName: string) { - return this.tableWidgetsList.includes(columnName); + return this.tableWidgets && columnName in this.tableWidgets; } getInputType(field: string) { @@ -468,28 +461,6 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { } } - setWidgets(widgets: any[]) { - this.tableWidgetsList = widgets.map((widget: any) => widget.field_name); - this.tableWidgets = Object.assign( - {}, - ...widgets.map((widget: any) => { - let params; - if (widget.widget_params !== '// No settings required') { - try { - params = JSON.parse(widget.widget_params); - } catch (_e) { - params = ''; - } - } else { - params = ''; - } - return { - [widget.field_name]: { ...widget, widget_params: params }, - }; - }), - ); - } - trackByFn(_index: number, item: any) { return item.key; } @@ -545,8 +516,6 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { return; } - console.log(value, 'value in updateDynamicColumnValue'); - selectedFilter.dynamicColumn.value = value; if (this.dynamicColumnValueDebounceTimer) { @@ -563,8 +532,6 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { const selectedFilter = this.savedFilterMap[this.selectedFilterSetId]; - console.log('Applying dynamic column changes for filter selectedFilter:', selectedFilter); - if (!selectedFilter || !selectedFilter.dynamicColumn) return; const dynamicColumn = { @@ -589,9 +556,6 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { } } - console.log('applyDynamicColumnChanges, filters:', filters); - - // Build filter-related params using the helper method const additionalParams: any = { filters: JsonURL.stringify(filters), dynamic_column: JsonURL.stringify(dynamicColumn), diff --git a/frontend/src/app/components/ui-components/filter-fields/FILTER_WIDGETS.md b/frontend/src/app/components/ui-components/filter-fields/FILTER_WIDGETS.md new file mode 100644 index 000000000..4e10e6b6f --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/FILTER_WIDGETS.md @@ -0,0 +1,257 @@ +# Filter Widget Architecture + +## Directory + +All filter components live under `filter-fields/`. Each has: `*.component.ts`, `*.component.html`, `*.component.css`, `*.component.spec.ts`. + +## Base Class + +`base-filter-field/base-filter-field.component.ts` + +Signal-based inputs and outputs every filter inherits: + +**Inputs:** `key`, `label`, `required`, `readonly`, `structure` (TableField), `disabled`, `widgetStructure`, `relations` (TableForeignKey), `autofocus` + +**Outputs:** `onFieldChange` (emits filter value), `onComparatorChange` (emits comparator string) + +**Computed:** `normalizedLabel` — human-readable field name + +## Two Types of Filter Components + +### 1. Simple filters (dialog manages comparator) + +Set `static type` to control which comparator dropdown the dialog shows: + +- `static type = 'text'` → dialog shows: startswith, endswith, eq, contains, icontains, empty +- `static type = 'number'` or `'datetime'` → dialog shows: eq, gt, lt, gte, lte +- No `static type` (or undefined) → `nonComparable`, dialog shows NO comparator + +The component only emits `onFieldChange`. The dialog renders its own `` for the comparator. + +**Examples:** `TextFilterComponent`, `NumberFilterComponent`, `DateFilterComponent` + +### 2. Smart filters (component manages its own comparator) + +Do NOT set `static type` — this makes `getComparatorType()` return `'nonComparable'`, so the dialog renders only the component with no external comparator dropdown. + +The component has an internal `filterMode` property, renders its own `` for mode selection, and emits BOTH `onFieldChange` (value) and `onComparatorChange` (comparator) to the dialog. + +**Examples:** `EmailFilterComponent`, `PhoneFilterComponent`, `DateTimeFilterComponent` + +## Comparator Routing Logic + +In `db-table-filters-dialog.component.ts` (and `saved-filters-panel`): + +``` +getInputType(field) → reads ComponentClass.type (static property) +getComparatorType(type): + 'text' → dialog shows text comparators + 'number'/'datetime' → dialog shows number comparators + anything else → 'nonComparable' → dialog shows nothing, component manages itself +``` + +## Registration + +### In `consts/filter-types.ts`: + +- `UIwidgets` object: maps widget type names → component classes (e.g., `DateTime: DateTimeFilterComponent`) +- `filterTypes` object: maps database column types → component classes per database (e.g., `postgres['timestamp without time zone']: DateTimeFilterComponent`) + +### In `db-table-filters-dialog`: + +- `UIwidgets` from `filter-types.ts` merged with `record-edit-types.ts` +- Widget-based fields use `UIwidgets[widget_type]` +- Database-type fields use `filterTypes[connectionType][columnType]` + +## Data Flow + +1. User adds filter → dialog creates entry in `tableRowFieldsShown[field]` and `tableRowFieldsComparator[field]` (default: `'eq'`) +2. `ndc-dynamic` renders the component with inputs (`value`, `key`, `label`, etc.) and output handlers (`onFieldChange` → `updateField`, `onComparatorChange` → `updateComparatorFromComponent`) +3. Component emits value/comparator → dialog stores them +4. User clicks "Filter" → dialog closes → filters encoded as JsonURL in URL query params → table refetches + +## Filter Data Format + +URL: `?filters=` where filters = `{ fieldName: { comparator: value } }` + +Example: `{ created_at: { gte: "2024-01-01T00:00:00Z" }, email: { endswith: "@gmail.com" } }` + +--- + +# How to Create a New Filter Widget + +## Step 1: Create Component Files + +Create directory: `filter-fields//` + +### TypeScript (`.component.ts`) + +```typescript +import { CommonModule } from '@angular/common'; +import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; // only for smart filters +import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; + +@Component({ + selector: 'app-filter-', + templateUrl: './.component.html', + styleUrls: ['./.component.css'], + imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule], +}) +export class FilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { + @Input() value: any; + @ViewChild('inputElement') inputElement: ElementRef; + + // For SIMPLE filter: set static type + // static type = 'text'; // or 'number' or 'datetime' + + // For SMART filter: do NOT set static type, add filterMode instead + public filterMode: string = 'eq'; + + ngOnInit(): void { + // Parse this.value if restoring from URL + } + + ngAfterViewInit(): void { + // Emit initial comparator (smart filters only) + this.onComparatorChange.emit(this.filterMode); + + // Autofocus support + if (this.autofocus() && this.inputElement) { + setTimeout(() => this.inputElement.nativeElement.focus(), 100); + } + } + + // Smart filter: handle mode changes + onFilterModeChange(mode: string): void { + this.filterMode = mode; + this.onComparatorChange.emit(mode); // or map to backend comparator + this.onFieldChange.emit(this.value); + } + + // Handle value changes + onValueChange(val: any): void { + this.value = val; + this.onFieldChange.emit(this.value); + this.onComparatorChange.emit(this.filterMode); // smart filter only + } +} +``` + +### Template (`.component.html`) + +**Simple filter** — just a value input: + +```html + + {{normalizedLabel()}} + + +``` + +**Smart filter** — mode selector + conditional input: + +```html +
+ + + equal + + + + + @if (filterMode !== 'some_special_mode') { + + {{normalizedLabel()}} + + + } +
+``` + +### CSS (`.component.css`) + +**Smart filter layout** (reuse across all smart filters): + +```css +.filter-row { + display: flex; + gap: 8px; + align-items: flex-start; + width: 100%; +} +.comparator-field { + flex: 0 0 auto; + min-width: 150px; +} +.value-field { + flex: 1; +} +``` + +## Step 2: Register the Component + +In `consts/filter-types.ts`: + +1. Import the component +2. Add to `UIwidgets` object if it's a custom widget type: + ```typescript + export const UIwidgets = { + // ...existing... + MyWidget: MyWidgetFilterComponent, + }; + ``` +3. Add to `filterTypes` database mappings if it maps to a database column type: + ```typescript + postgres: { + my_column_type: MyWidgetFilterComponent, + }, + ``` + +## Step 3: Write Tests + +```typescript +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +describe('FilterComponent', () => { + let component: FilterComponent; + let fixture: ComponentFixture<FilterComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FilterComponent, BrowserAnimationsModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FilterComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + // Test value emission + // Test comparator emission (smart filters) + // Test URL restoration (ngOnInit with existing value) + // Test mode switching (smart filters) +}); +``` + +**Note:** Tests use Vitest (`vi.spyOn`), not Jasmine. Use `toBe(true)` not `toBeTrue()`. + +## Key Gotchas + +- **Do NOT override `onFieldChange` or `onComparatorChange`** — the base class outputs work fine. Overriding creates a shadowed emitter. +- **Comparator mapping**: internal filter modes can differ from backend comparators. Email's `'domain'` mode emits `'endswith'`; phone's `'country'` mode emits `'startswith'`. +- **URL restoration**: when restoring from URL, you can't distinguish preset modes from custom ones — default to showing as a custom comparator. +- **Timezone**: for datetime values, use `toISOString()` for genuine UTC. Never use `format()` with literal `'Z'` suffix — it outputs local time with a fake UTC marker. +- **`fixture.detectChanges()`**: move it out of `beforeEach` if your component emits in `ngAfterViewInit` — call it only in tests that need the full lifecycle. diff --git a/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.ts b/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.ts index bc42c2f70..3bf9db36d 100644 --- a/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.ts @@ -1,6 +1,5 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, computed, input, OnInit, output } from '@angular/core'; import { TableField, TableForeignKey, WidgetStructure } from 'src/app/models/table'; - import { normalizeFieldName } from '../../../../lib/normalize'; @Component({ @@ -9,21 +8,20 @@ import { normalizeFieldName } from '../../../../lib/normalize'; styleUrl: './base-filter-field.component.css', }) export class BaseFilterFieldComponent implements OnInit { - @Input() key: string; - @Input() label: string; - @Input() required: boolean; - @Input() readonly: boolean; - @Input() structure: TableField; - @Input() disabled: boolean; - @Input() widgetStructure: WidgetStructure; - @Input() relations: TableForeignKey; - @Input() autofocus: boolean = false; + readonly key = input(); + readonly label = input(); + readonly required = input(false); + readonly readonly = input(false); + readonly structure = input(); + readonly disabled = input(false); + readonly widgetStructure = input(); + readonly relations = input(); + readonly autofocus = input(false); - @Output() onFieldChange = new EventEmitter(); + readonly onFieldChange = output(); + readonly onComparatorChange = output(); - public normalizedLabel: string; + readonly normalizedLabel = computed(() => normalizeFieldName(this.label() || '')); - ngOnInit(): void { - this.normalizedLabel = normalizeFieldName(this.label); - } + ngOnInit(): void {} } diff --git a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.html b/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.html index 824ba0f1b..ae4e7562d 100644 --- a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.html @@ -1,7 +1,7 @@
- {{normalizedLabel}} + {{normalizedLabel()}} + attr.data-testid="record-{{label()}}-binary-data-caption"> binary data -
\ No newline at end of file +

diff --git a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.html b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.html index 8bb291470..60c23eb0c 100644 --- a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.html @@ -1,24 +1,13 @@ -
- - + + - Yes - No - Unknown + Yes + No + @if (isRadiogroup) { + Null + }
- - -
- - - Yes - No - -
-
diff --git a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.spec.ts index 22a27f55e..7dfd1f4f0 100644 --- a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.spec.ts +++ b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.spec.ts @@ -9,7 +9,7 @@ describe('BooleanFilterComponent', () => { let component: BooleanFilterComponent; let fixture: ComponentFixture; - const fakeStructure = { + const fakeStructureNotNull = { column_name: 'banned', column_default: '0', data_type: 'tinyint', @@ -20,6 +20,11 @@ describe('BooleanFilterComponent', () => { character_maximum_length: 1, }; + const fakeStructureNullable = { + ...fakeStructureNotNull, + allow_null: true, + }; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [MatSnackBarModule, MatDialogModule, BooleanFilterComponent, BrowserAnimationsModule], @@ -36,44 +41,104 @@ describe('BooleanFilterComponent', () => { expect(component).toBeTruthy(); }); - it('should set booleanValue in false when input value is 0', () => { + it('should set booleanValue to false when input value is 0', () => { component.value = 0; - component.structure = fakeStructure; + fixture.componentRef.setInput('structure', fakeStructureNotNull); component.ngOnInit(); expect(component.booleanValue).toEqual(false); }); - it('should set booleanValue in unknown when input value is null', () => { + it('should set booleanValue to unknown when input value is null', () => { component.value = null; - component.structure = fakeStructure; + fixture.componentRef.setInput('structure', fakeStructureNullable); + component.ngOnInit(); + + expect(component.booleanValue).toEqual('unknown'); + }); + + it('should set booleanValue to unknown when input value is empty string', () => { + component.value = ''; + fixture.componentRef.setInput('structure', fakeStructureNullable); component.ngOnInit(); expect(component.booleanValue).toEqual('unknown'); }); - it('should set isRadiogroup in false if allow_null is false', () => { + it('should set isRadiogroup to false if allow_null is false', () => { component.value = undefined; - component.structure = fakeStructure; + fixture.componentRef.setInput('structure', fakeStructureNotNull); component.ngOnInit(); expect(component.isRadiogroup).toEqual(false); }); - it('should set isRadiogroup in true if allow_null is true', () => { + it('should set isRadiogroup to true if allow_null is true', () => { component.value = undefined; - component.structure = { - column_name: 'banned', - column_default: '0', - data_type: 'tinyint', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, - }; + fixture.componentRef.setInput('structure', fakeStructureNullable); component.ngOnInit(); expect(component.isRadiogroup).toEqual(true); }); + + it('should emit eq comparator when Yes is toggled', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.componentRef.setInput('structure', fakeStructureNullable); + component.ngOnInit(); + + component.booleanValue = true; + component.onBooleanChange(); + + expect(component.onFieldChange.emit).toHaveBeenCalled(); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); + }); + + it('should emit eq comparator when No is toggled', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.componentRef.setInput('structure', fakeStructureNullable); + component.ngOnInit(); + + component.booleanValue = false; + component.onBooleanChange(); + + expect(component.onFieldChange.emit).toHaveBeenCalled(); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); + }); + + it('should emit eq comparator and null value when Null is toggled', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.componentRef.setInput('structure', fakeStructureNullable); + component.ngOnInit(); + + component.booleanValue = 'unknown'; + component.onBooleanChange(); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith(null); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); + }); + + it('should render Null option when allow_null is true', () => { + fixture.componentRef.setInput('structure', fakeStructureNullable); + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll('mat-button-toggle'); + const labels = Array.from(buttons).map((b: Element) => b.textContent?.trim()); + + expect(labels).toContain('Null'); + }); + + it('should not render Null option when allow_null is false', () => { + fixture.componentRef.setInput('structure', fakeStructureNotNull); + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll('mat-button-toggle'); + const labels = Array.from(buttons).map((b: Element) => b.textContent?.trim()); + + expect(labels).not.toContain('Null'); + expect(labels).toContain('Yes'); + expect(labels).toContain('No'); + }); }); diff --git a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.ts b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.ts index 60f620646..8ba6c5a30 100644 --- a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { AfterViewInit, Component, Input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { DBtype } from 'src/app/models/connection'; @@ -12,63 +12,64 @@ import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field styleUrls: ['./boolean.component.css'], imports: [CommonModule, FormsModule, MatButtonToggleModule], }) -export class BooleanFilterComponent extends BaseFilterFieldComponent { +export class BooleanFilterComponent extends BaseFilterFieldComponent implements AfterViewInit { @Input() value; public isRadiogroup: boolean; - private connectionType: DBtype; public booleanValue: boolean | 'unknown'; + private connectionType: DBtype; constructor(private _connections: ConnectionsService) { super(); } ngOnInit(): void { - super.ngOnInit(); this.connectionType = this._connections.currentConnection.type; this.setBooleanValue(); - // Parse widget parameters if available let parsedParams = null; - if (this.widgetStructure?.widget_params) { - parsedParams = - typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; + const ws = this.widgetStructure(); + if (ws?.widget_params) { + parsedParams = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; } - // Check allow_null from either structure or widget params - this.isRadiogroup = this.structure?.allow_null || !!parsedParams?.allow_null; + this.isRadiogroup = this.structure()?.allow_null || !!parsedParams?.allow_null; + } + + ngAfterViewInit(): void { + this.onComparatorChange.emit('eq'); } setBooleanValue() { if (typeof this.value === 'boolean') { this.booleanValue = this.value; - } else if (this.value === null) { + return; + } + + if (this.value === null || this.value === undefined || this.value === '') { this.booleanValue = 'unknown'; - console.log('i entered condition this.value === null'); - } else { - switch (this.value) { - case 0: - case '0': - case 'F': - case 'N': - case 'false': - this.booleanValue = false; - break; - case 1: - case '1': - case 'T': - case 'Y': - case 'true': - this.booleanValue = true; - break; - } + return; + } + + switch (this.value) { + case 0: + case '0': + case 'F': + case 'N': + case 'false': + this.booleanValue = false; + break; + case 1: + case '1': + case 'T': + case 'Y': + case 'true': + this.booleanValue = true; + break; } } onBooleanChange() { - console.log(this.connectionType); let formattedValue; switch (this.connectionType) { case DBtype.MySQL: @@ -81,5 +82,6 @@ export class BooleanFilterComponent extends BaseFilterFieldComponent { } this.onFieldChange.emit(formattedValue); + this.onComparatorChange.emit('eq'); } } diff --git a/frontend/src/app/components/ui-components/filter-fields/country/country.component.html b/frontend/src/app/components/ui-components/filter-fields/country/country.component.html index 23a3dabdd..cbb2222cb 100644 --- a/frontend/src/app/components/ui-components/filter-fields/country/country.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/country/country.component.html @@ -1,8 +1,8 @@ - {{normalizedLabel}} + {{normalizedLabel()}} @@ -14,4 +14,4 @@ ({{country.value}}) - \ No newline at end of file + diff --git a/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts b/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts index c859c75a8..03c44b41f 100644 --- a/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts @@ -9,6 +9,18 @@ import { map, startWith } from 'rxjs/operators'; import { COUNTRIES, getCountryFlag } from '../../../../consts/countries'; import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; +interface CountryOption { + value: string | null; + label: string; + flag: string; +} + +const BASE_COUNTRIES: CountryOption[] = COUNTRIES.map((country) => ({ + value: country.code, + label: country.name, + flag: getCountryFlag(country.code), +})); + @Component({ selector: 'app-filter-country', templateUrl: './country.component.html', @@ -19,9 +31,9 @@ import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field export class CountryFilterComponent extends BaseFilterFieldComponent { @Input() value: string; - public countries: { value: string | null; label: string; flag: string }[] = []; - public countryControl = new FormControl<{ value: string | null; label: string; flag: string } | string>(''); - public filteredCountries: Observable<{ value: string | null; label: string; flag: string }[]>; + public countries: CountryOption[] = []; + public countryControl = new FormControl(''); + public filteredCountries: Observable; originalOrder = () => { return 0; @@ -30,12 +42,27 @@ export class CountryFilterComponent extends BaseFilterFieldComponent { getCountryFlag = getCountryFlag; ngOnInit(): void { - super.ngOnInit(); - this.loadCountries(); + const ws = this.widgetStructure(); + const struct = this.structure(); + if (ws?.widget_params?.allow_null || struct?.allow_null) { + this.countries = [{ value: null, label: '', flag: '' }, ...BASE_COUNTRIES]; + } else { + this.countries = BASE_COUNTRIES; + } + this.setupAutocomplete(); this.setInitialValue(); } + onCountrySelected(selectedCountry: CountryOption): void { + this.value = selectedCountry.value; + this.onFieldChange.emit(this.value); + } + + displayFn(country: any): string { + return country ? country.label : ''; + } + private setupAutocomplete(): void { this.filteredCountries = this.countryControl.valueChanges.pipe( startWith(''), @@ -52,32 +79,11 @@ export class CountryFilterComponent extends BaseFilterFieldComponent { } } - private _filter(value: string): { value: string | null; label: string; flag: string }[] { + private _filter(value: string): CountryOption[] { const filterValue = value.toLowerCase(); return this.countries.filter( (country) => country.label?.toLowerCase().includes(filterValue) || country.value?.toLowerCase().includes(filterValue), ); } - - onCountrySelected(selectedCountry: { value: string | null; label: string; flag: string }): void { - this.value = selectedCountry.value; - this.onFieldChange.emit(this.value); - } - - displayFn(country: any): string { - return country ? country.label : ''; - } - - private loadCountries(): void { - this.countries = COUNTRIES.map((country) => ({ - value: country.code, - label: country.name, - flag: getCountryFlag(country.code), - })); - - if (this.widgetStructure?.widget_params?.allow_null || this.structure?.allow_null) { - this.countries = [{ value: null, label: '', flag: '' }, ...this.countries]; - } - } } diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css index f63bf4c46..327f04173 100644 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css +++ b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css @@ -1,3 +1,19 @@ +.filter-row { + display: flex; + gap: 8px; + align-items: flex-start; + width: 100%; +} + +.comparator-field { + flex: 0 0 auto; + min-width: 150px; +} + +.value-field { + flex: 1; +} + .field-couple { display: grid; grid-template-columns: 1fr 1fr; diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html index e370aa7f3..4fabdca6f 100644 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html @@ -1,18 +1,37 @@ -
- - {{normalizedLabel}} (date) - +
+ + + last hour + last day + last week + last month + last year + on + after + before + on or after + on or before + - - {{normalizedLabel}} (time) - - + @if (!isPresetMode()) { +
+ + {{normalizedLabel()}} (date) + + + + + {{normalizedLabel()}} (time) + + +
+ }
diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts index 89daacb59..f455f2b24 100644 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts +++ b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts @@ -15,36 +15,116 @@ describe('DateTimeFilterComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(DateTimeFilterComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); - it('should prepare date and time for date and time inputs', () => { + it('should default to last_day filter mode', () => { + expect(component.filterMode).toEqual('last_day'); + }); + + it('should prepare date and time for date and time inputs when value is provided', () => { component.value = '2021-06-26T07:22:00.603'; component.ngOnInit(); expect(component.date).toEqual('2021-06-26'); expect(component.time).toEqual('07:22:00'); + expect(component.filterMode).toEqual('gte'); }); it('should send onChange event with new date value', () => { + component.filterMode = 'gte'; component.date = '2021-08-26'; component.time = '07:22:00'; - const event = vi.spyOn(component.onFieldChange, 'emit'); + const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); + const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); component.onDateChange(); - expect(event).toHaveBeenCalledWith('2021-08-26T07:22:00Z'); + expect(fieldEvent).toHaveBeenCalledWith('2021-08-26T07:22:00Z'); + expect(comparatorEvent).toHaveBeenCalledWith('gte'); }); it('should send onChange event with new time value', () => { + component.filterMode = 'lt'; component.date = '2021-07-26'; component.time = '07:20:00'; - const event = vi.spyOn(component.onFieldChange, 'emit'); + const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); + const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); component.onTimeChange(); - expect(event).toHaveBeenCalledWith('2021-07-26T07:20:00Z'); + expect(fieldEvent).toHaveBeenCalledWith('2021-07-26T07:20:00Z'); + expect(comparatorEvent).toHaveBeenCalledWith('lt'); + }); + + it('should identify preset modes correctly', () => { + component.filterMode = 'last_hour'; + expect(component.isPresetMode()).toBe(true); + + component.filterMode = 'last_day'; + expect(component.isPresetMode()).toBe(true); + + component.filterMode = 'last_week'; + expect(component.isPresetMode()).toBe(true); + + component.filterMode = 'last_month'; + expect(component.isPresetMode()).toBe(true); + + component.filterMode = 'last_year'; + expect(component.isPresetMode()).toBe(true); + + component.filterMode = 'eq'; + expect(component.isPresetMode()).toBe(false); + + component.filterMode = 'gt'; + expect(component.isPresetMode()).toBe(false); + }); + + it('should emit gte comparator and computed value for preset modes', () => { + const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); + const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); + + component.onFilterModeChange('last_hour'); + + expect(comparatorEvent).toHaveBeenCalledWith('gte'); + expect(fieldEvent).toHaveBeenCalled(); + const emittedValue = fieldEvent.mock.calls[0][0] as string; + expect(emittedValue).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + }); + + it('should emit correct comparator for custom modes', () => { + const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); + + component.onFilterModeChange('gt'); + expect(comparatorEvent).toHaveBeenCalledWith('gt'); + + component.onFilterModeChange('lt'); + expect(comparatorEvent).toHaveBeenCalledWith('lt'); + + component.onFilterModeChange('eq'); + expect(comparatorEvent).toHaveBeenCalledWith('eq'); + }); + + it('should emit datetime value when switching to custom mode with existing date', () => { + component.date = '2021-08-26'; + component.time = '10:00:00'; + const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); + + component.onFilterModeChange('gt'); + + expect(fieldEvent).toHaveBeenCalledWith('2021-08-26T10:00:00Z'); + }); + + it('should default time to 00:00 on date change if time is not set', () => { + component.filterMode = 'eq'; + component.date = '2021-08-26'; + component.time = undefined; + const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); + component.onDateChange(); + + expect(component.time).toEqual('00:00:00'); + expect(fieldEvent).toHaveBeenCalledWith('2021-08-26T00:00:00Z'); }); }); diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts index 4cd0c7173..1c976105a 100644 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts @@ -1,52 +1,111 @@ import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { format } from 'date-fns'; +import { MatSelectModule } from '@angular/material/select'; +import { format, subDays, subHours, subMonths, subYears } from 'date-fns'; import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; @Component({ selector: 'app-filter-date-time', templateUrl: './date-time.component.html', styleUrls: ['./date-time.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], + imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule], }) -export class DateTimeFilterComponent extends BaseFilterFieldComponent implements AfterViewInit { +export class DateTimeFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { @Input() value: string; @ViewChild('inputElement') inputElement: ElementRef; - @Output() onFieldChange = new EventEmitter(); - - static type = 'datetime'; + public filterMode: string = 'last_day'; public date: string; public time: string; + private _presetModes = ['last_hour', 'last_day', 'last_week', 'last_month', 'last_year']; + ngOnInit(): void { - super.ngOnInit(); if (this.value) { const datetime = new Date(this.value); this.date = format(datetime, 'yyyy-MM-dd'); this.time = format(datetime, 'HH:mm:ss'); + this.filterMode = 'gte'; + } + } + + ngAfterViewInit(): void { + if (this.value) { + this.onComparatorChange.emit(this.filterMode); + } else { + const value = this._computePresetValue(this.filterMode); + this.onFieldChange.emit(value); + this.onComparatorChange.emit('gte'); + } + + if (this.autofocus() && this.inputElement) { + setTimeout(() => { + this.inputElement.nativeElement.focus(); + }, 100); } } - onDateChange() { - if (!this.time) this.time = '00:00'; + onFilterModeChange(mode: string): void { + this.filterMode = mode; + + if (this._presetModes.includes(mode)) { + const value = this._computePresetValue(mode); + this.onFieldChange.emit(value); + this.onComparatorChange.emit('gte'); + } else { + this.onComparatorChange.emit(mode); + if (this.date) { + const time = this.time || '00:00'; + const datetime = `${this.date}T${time}Z`; + this.onFieldChange.emit(datetime); + } + } + } + + onDateChange(): void { + if (!this.time) this.time = '00:00:00'; const datetime = `${this.date}T${this.time}Z`; this.onFieldChange.emit(datetime); + this.onComparatorChange.emit(this.filterMode); } - onTimeChange() { + onTimeChange(): void { const datetime = `${this.date}T${this.time}Z`; this.onFieldChange.emit(datetime); + this.onComparatorChange.emit(this.filterMode); } - ngAfterViewInit(): void { - if (this.autofocus && this.inputElement) { - setTimeout(() => { - this.inputElement.nativeElement.focus(); - }, 100); + isPresetMode(): boolean { + return this._presetModes.includes(this.filterMode); + } + + private _computePresetValue(mode: string): string { + const now = new Date(); + let targetDate: Date; + + switch (mode) { + case 'last_hour': + targetDate = subHours(now, 1); + break; + case 'last_day': + targetDate = subDays(now, 1); + break; + case 'last_week': + targetDate = subDays(now, 7); + break; + case 'last_month': + targetDate = subMonths(now, 1); + break; + case 'last_year': + targetDate = subYears(now, 1); + break; + default: + targetDate = now; } + + return targetDate.toISOString().replace(/\.\d{3}Z$/, 'Z'); } } diff --git a/frontend/src/app/components/ui-components/filter-fields/date/date.component.html b/frontend/src/app/components/ui-components/filter-fields/date/date.component.html index 2b87fc46e..376e16138 100644 --- a/frontend/src/app/components/ui-components/filter-fields/date/date.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/date/date.component.html @@ -1,8 +1,8 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + diff --git a/frontend/src/app/components/ui-components/filter-fields/date/date.component.ts b/frontend/src/app/components/ui-components/filter-fields/date/date.component.ts index 9e5ad5b25..17b430f11 100644 --- a/frontend/src/app/components/ui-components/filter-fields/date/date.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/date/date.component.ts @@ -20,7 +20,6 @@ export class DateFilterComponent extends BaseFilterFieldComponent implements Aft public date: string; ngOnInit(): void { - super.ngOnInit(); if (this.value) { const datetime = new Date(this.value); this.date = format(datetime, 'yyyy-MM-dd'); @@ -28,7 +27,7 @@ export class DateFilterComponent extends BaseFilterFieldComponent implements Aft } ngAfterViewInit(): void { - if (this.autofocus && this.inputElement) { + if (this.autofocus() && this.inputElement) { setTimeout(() => { this.inputElement.nativeElement.focus(); }, 100); diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.css b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.css new file mode 100644 index 000000000..f9b0eb8b0 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.css @@ -0,0 +1,16 @@ +.filter-row { + display: flex; + gap: 8px; + align-items: flex-start; + width: 100%; +} + +.comparator-field { + flex: 0 0 auto; + min-width: 150px; +} + +.value-field { + flex: 1; + min-width: 0; +} diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.html b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.html new file mode 100644 index 000000000..a9b226b84 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.html @@ -0,0 +1,19 @@ +
+ + + @for (option of comparatorOptions; track option.value) { + {{ option.label }} + } + + + +
+ +
+
diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.spec.ts new file mode 100644 index 000000000..6e989c923 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.spec.ts @@ -0,0 +1,83 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { vi } from 'vitest'; +import { NumberFilterComponent } from '../number/number.component'; +import { TextFilterComponent } from '../text/text.component'; +import { DefaultFilterComponent } from './default-filter.component'; + +describe('DefaultFilterComponent', () => { + let component: DefaultFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DefaultFilterComponent, BrowserAnimationsModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DefaultFilterComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should expose text comparator options when wrapping a text-type filter', () => { + component.valueComponent = TextFilterComponent; + expect(component.comparatorOptions.map((o) => o.value)).toEqual([ + 'startswith', + 'endswith', + 'eq', + 'contains', + 'icontains', + 'empty', + ]); + }); + + it('should expose number comparator options when wrapping a number-type filter', () => { + component.valueComponent = NumberFilterComponent; + expect(component.comparatorOptions.map((o) => o.value)).toEqual(['eq', 'gt', 'lt', 'gte', 'lte']); + }); + + it('should emit initial comparator on view init', () => { + const spy = vi.spyOn(component.onComparatorChange, 'emit'); + component.comparator = 'contains'; + component.ngAfterViewInit(); + expect(spy).toHaveBeenCalledWith('contains'); + }); + + it('should emit value and reset it when "empty" comparator is selected', () => { + const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); + const cmpSpy = vi.spyOn(component.onComparatorChange, 'emit'); + component.value = 'foo'; + component.onComparatorSelect('empty'); + expect(component.value).toBe(''); + expect(valueSpy).toHaveBeenCalledWith(''); + expect(cmpSpy).toHaveBeenCalledWith('empty'); + }); + + it('should emit comparator without resetting value for non-empty selections', () => { + const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); + const cmpSpy = vi.spyOn(component.onComparatorChange, 'emit'); + component.value = 'foo'; + component.onComparatorSelect('contains'); + expect(component.value).toBe('foo'); + expect(valueSpy).not.toHaveBeenCalled(); + expect(cmpSpy).toHaveBeenCalledWith('contains'); + }); + + it('should re-emit value changes from inner widget via onValueChange', () => { + const spy = vi.spyOn(component.onFieldChange, 'emit'); + component.onValueChange('bar'); + expect(component.value).toBe('bar'); + expect(spy).toHaveBeenCalledWith('bar'); + }); + + it('should mark inner inputs as readonly when comparator is "empty"', () => { + component.comparator = 'empty'; + expect(component.innerInputs.readonly).toBe(true); + }); +}); diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.ts b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.ts new file mode 100644 index 000000000..f739c8847 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.ts @@ -0,0 +1,74 @@ +import { CommonModule } from '@angular/common'; +import { AfterViewInit, Component, Input, OnInit, Type } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { DynamicModule } from 'ng-dynamic-component'; +import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; +import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; + +const TEXT_COMPARATORS = [ + { value: 'startswith', label: 'starts with' }, + { value: 'endswith', label: 'ends with' }, + { value: 'eq', label: 'equal' }, + { value: 'contains', label: 'contains' }, + { value: 'icontains', label: 'not contains' }, + { value: 'empty', label: 'is empty' }, +]; + +const NUMBER_COMPARATORS = [ + { value: 'eq', label: 'equal' }, + { value: 'gt', label: 'greater than' }, + { value: 'lt', label: 'less than' }, + { value: 'gte', label: 'greater than or equal' }, + { value: 'lte', label: 'less than or equal' }, +]; + +@Component({ + selector: 'app-filter-default', + templateUrl: './default-filter.component.html', + styleUrls: ['./default-filter.component.css'], + imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule, DynamicModule, SignalComponentIoModule], +}) +export class DefaultFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { + @Input() value: any; + @Input() comparator: string = 'eq'; + @Input() valueComponent: Type; + + ngOnInit(): void {} + + ngAfterViewInit(): void { + this.onComparatorChange.emit(this.comparator); + } + + get comparatorOptions() { + const innerType = (this.valueComponent as { type?: string })?.type; + return innerType === 'text' ? TEXT_COMPARATORS : NUMBER_COMPARATORS; + } + + get innerInputs(): Record { + return { + key: this.key(), + label: this.label(), + value: this.value, + readonly: this.comparator === 'empty', + structure: this.structure(), + relations: this.relations(), + autofocus: this.autofocus(), + }; + } + + onComparatorSelect(comparator: string): void { + this.comparator = comparator; + if (comparator === 'empty') { + this.value = ''; + this.onFieldChange.emit(''); + } + this.onComparatorChange.emit(comparator); + } + + onValueChange = (val: any): void => { + this.value = val; + this.onFieldChange.emit(val); + }; +} diff --git a/frontend/src/app/components/ui-components/filter-fields/email/email.component.css b/frontend/src/app/components/ui-components/filter-fields/email/email.component.css new file mode 100644 index 000000000..3b1f34518 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/email/email.component.css @@ -0,0 +1,15 @@ +.filter-row { + display: flex; + gap: 8px; + align-items: flex-start; + width: 100%; +} + +.comparator-field { + flex: 0 0 auto; + min-width: 150px; +} + +.value-field { + flex: 1; +} diff --git a/frontend/src/app/components/ui-components/filter-fields/email/email.component.html b/frontend/src/app/components/ui-components/filter-fields/email/email.component.html new file mode 100644 index 000000000..24f5f9351 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/email/email.component.html @@ -0,0 +1,32 @@ +
+ + + equal + domain equal + contains + starts with + ends with + is empty + + + + @if (filterMode === 'domain') { + + {{normalizedLabel()}} (domain) + + + + } @else if (filterMode !== 'empty') { + + {{normalizedLabel()}} + + + } +
diff --git a/frontend/src/app/components/ui-components/filter-fields/email/email.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/email/email.component.spec.ts new file mode 100644 index 000000000..0e171dc2a --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/email/email.component.spec.ts @@ -0,0 +1,93 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { EmailFilterComponent } from './email.component'; + +describe('EmailFilterComponent', () => { + let component: EmailFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EmailFilterComponent, BrowserAnimationsModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(EmailFilterComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should default to eq filter mode', () => { + fixture.detectChanges(); + expect(component.filterMode).toBe('eq'); + }); + + it('should not emit comparator in default eq mode', () => { + const emitted: string[] = []; + component.onComparatorChange.subscribe((v: string) => emitted.push(v)); + + component.ngOnInit(); + component.ngAfterViewInit(); + + expect(emitted).toEqual([]); + }); + + it('should emit endswith comparator when initialized with @domain value', () => { + component.value = '@example.com'; + const emitted: string[] = []; + component.onComparatorChange.subscribe((v: string) => emitted.push(v)); + + component.ngOnInit(); + component.ngAfterViewInit(); + + expect(emitted).toEqual(['endswith']); + }); + + it('should prefix domain value with @ on domain change', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.detectChanges(); + + component.onDomainChange('gmail.com'); + + expect(component.value).toBe('@gmail.com'); + expect(component.onFieldChange.emit).toHaveBeenCalledWith('@gmail.com'); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('endswith'); + }); + + it('should switch to eq mode and emit plain text', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.detectChanges(); + + component.value = 'user@test.com'; + component.onFilterModeChange('eq'); + + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); + expect(component.onFieldChange.emit).toHaveBeenCalledWith('user@test.com'); + }); + + it('should switch to empty mode', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.detectChanges(); + + component.onFilterModeChange('empty'); + + expect(component.value).toBe(''); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('empty'); + }); + + it('should parse existing @domain value on init', () => { + component.value = '@example.com'; + fixture.detectChanges(); + + expect(component.filterMode).toBe('domain'); + expect(component.domainValue).toBe('example.com'); + }); +}); diff --git a/frontend/src/app/components/ui-components/filter-fields/email/email.component.ts b/frontend/src/app/components/ui-components/filter-fields/email/email.component.ts new file mode 100644 index 000000000..383eff263 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/email/email.component.ts @@ -0,0 +1,69 @@ +import { CommonModule } from '@angular/common'; +import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; + +@Component({ + selector: 'app-filter-email', + templateUrl: './email.component.html', + styleUrls: ['./email.component.css'], + imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule], +}) +export class EmailFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { + @Input() value: string; + @ViewChild('inputElement') inputElement: ElementRef; + + public filterMode: string = 'eq'; + public domainValue: string = ''; + + ngOnInit(): void { + if (this.value?.startsWith('@')) { + this.filterMode = 'domain'; + this.domainValue = this.value.substring(1); + } + } + + ngAfterViewInit(): void { + if (this.filterMode !== 'eq') { + this.onComparatorChange.emit(this.filterMode === 'domain' ? 'endswith' : this.filterMode); + } + + if (this.autofocus() && this.inputElement) { + setTimeout(() => { + this.inputElement.nativeElement.focus(); + }, 100); + } + } + + onFilterModeChange(mode: string): void { + this.filterMode = mode; + + if (mode === 'domain') { + this.value = this.domainValue ? `@${this.domainValue}` : ''; + this.onComparatorChange.emit('endswith'); + } else if (mode === 'empty') { + this.value = ''; + this.onComparatorChange.emit('empty'); + } else { + this.onComparatorChange.emit(mode); + } + + this.onFieldChange.emit(this.value); + } + + onDomainChange(domain: string): void { + this.domainValue = domain; + this.value = domain ? `@${domain}` : ''; + this.onFieldChange.emit(this.value); + this.onComparatorChange.emit('endswith'); + } + + onValueChange(text: string): void { + this.value = text; + this.onFieldChange.emit(this.value); + this.onComparatorChange.emit(this.filterMode); + } +} diff --git a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.html b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.html index 0cd1c5af9..5d825b064 100644 --- a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.html @@ -3,12 +3,12 @@
- {{normalizedLabel}} + {{normalizedLabel()}} @if (fetching()) { } @@ -22,10 +22,10 @@ } Improve search performance by configuring Foreign key search fields  - here. + here. - @@ -40,6 +40,6 @@ - + diff --git a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.spec.ts index 3eb625c7a..27b5990f1 100644 --- a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.spec.ts +++ b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.spec.ts @@ -138,7 +138,7 @@ describe('ForeignKeyFilterComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ForeignKeyFilterComponent); component = fixture.componentInstance; - component.relations = fakeRelations; + fixture.componentRef.setInput('relations', fakeRelations); tablesService = TestBed.inject(TablesService); connectionsService = TestBed.inject(ConnectionsService); // Mock the connectionID getter before ngOnInit runs @@ -223,14 +223,14 @@ describe('ForeignKeyFilterComponent', () => { vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); component.connectionID = '12345678'; - component.relations = { + fixture.componentRef.setInput('relations', { autocomplete_columns: [], column_name: 'userId', constraint_name: '', referenced_column_name: 'id', referenced_table_name: 'users', column_default: '', - }; + }); component.value = '33'; // Must be truthy to trigger currentDisplayedString setting await component.ngOnInit(); @@ -313,7 +313,7 @@ describe('ForeignKeyFilterComponent', () => { vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(searchSuggestionsNetwork)); - component.relations = fakeRelations; + fixture.componentRef.setInput('relations', fakeRelations); component.suggestions.set([ { @@ -405,7 +405,7 @@ describe('ForeignKeyFilterComponent', () => { const fakeFetchTable = vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(searchSuggestionsNetwork)); component.connectionID = '12345678'; - component.relations = fakeRelations; + fixture.componentRef.setInput('relations', fakeRelations); component.suggestions.set([ { @@ -429,12 +429,12 @@ describe('ForeignKeyFilterComponent', () => { expect(fakeFetchTable).toHaveBeenCalledWith({ connectionID: '12345678', - tableName: component.relations.referenced_table_name, + tableName: component.relations().referenced_table_name, requstedPage: 1, chunkSize: 20, foreignKeyRowName: 'autocomplete', foreignKeyRowValue: component.currentDisplayedString, - referencedColumn: component.relations.referenced_column_name, + referencedColumn: component.relations().referenced_column_name, }); expect(component.suggestions()).toEqual([ diff --git a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.ts b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.ts index c014911ec..3aa51ff68 100644 --- a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.ts @@ -63,18 +63,18 @@ export class ForeignKeyFilterComponent extends BaseFilterFieldComponent { } async ngOnInit(): Promise { - super.ngOnInit(); this.connectionID = this._connections.currentConnectionID; + const rels = this.relations(); - if (this.relations) { + if (rels) { try { const res = (await firstValueFrom( this._tables.fetchTable({ connectionID: this.connectionID, - tableName: this.relations.referenced_table_name, + tableName: rels.referenced_table_name, requstedPage: 1, chunkSize: 10, - foreignKeyRowName: this.relations.referenced_column_name, + foreignKeyRowName: rels.referenced_column_name, foreignKeyRowValue: this.value, }), )) as FetchTableResponse; @@ -90,7 +90,7 @@ export class ForeignKeyFilterComponent extends BaseFilterFieldComponent { : Object.values(modifiedRow) .filter((value) => value) .join(' | '); - this.currentFieldValue = res.rows[0][this.relations.referenced_column_name]; + this.currentFieldValue = res.rows[0][rels.referenced_column_name]; this.currentFieldQueryParams = Object.assign( {}, ...res.primaryColumns.map((primaeyKey) => ({ @@ -104,12 +104,12 @@ export class ForeignKeyFilterComponent extends BaseFilterFieldComponent { const suggestionsRes = (await firstValueFrom( this._tables.fetchTable({ connectionID: this.connectionID, - tableName: this.relations.referenced_table_name, + tableName: rels.referenced_table_name, requstedPage: 1, chunkSize: 20, foreignKeyRowName: 'autocomplete', foreignKeyRowValue: '', - referencedColumn: this.relations.referenced_column_name, + referencedColumn: rels.referenced_column_name, }), )) as FetchTableResponse; @@ -131,7 +131,7 @@ export class ForeignKeyFilterComponent extends BaseFilterFieldComponent { [primaeyKey.column_name]: row[primaeyKey.column_name], })), ), - fieldValue: row[this.relations.referenced_column_name], + fieldValue: row[rels.referenced_column_name], }; }), ); @@ -155,6 +155,7 @@ export class ForeignKeyFilterComponent extends BaseFilterFieldComponent { const currentRow = this.suggestions()?.find( (suggestion) => suggestion.displayString === this.currentDisplayedString, ); + const rels = this.relations(); if (currentRow !== undefined) { this.currentFieldValue = currentRow.fieldValue; this.onFieldChange.emit(this.currentFieldValue); @@ -164,12 +165,12 @@ export class ForeignKeyFilterComponent extends BaseFilterFieldComponent { const res = (await firstValueFrom( this._tables.fetchTable({ connectionID: this.connectionID, - tableName: this.relations.referenced_table_name, + tableName: rels.referenced_table_name, requstedPage: 1, chunkSize: 20, foreignKeyRowName: 'autocomplete', foreignKeyRowValue: this.currentDisplayedString, - referencedColumn: this.relations.referenced_column_name, + referencedColumn: rels.referenced_column_name, }), )) as FetchTableResponse; @@ -198,7 +199,7 @@ export class ForeignKeyFilterComponent extends BaseFilterFieldComponent { [primaeyKey.column_name]: row[primaeyKey.column_name], })), ), - fieldValue: row[this.relations.referenced_column_name], + fieldValue: row[rels.referenced_column_name], }; }), ); @@ -212,11 +213,11 @@ export class ForeignKeyFilterComponent extends BaseFilterFieldComponent { } getModifiedRow(row: Record): Record { + const rels = this.relations(); let modifiedRow: Record; - if (this.relations.autocomplete_columns && this.relations.autocomplete_columns.length > 0) { - let autocompleteColumns = [...this.relations.autocomplete_columns]; - if (this.identityColumn) - autocompleteColumns.splice(this.relations.autocomplete_columns.indexOf(this.identityColumn), 1); + if (rels.autocomplete_columns && rels.autocomplete_columns.length > 0) { + let autocompleteColumns = [...rels.autocomplete_columns]; + if (this.identityColumn) autocompleteColumns.splice(rels.autocomplete_columns.indexOf(this.identityColumn), 1); modifiedRow = autocompleteColumns.reduce>( (rowObject, columnName) => ((rowObject[columnName] = row[columnName]), rowObject), {}, diff --git a/frontend/src/app/components/ui-components/filter-fields/id/id.component.html b/frontend/src/app/components/ui-components/filter-fields/id/id.component.html index 58a206351..240b86de3 100644 --- a/frontend/src/app/components/ui-components/filter-fields/id/id.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/id/id.component.html @@ -1,10 +1,10 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + Value doesn't match pattern. diff --git a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.html b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.html index c5a44ac12..dfd3c6bf4 100644 --- a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.html @@ -1,11 +1,11 @@ -{{ normalizedLabel }} {{ required ? '*' : '' }} +{{ normalizedLabel() }} {{ required() ? '*' : '' }}
-
\ No newline at end of file +
diff --git a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.spec.ts index df3a24265..06bd7ae8a 100644 --- a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.spec.ts +++ b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.spec.ts @@ -23,7 +23,7 @@ describe('JsonEditorFilterComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(JsonEditorFilterComponent); component = fixture.componentInstance; - component.label = 'config'; + fixture.componentRef.setInput('label', 'config'); component.value = { key: 'value', nested: { data: 123 } }; fixture.detectChanges(); }); @@ -69,8 +69,7 @@ describe('JsonEditorFilterComponent', () => { }); it('should normalize label from base class', () => { - component.label = 'user_config_data'; - component.ngOnInit(); - expect(component.normalizedLabel).toBeDefined(); + fixture.componentRef.setInput('label', 'user_config_data'); + expect(component.normalizedLabel()).toBeDefined(); }); }); diff --git a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.ts b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.ts index f49543053..8c9ce4098 100644 --- a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.ts @@ -22,10 +22,9 @@ export class JsonEditorFilterComponent extends BaseFilterFieldComponent { }; ngOnInit(): void { - super.ngOnInit(); this.mutableCodeModel = { language: 'json', - uri: `${this.label}.json`, + uri: `${this.label()}.json`, value: JSON.stringify(this.value, undefined, 4) || '{}', }; } diff --git a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.html b/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.html index 41a02b8d8..0c2552d6b 100644 --- a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.html @@ -1,10 +1,10 @@ - {{normalizedLabel}} - diff --git a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.ts b/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.ts index e54786c33..2dfadab93 100644 --- a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.ts @@ -19,16 +19,16 @@ export class LongTextFilterComponent extends BaseFilterFieldComponent implements public rowsCount: string; ngOnInit(): void { - super.ngOnInit(); - if (this.widgetStructure?.widget_params) { - this.rowsCount = this.widgetStructure.widget_params.rows; + const ws = this.widgetStructure(); + if (ws?.widget_params) { + this.rowsCount = ws.widget_params.rows; } else { this.rowsCount = '4'; } } ngAfterViewInit(): void { - if (this.autofocus && this.inputElement) { + if (this.autofocus() && this.inputElement) { setTimeout(() => { this.inputElement.nativeElement.focus(); }, 100); diff --git a/frontend/src/app/components/ui-components/filter-fields/number/number.component.html b/frontend/src/app/components/ui-components/filter-fields/number/number.component.html index 7dbb2dad8..349ad3fb4 100644 --- a/frontend/src/app/components/ui-components/filter-fields/number/number.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/number/number.component.html @@ -1,8 +1,8 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + diff --git a/frontend/src/app/components/ui-components/filter-fields/number/number.component.ts b/frontend/src/app/components/ui-components/filter-fields/number/number.component.ts index eed783886..c9d2fcceb 100644 --- a/frontend/src/app/components/ui-components/filter-fields/number/number.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/number/number.component.ts @@ -18,7 +18,7 @@ export class NumberFilterComponent extends BaseFilterFieldComponent implements A static type = 'number'; ngAfterViewInit(): void { - if (this.autofocus && this.inputElement) { + if (this.autofocus() && this.inputElement) { setTimeout(() => { this.inputElement.nativeElement.focus(); }, 100); diff --git a/frontend/src/app/components/ui-components/filter-fields/password/password.component.html b/frontend/src/app/components/ui-components/filter-fields/password/password.component.html index 0ec00563c..d34442400 100644 --- a/frontend/src/app/components/ui-components/filter-fields/password/password.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/password/password.component.html @@ -1,8 +1,8 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + To keep password the same keep this field blank. diff --git a/frontend/src/app/components/ui-components/filter-fields/password/password.component.ts b/frontend/src/app/components/ui-components/filter-fields/password/password.component.ts index 9aa0ad216..3bd613f27 100644 --- a/frontend/src/app/components/ui-components/filter-fields/password/password.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/password/password.component.ts @@ -19,13 +19,12 @@ export class PasswordFilterComponent extends BaseFilterFieldComponent implements public clearPassword: boolean; ngOnInit(): void { - super.ngOnInit(); if (this.value === '***') this.value = ''; this.onFieldChange.emit(this.value); } ngAfterViewInit(): void { - if (this.autofocus && this.inputElement) { + if (this.autofocus() && this.inputElement) { setTimeout(() => { this.inputElement.nativeElement.focus(); }, 100); diff --git a/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.css b/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.css new file mode 100644 index 000000000..ec467bf1b --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.css @@ -0,0 +1,35 @@ +.filter-row { + display: flex; + gap: 8px; + align-items: flex-start; + width: 100%; +} + +.comparator-field { + flex: 0 0 auto; + min-width: 150px; +} + +.value-field { + flex: 1; +} + +.country-flag { + margin-right: 8px; + font-size: 16px; +} + +.country-name { + flex: 1; +} + +.country-dial-code { + margin-left: 8px; + opacity: 0.7; + font-size: 12px; +} + +mat-option { + display: flex; + align-items: center; +} diff --git a/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.html b/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.html new file mode 100644 index 000000000..d0308d4a3 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.html @@ -0,0 +1,37 @@ +
+ + + equal + country code + starts with + contains + is empty + + + + @if (filterMode === 'country') { + + {{normalizedLabel()}} (country) + + + @for (country of filteredCountries | async; track country.code) { + + {{country.flag}} + {{country.name}} + ({{country.dialCode}}) + + } + + + } @else if (filterMode !== 'empty') { + + {{normalizedLabel()}} + + + } +
diff --git a/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.ts b/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.ts new file mode 100644 index 000000000..836e75302 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.ts @@ -0,0 +1,113 @@ +import { CommonModule } from '@angular/common'; +import { AfterViewInit, Component, Input, OnInit } from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { Observable } from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; +import { COUNTRIES, Country, getCountryFlag } from '../../../../consts/countries'; +import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; + +interface CountryWithFlag extends Country { + flag: string; +} + +const COUNTRIES_WITH_FLAGS: CountryWithFlag[] = COUNTRIES.filter((country) => country.dialCode).map((country) => ({ + ...country, + flag: getCountryFlag(country.code), +})); + +@Component({ + selector: 'app-filter-phone', + templateUrl: './phone.component.html', + styleUrls: ['./phone.component.css'], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatFormFieldModule, + MatAutocompleteModule, + MatInputModule, + MatSelectModule, + ], +}) +export class PhoneFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { + @Input() value: string; + + public filterMode: string = 'eq'; + public countries: CountryWithFlag[] = COUNTRIES_WITH_FLAGS; + public countryControl = new FormControl(''); + public filteredCountries: Observable; + + getCountryFlag = getCountryFlag; + + ngOnInit(): void { + this.setupAutocomplete(); + + if (this.value) { + const country = this.countries.find((c) => c.dialCode === this.value); + if (country) { + this.filterMode = 'country'; + this.countryControl.setValue(country); + } + } + } + + ngAfterViewInit(): void { + if (this.filterMode !== 'eq') { + this.onComparatorChange.emit(this.filterMode === 'country' ? 'startswith' : this.filterMode); + } + } + + onFilterModeChange(mode: string): void { + this.filterMode = mode; + + if (mode === 'country') { + const selected = this.countryControl.value; + this.value = typeof selected === 'object' && selected ? selected.dialCode : ''; + this.onComparatorChange.emit('startswith'); + } else if (mode === 'empty') { + this.value = ''; + this.onComparatorChange.emit('empty'); + } else { + this.onComparatorChange.emit(mode); + } + + this.onFieldChange.emit(this.value); + } + + onCountrySelected(selectedCountry: CountryWithFlag): void { + this.value = selectedCountry.dialCode; + this.onFieldChange.emit(this.value); + this.onComparatorChange.emit('startswith'); + } + + onValueChange(text: string): void { + this.value = text; + this.onFieldChange.emit(this.value); + this.onComparatorChange.emit(this.filterMode); + } + + displayFn(country: CountryWithFlag): string { + return country ? `${country.flag} ${country.name} (${country.dialCode})` : ''; + } + + private setupAutocomplete(): void { + this.filteredCountries = this.countryControl.valueChanges.pipe( + startWith(''), + map((value) => this._filter(typeof value === 'string' ? value : value?.name || '')), + ); + } + + private _filter(value: string): CountryWithFlag[] { + const filterValue = value.toLowerCase(); + return this.countries.filter( + (country) => + country.name.toLowerCase().includes(filterValue) || + country.code.toLowerCase().includes(filterValue) || + country.dialCode?.includes(filterValue), + ); + } +} diff --git a/frontend/src/app/components/ui-components/filter-fields/point/point.component.html b/frontend/src/app/components/ui-components/filter-fields/point/point.component.html index c58614f49..0cfdc9b76 100644 --- a/frontend/src/app/components/ui-components/filter-fields/point/point.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/point/point.component.html @@ -1,11 +1,11 @@ - {{normalizedLabel}} X coordinate - + {{normalizedLabel()}} X coordinate + - {{normalizedLabel}} Y coordinate - + {{normalizedLabel()}} Y coordinate + diff --git a/frontend/src/app/components/ui-components/filter-fields/select/select.component.html b/frontend/src/app/components/ui-components/filter-fields/select/select.component.html index b8546cb7f..9a56fd5f6 100644 --- a/frontend/src/app/components/ui-components/filter-fields/select/select.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/select/select.component.html @@ -1,12 +1,11 @@ - {{normalizedLabel}} - - - {{option.label}} - + {{normalizedLabel()}} + + @for (option of options; track option.value) { + {{option.label}} + } diff --git a/frontend/src/app/components/ui-components/filter-fields/select/select.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/select/select.component.spec.ts index 40cd51102..67f0678dc 100644 --- a/frontend/src/app/components/ui-components/filter-fields/select/select.component.spec.ts +++ b/frontend/src/app/components/ui-components/filter-fields/select/select.component.spec.ts @@ -6,6 +6,23 @@ describe('SelectFilterComponent', () => { let component: SelectFilterComponent; let fixture: ComponentFixture; + const baseStructure = { + column_name: 'status', + column_default: null, + data_type: 'enum', + data_type_params: ['active', 'pending', 'archived'], + isExcluded: false, + isSearched: false, + auto_increment: false, + allow_null: false, + character_maximum_length: null, + }; + + const nullableStructure = { + ...baseStructure, + allow_null: true, + }; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [SelectFilterComponent, BrowserAnimationsModule], @@ -15,10 +32,113 @@ describe('SelectFilterComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(SelectFilterComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); + + it('should populate options from structure.data_type_params', () => { + fixture.componentRef.setInput('structure', baseStructure); + component.ngOnInit(); + + expect(component.options).toEqual([ + { value: 'active', label: 'active' }, + { value: 'pending', label: 'pending' }, + { value: 'archived', label: 'archived' }, + ]); + }); + + it('should prepend null option when allow_null is true', () => { + fixture.componentRef.setInput('structure', nullableStructure); + component.ngOnInit(); + + expect(component.options[0]).toEqual({ value: null, label: '' }); + }); + + it('should start with empty selectedValues when no value is provided', () => { + fixture.componentRef.setInput('structure', baseStructure); + component.ngOnInit(); + + expect(component.selectedValues).toEqual([]); + }); + + it('should restore selectedValues from a scalar value', () => { + component.value = 'active'; + fixture.componentRef.setInput('structure', baseStructure); + component.ngOnInit(); + + expect(component.selectedValues).toEqual(['active']); + }); + + it('should restore selectedValues from a null value', () => { + component.value = null; + fixture.componentRef.setInput('structure', nullableStructure); + component.ngOnInit(); + + expect(component.selectedValues).toEqual([null]); + }); + + it('should restore selectedValues from an array value', () => { + component.value = ['active', 'pending']; + fixture.componentRef.setInput('structure', baseStructure); + component.ngOnInit(); + + expect(component.selectedValues).toEqual(['active', 'pending']); + }); + + it('should emit initial eq comparator after view init', () => { + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.componentRef.setInput('structure', baseStructure); + fixture.detectChanges(); + + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); + }); + + it('should emit in comparator with array when more than one value is selected', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.componentRef.setInput('structure', baseStructure); + component.ngOnInit(); + + component.onSelectionChange(['active', 'pending']); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith(['active', 'pending']); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('in'); + }); + + it('should emit eq comparator with scalar when exactly one value is selected', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.componentRef.setInput('structure', baseStructure); + component.ngOnInit(); + + component.onSelectionChange(['active']); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith('active'); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); + }); + + it('should emit eq comparator with null when null is the only selected value', () => { + vi.spyOn(component.onFieldChange, 'emit'); + vi.spyOn(component.onComparatorChange, 'emit'); + fixture.componentRef.setInput('structure', nullableStructure); + component.ngOnInit(); + + component.onSelectionChange([null]); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith(null); + expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); + }); + + it('should emit undefined when selection is cleared', () => { + vi.spyOn(component.onFieldChange, 'emit'); + fixture.componentRef.setInput('structure', baseStructure); + component.ngOnInit(); + + component.onSelectionChange([]); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith(undefined); + }); }); diff --git a/frontend/src/app/components/ui-components/filter-fields/select/select.component.ts b/frontend/src/app/components/ui-components/filter-fields/select/select.component.ts index aaf92465c..a17995e5a 100644 --- a/frontend/src/app/components/ui-components/filter-fields/select/select.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/select/select.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input } from '@angular/core'; +import { AfterViewInit, Component, CUSTOM_ELEMENTS_SCHEMA, Input } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; @@ -8,32 +9,65 @@ import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field selector: 'app-filter-select', templateUrl: './select.component.html', styleUrls: ['./select.component.css'], - imports: [CommonModule, FormsModule, MatSelectModule], + imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) -export class SelectFilterComponent extends BaseFilterFieldComponent { - @Input() value: string; +export class SelectFilterComponent extends BaseFilterFieldComponent implements AfterViewInit { + @Input() value: string | (string | null)[] | null; public options: { value: string | null; label: string }[] = []; + public selectedValues: (string | null)[] = []; originalOrder = () => { return 0; }; ngOnInit(): void { - super.ngOnInit(); - if (this.widgetStructure) { - this.options = this.widgetStructure.widget_params.options; - if (this.widgetStructure.widget_params.allow_null) { + const ws = this.widgetStructure(); + const struct = this.structure(); + if (ws) { + this.options = ws.widget_params.options; + if (ws.widget_params.allow_null) { this.options = [{ value: null, label: '' }, ...this.options]; } - } else if (this.structure) { - this.options = this.structure.data_type_params.map((option) => { + } else if (struct) { + this.options = struct.data_type_params.map((option) => { return { value: option, label: option }; }); - if (this.structure.allow_null) { + if (struct.allow_null) { this.options = [{ value: null, label: '' }, ...this.options]; } } + + if (Array.isArray(this.value)) { + this.selectedValues = [...this.value]; + } else if (this.value === undefined) { + this.selectedValues = []; + } else { + this.selectedValues = [this.value]; + } + } + + ngAfterViewInit(): void { + this.onComparatorChange.emit('eq'); + } + + onSelectionChange(values: (string | null)[]): void { + this.selectedValues = values; + + if (!values || values.length === 0) { + this.onFieldChange.emit(undefined); + this.onComparatorChange.emit('eq'); + return; + } + + if (values.length === 1) { + this.onFieldChange.emit(values[0]); + this.onComparatorChange.emit('eq'); + return; + } + + this.onFieldChange.emit(values); + this.onComparatorChange.emit('in'); } } diff --git a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.html b/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.html index 6ba673f8c..c5faf2630 100644 --- a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.html @@ -1,5 +1,4 @@
- {{ normalizedLabel }} + {{ normalizedLabel() }} {{ value }}
- diff --git a/frontend/src/app/components/ui-components/filter-fields/text/text.component.html b/frontend/src/app/components/ui-components/filter-fields/text/text.component.html index ff0e24ec6..2208c5cf9 100644 --- a/frontend/src/app/components/ui-components/filter-fields/text/text.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/text/text.component.html @@ -1,8 +1,8 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + - \ No newline at end of file +
diff --git a/frontend/src/app/components/ui-components/filter-fields/text/text.component.ts b/frontend/src/app/components/ui-components/filter-fields/text/text.component.ts index 5ccbdf983..d46ee28bc 100644 --- a/frontend/src/app/components/ui-components/filter-fields/text/text.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/text/text.component.ts @@ -1,12 +1,10 @@ import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, Injectable, Input, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; -@Injectable() - @Component({ selector: 'app-filter-text', templateUrl: './text.component.html', @@ -20,7 +18,7 @@ export class TextFilterComponent extends BaseFilterFieldComponent implements Aft static type = 'text'; ngAfterViewInit(): void { - if (this.autofocus && this.inputElement) { + if (this.autofocus() && this.inputElement) { setTimeout(() => { this.inputElement.nativeElement.focus(); }, 100); diff --git a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.html b/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.html index 3f6f22293..e48f18bed 100644 --- a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.html @@ -1,39 +1,39 @@
- {{normalizedLabel}} * + {{normalizedLabel()}} * years - months - days - hours - minutes - seconds - -
\ No newline at end of file +
diff --git a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.ts b/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.ts index 290991207..14ea6fe93 100644 --- a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.ts @@ -28,8 +28,6 @@ export class TimeIntervalFilterComponent extends BaseFilterFieldComponent { }; ngOnInit(): void { - super.ngOnInit(); - if (this.value) this.interval = this.parseIntervalString(this.value); } diff --git a/frontend/src/app/components/ui-components/filter-fields/time/time.component.html b/frontend/src/app/components/ui-components/filter-fields/time/time.component.html index 26644f2b2..44b2b29f5 100644 --- a/frontend/src/app/components/ui-components/filter-fields/time/time.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/time/time.component.html @@ -1,7 +1,7 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + diff --git a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.html b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.html index 06fd4539f..b776b2e7e 100644 --- a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.html @@ -1,11 +1,11 @@ - {{normalizedLabel}} + {{normalizedLabel()}} diff --git a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.spec.ts index 781af2fd1..591133b92 100644 --- a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.spec.ts +++ b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.spec.ts @@ -42,9 +42,9 @@ describe('TimezoneFilterComponent', () => { }); it('should add null option when allow_null is true', () => { - component.widgetStructure = { + fixture.componentRef.setInput('widgetStructure', { widget_params: { allow_null: true }, - } as any; + }); component.ngOnInit(); const nullOption = component.timezones.find((tz) => tz.value === null); expect(nullOption).toBeDefined(); diff --git a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.ts b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.ts index 109fcb7d0..5ea9f120f 100644 --- a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.ts @@ -1,12 +1,46 @@ import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, Injectable, Input } from '@angular/core'; +import { Component, CUSTOM_ELEMENTS_SCHEMA, Input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; -@Injectable() +function getTimezoneOffset(timezone: string): string { + try { + const now = new Date(); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'longOffset', + }); + + const parts = formatter.formatToParts(now); + const offsetPart = parts.find((part) => part.type === 'timeZoneName'); + + if (offsetPart?.value.includes('GMT')) { + const offset = offsetPart.value.replace('GMT', ''); + return offset === '' ? '+00:00' : offset; + } + + const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })); + const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone })); + const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / 60000; + const hours = Math.floor(Math.abs(offsetMinutes) / 60); + const minutes = Math.abs(offsetMinutes) % 60; + const sign = offsetMinutes >= 0 ? '+' : '-'; + return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; + } catch (_error) { + return ''; + } +} + +const BASE_TIMEZONES: { value: string; label: string }[] = Intl.supportedValuesOf('timeZone') + .map((tz) => ({ + value: tz, + label: `${tz} (UTC${getTimezoneOffset(tz)})`, + })) + .sort((a, b) => a.value.localeCompare(b.value)); + @Component({ selector: 'app-filter-timezone', imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule], @@ -26,61 +60,12 @@ export class TimezoneFilterComponent extends BaseFilterFieldComponent { static type = 'timezone'; ngOnInit(): void { - super.ngOnInit(); - this.initializeTimezones(); - } - - private initializeTimezones(): void { - // Get all available timezone identifiers from Intl API - const timezoneList = Intl.supportedValuesOf('timeZone'); - - // Map timezones to format with offset and readable label - this.timezones = timezoneList.map((tz) => { - const offset = this.getTimezoneOffset(tz); - return { - value: tz, - label: `${tz} (UTC${offset})`, - }; - }); - - // Sort by timezone name - this.timezones.sort((a, b) => a.value.localeCompare(b.value)); - - // Check widget params for allow_null option - if (this.widgetStructure?.widget_params?.allow_null) { - this.timezones = [{ value: null, label: '' }, ...this.timezones]; - } else if (this.structure?.allow_null) { - this.timezones = [{ value: null, label: '' }, ...this.timezones]; - } - } - - private getTimezoneOffset(timezone: string): string { - try { - const now = new Date(); - const formatter = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - timeZoneName: 'longOffset', - }); - - const parts = formatter.formatToParts(now); - const offsetPart = parts.find((part) => part.type === 'timeZoneName'); - - if (offsetPart?.value.includes('GMT')) { - // Extract offset from "GMT+XX:XX" format - const offset = offsetPart.value.replace('GMT', ''); - return offset === '' ? '+00:00' : offset; - } - - // Fallback: calculate offset manually - const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })); - const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone })); - const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / 60000; - const hours = Math.floor(Math.abs(offsetMinutes) / 60); - const minutes = Math.abs(offsetMinutes) % 60; - const sign = offsetMinutes >= 0 ? '+' : '-'; - return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; - } catch (_error) { - return ''; + const ws = this.widgetStructure(); + const struct = this.structure(); + if (ws?.widget_params?.allow_null || struct?.allow_null) { + this.timezones = [{ value: null, label: '' }, ...BASE_TIMEZONES]; + } else { + this.timezones = BASE_TIMEZONES; } } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts index 57825999a..f52f42fdf 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts @@ -20,6 +20,7 @@ export class BaseEditFieldComponent implements OnInit { readonly relations = input(); readonly onFieldChange = output(); + readonly onComparatorChange = output(); readonly normalizedLabel = computed(() => normalizeFieldName(this.label() || '')); diff --git a/frontend/src/app/consts/filter-types.ts b/frontend/src/app/consts/filter-types.ts index 85496397a..75bbec5c3 100644 --- a/frontend/src/app/consts/filter-types.ts +++ b/frontend/src/app/consts/filter-types.ts @@ -6,11 +6,13 @@ import { TextFilterComponent } from 'src/app/components/ui-components/filter-fie import { CountryFilterComponent } from '../components/ui-components/filter-fields/country/country.component'; import { DateFilterComponent } from '../components/ui-components/filter-fields/date/date.component'; import { DateTimeFilterComponent } from '../components/ui-components/filter-fields/date-time/date-time.component'; +import { EmailFilterComponent } from '../components/ui-components/filter-fields/email/email.component'; import { FileFilterComponent } from '../components/ui-components/filter-fields/file/file.component'; import { ForeignKeyFilterComponent } from '../components/ui-components/filter-fields/foreign-key/foreign-key.component'; import { IdFilterComponent } from '../components/ui-components/filter-fields/id/id.component'; import { JsonEditorFilterComponent } from '../components/ui-components/filter-fields/json-editor/json-editor.component'; import { PasswordFilterComponent } from '../components/ui-components/filter-fields/password/password.component'; +import { PhoneFilterComponent } from '../components/ui-components/filter-fields/phone/phone.component'; import { SelectFilterComponent } from '../components/ui-components/filter-fields/select/select.component'; import { StaticTextFilterComponent } from '../components/ui-components/filter-fields/static-text/static-text.component'; import { TimeFilterComponent } from '../components/ui-components/filter-fields/time/time.component'; @@ -37,6 +39,8 @@ export const UIwidgets = { Timezone: TimezoneFilterComponent, Point: PointFilterComponent, ID: IdFilterComponent, + Phone: PhoneFilterComponent, + Email: EmailFilterComponent, }; export const filterTypes = { diff --git a/frontend/src/app/consts/record-edit-types.ts b/frontend/src/app/consts/record-edit-types.ts index e5dfc2e35..8921e0fee 100644 --- a/frontend/src/app/consts/record-edit-types.ts +++ b/frontend/src/app/consts/record-edit-types.ts @@ -79,6 +79,7 @@ export const UIwidgets = { URL: UrlEditComponent, UUID: UuidEditComponent, S3: S3EditComponent, + Email: TextEditComponent, }; export const recordEditTypes = { diff --git a/frontend/src/app/consts/table-display-types.ts b/frontend/src/app/consts/table-display-types.ts index 29fd6069c..8e8c514dc 100644 --- a/frontend/src/app/consts/table-display-types.ts +++ b/frontend/src/app/consts/table-display-types.ts @@ -56,6 +56,7 @@ export const UIwidgets = { URL: UrlDisplayComponent, UUID: UuidDisplayComponent, S3: S3DisplayComponent, + Email: TextDisplayComponent, }; export const tableDisplayTypes = { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7b8d14d2f..ecccd40e2 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4642,331 +4642,177 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.52.3" +"@rollup/rollup-android-arm-eabi@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.59.0" conditions: os=android & cpu=arm languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.57.1" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - -"@rollup/rollup-android-arm64@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-android-arm64@npm:4.52.3" +"@rollup/rollup-android-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-android-arm64@npm:4.59.0" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-android-arm64@npm:4.57.1" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"@rollup/rollup-darwin-arm64@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-darwin-arm64@npm:4.52.3" +"@rollup/rollup-darwin-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-darwin-arm64@npm:4.59.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-darwin-arm64@npm:4.57.1" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@rollup/rollup-darwin-x64@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-darwin-x64@npm:4.52.3" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@rollup/rollup-darwin-x64@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-darwin-x64@npm:4.57.1" +"@rollup/rollup-darwin-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-darwin-x64@npm:4.59.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.52.3" +"@rollup/rollup-freebsd-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.59.0" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.57.1" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - -"@rollup/rollup-freebsd-x64@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-freebsd-x64@npm:4.52.3" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"@rollup/rollup-freebsd-x64@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-freebsd-x64@npm:4.57.1" +"@rollup/rollup-freebsd-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-freebsd-x64@npm:4.59.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.52.3" +"@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.57.1" - conditions: os=linux & cpu=arm & libc=glibc - languageName: node - linkType: hard - -"@rollup/rollup-linux-arm-musleabihf@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.52.3" - conditions: os=linux & cpu=arm & libc=musl - languageName: node - linkType: hard - -"@rollup/rollup-linux-arm-musleabihf@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.57.1" +"@rollup/rollup-linux-arm-musleabihf@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.59.0" conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.52.3" +"@rollup/rollup-linux-arm64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.59.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.57.1" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"@rollup/rollup-linux-arm64-musl@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.52.3" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"@rollup/rollup-linux-arm64-musl@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.57.1" +"@rollup/rollup-linux-arm64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.59.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-loong64-gnu@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.52.3" - conditions: os=linux & cpu=loong64 & libc=glibc - languageName: node - linkType: hard - -"@rollup/rollup-linux-loong64-gnu@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.57.1" +"@rollup/rollup-linux-loong64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.59.0" conditions: os=linux & cpu=loong64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-loong64-musl@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-loong64-musl@npm:4.57.1" +"@rollup/rollup-linux-loong64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.59.0" conditions: os=linux & cpu=loong64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-ppc64-gnu@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.52.3" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - -"@rollup/rollup-linux-ppc64-gnu@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.57.1" +"@rollup/rollup-linux-ppc64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.59.0" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-ppc64-musl@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.57.1" +"@rollup/rollup-linux-ppc64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.59.0" conditions: os=linux & cpu=ppc64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.52.3" +"@rollup/rollup-linux-riscv64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.59.0" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.57.1" - conditions: os=linux & cpu=riscv64 & libc=glibc - languageName: node - linkType: hard - -"@rollup/rollup-linux-riscv64-musl@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.52.3" +"@rollup/rollup-linux-riscv64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.59.0" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-musl@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.57.1" - conditions: os=linux & cpu=riscv64 & libc=musl - languageName: node - linkType: hard - -"@rollup/rollup-linux-s390x-gnu@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.52.3" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - -"@rollup/rollup-linux-s390x-gnu@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.57.1" +"@rollup/rollup-linux-s390x-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.59.0" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.52.3" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"@rollup/rollup-linux-x64-gnu@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.57.1" +"@rollup/rollup-linux-x64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.59.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.52.3" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"@rollup/rollup-linux-x64-musl@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.57.1" +"@rollup/rollup-linux-x64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.59.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-openbsd-x64@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-openbsd-x64@npm:4.57.1" +"@rollup/rollup-openbsd-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-openbsd-x64@npm:4.59.0" conditions: os=openbsd & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-openharmony-arm64@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-openharmony-arm64@npm:4.52.3" +"@rollup/rollup-openharmony-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.59.0" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-openharmony-arm64@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-openharmony-arm64@npm:4.57.1" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - -"@rollup/rollup-win32-arm64-msvc@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.52.3" +"@rollup/rollup-win32-arm64-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.59.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.57.1" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@rollup/rollup-win32-ia32-msvc@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.52.3" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - -"@rollup/rollup-win32-ia32-msvc@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.57.1" +"@rollup/rollup-win32-ia32-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.59.0" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@rollup/rollup-win32-x64-gnu@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-win32-x64-gnu@npm:4.52.3" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@rollup/rollup-win32-x64-gnu@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-win32-x64-gnu@npm:4.57.1" +"@rollup/rollup-win32-x64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.59.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.52.3": - version: 4.52.3 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.52.3" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@rollup/rollup-win32-x64-msvc@npm:4.57.1": - version: 4.57.1 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.57.1" +"@rollup/rollup-win32-x64-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.59.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -6928,13 +6774,13 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^1.1.7": - version: 1.1.12 - resolution: "brace-expansion@npm:1.1.12" +"brace-expansion@npm:1.1.13": + version: 1.1.13 + resolution: "brace-expansion@npm:1.1.13" dependencies: balanced-match: ^1.0.0 concat-map: 0.0.1 - checksum: 12cb6d6310629e3048cadb003e1aca4d8c9bb5c67c3c321bafdd7e7a50155de081f78ea3e0ed92ecc75a9015e784f301efc8132383132f4f7904ad1ac529c562 + checksum: b5f4329fdbe9d2e25fa250c8f866ebd054ba946179426e99b86dcccddabdb1d481f0e40ee5430032e62a7d0a6c2837605ace6783d015aa1d65d85ca72154d936 languageName: node linkType: hard @@ -8385,27 +8231,15 @@ __metadata: languageName: node linkType: hard -"dompurify@npm:3.2.7": - version: 3.2.7 - resolution: "dompurify@npm:3.2.7" +"dompurify@npm:3.3.2": + version: 3.3.2 + resolution: "dompurify@npm:3.3.2" dependencies: "@types/trusted-types": ^2.0.7 dependenciesMeta: "@types/trusted-types": optional: true - checksum: 15958b76e0266f463303b81bc190ab78808c20640e5211dcf7e5071e4fe4396b904c04a8cb68e149e02ab44360defa13a00147e3f23721d41a81ca4bf6d7c983 - languageName: node - linkType: hard - -"dompurify@npm:^3.2.5, dompurify@npm:^3.3.1": - version: 3.3.1 - resolution: "dompurify@npm:3.3.1" - dependencies: - "@types/trusted-types": ^2.0.7 - dependenciesMeta: - "@types/trusted-types": - optional: true - checksum: 884fe0acc21a9a2e5aa1b8ce4cecc8f9a71217423b389f760fca7b44595d3c9376d234f1c4ba16d79824789762b3d611d10653c4a90a7e23b351b71e5ef7dd33 + checksum: 27856958c4088de2e2279b9514fcf3427c925e3cedf9d176957d9469a5199c39b572931a441cbb2025d1910c2890644280d0db8d5180b1366164cac309da08e5 languageName: node linkType: hard @@ -11556,10 +11390,10 @@ __metadata: languageName: node linkType: hard -"node-forge@npm:^1": - version: 1.3.3 - resolution: "node-forge@npm:1.3.3" - checksum: 045b650d61eeba57588744b7be4671044e83871e2c4dc5d4a38a8eb5af7e55fa790c93ba9db1d1ee14a567d25fde41e97a5132e076cff738622e0916c77b48d2 +"node-forge@npm:1.4.0": + version: 1.4.0 + resolution: "node-forge@npm:1.4.0" + checksum: c97c634d4d483aae815677db5b1bd14bfea4d873ab48817e020610a2b4d8bc6b3e77994860189b44151ff8e0842c0c4ba6faa80b9a6e6fbd6989865e8eb80b96 languageName: node linkType: hard @@ -12102,17 +11936,10 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:^8.0.0": - version: 8.3.0 - resolution: "path-to-regexp@npm:8.3.0" - checksum: 73e0d3db449f9899692b10be8480bbcfa294fd575be2d09bce3e63f2f708d1fccd3aaa8591709f8b82062c528df116e118ff9df8f5c52ccc4c2443a90be73e10 - languageName: node - linkType: hard - -"path-to-regexp@npm:~0.1.12": - version: 0.1.12 - resolution: "path-to-regexp@npm:0.1.12" - checksum: ab237858bee7b25ecd885189f175ab5b5161e7b712b360d44f5c4516b8d271da3e4bf7bf0a7b9153ecb04c7d90ce8ff5158614e1208819cf62bac2b08452722e +"path-to-regexp@npm:8.4.0": + version: 8.4.0 + resolution: "path-to-regexp@npm:8.4.0" + checksum: fa75cb500adc481d4f954c6764d7465ceb410a377b7dd2500d9e872aaf8bc873b37aac1cde735b90ce5baf19812860f0db9d39560ac952a7393f434a3522b9c4 languageName: node linkType: hard @@ -12151,17 +11978,10 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:4.0.3, picomatch@npm:^4.0.1, picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 6817fb74eb745a71445debe1029768de55fd59a42b75606f478ee1d0dc1aa6e78b711d041a7c9d5550e042642029b7f373dc1a43b224c4b7f12d23436735dba0 - languageName: node - linkType: hard - -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": - version: 2.3.1 - resolution: "picomatch@npm:2.3.1" - checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf +"picomatch@npm:4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 76b387b5157951422fa6049a96bdd1695e39dd126cd99df34d343638dc5cdb8bcdc83fff288c23eddcf7c26657c35e3173d4d5f488c4f28b889b314472e0a662 languageName: node linkType: hard @@ -12592,15 +12412,6 @@ __metadata: languageName: node linkType: hard -"randombytes@npm:^2.1.0": - version: 2.1.0 - resolution: "randombytes@npm:2.1.0" - dependencies: - safe-buffer: ^5.1.0 - checksum: d779499376bd4cbb435ef3ab9a957006c8682f343f14089ed5f27764e4645114196e75b7f6abf1cbd84fd247c0cb0651698444df8c9bf30e62120fbbc52269d6 - languageName: node - linkType: hard - "range-parser@npm:^1.2.1, range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1" @@ -13000,116 +12811,35 @@ __metadata: languageName: unknown linkType: soft -"rollup@npm:4.52.3": - version: 4.52.3 - resolution: "rollup@npm:4.52.3" - dependencies: - "@rollup/rollup-android-arm-eabi": 4.52.3 - "@rollup/rollup-android-arm64": 4.52.3 - "@rollup/rollup-darwin-arm64": 4.52.3 - "@rollup/rollup-darwin-x64": 4.52.3 - "@rollup/rollup-freebsd-arm64": 4.52.3 - "@rollup/rollup-freebsd-x64": 4.52.3 - "@rollup/rollup-linux-arm-gnueabihf": 4.52.3 - "@rollup/rollup-linux-arm-musleabihf": 4.52.3 - "@rollup/rollup-linux-arm64-gnu": 4.52.3 - "@rollup/rollup-linux-arm64-musl": 4.52.3 - "@rollup/rollup-linux-loong64-gnu": 4.52.3 - "@rollup/rollup-linux-ppc64-gnu": 4.52.3 - "@rollup/rollup-linux-riscv64-gnu": 4.52.3 - "@rollup/rollup-linux-riscv64-musl": 4.52.3 - "@rollup/rollup-linux-s390x-gnu": 4.52.3 - "@rollup/rollup-linux-x64-gnu": 4.52.3 - "@rollup/rollup-linux-x64-musl": 4.52.3 - "@rollup/rollup-openharmony-arm64": 4.52.3 - "@rollup/rollup-win32-arm64-msvc": 4.52.3 - "@rollup/rollup-win32-ia32-msvc": 4.52.3 - "@rollup/rollup-win32-x64-gnu": 4.52.3 - "@rollup/rollup-win32-x64-msvc": 4.52.3 - "@types/estree": 1.0.8 - fsevents: ~2.3.2 - dependenciesMeta: - "@rollup/rollup-android-arm-eabi": - optional: true - "@rollup/rollup-android-arm64": - optional: true - "@rollup/rollup-darwin-arm64": - optional: true - "@rollup/rollup-darwin-x64": - optional: true - "@rollup/rollup-freebsd-arm64": - optional: true - "@rollup/rollup-freebsd-x64": - optional: true - "@rollup/rollup-linux-arm-gnueabihf": - optional: true - "@rollup/rollup-linux-arm-musleabihf": - optional: true - "@rollup/rollup-linux-arm64-gnu": - optional: true - "@rollup/rollup-linux-arm64-musl": - optional: true - "@rollup/rollup-linux-loong64-gnu": - optional: true - "@rollup/rollup-linux-ppc64-gnu": - optional: true - "@rollup/rollup-linux-riscv64-gnu": - optional: true - "@rollup/rollup-linux-riscv64-musl": - optional: true - "@rollup/rollup-linux-s390x-gnu": - optional: true - "@rollup/rollup-linux-x64-gnu": - optional: true - "@rollup/rollup-linux-x64-musl": - optional: true - "@rollup/rollup-openharmony-arm64": - optional: true - "@rollup/rollup-win32-arm64-msvc": - optional: true - "@rollup/rollup-win32-ia32-msvc": - optional: true - "@rollup/rollup-win32-x64-gnu": - optional: true - "@rollup/rollup-win32-x64-msvc": - optional: true - fsevents: - optional: true - bin: - rollup: dist/bin/rollup - checksum: d1b184fe28d6e30334901a59eccb01c5b3c777f9c34a27ba925ba75282b9374848a85f1b36722c7a373f39cfd5849bdc29b804d9d271ce1a88188ed6b0323ff4 - languageName: node - linkType: hard - -"rollup@npm:^4.43.0": - version: 4.57.1 - resolution: "rollup@npm:4.57.1" - dependencies: - "@rollup/rollup-android-arm-eabi": 4.57.1 - "@rollup/rollup-android-arm64": 4.57.1 - "@rollup/rollup-darwin-arm64": 4.57.1 - "@rollup/rollup-darwin-x64": 4.57.1 - "@rollup/rollup-freebsd-arm64": 4.57.1 - "@rollup/rollup-freebsd-x64": 4.57.1 - "@rollup/rollup-linux-arm-gnueabihf": 4.57.1 - "@rollup/rollup-linux-arm-musleabihf": 4.57.1 - "@rollup/rollup-linux-arm64-gnu": 4.57.1 - "@rollup/rollup-linux-arm64-musl": 4.57.1 - "@rollup/rollup-linux-loong64-gnu": 4.57.1 - "@rollup/rollup-linux-loong64-musl": 4.57.1 - "@rollup/rollup-linux-ppc64-gnu": 4.57.1 - "@rollup/rollup-linux-ppc64-musl": 4.57.1 - "@rollup/rollup-linux-riscv64-gnu": 4.57.1 - "@rollup/rollup-linux-riscv64-musl": 4.57.1 - "@rollup/rollup-linux-s390x-gnu": 4.57.1 - "@rollup/rollup-linux-x64-gnu": 4.57.1 - "@rollup/rollup-linux-x64-musl": 4.57.1 - "@rollup/rollup-openbsd-x64": 4.57.1 - "@rollup/rollup-openharmony-arm64": 4.57.1 - "@rollup/rollup-win32-arm64-msvc": 4.57.1 - "@rollup/rollup-win32-ia32-msvc": 4.57.1 - "@rollup/rollup-win32-x64-gnu": 4.57.1 - "@rollup/rollup-win32-x64-msvc": 4.57.1 +"rollup@npm:4.59.0": + version: 4.59.0 + resolution: "rollup@npm:4.59.0" + dependencies: + "@rollup/rollup-android-arm-eabi": 4.59.0 + "@rollup/rollup-android-arm64": 4.59.0 + "@rollup/rollup-darwin-arm64": 4.59.0 + "@rollup/rollup-darwin-x64": 4.59.0 + "@rollup/rollup-freebsd-arm64": 4.59.0 + "@rollup/rollup-freebsd-x64": 4.59.0 + "@rollup/rollup-linux-arm-gnueabihf": 4.59.0 + "@rollup/rollup-linux-arm-musleabihf": 4.59.0 + "@rollup/rollup-linux-arm64-gnu": 4.59.0 + "@rollup/rollup-linux-arm64-musl": 4.59.0 + "@rollup/rollup-linux-loong64-gnu": 4.59.0 + "@rollup/rollup-linux-loong64-musl": 4.59.0 + "@rollup/rollup-linux-ppc64-gnu": 4.59.0 + "@rollup/rollup-linux-ppc64-musl": 4.59.0 + "@rollup/rollup-linux-riscv64-gnu": 4.59.0 + "@rollup/rollup-linux-riscv64-musl": 4.59.0 + "@rollup/rollup-linux-s390x-gnu": 4.59.0 + "@rollup/rollup-linux-x64-gnu": 4.59.0 + "@rollup/rollup-linux-x64-musl": 4.59.0 + "@rollup/rollup-openbsd-x64": 4.59.0 + "@rollup/rollup-openharmony-arm64": 4.59.0 + "@rollup/rollup-win32-arm64-msvc": 4.59.0 + "@rollup/rollup-win32-ia32-msvc": 4.59.0 + "@rollup/rollup-win32-x64-gnu": 4.59.0 + "@rollup/rollup-win32-x64-msvc": 4.59.0 "@types/estree": 1.0.8 fsevents: ~2.3.2 dependenciesMeta: @@ -13167,7 +12897,7 @@ __metadata: optional: true bin: rollup: dist/bin/rollup - checksum: 687947da3f450120478aaf697f03e5a7e0a98075de3fc3c04e4d849155c44631e79e646f9f7d7c8bd11e346a95f20c7f9683e2c1cc8011496a248aa9c955a817 + checksum: c2427c6907f05bb98c92064c1660bbeaebb11f3022ec853635e6a994f8e1d39ed18ccee8d70b219954787dca0a2eb3082941bd2c3e054071eec2569acc1c1509 languageName: node linkType: hard @@ -13228,7 +12958,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 @@ -13440,12 +13170,10 @@ __metadata: languageName: node linkType: hard -"serialize-javascript@npm:^6.0.2": - version: 6.0.2 - resolution: "serialize-javascript@npm:6.0.2" - dependencies: - randombytes: ^2.1.0 - checksum: c4839c6206c1d143c0f80763997a361310305751171dd95e4b57efee69b8f6edd8960a0b7fbfc45042aadff98b206d55428aee0dc276efe54f100899c7fa8ab7 +"serialize-javascript@npm:7.0.5": + version: 7.0.5 + resolution: "serialize-javascript@npm:7.0.5" + checksum: 9e5f4c234c5cfdbe7f720107755ea06f2247d3202a8309e6c6f7dd4241f1dc92ba2e2a3282dcc82796a2ce2a327be48d692790019aeeb681f9870b1d3b49e8b7 languageName: node linkType: hard @@ -14053,16 +13781,16 @@ __metadata: languageName: node linkType: hard -"tar@npm:^7.4.3, tar@npm:^7.5.4": - version: 7.5.9 - resolution: "tar@npm:7.5.9" +"tar@npm:7.5.11": + version: 7.5.11 + resolution: "tar@npm:7.5.11" dependencies: "@isaacs/fs-minipass": ^4.0.0 chownr: ^3.0.0 minipass: ^7.1.2 minizlib: ^3.1.0 yallist: ^5.0.0 - checksum: 26fbbdf536895814167d03e4883f80febb6520729169c54d0f29ee8a163557283862752493f0e5b60800a6f3608aac3250c41fac8e20a4f056ba4fa63f3dbad7 + checksum: 7f6785a85dd571b88985e493ec86f692962cbfa7b4017961fddfd2241e0ff3bcd89ed347f4c02b5433aa22b30cca5566e8711543df054fda8fd12425f505378f languageName: node linkType: hard