diff --git a/WIDGETS.md b/WIDGETS.md index 66567cb89..b4f95971d 100644 --- a/WIDGETS.md +++ b/WIDGETS.md @@ -4,14 +4,42 @@ The widget system in RocketAdmin allows customization of how database fields are displayed and edited in the UI. Each field in a database table can be assigned a specific widget type with custom parameters to control its behavior and appearance. +### Widget Contexts + +Widgets are implemented across **four different contexts**, each serving a specific purpose in the UI: + +| Context | Location | Purpose | Editable | Base Component | +|---------|----------|---------|----------|----------------| +| **Record Edit Fields** | Create/Edit forms | Interactive input for data entry | ✅ Yes | `BaseEditFieldComponent` | +| **Record View Fields** | Single record detail page | Read-only formatted display | ❌ No | `BaseRecordViewFieldComponent` | +| **Table Display Fields** | Table rows | Compact display in lists | ❌ No | `BaseTableDisplayFieldComponent` | +| **Filter Fields** | Filter panels | Search and filter inputs | ✅ Yes | `BaseFilterFieldComponent` | + +**Example:** The `Boolean` widget type has four implementations: +- `record-edit-fields/boolean/` - Checkbox toggle for editing +- `record-view-fields/boolean/` - "Yes/No" or icon display +- `table-display-fields/boolean/` - Badge or icon in table cells +- `filter-fields/boolean/` - True/False/All dropdown for filtering + ## Table of Contents 1. [Where Widgets Are Stored](#where-widgets-are-stored) + - [Backend (Database & Entity)](#backend-database--entity) + - [Frontend (UI Components)](#frontend-ui-components) + - [Record Edit Fields](#1-record-edit-fields-record-edit-fields) + - [Record View Fields](#2-record-view-fields-record-view-fields) + - [Table Display Fields](#3-table-display-fields-table-display-fields) + - [Filter Fields](#4-filter-fields-filter-fields) + - [Shared Code](#shared-code) 2. [Widget Structure](#widget-structure) 3. [Available Widget Types](#available-widget-types) 4. [How to Add a New Widget](#how-to-add-a-new-widget) 5. [Widget Lifecycle](#widget-lifecycle) -6. [Testing](#testing) +6. [Widget Parameters Examples](#widget-parameters-examples) +7. [Testing](#testing) +8. [Real-World Example: Timezone Widget](#real-world-example-timezone-widget) +9. [Important Notes](#important-notes) +10. [Key Files Reference](#key-files-reference) --- @@ -37,30 +65,150 @@ backend/src/entities/widget/ ### Frontend (UI Components) -**Widget Components:** -``` -frontend/src/app/components/ui-components/ -├── boolean-edit/ -├── code-edit/ -├── color-edit/ -├── country-edit/ -├── date-edit/ -├── datetime-edit/ -├── file-edit/ -├── foreign-key-edit/ -├── image-edit/ -├── json-editor-edit/ -├── number-edit/ -├── password-edit/ -├── phone-edit/ -├── range-edit/ -├── readonly-edit/ -├── select-edit/ -├── string-edit/ -├── textarea-edit/ -├── time-edit/ -├── url-edit/ -└── uuid-edit/ +The frontend implements widgets across **four different contexts**, each with its own set of components: + +#### 1. Record Edit Fields (`record-edit-fields/`) +**Purpose:** Interactive form inputs for editing/creating records +**Base Component:** `BaseEditFieldComponent` +**Key Features:** +- Two-way data binding with `@Output() onFieldChange` +- Supports validation, required fields, disabled/readonly states +- Full CRUD functionality + +**Available Components:** +``` +frontend/src/app/components/ui-components/record-edit-fields/ +├── boolean/ # Checkbox/toggle for true/false +├── code/ # Code editor with syntax highlighting +├── color/ # Color picker +├── country/ # Country selector with flags +├── date/ # Date picker +├── date-time/ # Date and time picker +├── file/ # File upload +├── foreign-key/ # Related record selector +├── image/ # Image upload with preview +├── json-editor/ # JSON editor +├── long-text/ # Multi-line textarea +├── money/ # Currency input +├── number/ # Numeric input +├── password/ # Password input with encryption +├── phone/ # International phone number +├── point/ # Geographic point input +├── range/ # Range slider +├── select/ # Dropdown select +├── static-text/ # Read-only text +├── text/ # Single-line text input +├── time/ # Time picker +├── time-interval/ # Time interval input +├── timezone/ # Timezone selector with UTC offsets +├── url/ # URL input with validation +└── uuid/ # UUID display/input +``` + +#### 2. Record View Fields (`record-view-fields/`) +**Purpose:** Read-only display for single record detail view +**Base Component:** `BaseRecordViewFieldComponent` +**Key Features:** +- Display-only (no editing) +- Copy-to-clipboard functionality via `@Output() onCopyToClipboard` +- Formatted value presentation + +**Available Components:** +``` +frontend/src/app/components/ui-components/record-view-fields/ +├── boolean/ # Display boolean as Yes/No or icons +├── code/ # Syntax-highlighted code display +├── color/ # Color swatch display +├── country/ # Country name with flag +├── date/ # Formatted date display +├── date-time/ # Formatted datetime display +├── file/ # File download link +├── foreign-key/ # Related record link +├── image/ # Image display +├── json-editor/ # Formatted JSON display +├── long-text/ # Multi-line text display +├── money/ # Formatted currency display +├── number/ # Formatted number display +├── password/ # Masked password display +├── phone/ # Formatted phone number +├── point/ # Geographic coordinates display +├── range/ # Range value display +├── select/ # Selected option display +├── static-text/ # Plain text display +├── text/ # Text display +├── time/ # Formatted time display +├── time-interval/ # Time interval display +├── url/ # Clickable URL link +└── uuid/ # UUID display with copy option +``` + +#### 3. Table Display Fields (`table-display-fields/`) +**Purpose:** Compact, read-only display for table rows +**Base Component:** `BaseTableDisplayFieldComponent` +**Key Features:** +- Condensed display optimized for tables +- Copy-to-clipboard functionality +- Truncation for long values +- Inline formatting + +**Available Components:** +``` +frontend/src/app/components/ui-components/table-display-fields/ +├── boolean/ # Icon or badge display +├── code/ # Truncated code snippet +├── color/ # Color swatch +├── country/ # Flag or country code +├── date/ # Short date format +├── date-time/ # Short datetime format +├── file/ # File icon/link +├── foreign-key/ # Related record link +├── image/ # Thumbnail image +├── json-editor/ # JSON preview (truncated) +├── long-text/ # Truncated text with tooltip +├── money/ # Currency symbol + amount +├── number/ # Formatted number +├── password/ # Masked (*****) +├── phone/ # Phone number +├── point/ # Coordinates +├── range/ # Range value +├── select/ # Selected option +├── static-text/ # Plain text +├── text/ # Text (truncated if long) +├── time/ # Time display +├── time-interval/ # Interval display +├── url/ # Clickable link (truncated) +└── uuid/ # Shortened UUID +``` + +#### 4. Filter Fields (`filter-fields/`) +**Purpose:** Search/filter inputs for table data +**Base Component:** `BaseFilterFieldComponent` +**Key Features:** +- Specialized for filtering operations +- Supports comparison operators (equals, contains, greater than, etc.) +- `@Output() onFieldChange` for filter updates +- Auto-focus support for better UX + +**Available Components:** +``` +frontend/src/app/components/ui-components/filter-fields/ +├── boolean/ # Boolean filter (true/false/all) +├── country/ # Country filter dropdown +├── date/ # Date range picker +├── date-time/ # Datetime range picker +├── file/ # File type filter +├── foreign-key/ # Related record filter +├── id/ # ID search +├── json-editor/ # JSON query filter +├── long-text/ # Text search with operators +├── number/ # Numeric range filter +├── password/ # Password field filter +├── point/ # Geographic filter +├── select/ # Dropdown filter +├── static-text/ # Text filter +├── text/ # Text search input +├── time/ # Time range filter +└── time-interval/ # Interval filter ``` **Widget Registration:** @@ -119,7 +267,7 @@ export class TableWidgetEntity { ## Available Widget Types -Currently supported widget types (23 total): +Currently supported widget types (24 total): | Widget Type | Description | Common Parameters | |------------|-------------|-------------------| @@ -143,6 +291,7 @@ Currently supported widget types (23 total): | `String` | Single-line text input | `pattern` | | `Textarea` | Multi-line text input | `rows`, `cols` | | `Time` | Time picker (HH:MM:SS) | `format` | +| `Timezone` | Timezone selector with UTC offsets | `allow_null` | | `URL` | URL input with validation | N/A | | `UUID` | UUID display/input | N/A | | `Default` | Default rendering (inferred) | N/A | @@ -169,29 +318,47 @@ export enum WidgetTypeEnum { } ``` -### Step 2: Create Frontend Component +### Step 2: Create Frontend Components + +When adding a new widget, you need to create components for **all four contexts**: + +1. **Record Edit Field** (required) - `record-edit-fields/your-new-widget/` +2. **Record View Field** (optional) - `record-view-fields/your-new-widget/` +3. **Table Display Field** (optional) - `table-display-fields/your-new-widget/` +4. **Filter Field** (optional) - `filter-fields/your-new-widget/` + +**Note:** At minimum, create the **record-edit-field** component. The other contexts will fall back to default rendering if not provided. -**Location:** `frontend/src/app/components/ui-components/your-new-widget-edit/` +#### Creating the Record Edit Field Component + +**Location:** `frontend/src/app/components/ui-components/record-edit-fields/your-new-widget/` Create three files: -#### 1. Component TypeScript (`your-new-widget-edit.component.ts`) +#### 1. Component TypeScript (`your-new-widget.component.ts`) ```typescript -import { Component, Input, OnInit, Output, EventEmitter } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA, Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; @Component({ - selector: 'app-your-new-widget-edit', - templateUrl: './your-new-widget-edit.component.html', - styleUrls: ['./your-new-widget-edit.component.css'] + selector: 'app-edit-your-new-widget', + imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], + templateUrl: './your-new-widget.component.html', + styleUrls: ['./your-new-widget.component.css'], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }) -export class YourNewWidgetEditComponent implements OnInit { - @Input() field: any; // Field configuration - @Input() value: any; // Current field value - @Output() onFieldChange = new EventEmitter(); +export class YourNewWidgetEditComponent extends BaseEditFieldComponent { + @Input() value: any; ngOnInit(): void { - // Initialize component + super.ngOnInit(); + // Additional initialization here } handleChange(newValue: any): void { @@ -200,57 +367,153 @@ export class YourNewWidgetEditComponent implements OnInit { } ``` -#### 2. Component Template (`your-new-widget-edit.component.html`) +#### 2. Component Template (`your-new-widget.component.html`) ```html -
- + + {{normalizedLabel}} -
+ ``` -#### 3. Component Styles (`your-new-widget-edit.component.css`) +#### 3. Component Styles (`your-new-widget.component.css`) ```css -.your-new-widget-wrapper { - /* Your styles here */ +.your-new-widget-form-field { + width: 100%; } ``` -### Step 3: Register Component in Module +#### Optional: Create Other Field Type Components -**File:** `frontend/src/app/app.module.ts` +**Record View Field** (`record-view-fields/your-new-widget/`): +```typescript +import { Component, Input } from '@angular/core'; +import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; +@Component({ + selector: 'app-view-your-new-widget', + templateUrl: './your-new-widget.component.html', + styleUrls: ['./your-new-widget.component.css'] +}) +export class YourNewWidgetViewComponent extends BaseRecordViewFieldComponent { + // Read-only display logic +} +``` + +**Table Display Field** (`table-display-fields/your-new-widget/`): ```typescript -import { YourNewWidgetEditComponent } from './components/ui-components/your-new-widget-edit/your-new-widget-edit.component'; - -@NgModule({ - declarations: [ - // ... existing components - YourNewWidgetEditComponent, - ], - // ... +import { Component } from '@angular/core'; +import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; + +@Component({ + selector: 'app-display-your-new-widget', + templateUrl: './your-new-widget.component.html', + styleUrls: ['./your-new-widget.component.css'] }) -export class AppModule { } +export class YourNewWidgetDisplayComponent extends BaseTableDisplayFieldComponent { + // Condensed table display logic +} ``` -### Step 4: Add to Widget Registry +**Filter Field** (`filter-fields/your-new-widget/`): +```typescript +import { Component } from '@angular/core'; +import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; + +@Component({ + selector: 'app-filter-your-new-widget', + templateUrl: './your-new-widget.component.html', + styleUrls: ['./your-new-widget.component.css'] +}) +export class YourNewWidgetFilterComponent extends BaseFilterFieldComponent { + // Filter logic with operators +} +``` + +### Step 3: Register Component (Angular Standalone) + +**Note:** With Angular 19's standalone components, you don't need to register in NgModule. Components are self-contained with their imports. + +### Step 4: Add to Widget Registries + +You need to register your widget components in **four separate registry files**, one for each context: + +#### 4.1 Record Edit Types Registry **File:** `frontend/src/app/consts/record-edit-types.ts` ```typescript -import { YourNewWidgetEditComponent } from '../components/ui-components/your-new-widget-edit/your-new-widget-edit.component'; +// Add import at the top +import { YourNewWidgetEditComponent } from '../components/ui-components/record-edit-fields/your-new-widget/your-new-widget.component'; +// Add to UIwidgets export export const UIwidgets = { // ... existing widgets Your_new_widget: YourNewWidgetEditComponent, } ``` +#### 4.2 Record View Types Registry + +**File:** `frontend/src/app/consts/record-view-types.ts` + +```typescript +// Add import at the top +import { YourNewWidgetRecordViewComponent } from '../components/ui-components/record-view-fields/your-new-widget/your-new-widget.component'; + +// Add to UIwidgets export +export const UIwidgets = { + // ... existing widgets + Your_new_widget: YourNewWidgetRecordViewComponent, +} +``` + +#### 4.3 Table Display Types Registry + +**File:** `frontend/src/app/consts/table-display-types.ts` + +```typescript +// Add import at the top +import { YourNewWidgetDisplayComponent } from '../components/ui-components/table-display-fields/your-new-widget/your-new-widget.component'; + +// Add to UIwidgets export +export const UIwidgets = { + // ... existing widgets + Your_new_widget: YourNewWidgetDisplayComponent, +} +``` + +#### 4.4 Filter Types Registry + +**File:** `frontend/src/app/consts/filter-types.ts` + +```typescript +// Add import at the top +import { YourNewWidgetFilterComponent } from '../components/ui-components/filter-fields/your-new-widget/your-new-widget.component'; + +// Add to UIwidgets export +export const UIwidgets = { + // ... existing widgets + Your_new_widget: YourNewWidgetFilterComponent, +} +``` + +**Important Notes:** +- Each registry file has its own `UIwidgets` export mapping widget types to components +- If you only created the record-edit-fields component, only add it to `record-edit-types.ts` +- The system will gracefully fall back to default rendering for missing contexts +- Component names follow the pattern: `{WidgetName}{Context}Component` where Context is Edit, RecordView, Display, or Filter + ### Step 5: Add Default Parameters (Optional) **File:** `frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts` @@ -456,6 +719,15 @@ Supported algorithms: `sha1`, `sha3`, `sha224`, `sha256`, `sha512`, `sha384`, `b } ``` +### Timezone Widget +```json +{ + "allow_null": false +} +``` + +The Timezone widget automatically populates the dropdown with all available timezones from the Intl API, displaying them with their UTC offsets (e.g., "America/New_York (UTC-05:00)"). + --- ## Testing @@ -480,23 +752,198 @@ cd frontend && yarn test --browsers=ChromeHeadlessCustom --no-watch --no-progres ``` Widget component tests located in: -- `frontend/src/app/components/ui-components/*/your-widget-edit.component.spec.ts` +- `frontend/src/app/components/ui-components/record-edit-fields/*/your-widget.component.spec.ts` +- `frontend/src/app/components/ui-components/record-view-fields/*/your-widget.component.spec.ts` +- `frontend/src/app/components/ui-components/table-display-fields/*/your-widget.component.spec.ts` +- `frontend/src/app/components/ui-components/filter-fields/*/your-widget.component.spec.ts` + +--- + +## Real-World Example: Timezone Widget + +The **Timezone widget** is a complete example demonstrating all the concepts covered in this guide. + +### Implementation Details + +**Backend Enum** (`backend/src/enums/widget-type.enum.ts:24`): +```typescript +Timezone = 'Timezone' +``` + +**Frontend Component** (`record-edit-fields/timezone/timezone.component.ts`): +```typescript +import { CUSTOM_ELEMENTS_SCHEMA, Component, 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 { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; + +@Component({ + selector: 'app-edit-timezone', + imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule], + templateUrl: './timezone.component.html', + styleUrls: ['./timezone.component.css'], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class TimezoneEditComponent extends BaseEditFieldComponent { + @Input() value: string; + public timezones: { value: string, label: string }[] = []; + + 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)); + + // Add null option if allowed + if (this.widgetStructure?.widget_params?.allow_null) { + this.timezones = [{ value: null, label: '' }, ...this.timezones]; + } + } + + private getTimezoneOffset(timezone: string): string { + // Implementation details for calculating UTC offset + // ... + } +} +``` + +**Widget Registration in All Four Registries:** + +1. `consts/record-edit-types.ts:57`: +```typescript +export const UIwidgets = { + // ... other widgets + Timezone: TimezoneEditComponent, + // ... other widgets +} +``` + +2. `consts/record-view-types.ts:52`: +```typescript +export const UIwidgets = { + // ... other widgets + Timezone: TimezoneRecordViewComponent, + // ... other widgets +} +``` + +3. `consts/table-display-types.ts:50`: +```typescript +export const UIwidgets = { + // ... other widgets + Timezone: TimezoneDisplayComponent, + // ... other widgets +} +``` + +4. `consts/filter-types.ts:38`: +```typescript +export const UIwidgets = { + // ... other widgets + Timezone: TimezoneFilterComponent, + // ... other widgets +} +``` + +**Default Parameters** (`db-table-widgets.component.ts:249-255`): +```typescript +Timezone: `// Configure timezone widget options +// Uses Intl API to populate timezone list automatically +// allow_null: Allow empty/null value selection +{ + "allow_null": false +} +`, +``` + +**Key Features:** +- Uses browser's Intl API (`Intl.supportedValuesOf('timeZone')`) +- Automatically calculates UTC offsets for each timezone +- Supports `allow_null` parameter +- Material Design dropdown with search capability +- Alphabetically sorted timezone list +- Comprehensive test coverage + +**Component Files (All Four Contexts):** + +**Record Edit Fields:** +- Component: `frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts` +- Template: `frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html` +- Styles: `frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.css` +- Tests: `frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts` + +**Record View Fields:** +- Component: `frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.ts` +- Template: `frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.html` +- Styles: `frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.css` +- Tests: `frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.spec.ts` + +**Table Display Fields:** +- Component: `frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.ts` +- Template: `frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.html` +- Styles: `frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.css` +- Tests: `frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.spec.ts` + +**Filter Fields:** +- Component: `frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.ts` +- Template: `frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.html` +- Styles: `frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.css` +- Tests: `frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.spec.ts` + +**Registry Files:** +- `frontend/src/app/consts/record-edit-types.ts` (line 25, 57) +- `frontend/src/app/consts/record-view-types.ts` (line 24, 52) +- `frontend/src/app/consts/table-display-types.ts` (line 24, 50) +- `frontend/src/app/consts/filter-types.ts` (line 19, 38) --- ## Important Notes -1. **JSON5 Support:** Widget parameters support JSON5 format (comments, unquoted keys) in the UI editor, but are stored as standard JSON +1. **Four Field Contexts:** Understanding when each field type is used: + - **Record Edit Fields:** Used in create/edit forms (modals, dedicated edit pages) + - **Record View Fields:** Used in single record detail/view pages (read-only) + - **Table Display Fields:** Used in table rows for listing multiple records (compact display) + - **Filter Fields:** Used in filter panels for searching/filtering table data + +2. **Component Inheritance:** All field components should extend their respective base components: + - `BaseEditFieldComponent` for record-edit-fields + - `BaseRecordViewFieldComponent` for record-view-fields + - `BaseTableDisplayFieldComponent` for table-display-fields + - `BaseFilterFieldComponent` for filter-fields -2. **Security:** Widget parameters are parsed using `secure-json-parse` to prevent prototype pollution attacks +3. **Standalone Architecture:** Angular 19 uses standalone components. No NgModule registration needed. Import dependencies directly in component metadata. -3. **Validation:** Always validate widget parameters both on frontend (user experience) and backend (security) +4. **JSON5 Support:** Widget parameters support JSON5 format (comments, unquoted keys) in the UI editor, but are stored as standard JSON -4. **Database Types:** Consider which database column types are compatible with your widget +5. **Security:** Widget parameters are parsed using `secure-json-parse` to prevent prototype pollution attacks -5. **Cascading Deletes:** Widgets are automatically deleted when their parent `table_settings` record is removed +6. **Validation:** Always validate widget parameters both on frontend (user experience) and backend (security) -6. **Null Values:** Widget type can be null (defaults to standard rendering) +7. **Database Types:** Consider which database column types are compatible with your widget + +8. **Cascading Deletes:** Widgets are automatically deleted when their parent `table_settings` record is removed + +9. **Null Values:** Widget type can be null (defaults to standard rendering) + +10. **Progressive Enhancement:** Start with the record-edit-field component. Add view/display/filter components only if you need custom behavior for those contexts --- @@ -510,10 +957,22 @@ Widget component tests located in: - Use Cases: `backend/src/entities/widget/use-cases/` ### Frontend -- Registry: `frontend/src/app/consts/record-edit-types.ts` -- Widget Manager: `frontend/src/app/components/dashboard/db-table-view/db-table-widgets/` -- Components: `frontend/src/app/components/ui-components/` -- Service: `frontend/src/app/services/tables.service.ts` + +**Widget Registries (Type to Component Mapping):** +- `frontend/src/app/consts/record-edit-types.ts` - Edit form components +- `frontend/src/app/consts/record-view-types.ts` - Detail view components +- `frontend/src/app/consts/table-display-types.ts` - Table row components +- `frontend/src/app/consts/filter-types.ts` - Filter panel components + +**Widget Configuration:** +- **Widget Manager:** `frontend/src/app/components/dashboard/db-table-view/db-table-widgets/` - Configuration UI +- **Service:** `frontend/src/app/services/tables.service.ts` - Widget CRUD operations + +**Component Directories:** +- **Record Edit Fields:** `frontend/src/app/components/ui-components/record-edit-fields/` - Interactive form inputs +- **Record View Fields:** `frontend/src/app/components/ui-components/record-view-fields/` - Read-only detail display +- **Table Display Fields:** `frontend/src/app/components/ui-components/table-display-fields/` - Compact table display +- **Filter Fields:** `frontend/src/app/components/ui-components/filter-fields/` - Search/filter inputs ### Shared - Data Structures: `shared-code/src/data-access-layer/shared/data-structures/table-widget.ds.ts` diff --git a/backend/src/enums/widget-type.enum.ts b/backend/src/enums/widget-type.enum.ts index a02e8989b..9baed383f 100644 --- a/backend/src/enums/widget-type.enum.ts +++ b/backend/src/enums/widget-type.enum.ts @@ -20,5 +20,6 @@ export enum WidgetTypeEnum { Phone = 'Phone', Country = 'Country', Color = 'Color', - Range = 'Range' + Range = 'Range', + Timezone = 'Timezone' } diff --git a/frontend/angular.json b/frontend/angular.json index ad08affcd..32dd8e811 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -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" - } - } } } } diff --git a/frontend/e2e/protractor.conf.js b/frontend/e2e/protractor.conf.js deleted file mode 100644 index 73e4e6806..000000000 --- a/frontend/e2e/protractor.conf.js +++ /dev/null @@ -1,32 +0,0 @@ -// @ts-check -// Protractor configuration file, see link for more information -// https://github.com/angular/protractor/blob/master/lib/config.ts - -const { SpecReporter } = require('jasmine-spec-reporter'); - -/** - * @type { import("protractor").Config } - */ -exports.config = { - allScriptsTimeout: 11000, - specs: [ - './src/**/*.e2e-spec.ts' - ], - capabilities: { - 'browserName': 'chrome' - }, - directConnect: true, - baseUrl: 'http://localhost:4200/', - framework: 'jasmine', - jasmineNodeOpts: { - showColors: true, - defaultTimeoutInterval: 30000, - print: function() {} - }, - onPrepare() { - require('ts-node').register({ - project: require('path').join(__dirname, './tsconfig.json') - }); - jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); - } -}; \ No newline at end of file diff --git a/frontend/e2e/src/app.e2e-spec.ts b/frontend/e2e/src/app.e2e-spec.ts deleted file mode 100644 index 0234ffb12..000000000 --- a/frontend/e2e/src/app.e2e-spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AppPage } from './app.po'; -import { browser, logging } from 'protractor'; - -describe('workspace-project App', () => { - let page: AppPage; - - beforeEach(() => { - page = new AppPage(); - }); - - it('should display welcome message', () => { - page.navigateTo(); - expect(page.getTitleText()).toEqual('Welcome to dissendium-v0!'); - }); - - afterEach(async () => { - // Assert that there are no errors emitted from the browser - const logs = await browser.manage().logs().get(logging.Type.BROWSER); - expect(logs).not.toContain(jasmine.objectContaining({ - level: logging.Level.SEVERE, - } as logging.Entry)); - }); -}); diff --git a/frontend/e2e/src/app.po.ts b/frontend/e2e/src/app.po.ts deleted file mode 100644 index 5776aa9eb..000000000 --- a/frontend/e2e/src/app.po.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { browser, by, element } from 'protractor'; - -export class AppPage { - navigateTo() { - return browser.get(browser.baseUrl) as Promise; - } - - getTitleText() { - return element(by.css('app-root h1')).getText() as Promise; - } -} diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json deleted file mode 100644 index 39b800f78..000000000 --- a/frontend/e2e/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/e2e", - "module": "commonjs", - "target": "es5", - "types": [ - "jasmine", - "jasminewd2", - "node" - ] - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts index 502d612a2..46921fd95 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts @@ -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: { diff --git a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.css b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.css new file mode 100644 index 000000000..e9739a06c --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.css @@ -0,0 +1,3 @@ +.timezone-form-field { + width: 100%; +} 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 new file mode 100644 index 000000000..06fd4539f --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.html @@ -0,0 +1,15 @@ + + {{normalizedLabel}} + + + {{timezone.label}} + + + 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 new file mode 100644 index 000000000..5ca927465 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.spec.ts @@ -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; + + 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(); + }); +}); 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 new file mode 100644 index 000000000..50344a182 --- /dev/null +++ b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.ts @@ -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 ''; + } + } +} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.css b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.css new file mode 100644 index 000000000..e9739a06c --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.css @@ -0,0 +1,3 @@ +.timezone-form-field { + width: 100%; +} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html new file mode 100644 index 000000000..da953d197 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html @@ -0,0 +1,15 @@ + + {{normalizedLabel}} + + + {{timezone.label}} + + + diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts new file mode 100644 index 000000000..0ed6c352f --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts new file mode 100644 index 000000000..93d599585 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts @@ -0,0 +1,81 @@ +import { CUSTOM_ELEMENTS_SCHEMA, Component, 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 { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; + +@Component({ + selector: 'app-edit-timezone', + imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule], + templateUrl: './timezone.component.html', + styleUrls: ['./timezone.component.css'], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class TimezoneEditComponent extends BaseEditFieldComponent { + @Input() value: string; + + public timezones: { value: string, label: string }[] = []; + + originalOrder = () => { return 0; } + + 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 ''; + } + } +} diff --git a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.css b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.css new file mode 100644 index 000000000..99aa281f0 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.css @@ -0,0 +1 @@ +/* Timezone specific styles */ diff --git a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.html b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.html new file mode 100644 index 000000000..025c37db6 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.html @@ -0,0 +1 @@ +{{formattedTimezone}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.spec.ts new file mode 100644 index 000000000..396c2a457 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.spec.ts @@ -0,0 +1,34 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TimezoneRecordViewComponent } from './timezone.component'; + +describe('TimezoneRecordViewComponent', () => { + let component: TimezoneRecordViewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TimezoneRecordViewComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TimezoneRecordViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display formatted timezone with UTC offset', () => { + component.value = 'America/New_York'; + expect(component.formattedTimezone).toContain('America/New_York'); + expect(component.formattedTimezone).toContain('UTC'); + }); + + it('should display dash for null value', () => { + component.value = null; + expect(component.formattedTimezone).toBe('—'); + }); +}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.ts b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.ts new file mode 100644 index 000000000..e02d57812 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.ts @@ -0,0 +1,55 @@ +import { Component, Injectable } from '@angular/core'; +import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; + +@Injectable() +@Component({ + selector: 'app-timezone-record-view', + templateUrl: './timezone.component.html', + styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './timezone.component.css'], + imports: [] +}) +export class TimezoneRecordViewComponent extends BaseRecordViewFieldComponent { + get formattedTimezone(): string { + console.log('timezone', this.value) + if (!this.value) { + return '—'; + } + + try { + const offset = this.getTimezoneOffset(this.value); + return `${this.value} (UTC${offset})`; + } catch (error) { + return this.value; + } + } + + 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')) { + 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) { + console.error(error) + return ''; + } + } +} diff --git a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.css b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.css new file mode 100644 index 000000000..99aa281f0 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.css @@ -0,0 +1 @@ +/* Timezone specific styles */ diff --git a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.html b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.html new file mode 100644 index 000000000..888ab730e --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.html @@ -0,0 +1,11 @@ +
+ {{formattedTimezone}} + +
diff --git a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.spec.ts new file mode 100644 index 000000000..c8425b97f --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.spec.ts @@ -0,0 +1,42 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TimezoneDisplayComponent } from './timezone.component'; + +describe('TimezoneDisplayComponent', () => { + let component: TimezoneDisplayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TimezoneDisplayComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TimezoneDisplayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display formatted timezone with UTC offset', () => { + component.value = 'America/New_York'; + expect(component.formattedTimezone).toContain('America/New_York'); + expect(component.formattedTimezone).toContain('UTC'); + }); + + it('should display dash for null value', () => { + component.value = null; + expect(component.formattedTimezone).toBe('—'); + }); + + it('should emit copy event on button click', () => { + spyOn(component.onCopyToClipboard, 'emit'); + component.value = 'Europe/London'; + const compiled = fixture.nativeElement; + const button = compiled.querySelector('button'); + expect(button).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.ts b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.ts new file mode 100644 index 000000000..b266e6ff8 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.ts @@ -0,0 +1,57 @@ +import { Component, Injectable } from '@angular/core'; +import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +@Injectable() +@Component({ + selector: 'app-display-timezone', + templateUrl: './timezone.component.html', + styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './timezone.component.css'], + imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] +}) +export class TimezoneDisplayComponent extends BaseTableDisplayFieldComponent { + get formattedTimezone(): string { + if (!this.value) { + return '—'; + } + + try { + const offset = this.getTimezoneOffset(this.value); + return `${this.value} (UTC${offset})`; + } catch (error) { + return this.value; + } + } + + 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')) { + 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 ''; + } + } +} diff --git a/frontend/src/app/consts/filter-types.ts b/frontend/src/app/consts/filter-types.ts index 4da90fce5..5ce8a01a0 100644 --- a/frontend/src/app/consts/filter-types.ts +++ b/frontend/src/app/consts/filter-types.ts @@ -16,6 +16,7 @@ import { StaticTextFilterComponent } from '../components/ui-components/filter-fi import { TextFilterComponent } from 'src/app/components/ui-components/filter-fields/text/text.component'; import { TimeFilterComponent } from '../components/ui-components/filter-fields/time/time.component'; import { TimeIntervalFilterComponent } from '../components/ui-components/filter-fields/time-interval/time-interval.component'; +import { TimezoneFilterComponent } from '../components/ui-components/filter-fields/timezone/timezone.component'; export const UIwidgets = { Default: '', @@ -34,6 +35,7 @@ export const UIwidgets = { Textarea: LongTextFilterComponent, Time: TimeFilterComponent, TimeInterval: TimeIntervalFilterComponent, + Timezone: TimezoneFilterComponent, Point: PointFilterComponent, ID: IdFilterComponent, } diff --git a/frontend/src/app/consts/record-edit-types.ts b/frontend/src/app/consts/record-edit-types.ts index 5b6973dbd..7f01a9548 100644 --- a/frontend/src/app/consts/record-edit-types.ts +++ b/frontend/src/app/consts/record-edit-types.ts @@ -22,6 +22,7 @@ import { StaticTextEditComponent } from '../components/ui-components/record-edit import { TextEditComponent } from 'src/app/components/ui-components/record-edit-fields/text/text.component'; import { TimeEditComponent } from '../components/ui-components/record-edit-fields/time/time.component'; import { TimeIntervalEditComponent } from '../components/ui-components/record-edit-fields/time-interval/time-interval.component'; +import { TimezoneEditComponent } from '../components/ui-components/record-edit-fields/timezone/timezone.component'; import { UrlEditComponent } from '../components/ui-components/record-edit-fields/url/url.component'; import { UuidEditComponent } from '../components/ui-components/record-edit-fields/uuid/uuid.component'; @@ -53,6 +54,7 @@ export const UIwidgets = { String: TextEditComponent, Textarea: LongTextEditComponent, Time: TimeEditComponent, + Timezone: TimezoneEditComponent, URL: UrlEditComponent, UUID: UuidEditComponent, } diff --git a/frontend/src/app/consts/record-view-types.ts b/frontend/src/app/consts/record-view-types.ts index 466a350b3..eb5d416a8 100644 --- a/frontend/src/app/consts/record-view-types.ts +++ b/frontend/src/app/consts/record-view-types.ts @@ -21,6 +21,7 @@ import { StaticTextRecordViewComponent } from '../components/ui-components/recor import { TextRecordViewComponent } from 'src/app/components/ui-components/record-view-fields/text/text.component'; import { TimeIntervalRecordViewComponent } from '../components/ui-components/record-view-fields/time-interval/time-interval.component'; import { TimeRecordViewComponent } from '../components/ui-components/record-view-fields/time/time.component'; +import { TimezoneRecordViewComponent } from '../components/ui-components/record-view-fields/timezone/timezone.component'; import { UrlRecordViewComponent } from '../components/ui-components/record-view-fields/url/url.component'; import { UuidRecordViewComponent } from '../components/ui-components/record-view-fields/uuid/uuid.component'; @@ -47,7 +48,8 @@ export const UIwidgets = { Foreign_key: ForeignKeyRecordViewComponent, Color: ColorRecordViewComponent, UUID: UuidRecordViewComponent, - Range: RangeRecordViewComponent + Range: RangeRecordViewComponent, + Timezone: TimezoneRecordViewComponent } export const recordViewFieldTypes = { diff --git a/frontend/src/app/consts/table-display-types.ts b/frontend/src/app/consts/table-display-types.ts index 5a4642b9c..d0eda963a 100644 --- a/frontend/src/app/consts/table-display-types.ts +++ b/frontend/src/app/consts/table-display-types.ts @@ -21,6 +21,7 @@ import { StaticTextDisplayComponent } from '../components/ui-components/table-di import { TextDisplayComponent } from 'src/app/components/ui-components/table-display-fields/text/text.component'; import { TimeDisplayComponent } from '../components/ui-components/table-display-fields/time/time.component'; import { TimeIntervalDisplayComponent } from '../components/ui-components/table-display-fields/time-interval/time-interval.component'; +import { TimezoneDisplayComponent } from '../components/ui-components/table-display-fields/timezone/timezone.component'; import { UrlDisplayComponent } from '../components/ui-components/table-display-fields/url/url.component'; import { UuidDisplayComponent } from '../components/ui-components/table-display-fields/uuid/uuid.component'; @@ -46,6 +47,7 @@ export const UIwidgets = { String: TextDisplayComponent, Textarea: LongTextDisplayComponent, Time: TimeDisplayComponent, + Timezone: TimezoneDisplayComponent, URL: UrlDisplayComponent, UUID: UuidDisplayComponent, } diff --git a/shared-code/src/data-access-layer/shared/enums/table-widget-type.enum.ts b/shared-code/src/data-access-layer/shared/enums/table-widget-type.enum.ts index 71db0bfcd..745996e62 100644 --- a/shared-code/src/data-access-layer/shared/enums/table-widget-type.enum.ts +++ b/shared-code/src/data-access-layer/shared/enums/table-widget-type.enum.ts @@ -21,4 +21,5 @@ export enum TableWidgetTypeEnum { Country = 'Country', Color = 'Color', Range = 'Range', + Timezone = 'Timezone', }