Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
603 changes: 531 additions & 72 deletions WIDGETS.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion backend/src/enums/widget-type.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ export enum WidgetTypeEnum {
Phone = 'Phone',
Country = 'Country',
Color = 'Color',
Range = 'Range'
Range = 'Range',
Timezone = 'Timezone'
}
12 changes: 0 additions & 12 deletions frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,18 +189,6 @@
"**/node_modules/**"
]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "dissendium-v0:serve"
},
"configurations": {
"production": {
"devServerTarget": "dissendium-v0:serve:production"
}
}
}
}
}
Expand Down
32 changes: 0 additions & 32 deletions frontend/e2e/protractor.conf.js

This file was deleted.

23 changes: 0 additions & 23 deletions frontend/e2e/src/app.e2e-spec.ts

This file was deleted.

11 changes: 0 additions & 11 deletions frontend/e2e/src/app.po.ts

This file was deleted.

13 changes: 0 additions & 13 deletions frontend/e2e/tsconfig.json

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,13 @@ export class DbTableWidgetsComponent implements OnInit {
"rows": 5
}`,
Time: `// No settings required`,
Timezone: `// Configure timezone widget options
// Uses Intl API to populate timezone list automatically
// allow_null: Allow empty/null value selection
{
"allow_null": false
}
`,
URL: `// prefix: optional URL prefix to prepend to the href
// example:
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.timezone-form-field {
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<mat-form-field class="timezone-form-field" appearance="outline">
<mat-label>{{normalizedLabel}}</mat-label>
<mat-select
name="{{label}}-{{key}}"
[required]="required"
[disabled]="disabled"
[readonly]="readonly"
attr.data-testid="filter-{{label}}-timezone"
[(ngModel)]="value"
(ngModelChange)="onFieldChange.emit($event)">
<mat-option *ngFor="let timezone of timezones" [value]="timezone.value">
{{timezone.label}}
</mat-option>
</mat-select>
</mat-form-field>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { TimezoneFilterComponent } from './timezone.component';

describe('TimezoneFilterComponent', () => {
let component: TimezoneFilterComponent;
let fixture: ComponentFixture<TimezoneFilterComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TimezoneFilterComponent, BrowserAnimationsModule]
}).compileComponents();
});

beforeEach(() => {
fixture = TestBed.createComponent(TimezoneFilterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should populate timezones using Intl API', () => {
expect(component.timezones.length).toBeGreaterThan(0);
});

it('should include timezone offset in labels', () => {
const timezone = component.timezones.find(tz => tz.value === 'Europe/London');
expect(timezone).toBeDefined();
expect(timezone.label).toContain('UTC');
});

it('should emit value on change', () => {
spyOn(component.onFieldChange, 'emit');
const testValue = 'Asia/Tokyo';
component.value = testValue;
component.onFieldChange.emit(testValue);
expect(component.onFieldChange.emit).toHaveBeenCalledWith(testValue);
});

it('should add null option when allow_null is true', () => {
component.widgetStructure = {
widget_params: { allow_null: true }
} as any;
component.ngOnInit();
const nullOption = component.timezones.find(tz => tz.value === null);
expect(nullOption).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { CUSTOM_ELEMENTS_SCHEMA, Component, Injectable, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
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()
@Component({
selector: 'app-filter-timezone',
imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule],
templateUrl: './timezone.component.html',
styleUrls: ['./timezone.component.css'],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class TimezoneFilterComponent extends BaseFilterFieldComponent {
@Input() value: string;

public timezones: { value: string, label: string }[] = [];

originalOrder = () => { return 0; }

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 && 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 '';
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.timezone-form-field {
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<mat-form-field class="timezone-form-field" appearance="outline">
<mat-label>{{normalizedLabel}}</mat-label>
<mat-select
name="{{label}}-{{key}}"
[required]="required"
[disabled]="disabled"
[readonly]="readonly"
attr.data-testid="record-{{label}}-timezone"
[(ngModel)]="value"
(ngModelChange)="onFieldChange.emit($event)">
<mat-option *ngFor="let timezone of timezones" [value]="timezone.value">
{{timezone.label}}
</mat-option>
</mat-select>
</mat-form-field>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { TimezoneEditComponent } from './timezone.component';

describe('TimezoneEditComponent', () => {
let component: TimezoneEditComponent;
let fixture: ComponentFixture<TimezoneEditComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TimezoneEditComponent, BrowserAnimationsModule]
}).compileComponents();
});

beforeEach(() => {
fixture = TestBed.createComponent(TimezoneEditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should populate timezones using Intl API', () => {
expect(component.timezones.length).toBeGreaterThan(0);
});

it('should include timezone offset in labels', () => {
const timezone = component.timezones.find(tz => tz.value === 'America/New_York');
expect(timezone).toBeDefined();
expect(timezone.label).toContain('UTC');
});

it('should emit value on change', () => {
spyOn(component.onFieldChange, 'emit');
const testValue = 'America/New_York';
component.value = testValue;
component.onFieldChange.emit(testValue);
expect(component.onFieldChange.emit).toHaveBeenCalledWith(testValue);
});

it('should add null option when allow_null is true', () => {
component.widgetStructure = {
widget_params: { allow_null: true }
} as any;
component.ngOnInit();
const nullOption = component.timezones.find(tz => tz.value === null);
expect(nullOption).toBeDefined();
});
});
Loading