Skip to content

Commit b3859e9

Browse files
guguclaude
andcommitted
Add filter widget architecture documentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5a23147 commit b3859e9

1 file changed

Lines changed: 257 additions & 0 deletions

File tree

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
# Filter Widget Architecture
2+
3+
## Directory
4+
5+
All filter components live under `filter-fields/`. Each has: `*.component.ts`, `*.component.html`, `*.component.css`, `*.component.spec.ts`.
6+
7+
## Base Class
8+
9+
`base-filter-field/base-filter-field.component.ts`
10+
11+
Signal-based inputs and outputs every filter inherits:
12+
13+
**Inputs:** `key`, `label`, `required`, `readonly`, `structure` (TableField), `disabled`, `widgetStructure`, `relations` (TableForeignKey), `autofocus`
14+
15+
**Outputs:** `onFieldChange` (emits filter value), `onComparatorChange` (emits comparator string)
16+
17+
**Computed:** `normalizedLabel` — human-readable field name
18+
19+
## Two Types of Filter Components
20+
21+
### 1. Simple filters (dialog manages comparator)
22+
23+
Set `static type` to control which comparator dropdown the dialog shows:
24+
25+
- `static type = 'text'` → dialog shows: startswith, endswith, eq, contains, icontains, empty
26+
- `static type = 'number'` or `'datetime'` → dialog shows: eq, gt, lt, gte, lte
27+
- No `static type` (or undefined) → `nonComparable`, dialog shows NO comparator
28+
29+
The component only emits `onFieldChange`. The dialog renders its own `<mat-select>` for the comparator.
30+
31+
**Examples:** `TextFilterComponent`, `NumberFilterComponent`, `DateFilterComponent`
32+
33+
### 2. Smart filters (component manages its own comparator)
34+
35+
Do NOT set `static type` — this makes `getComparatorType()` return `'nonComparable'`, so the dialog renders only the component with no external comparator dropdown.
36+
37+
The component has an internal `filterMode` property, renders its own `<mat-select>` for mode selection, and emits BOTH `onFieldChange` (value) and `onComparatorChange` (comparator) to the dialog.
38+
39+
**Examples:** `EmailFilterComponent`, `PhoneFilterComponent`, `DateTimeFilterComponent`
40+
41+
## Comparator Routing Logic
42+
43+
In `db-table-filters-dialog.component.ts` (and `saved-filters-panel`):
44+
45+
```
46+
getInputType(field) → reads ComponentClass.type (static property)
47+
getComparatorType(type):
48+
'text' → dialog shows text comparators
49+
'number'/'datetime' → dialog shows number comparators
50+
anything else → 'nonComparable' → dialog shows nothing, component manages itself
51+
```
52+
53+
## Registration
54+
55+
### In `consts/filter-types.ts`:
56+
57+
- `UIwidgets` object: maps widget type names → component classes (e.g., `DateTime: DateTimeFilterComponent`)
58+
- `filterTypes` object: maps database column types → component classes per database (e.g., `postgres['timestamp without time zone']: DateTimeFilterComponent`)
59+
60+
### In `db-table-filters-dialog`:
61+
62+
- `UIwidgets` from `filter-types.ts` merged with `record-edit-types.ts`
63+
- Widget-based fields use `UIwidgets[widget_type]`
64+
- Database-type fields use `filterTypes[connectionType][columnType]`
65+
66+
## Data Flow
67+
68+
1. User adds filter → dialog creates entry in `tableRowFieldsShown[field]` and `tableRowFieldsComparator[field]` (default: `'eq'`)
69+
2. `ndc-dynamic` renders the component with inputs (`value`, `key`, `label`, etc.) and output handlers (`onFieldChange``updateField`, `onComparatorChange``updateComparatorFromComponent`)
70+
3. Component emits value/comparator → dialog stores them
71+
4. User clicks "Filter" → dialog closes → filters encoded as JsonURL in URL query params → table refetches
72+
73+
## Filter Data Format
74+
75+
URL: `?filters=<JsonURL-encoded>` where filters = `{ fieldName: { comparator: value } }`
76+
77+
Example: `{ created_at: { gte: "2024-01-01T00:00:00Z" }, email: { endswith: "@gmail.com" } }`
78+
79+
---
80+
81+
# How to Create a New Filter Widget
82+
83+
## Step 1: Create Component Files
84+
85+
Create directory: `filter-fields/<widget-name>/`
86+
87+
### TypeScript (`<widget-name>.component.ts`)
88+
89+
```typescript
90+
import { CommonModule } from '@angular/common';
91+
import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core';
92+
import { FormsModule } from '@angular/forms';
93+
import { MatFormFieldModule } from '@angular/material/form-field';
94+
import { MatInputModule } from '@angular/material/input';
95+
import { MatSelectModule } from '@angular/material/select'; // only for smart filters
96+
import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component';
97+
98+
@Component({
99+
selector: 'app-filter-<widget-name>',
100+
templateUrl: './<widget-name>.component.html',
101+
styleUrls: ['./<widget-name>.component.css'],
102+
imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule],
103+
})
104+
export class <WidgetName>FilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit {
105+
@Input() value: any;
106+
@ViewChild('inputElement') inputElement: ElementRef<HTMLInputElement>;
107+
108+
// For SIMPLE filter: set static type
109+
// static type = 'text'; // or 'number' or 'datetime'
110+
111+
// For SMART filter: do NOT set static type, add filterMode instead
112+
public filterMode: string = 'eq';
113+
114+
ngOnInit(): void {
115+
// Parse this.value if restoring from URL
116+
}
117+
118+
ngAfterViewInit(): void {
119+
// Emit initial comparator (smart filters only)
120+
this.onComparatorChange.emit(this.filterMode);
121+
122+
// Autofocus support
123+
if (this.autofocus() && this.inputElement) {
124+
setTimeout(() => this.inputElement.nativeElement.focus(), 100);
125+
}
126+
}
127+
128+
// Smart filter: handle mode changes
129+
onFilterModeChange(mode: string): void {
130+
this.filterMode = mode;
131+
this.onComparatorChange.emit(mode); // or map to backend comparator
132+
this.onFieldChange.emit(this.value);
133+
}
134+
135+
// Handle value changes
136+
onValueChange(val: any): void {
137+
this.value = val;
138+
this.onFieldChange.emit(this.value);
139+
this.onComparatorChange.emit(this.filterMode); // smart filter only
140+
}
141+
}
142+
```
143+
144+
### Template (`<widget-name>.component.html`)
145+
146+
**Simple filter** — just a value input:
147+
148+
```html
149+
<mat-form-field appearance="outline">
150+
<mat-label>{{normalizedLabel()}}</mat-label>
151+
<input matInput [(ngModel)]="value" (ngModelChange)="onFieldChange.emit($event)">
152+
</mat-form-field>
153+
```
154+
155+
**Smart filter** — mode selector + conditional input:
156+
157+
```html
158+
<div class="filter-row">
159+
<mat-form-field class="comparator-field" appearance="outline">
160+
<mat-select [(ngModel)]="filterMode" (ngModelChange)="onFilterModeChange($event)">
161+
<mat-option value="eq">equal</mat-option>
162+
<!-- more options -->
163+
</mat-select>
164+
</mat-form-field>
165+
166+
@if (filterMode !== 'some_special_mode') {
167+
<mat-form-field class="value-field" appearance="outline">
168+
<mat-label>{{normalizedLabel()}}</mat-label>
169+
<input matInput #inputElement
170+
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
171+
[(ngModel)]="value" (ngModelChange)="onValueChange($event)">
172+
</mat-form-field>
173+
}
174+
</div>
175+
```
176+
177+
### CSS (`<widget-name>.component.css`)
178+
179+
**Smart filter layout** (reuse across all smart filters):
180+
181+
```css
182+
.filter-row {
183+
display: flex;
184+
gap: 8px;
185+
align-items: flex-start;
186+
width: 100%;
187+
}
188+
.comparator-field {
189+
flex: 0 0 auto;
190+
min-width: 150px;
191+
}
192+
.value-field {
193+
flex: 1;
194+
}
195+
```
196+
197+
## Step 2: Register the Component
198+
199+
In `consts/filter-types.ts`:
200+
201+
1. Import the component
202+
2. Add to `UIwidgets` object if it's a custom widget type:
203+
```typescript
204+
export const UIwidgets = {
205+
// ...existing...
206+
MyWidget: MyWidgetFilterComponent,
207+
};
208+
```
209+
3. Add to `filterTypes` database mappings if it maps to a database column type:
210+
```typescript
211+
postgres: {
212+
my_column_type: MyWidgetFilterComponent,
213+
},
214+
```
215+
216+
## Step 3: Write Tests
217+
218+
```typescript
219+
import { ComponentFixture, TestBed } from '@angular/core/testing';
220+
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
221+
222+
describe('<WidgetName>FilterComponent', () => {
223+
let component: <WidgetName>FilterComponent;
224+
let fixture: ComponentFixture<<WidgetName>FilterComponent>;
225+
226+
beforeEach(async () => {
227+
await TestBed.configureTestingModule({
228+
imports: [<WidgetName>FilterComponent, BrowserAnimationsModule],
229+
}).compileComponents();
230+
});
231+
232+
beforeEach(() => {
233+
fixture = TestBed.createComponent(<WidgetName>FilterComponent);
234+
component = fixture.componentInstance;
235+
});
236+
237+
it('should create', () => {
238+
fixture.detectChanges();
239+
expect(component).toBeTruthy();
240+
});
241+
242+
// Test value emission
243+
// Test comparator emission (smart filters)
244+
// Test URL restoration (ngOnInit with existing value)
245+
// Test mode switching (smart filters)
246+
});
247+
```
248+
249+
**Note:** Tests use Vitest (`vi.spyOn`), not Jasmine. Use `toBe(true)` not `toBeTrue()`.
250+
251+
## Key Gotchas
252+
253+
- **Do NOT override `onFieldChange` or `onComparatorChange`** — the base class outputs work fine. Overriding creates a shadowed emitter.
254+
- **Comparator mapping**: internal filter modes can differ from backend comparators. Email's `'domain'` mode emits `'endswith'`; phone's `'country'` mode emits `'startswith'`.
255+
- **URL restoration**: when restoring from URL, you can't distinguish preset modes from custom ones — default to showing as a custom comparator.
256+
- **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.
257+
- **`fixture.detectChanges()`**: move it out of `beforeEach` if your component emits in `ngAfterViewInit` — call it only in tests that need the full lifecycle.

0 commit comments

Comments
 (0)