From 0906a506d6bb7a9e57172ad274d0525c997b6ebc Mon Sep 17 00:00:00 2001 From: tulas Date: Wed, 22 Jul 2026 10:05:54 +0200 Subject: [PATCH 01/22] feat(forms-map): unify Table/Map search filter and restyle filter toolbar Reuse the shared + FiltersService + ListDataSource on the form-data Map view so switching Tabella<->Mappa preserves the filter (via the ?filters= query param) and both views apply identical filtering semantics. Replaces the bespoke client-side #filtersContainer / applyFilters() with a reactive marker pipeline driven by FiltersService.queryString. Toolbar restyle (search-filters-bar): - remove the "Search by keywords" field - add an always-visible toolbar row: Tabella|Mappa segmented switcher (left), Filtri avanzati + Esporta labeled buttons (right), replacing the old icon-only buttons; presets stay inside the collapsible FILTRI panel - viewList()/switchView() + queryParamsHandling:'preserve' for state transfer Map view (forms-map): - breadcrumb + count toolbar matching the table - ListDataSource wiring (schema, node visibility, metric basic filters, data headers) mirroring FormsListComponent; export via shared ExportList - remove now-unused text-input-autocomp Add transloco keys: Table, Map, Advanced filters, pin (eng + ita). Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/core/translations/src/eng.ts | 4 + projects/core/translations/src/ita.ts | 4 + .../app/forms-map/components/forms-map.html | 32 +- .../app/forms-map/components/forms-map.scss | 87 +-- .../src/app/forms-map/components/forms-map.ts | 533 +++++++++++------- .../components/text-input-autocomp.html | 11 - .../components/text-input-autocomp.scss | 3 - .../components/text-input-autocomp.ts | 48 -- .../src/app/forms-map/forms-map.module.ts | 27 +- .../src/search-filters-bar.html | 115 ++-- .../src/search-filters-bar.module.ts | 2 + .../src/search-filters-bar.scss | 74 ++- .../src/search-filters-bar.ts | 48 +- 13 files changed, 547 insertions(+), 441 deletions(-) delete mode 100644 projects/dinoapp/src/app/forms-map/components/text-input-autocomp.html delete mode 100644 projects/dinoapp/src/app/forms-map/components/text-input-autocomp.scss delete mode 100644 projects/dinoapp/src/app/forms-map/components/text-input-autocomp.ts diff --git a/projects/core/translations/src/eng.ts b/projects/core/translations/src/eng.ts index bb063b3b4..42ab19ee3 100644 --- a/projects/core/translations/src/eng.ts +++ b/projects/core/translations/src/eng.ts @@ -220,6 +220,10 @@ export const ENG: Translation = { 'Filter': 'Filter', 'Filter condition': 'Filter condition', 'Filters': 'Filters', + 'Advanced filters': 'Advanced filters', + 'Table': 'Table', + 'Map': 'Map', + 'pin': 'pin', 'First page': 'First page', 'Forgot your password?': 'Forgot your password?', 'Form Fields': 'Form Fields', diff --git a/projects/core/translations/src/ita.ts b/projects/core/translations/src/ita.ts index 5a5793178..c8ad68d2d 100644 --- a/projects/core/translations/src/ita.ts +++ b/projects/core/translations/src/ita.ts @@ -208,6 +208,10 @@ export const ITA: Translation = { 'Filter': 'Filtra', 'Filter condition': 'Condizione di filtro', 'Filters': 'Filtri', + 'Advanced filters': 'Filtri avanzati', + 'Table': 'Tabella', + 'Map': 'Mappa', + 'pin': 'pin', 'First page': 'Prima pagina', 'Forgot your password?': 'Ti sei dimenticato la tua password?', 'Form Fields': 'Campi del Form', diff --git a/projects/dinoapp/src/app/forms-map/components/forms-map.html b/projects/dinoapp/src/app/forms-map/components/forms-map.html index b5e0f1768..9f7282485 100644 --- a/projects/dinoapp/src/app/forms-map/components/forms-map.html +++ b/projects/dinoapp/src/app/forms-map/components/forms-map.html @@ -1,18 +1,18 @@ -
-
- - Creation date range - - - - - - - - - -
- +
+ +
+ place + + {{pinCount}} {{'pin'|transloco}} · + {{dataSource.dataResultsCount|async}} {{'Items found'|transloco}} +
+ +
diff --git a/projects/dinoapp/src/app/forms-map/components/forms-map.scss b/projects/dinoapp/src/app/forms-map/components/forms-map.scss index 92ac0ff67..7faa5b4cd 100644 --- a/projects/dinoapp/src/app/forms-map/components/forms-map.scss +++ b/projects/dinoapp/src/app/forms-map/components/forms-map.scss @@ -1,80 +1,43 @@ dinoapp-forms-map { display: flex; - align-items: flex-start; + flex-direction: column; + height: calc(100vh - 73px); - #mapContainer { - overflow: hidden; - flex-grow: 1; - height: calc(100vh - 73px); - min-width: 300px; - min-height: 300px; - } - - .leaflet-popup-content { - margin: 7px 10px; - } - - #filtersContainer { - width: 330px; - padding-left: 5px; - display: grid; - gap: 5px; - grid-template-columns: 1fr; - overflow-y: auto; - - mat-form-field { - height: 50px; - } - - mat-label { - font-size: 13px; - } + .dino-map-toolbar { + flex: 0 0 auto; + background: transparent; + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-content: center; } - #applyFilters { - height: 50px; + .dino-map-count { display: flex; + flex-flow: row wrap; + justify-content: flex-end; align-items: center; - justify-content: center; - } - - #applyFilters button { - display: block; - width: 67%; - } - - .mat-mdc-form-field-subscript-wrapper { - display: none; - } - - @media only screen and (max-width: 1200px) { - flex-direction: column-reverse; - height: calc(100vh - 73px); + flex: 1 0 auto; + margin: auto; + margin-right: 10px; + font-size: 13px; - #mapContainer { - width: 100%; - height: unset; + .mat-icon { + margin-right: 4px; } + } - #filtersContainer { - padding-left: 0; - padding-bottom: 5px; - width: 100%; - grid-template-columns: 1fr 1fr 1fr; - } + #mapContainer { + overflow: hidden; + flex: 1 1 auto; + min-height: 300px; } - @media only screen and (max-width: 900px) { - #filtersContainer { - grid-template-columns: 1fr 1fr; - } + .leaflet-popup-content { + margin: 7px 10px; } @media only screen and (max-width: 599px) { height: calc(100vh - 65px); - - #filtersContainer { - grid-template-columns: 1fr; - } } } diff --git a/projects/dinoapp/src/app/forms-map/components/forms-map.ts b/projects/dinoapp/src/app/forms-map/components/forms-map.ts index bbc198d63..83d34d239 100644 --- a/projects/dinoapp/src/app/forms-map/components/forms-map.ts +++ b/projects/dinoapp/src/app/forms-map/components/forms-map.ts @@ -1,19 +1,43 @@ -import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, Optional, ViewEncapsulation} from '@angular/core'; -import {FormControl} from '@angular/forms'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + Optional, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import {MatDialog, MatDialogConfig} from '@angular/material/dialog'; import {ActivatedRoute} from '@angular/router'; import {b64_to_utf8} from '@dino/core/auth'; -import {FormData, FormDataManager, FormSchema, FormSchemaManager} from '@dino/core/forms'; import {Area, AreaManager} from '@dino/core/areas'; import {Case, CaseManager} from '@dino/core/cases'; -import {Metric} from '@dino/core/data'; -import {ListHeader} from '@dino/core/list'; +import {ActionTrigger, Metric} from '@dino/core/data'; +import {ExportListData} from '@dino/core/exporter'; +import {FormData, FormDataManager, FormInfo, FormSchema, FormSchemaManager} from '@dino/core/forms'; +import {FiltersService, ListHeader, NodeVisibility} from '@dino/core/list'; import {Location, LocationManager} from '@dino/core/locations'; import {Organization, OrganizationManager} from '@dino/core/organizations'; import {Project, ProjectManager} from '@dino/core/projects'; +import {UserDataManager, UserGroupManager} from '@dino/core/users'; +import {ExportList} from '@dino/material/export-list'; +import {ListDataSource} from '@dino/material/list'; +import {SearchFiltersBar} from '@dino/material/search-filters-bar'; import {RxDocument} from 'rxdb'; -import {Observable, of} from 'rxjs'; -import {combineLatestWith, map, take} from 'rxjs/operators'; -import {format} from 'date-fns'; +import {combineLatest, Observable, of, Subject} from 'rxjs'; +import { + debounceTime, + filter, + map, + shareReplay, + startWith, + switchMap, + take, + takeUntil, +} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {ActionsService} from 'src/app/actions.service'; import * as L from 'leaflet'; import 'leaflet.markercluster'; @@ -26,10 +50,6 @@ interface LocationWithLatLon extends Location { latLon?: [number, number]; } -interface FieldValues { - [fieldName: string]: string[]; -} - function loadHeaders(schemaId: string): ListHeader[] { const b64 = localStorage.getItem('columns_' + schemaId); if (b64 == null) { @@ -78,229 +98,364 @@ function markerPopup(form: FormData, dataHeaders: ListHeader[]): strin changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) -export class FormsMapComponent implements AfterViewInit { - readonly dateStartControl = new FormControl(null); - readonly dateEndControl = new FormControl(null); +export class FormsMapComponent implements AfterViewInit, OnDestroy { + @ViewChild(SearchFiltersBar) filtersBar?: SearchFiltersBar; + + readonly isDataList = 'form'; + readonly secondaryMetricFieldsDisplayed: {[metricName: string]: string | string[]} | null = + environment.metricsConfig.secondaryMetricFieldsDisplayed ?? null; + + /** + * The shared list data source. Reused only to populate the filter fields and to + * decode the active filter into a Mango query (via `queryDM`) — its paginated + * `dataResults` are not used to plot markers. + */ + readonly dataSource: ListDataSource; + /** Displayed headers, used both for marker popups and keyword-search columns. */ headers: ListHeader[] = []; - fieldValues: FieldValues = {}; - private allForms!: FormData[]; - private map!: L.Map; - private markers!: L.MarkerClusterGroup; + /** Number of plotted (geolocated) pins currently on the map. */ + pinCount = 0; - private schemaId: string; - private formSchema: Observable>; - private formData: Observable[]>; - private areas: Observable[]>; - private cases: Observable[]>; - private locations: Observable; - private organizations: Observable[]>; - private projects: Observable[]>; + private _map?: L.Map; + private _markers?: L.MarkerClusterGroup; + private _metricsTab: {[id: string]: Metric} = {}; + private readonly _destroy = new Subject(); + + private readonly _schemaId: string; + private readonly _additionalDataSchema$: Observable; + private readonly _nodesVisibility$: Observable; + private readonly _dataHeaders$: Observable[]>; + private readonly _metricsTab$: Observable<{[id: string]: Metric}>; constructor( - route: ActivatedRoute, - private cdr: ChangeDetectorRef, - private formSchemaManager: FormSchemaManager, - formDataManager: FormDataManager, + private _route: ActivatedRoute, + private _cdr: ChangeDetectorRef, + private _filtersService: FiltersService, + private _formDataManager: FormDataManager, + private _formSchemaManager: FormSchemaManager, + private _dialog: MatDialog, + private _actionsService: ActionsService, + private _udm: UserDataManager, + private _ugm: UserGroupManager, @Optional() areaManager: AreaManager | null, @Optional() caseManager: CaseManager | null, - @Optional() locationManager: LocationManager | null, + @Optional() private _locationManager: LocationManager | null, @Optional() orgManager: OrganizationManager | null, @Optional() projectManager: ProjectManager | null, ) { - this.schemaId = route.snapshot.params['form_schema_id']; + this._schemaId = this._route.snapshot.params['form_schema_id']; - this.formSchema = this.formSchemaManager.get(this.schemaId).pipe(map(schema => { - if (schema == null) { - throw new Error('No form schema with id ' + this.schemaId); - } - return schema; - }), take(1)); - - this.formData = formDataManager.query({selector: - {_deleted: {$ne: true}, form_schema_ref_id: {$eq: this.schemaId}} - }).pipe(take(1)); - - if (locationManager == null) { + if (this._locationManager == null) { throw new Error('the locations module must be enabled to use the map'); } - this.locations = locationManager.query({selector: - {_deleted: {$ne: true}} - }).pipe(map(locations => { - return locations.map(doc => { - const loc = doc.toJSON() as LocationWithLatLon; - const coord = loc.coordinates as unknown as string; - if (typeof coord === 'string' && coord.includes(',')) { - const latLon = coord.split(',').slice(0, 2).map(s => Number(s)) as [number, number]; - if (!isNaN(latLon[0]) && !isNaN(latLon[1])) { - loc.latLon = latLon; - } + + // Reset any basic-filter form groups left over from another view: the FiltersService + // is a root singleton shared with the Table view. + this._filtersService.clearAdditionalBasicFilters(); + + this.dataSource = new ListDataSource( + this._formDataManager, + this._filtersService, + this._formSchemaManager, + this.isDataList, + ); + + // Schema with resolved relationships — mirrors FormsListComponent. + this._additionalDataSchema$ = this._formSchemaManager.get(this._schemaId).pipe( + filter(schema => schema != null), + switchMap(schema => this._formSchemaManager.getSchemaWithRelationships(schema, true, null)), + shareReplay(1), + ); + + // Ajf node visibility — drives which advanced-filter fields are available. + this._nodesVisibility$ = combineLatest([ + this._additionalDataSchema$, + this._udm.getActiveUserData(), + this._ugm.getActiveUserGroups(), + ]).pipe( + map(([fschema, activeUser, activeUserGroups]) => { + if (fschema == null || activeUser == null || activeUserGroups == null) { + return []; } - return loc; - }).filter(l => l.latLon != null) as LocationWithLatLon[]; - }), take(1)); - this.areas = areaManager == null ? of([]) : areaManager.query({selector: - {_deleted: {$ne: true}} - }).pipe(take(1)); - this.cases = caseManager == null ? of([]) : caseManager.query({selector: - {_deleted: {$ne: true}} - }).pipe(take(1)); - this.organizations = orgManager == null ? of([]) : orgManager.query({selector: - {_deleted: {$ne: true}} - }).pipe(take(1)); - this.projects = projectManager == null ? of([]) : projectManager.query({selector: - {_deleted: {$ne: true}} - }).pipe(take(1)); - } + const dinoFormInfo: FormInfo = { + activeUser, + activeUserGroups, + createdAt: null, + status: null, + allStatuses: [], + user: null, + userGroups: null, + }; + return this._formSchemaManager.getPermissionsRelevant(fschema.schema.nodes, dinoFormInfo); + }), + shareReplay(1), + ); - ngAfterViewInit(): void { - this.formSchema.pipe( - combineLatestWith(this.formData, this.areas, this.cases, this.locations, this.organizations, this.projects), - take(1), - ).subscribe(([schema, formData, areas, cases, locations, orgs, projects]) => { - const metrics: Metric[] = [ - ...areas, - ...cases, - ...locations, - ...orgs, - ...projects, - ]; - const metricsTab: {[id: string]: Metric} = {}; - for (const m of metrics) { - metricsTab[m.id] = m; - } + // Displayed headers: reuse the user's saved column preset, falling back to the schema. + this._dataHeaders$ = this._additionalDataSchema$.pipe( + map(schema => { + if (schema == null) { + return []; + } + let headers = loadHeaders(this._schemaId); + if (headers.length === 0) { + headers = filterHeaders(this._formSchemaManager.generateSchemaListHeaders(schema)); + } + return headers; + }), + shareReplay(1), + ); - const forms = formData.map(f => f.toJSON() as FormData); - for (const form of forms) { - for (const key in form) { - if (key.endsWith('_ref_id')) { - const metricId = form[key as keyof FormData] as string | null; - if (metricId == null) { - continue; + // Metrics lookup table (id -> Metric), including locations decorated with latLon. + const locations$: Observable = this._locationManager + .query({selector: {_deleted: {$ne: true}}}) + .pipe( + map(locations => + locations.map(doc => { + const loc = doc.toJSON() as LocationWithLatLon; + const coord = loc.coordinates as unknown as string; + if (typeof coord === 'string' && coord.includes(',')) { + const latLon = coord + .split(',') + .slice(0, 2) + .map(s => Number(s)) as [number, number]; + if (!isNaN(latLon[0]) && !isNaN(latLon[1])) { + loc.latLon = latLon; + } } - const metric = metricsTab[metricId]; - if (metric == null) { - continue; - } - // Store the metric name in the form's data, - // so that we can treat it as a regular field for displaying and filtering: - form.data[key] = metric.name; - if (key === 'location_ref_id') { - form.data['latLon'] = (metric as LocationWithLatLon).latLon; - } - } + return loc; + }), + ), + take(1), + ); + const areas$: Observable[]> = + areaManager == null ? of([]) : areaManager.query({selector: {_deleted: {$ne: true}}}).pipe(take(1)); + const cases$: Observable[]> = + caseManager == null ? of([]) : caseManager.query({selector: {_deleted: {$ne: true}}}).pipe(take(1)); + const orgs$: Observable[]> = + orgManager == null ? of([]) : orgManager.query({selector: {_deleted: {$ne: true}}}).pipe(take(1)); + const projects$: Observable[]> = + projectManager == null ? of([]) : projectManager.query({selector: {_deleted: {$ne: true}}}).pipe(take(1)); + + this._metricsTab$ = combineLatest([locations$, areas$, cases$, orgs$, projects$]).pipe( + map(([locations, areas, cases, orgs, projects]) => { + const metrics: Metric[] = [...areas, ...cases, ...locations, ...orgs, ...projects]; + const metricsTab: {[id: string]: Metric} = {}; + for (const m of metrics) { + metricsTab[m.id] = m; } - } - this.allForms = forms.filter(f => f.data['latLon'] != null); + return metricsTab; + }), + take(1), + shareReplay(1), + ); + } - this.headers = loadHeaders(this.schemaId); - if (this.headers.length === 0) { - this.headers = filterHeaders(this.formSchemaManager.generateSchemaListHeaders(schema)); - } - this.extractFieldValues(); - this.cdr.markForCheck(); + ngAfterViewInit(): void { + // Create the map right away so it always renders, independently of the + // (potentially slow) filter-field data streams. + this._createMap(); + + // Feed node visibility to the data source when available — this only affects + // which advanced-filter fields appear, so it must not block the map or markers. + this._nodesVisibility$ + .pipe(takeUntil(this._destroy)) + .subscribe(nv => (this.dataSource.nodesVisibility = nv)); + + // Once the metrics lookup, headers and schema are ready, wire the data source + // like does and start the reactive marker pipeline. + combineLatest([this._metricsTab$, this._dataHeaders$, this._additionalDataSchema$]) + .pipe(take(1)) + .subscribe(([metricsTab, headers, schema]) => { + this._metricsTab = metricsTab; + this.headers = headers; + this.dataSource.dataHeaders = headers.filter(h => h.displayed); + + // IMPORTANT: subscribe to the marker pipeline BEFORE triggering filter + // initialization. FiltersService.queryString is a hot combineLatest with + // skip(1) that does not replay — it fires exactly once when the filters are + // first initialized. A late subscriber would miss that initial emission and + // the map would stay empty until the user changed a filter. + combineLatest([ + // startWith an empty-filter query so the initial (unfiltered) set of pins is + // always plotted, even before the hot queryString fires its first value. + this._filtersService.queryString.pipe(startWith(this._emptyQueryString())), + this._formDataManager.permissionContext, + this._additionalDataSchema$, + this._dataHeaders$, + ]) + .pipe( + debounceTime(50), + switchMap(([queryString, permissionContext, addSchema, dataHeaders]) => { + const query = this.dataSource.queryDM( + queryString, + permissionContext, + false, + addSchema, + null, + null, + dataHeaders, + true, + ); + return this._formDataManager.query(query).pipe(take(1)); + }), + takeUntil(this._destroy), + ) + .subscribe(docs => { + const forms = docs + .map(doc => doc.toJSON() as FormData) + .map(form => this._resolveRefsAndLatLon(form)) + .filter(form => form.data['latLon'] != null); + this.pinCount = forms.length; + this._plotMarkers(forms); + this._cdr.markForCheck(); + }); - this.createMap(); - }); + // Now trigger the filter initialization that makes queryString emit: + // setting the schema pushes the generated additional filters, and + // _initBasicFilters registers the metric basic filters + (re)inits the bar. + this.dataSource.additionalDataSchema = schema as FormSchema; + this._initBasicFilters(schema); + this._cdr.markForCheck(); + }); } - private extractFieldValues() { - const sets: {[field: string]: Set} = {}; - for (const h of this.headers) { - const field = h.column; - const set = new Set(); - for (const f of this.allForms) { - const val = f.data[field]; - if (val == null) { - set.add('null'); + ngOnDestroy(): void { + this._destroy.next(); + this._destroy.complete(); + this.dataSource.disconnect(); + this._filtersService.clearModelFilters(); + this._filtersService.clearCustomFilters(); + this._filtersService.clearAdditionalBasicFilters(); + if (this._map != null) { + this._map.remove(); + } + } + + /** + * Opens the shared Export dialog for the currently-filtered set of records. + * Mirrors SelectionList._exportForms / _openExportDialog. + */ + export(ev: 'XLSX' | 'CSV' | 'dialog'): void { + if ( + this.dataSource.additionalDataSchema == null || + (this.dataSource.additionalDataSchema as FormSchema).schema == null || + this.dataSource.dataResults.value == null + ) { + return; + } + const formSchema: FormSchema = this.dataSource.additionalDataSchema as FormSchema; + const dialogConfig = new MatDialogConfig(); + if (ev === 'XLSX' || ev === 'CSV') { + dialogConfig.data = { + exportFormat: ev === 'XLSX' ? 'xlsx' : 'csv', + selectAll: true, + listType: 'forms', + nodesVisibility: this._nodesVisibility$, + formSchema, + downloadFile: true, + }; + } + const dialogRef = this._dialog.open(ExportList, dialogConfig); + dialogRef.componentInstance.emitExportActionTrigger + .pipe(take(1)) + .subscribe((trigger: ActionTrigger) => this._actionsService.processTrigger(trigger)); + dialogRef.componentInstance.data = this.dataSource.data as any[]; + dialogRef.componentInstance.filteredQueryObs = this.dataSource.filteredQueryObs; + dialogRef.componentInstance.allItemsQueryObs = this.dataSource.allItemsQueryObs; + dialogRef.componentInstance.filtersCount = this.dataSource.filtersCount; + } + + /** + * Registers the metric/status/user basic filters on the shared FiltersService, + * then re-initializes the bar so their autocompletes appear. Mirrors the + * SelectionList.additionalBasicFilters input setter. + */ + private _initBasicFilters(schema: FormSchema | null): void { + const labels = ['form_status', 'user_data', 'unavailableFilter']; + if (schema) { + if (!schema.form_schema_metrics || !schema.form_schema_metrics.length) { + labels.push('project', 'location', 'area', 'case', 'organization'); + } else { + labels.push(...schema.form_schema_metrics); + } + } + for (const label of labels) { + if (this._filtersService.availableBasicFilterLabels.indexOf(label) > -1) { + this._filtersService.addBasicFilter(label); + } + } + if (this.filtersBar != null) { + this.filtersBar.initFilters(); + } + } + + /** + * Resolves each `*_ref_id` field to its metric name (so it can be shown/filtered + * like a regular field) and, for the location, extracts its latLon coordinates. + */ + private _resolveRefsAndLatLon(form: FormData): FormData { + for (const key in form) { + if (key.endsWith('_ref_id')) { + const metricId = form[key as keyof FormData] as string | null; + if (metricId == null) { continue; } - if (Array.isArray(val)) { - for (const v of val) { - set.add(String(v)); - } + const metric = this._metricsTab[metricId]; + if (metric == null) { continue; } - set.add(String(val)); + form.data[key] = metric.name; + if (key === 'location_ref_id') { + form.data['latLon'] = (metric as LocationWithLatLon).latLon; + } } - sets[field] = set; - } - for (const field in sets) { - this.fieldValues[field] = [...sets[field]].filter(v => v.trim() !== '').sort(); } + return form; } - private createMap() { - this.map = L.map('mapContainer', {zoomControl: false}); - this.map.setView([43.726, 10.411], 13); - L.control.zoom({position: 'bottomright'}).addTo(this.map); + /** + * The base64-encoded empty filter, matching how ListDataSource encodes the + * "no filters" query. Used to seed the marker pipeline for the initial plot. + */ + private _emptyQueryString(): string { + return btoa(encodeURI(JSON.stringify({filters: [], additionalFiltersLogic: 'and'}))); + } + + private _createMap(): void { + this._map = L.map('mapContainer', {zoomControl: false}); + this._map.setView([43.726, 10.411], 13); + L.control.zoom({position: 'bottomright'}).addTo(this._map); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap', - }).addTo(this.map); + }).addTo(this._map); - this.markers = L.markerClusterGroup(); - for (const f of this.allForms) { - const m = L.marker(f.data['latLon']); - m.bindPopup(markerPopup(f, this.headers), {closeButton: false}); - this.markers.addLayer(m); - } - this.map.addLayer(this.markers); - if (this.allForms.length > 0) { - this.map.fitBounds(this.markers.getBounds()); - } + this._markers = L.markerClusterGroup(); + this._map.addLayer(this._markers); } - applyFilters() { - const isoFormat = 'yyyy-MM-dd'; - const dateStart = this.dateStartControl.value; - const start = dateStart == null ? '0000-01-01' : format(dateStart, isoFormat); - const dateEnd = this.dateEndControl.value; - const end = dateEnd == null ? '9999-12-31' : format(dateEnd, isoFormat); - - const filterInputs: NodeListOf = document.querySelectorAll('#filtersContainer input'); - const filterVals: string[] = []; - for (let i = 0; i < this.headers.length; i++) { - // Skip the first two filterInputs, which are dateStart and dateEnd - filterVals.push(filterInputs[i + 2].value.toLowerCase()); + private _plotMarkers(forms: FormData[]): void { + if (this._map == null) { + return; } - - const forms = this.allForms.filter(f => { - if (f.created_at < start || f.created_at > end) { - return false; - } - for (let i = 0; i < this.headers.length; i++) { - const filterVal = filterVals[i]; - if (filterVal === '') { - continue; - } - const val = f.data[this.headers[i].column]; - if (val == null && filterVal !== 'null') { - return false; - } - if (typeof val === 'number' && String(val) !== filterVal) { - return false; - } - if (!String(val).toLowerCase().includes(filterVal)) { - return false; - } - } - return true; - }); - const newMarkers = L.markerClusterGroup(); for (const f of forms) { const m = L.marker(f.data['latLon']); m.bindPopup(markerPopup(f, this.headers), {closeButton: false}); newMarkers.addLayer(m); } - this.map.removeLayer(this.markers); - this.map.addLayer(newMarkers); - this.markers = newMarkers; + if (this._markers != null) { + this._map.removeLayer(this._markers); + } + this._map.addLayer(newMarkers); + this._markers = newMarkers; + this._map.invalidateSize(); if (forms.length > 0) { - this.map.fitBounds(newMarkers.getBounds()); + this._map.fitBounds(newMarkers.getBounds()); } } } diff --git a/projects/dinoapp/src/app/forms-map/components/text-input-autocomp.html b/projects/dinoapp/src/app/forms-map/components/text-input-autocomp.html deleted file mode 100644 index da364a377..000000000 --- a/projects/dinoapp/src/app/forms-map/components/text-input-autocomp.html +++ /dev/null @@ -1,11 +0,0 @@ - - {{label}} - - close - - - {{val}} - - - diff --git a/projects/dinoapp/src/app/forms-map/components/text-input-autocomp.scss b/projects/dinoapp/src/app/forms-map/components/text-input-autocomp.scss deleted file mode 100644 index 5205d5e14..000000000 --- a/projects/dinoapp/src/app/forms-map/components/text-input-autocomp.scss +++ /dev/null @@ -1,3 +0,0 @@ -dinoapp-text-input-autocomp mat-form-field { - width: 100%; -} diff --git a/projects/dinoapp/src/app/forms-map/components/text-input-autocomp.ts b/projects/dinoapp/src/app/forms-map/components/text-input-autocomp.ts deleted file mode 100644 index febc7f374..000000000 --- a/projects/dinoapp/src/app/forms-map/components/text-input-autocomp.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, ViewChild, ViewEncapsulation} from '@angular/core'; - -@Component({ - selector: 'dinoapp-text-input-autocomp', - templateUrl: 'text-input-autocomp.html', - styleUrls: ['text-input-autocomp.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, -}) -export class TextInputAutocomp { - @Input() label: string = ''; - private _options: string[] = []; - @Input() - get options(): string[] { - return this._options; - } - set options(opts: string[]) { - this._options = opts; - this.filteredOptions = opts; - } - - showClearButton = false; - filteredOptions: string[] = []; - - @ViewChild('input', {static: false, read: ElementRef}) input!: ElementRef; - - constructor(private cdr: ChangeDetectorRef) {} - - onInput() { - const val = this.input.nativeElement.value.toLowerCase(); - this.showClearButton = val !== ''; - this.filteredOptions = this._options.filter(opt => opt.toLowerCase().includes(val)); - this.cdr.markForCheck(); - } - - onSelect() { - this.showClearButton = true; - this.cdr.markForCheck(); - } - - clear(event: Event) { - event.stopPropagation(); - this.input.nativeElement.value = ''; - this.showClearButton = false; - this.filteredOptions = this._options; - this.cdr.markForCheck(); - } -} diff --git a/projects/dinoapp/src/app/forms-map/forms-map.module.ts b/projects/dinoapp/src/app/forms-map/forms-map.module.ts index 5ddb727ef..605674385 100644 --- a/projects/dinoapp/src/app/forms-map/forms-map.module.ts +++ b/projects/dinoapp/src/app/forms-map/forms-map.module.ts @@ -1,31 +1,26 @@ +import {AjfTranslocoModule} from '@ajf/core/transloco'; import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; -import {ReactiveFormsModule} from '@angular/forms'; -import {MatAutocompleteModule} from '@angular/material/autocomplete'; -import {MatButtonModule} from '@angular/material/button'; -import {MatDatepickerModule} from '@angular/material/datepicker'; -import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatDialogModule} from '@angular/material/dialog'; import {MatIconModule} from '@angular/material/icon'; -import {MatInputModule} from '@angular/material/input'; -import {MatNativeDateModule} from '@angular/material/core'; +import {BreadcrumbsModule} from '@dino/material/breadcrumbs'; +import {ExportListModule} from '@dino/material/export-list'; +import {SearchFiltersBarModule} from '@dino/material/search-filters-bar'; import {FormsMapComponent} from './components/forms-map'; -import {TextInputAutocomp} from './components/text-input-autocomp'; import {FormsMapRoutingModule} from './forms-map-routing.module'; @NgModule({ - declarations: [FormsMapComponent, TextInputAutocomp], + declarations: [FormsMapComponent], imports: [ + AjfTranslocoModule, + BreadcrumbsModule, CommonModule, + ExportListModule, FormsMapRoutingModule, - MatAutocompleteModule, - MatButtonModule, - MatDatepickerModule, - MatFormFieldModule, + MatDialogModule, MatIconModule, - MatInputModule, - MatNativeDateModule, - ReactiveFormsModule, + SearchFiltersBarModule, ], providers: [], }) diff --git a/projects/material/search-filters-bar/src/search-filters-bar.html b/projects/material/search-filters-bar/src/search-filters-bar.html index ef3e54cfb..91c68424f 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.html +++ b/projects/material/search-filters-bar/src/search-filters-bar.html @@ -1,8 +1,55 @@ +
+
+ + + table_chart + {{'Table'|transloco}} + + + place + {{'Map'|transloco}} + + +
+ +
+ + + + + + + +
+
+ - - + + - + {{'Filters'|transloco}}
@@ -14,34 +61,6 @@ - - - - - search - close - - - -
- - - - - - - - - - - - -
diff --git a/projects/material/search-filters-bar/src/search-filters-bar.module.ts b/projects/material/search-filters-bar/src/search-filters-bar.module.ts index ade46465d..b658c1fd9 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.module.ts +++ b/projects/material/search-filters-bar/src/search-filters-bar.module.ts @@ -27,6 +27,7 @@ import {ReactiveFormsModule} from '@angular/forms'; import {MatAutocompleteModule} from '@angular/material/autocomplete'; import {MatBottomSheetModule} from '@angular/material/bottom-sheet'; import {MatButtonModule} from '@angular/material/button'; +import {MatButtonToggleModule} from '@angular/material/button-toggle'; import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatNativeDateModule} from '@angular/material/core'; import {MatDatepickerModule} from '@angular/material/datepicker'; @@ -57,6 +58,7 @@ import {SearchFiltersBar} from './search-filters-bar'; ExportListModule, MatAutocompleteModule, MatButtonModule, + MatButtonToggleModule, MatCheckboxModule, MatDatepickerModule, MatDialogModule, diff --git a/projects/material/search-filters-bar/src/search-filters-bar.scss b/projects/material/search-filters-bar/src/search-filters-bar.scss index c44491637..6b87a1b54 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.scss +++ b/projects/material/search-filters-bar/src/search-filters-bar.scss @@ -23,22 +23,53 @@ dino-search-filters-bar { margin: auto; } } + .mat-mdc-icon-button { + align-self: center; + } + } + + .dino-filters-toolbar { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 5px; + } + + .dino-filters-toolbar-left, + .dino-filters-toolbar-right { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + } + + .dino-view-switcher { + .mat-button-toggle-label-content { + display: inline-flex; + align-items: center; + gap: 4px; + } + .mat-icon { + margin: 0; + cursor: pointer; + } + } + + .dino-filters-toolbar-right { .dino-filters-dialog-button, - .dino-form-map-button, .dino-export-button { - flex: 1 1 auto; - height: 44px; - margin-right: 5px; + height: 40px; .mat-icon { - margin-left: 0px; - margin-right: 0px; - margin: auto; + margin: 0 4px 0 0; } } + } - .mat-mdc-icon-button { - align-self: center; - } + .dino-filters-panel-header .dino-filters-panel-title { + align-items: center; + text-transform: uppercase; } .dino-open-aggregation-filters { @@ -65,19 +96,6 @@ dino-search-filters-bar { padding-right: 27px; } padding: 5px; - .mat-mdc-form-field.dino-filter-keyword { - flex: 1 0 auto; - max-width: 100%; - height: 60px; - border-top-left-radius: 10px; - border-top-right-radius: 10px; - .mat-mdc-input-element, - .mat-icon { - position: relative; - bottom: 8px; - } - } - .mat-expansion-indicator { position: relative; left: 3px; @@ -94,11 +112,11 @@ dino-search-filters-bar { } @media only screen and (max-width: 768px) { - .dino-filters-dialog-button, - .dino-form-map-button, - .dino-export-button { - margin-bottom: 15px; - width: 100%; + .dino-filters-toolbar { + .dino-filters-dialog-button, + .dino-export-button { + flex: 1 1 auto; + } } } } diff --git a/projects/material/search-filters-bar/src/search-filters-bar.ts b/projects/material/search-filters-bar/src/search-filters-bar.ts index 3f21d7f57..b58729111 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.ts +++ b/projects/material/search-filters-bar/src/search-filters-bar.ts @@ -35,7 +35,7 @@ import { import {UntypedFormGroup} from '@angular/forms'; import {MatBottomSheet} from '@angular/material/bottom-sheet'; import {MatDialog, MatDialogConfig, MatDialogRef} from '@angular/material/dialog'; -import {ActivatedRoute, Router} from '@angular/router'; +import {ActivatedRoute, NavigationEnd, Router} from '@angular/router'; import {AreaManager} from '@dino/core/areas'; import {CaseManager} from '@dino/core/cases'; import {DataModelManager, DataQueryOptions, Metric, MetricsService} from '@dino/core/data'; @@ -59,6 +59,7 @@ import {combineLatest, Observable, of as obsOf, Subject, Subscription, throwErro import { catchError, debounceTime, + filter, map, startWith, switchMap, @@ -118,6 +119,12 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, */ displayFormMapButton: Observable; + /** + * Emits true when the current route is the form Map view, false for the Table view. + * Drives the active state of the Tabella/Mappa switcher. + */ + isMapView$: Observable; + /** * The Filter Service Generated filters */ @@ -273,6 +280,12 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, }), ); + this.isMapView$ = this._router.events.pipe( + filter(e => e instanceof NavigationEnd), + startWith(null), + map(() => this._router.url.split('?')[0].endsWith('/map')), + ); + this.minDatePicker = (d: Date | null): boolean => { const minDate = this.dateSearchFilters.get('dateStart')?.value ? new Date(this.dateSearchFilters.get('dateStart')?.value) @@ -361,12 +374,41 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, } /** - * Redirects to the forms' View Map component + * Switches between the Table and Map views of the current form schema, + * preserving the active filters (carried in the `?filters=` query param). + */ + switchView(view: 'table' | 'map'): void { + if (view === 'map') { + this.viewMap(); + } else { + this.viewList(); + } + } + + /** + * Redirects to the forms' View Map component. + * Preserves the `?filters=` query param so the current filter transfers. */ viewMap(): void { this._route.params.pipe(take(1)).subscribe(params => { if (params['form_schema_id']) { - this._router.navigate(['forms', params['form_schema_id'], 'map']); + this._router.navigate(['forms', params['form_schema_id'], 'map'], { + queryParamsHandling: 'preserve', + }); + } + }); + } + + /** + * Redirects to the forms' Table (list) component. + * Preserves the `?filters=` query param so the current filter transfers. + */ + viewList(): void { + this._route.params.pipe(take(1)).subscribe(params => { + if (params['form_schema_id']) { + this._router.navigate(['forms', params['form_schema_id']], { + queryParamsHandling: 'preserve', + }); } }); } From 9dd94406821cf37ea7696e7d39bfecebe6efd987 Mon Sep 17 00:00:00 2001 From: tulas Date: Wed, 22 Jul 2026 18:36:52 +0200 Subject: [PATCH 02/22] feat(list/search-filters-bar): form-list toolbar, unified Filtri modal, selection action-row Toolbar (search-filters-bar): - move Add / Import actions into the toolbar (before Filtri/Esporta) via a projected [toolbarActions] slot; remove the floating FABs from forms-list - merge the collapsible Simple filters and the Advanced dialog into one Filtri modal with Semplice/Avanzati tabs (Cerca commits advanced, Chiudi discards; simple filters apply live); 3-column layouts, padding - Filtri button shows a count badge of applied filters (simple + advanced) - Table/Map switcher always shown on form-data lists; Map toggle disabled when the schema has no location; align map toolbar + count with the table List (dino-list): - add a selection action-row above the table: single-selection shows row actions as icons that expand to labelled icons on hover; multi-selection shows bulk actions; a placeholder hint when nothing is selected - remove the per-row floating actions column; drop 'actions' from displayedColumns - relocate the Columns selector into the action-row; uniform 40px, pill-shaped buttons i18n: add Simple, Advanced, Advanced filters, Table, Map, pin, selected, Columns, Clear selection and the selection hint to all locales (eng, ita, fra, esp, prt, ukr, ar, uga). Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/core/list/src/list.ts | 1 - projects/core/translations/src/ar.ts | 11 + projects/core/translations/src/eng.ts | 7 + projects/core/translations/src/esp.ts | 11 + projects/core/translations/src/fra.ts | 11 + projects/core/translations/src/ita.ts | 7 + projects/core/translations/src/prt.ts | 11 + projects/core/translations/src/uga.ts | 11 + projects/core/translations/src/ukr.ts | 11 + .../components/forms-list.component.html | 39 +-- .../src/app/forms-list/forms-list.module.ts | 8 +- .../app/forms-map/components/forms-map.scss | 14 +- projects/material/list/src/list.html | 160 ++++++------ projects/material/list/src/list.scss | 89 +++++++ projects/material/list/src/list.ts | 15 ++ .../src/search-filters-bar.html | 110 +++++--- .../src/search-filters-bar.module.ts | 4 + .../src/search-filters-bar.scss | 144 ++++++++++- .../src/search-filters-bar.ts | 243 ++++++++++++++++-- 19 files changed, 756 insertions(+), 151 deletions(-) diff --git a/projects/core/list/src/list.ts b/projects/core/list/src/list.ts index c90078c67..c9db53c24 100644 --- a/projects/core/list/src/list.ts +++ b/projects/core/list/src/list.ts @@ -98,7 +98,6 @@ export abstract class List { ...headers .filter(header => (header.displayed || header.displayed === undefined) && !header.hidden) .map(header => header.column.toString()), - 'actions', ]; if (this._showCheckbox) { this._displayedColumns.unshift('select'); diff --git a/projects/core/translations/src/ar.ts b/projects/core/translations/src/ar.ts index f2204c5d5..894fbe5ea 100644 --- a/projects/core/translations/src/ar.ts +++ b/projects/core/translations/src/ar.ts @@ -214,6 +214,17 @@ export const AR: Translation = { 'Filter': 'تصفية', 'Filter condition': 'شرط التصفية', 'Filters': 'عوامل التصفية', + 'Advanced filters': 'عوامل تصفية متقدمة', + 'Simple': 'بسيط', + 'Advanced': 'متقدم', + 'Table': 'جدول', + 'Map': 'خريطة', + 'pin': 'علامات', + 'selected': 'محدد', + 'Columns': 'أعمدة', + 'Clear selection': 'مسح التحديد', + 'Select one or more rows to see the available actions': + 'حدد صفًا واحدًا أو أكثر لعرض الإجراءات المتاحة', 'First page': 'الصفحة الأولى', 'Forgot your password?': 'نسيت كلمة المرور؟', 'Form Fields': 'حقول النموذج', diff --git a/projects/core/translations/src/eng.ts b/projects/core/translations/src/eng.ts index 42ab19ee3..cdbd8b613 100644 --- a/projects/core/translations/src/eng.ts +++ b/projects/core/translations/src/eng.ts @@ -221,9 +221,16 @@ export const ENG: Translation = { 'Filter condition': 'Filter condition', 'Filters': 'Filters', 'Advanced filters': 'Advanced filters', + 'Simple': 'Simple', + 'Advanced': 'Advanced', 'Table': 'Table', 'Map': 'Map', 'pin': 'pin', + 'selected': 'selected', + 'Columns': 'Columns', + 'Clear selection': 'Clear selection', + 'Select one or more rows to see the available actions': + 'Select one or more rows to see the available actions', 'First page': 'First page', 'Forgot your password?': 'Forgot your password?', 'Form Fields': 'Form Fields', diff --git a/projects/core/translations/src/esp.ts b/projects/core/translations/src/esp.ts index 5a506e7d0..4e458f444 100644 --- a/projects/core/translations/src/esp.ts +++ b/projects/core/translations/src/esp.ts @@ -219,6 +219,17 @@ export const ESP: Translation = { 'Filter': 'Filtrar', 'Filter condition': 'Condición de filtro', 'Filters': 'Filtros', + 'Advanced filters': 'Filtros avanzados', + 'Simple': 'Simple', + 'Advanced': 'Avanzado', + 'Table': 'Tabla', + 'Map': 'Mapa', + 'pin': 'marcadores', + 'selected': 'seleccionados', + 'Columns': 'Columnas', + 'Clear selection': 'Borrar selección', + 'Select one or more rows to see the available actions': + 'Seleccione una o más filas para ver las acciones disponibles', 'First page': 'Primera página', 'Forgot your password?': '¿Olvidaste tu contraseña?', 'Form Fields': 'Campos de formulario', diff --git a/projects/core/translations/src/fra.ts b/projects/core/translations/src/fra.ts index b84139bf6..08be61572 100644 --- a/projects/core/translations/src/fra.ts +++ b/projects/core/translations/src/fra.ts @@ -202,6 +202,17 @@ export const FRA: Translation = { 'Filter': 'Filtre', 'Filter condition': 'Condition de filtre', 'Filters': 'Filtres', + 'Advanced filters': 'Filtres avancés', + 'Simple': 'Simple', + 'Advanced': 'Avancé', + 'Table': 'Tableau', + 'Map': 'Carte', + 'pin': 'repères', + 'selected': 'sélectionnés', + 'Columns': 'Colonnes', + 'Clear selection': 'Effacer la sélection', + 'Select one or more rows to see the available actions': + 'Sélectionnez une ou plusieurs lignes pour voir les actions disponibles', 'First page': 'Première page', 'Forgot your password?': 'Votre mot de passe oublié?', 'Form Fields': 'Champs de formulaires', diff --git a/projects/core/translations/src/ita.ts b/projects/core/translations/src/ita.ts index c8ad68d2d..b4e4a8d0b 100644 --- a/projects/core/translations/src/ita.ts +++ b/projects/core/translations/src/ita.ts @@ -209,9 +209,16 @@ export const ITA: Translation = { 'Filter condition': 'Condizione di filtro', 'Filters': 'Filtri', 'Advanced filters': 'Filtri avanzati', + 'Simple': 'Semplice', + 'Advanced': 'Avanzati', 'Table': 'Tabella', 'Map': 'Mappa', 'pin': 'pin', + 'selected': 'selezionati', + 'Columns': 'Colonne', + 'Clear selection': 'Deseleziona', + 'Select one or more rows to see the available actions': + 'Seleziona una o più righe per visualizzare le azioni', 'First page': 'Prima pagina', 'Forgot your password?': 'Ti sei dimenticato la tua password?', 'Form Fields': 'Campi del Form', diff --git a/projects/core/translations/src/prt.ts b/projects/core/translations/src/prt.ts index 735f2e065..57ac51989 100644 --- a/projects/core/translations/src/prt.ts +++ b/projects/core/translations/src/prt.ts @@ -218,6 +218,17 @@ export const PRT: Translation = { 'Filter': 'Filtrar', 'Filter condition': 'Condição de filtro', 'Filters': 'Filtros', + 'Advanced filters': 'Filtros avançados', + 'Simple': 'Simples', + 'Advanced': 'Avançado', + 'Table': 'Tabela', + 'Map': 'Mapa', + 'pin': 'marcadores', + 'selected': 'selecionados', + 'Columns': 'Colunas', + 'Clear selection': 'Limpar seleção', + 'Select one or more rows to see the available actions': + 'Selecione uma ou mais linhas para ver as ações disponíveis', 'First page': 'Primeira página', 'Forgot your password?': 'Esqueceu sua senha?', 'Form Fields': 'Campos do formulário', diff --git a/projects/core/translations/src/uga.ts b/projects/core/translations/src/uga.ts index 15d69fe98..80ce4b9c2 100644 --- a/projects/core/translations/src/uga.ts +++ b/projects/core/translations/src/uga.ts @@ -22,6 +22,17 @@ import {Translation} from '@ajf/core/transloco'; // tslint:disable:max-line-length export const UGA: Translation = { + 'Advanced filters': 'Advanced filters', + 'Simple': 'Simple', + 'Advanced': 'Advanced', + 'Table': 'Table', + 'Map': 'Map', + 'pin': 'pin', + 'selected': 'selected', + 'Columns': 'Columns', + 'Clear selection': 'Clear selection', + 'Select one or more rows to see the available actions': + 'Select one or more rows to see the available actions', 'Import form data': 'Import form data', 'Match the columns in your file to the fields of the form.': 'Match the columns in your file to the fields of the form.', diff --git a/projects/core/translations/src/ukr.ts b/projects/core/translations/src/ukr.ts index 661f4b3f7..bffbd57f5 100644 --- a/projects/core/translations/src/ukr.ts +++ b/projects/core/translations/src/ukr.ts @@ -209,6 +209,17 @@ export const UKR: Translation = { 'Filter': 'Фільтр', 'Filter condition': 'Умова фільтра', 'Filters': 'Фільтри', + 'Advanced filters': 'Розширені фільтри', + 'Simple': 'Простий', + 'Advanced': 'Розширений', + 'Table': 'Таблиця', + 'Map': 'Карта', + 'pin': 'позначки', + 'selected': 'вибрано', + 'Columns': 'Стовпці', + 'Clear selection': 'Очистити вибір', + 'Select one or more rows to see the available actions': + 'Виберіть один або кілька рядків, щоб побачити доступні дії', 'First page': 'Перша сторінка', 'Forgot your password?': 'Забули пароль?', 'Form Fields': 'Поля форми', diff --git a/projects/dinoapp/src/app/forms-list/components/forms-list.component.html b/projects/dinoapp/src/app/forms-list/components/forms-list.component.html index 0b08a6cdb..32e099181 100644 --- a/projects/dinoapp/src/app/forms-list/components/forms-list.component.html +++ b/projects/dinoapp/src/app/forms-list/components/forms-list.component.html @@ -24,21 +24,28 @@ [exportable]="(displayExportButton|async) ?? false" [secondaryMetricFieldsDisplayed]="secondaryMetricFieldsDisplayed" (exportEvt)="dinoList.export($event, 'forms')" - > + > + + + - - - - diff --git a/projects/dinoapp/src/app/forms-list/forms-list.module.ts b/projects/dinoapp/src/app/forms-list/forms-list.module.ts index c1ab4d41c..32b782ef5 100644 --- a/projects/dinoapp/src/app/forms-list/forms-list.module.ts +++ b/projects/dinoapp/src/app/forms-list/forms-list.module.ts @@ -1,15 +1,18 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatDialogModule} from '@angular/material/dialog'; +import {MatIconModule} from '@angular/material/icon'; import {FormsModule as DinoFormsModule} from '@dino/core/forms'; import {FloatingButtonModule} from '@dino/material/floating-button'; import {ListModule as DinoListModule} from '@dino/material/list'; import {SearchFiltersBarModule as DinoFiltersBarModule} from '@dino/material/search-filters-bar'; import {BreadcrumbsModule as DinoBreadcrumbsModule} from '@dino/material/breadcrumbs'; import {TranslocoModule} from '@ngneat/transloco'; +import {TourMatMenuModule} from 'ngx-ui-tour-md-menu'; import {FormsListComponent} from './components/forms-list.component'; import {FormsListRoutingModule} from './forms-list-routing.module'; -import {MatDialogModule} from '@angular/material/dialog'; @NgModule({ declarations: [FormsListComponent], @@ -21,7 +24,10 @@ import {MatDialogModule} from '@angular/material/dialog'; DinoListModule, FloatingButtonModule, FormsListRoutingModule, + MatButtonModule, MatDialogModule, + MatIconModule, + TourMatMenuModule, TranslocoModule, ], providers: [], diff --git a/projects/dinoapp/src/app/forms-map/components/forms-map.scss b/projects/dinoapp/src/app/forms-map/components/forms-map.scss index 7faa5b4cd..7aa7772eb 100644 --- a/projects/dinoapp/src/app/forms-map/components/forms-map.scss +++ b/projects/dinoapp/src/app/forms-map/components/forms-map.scss @@ -3,6 +3,9 @@ dinoapp-forms-map { flex-direction: column; height: calc(100vh - 73px); + // Mirror the Table's .dino-list-toolbar exactly (same flex props + height) so + // both the breadcrumb and the Tabella/Mappa switcher stay in the same place + // when toggling views. The Table toolbar height is driven by its mat-paginator. .dino-map-toolbar { flex: 0 0 auto; background: transparent; @@ -10,19 +13,26 @@ dinoapp-forms-map { flex-flow: row wrap; justify-content: space-between; align-content: center; + min-height: 56px; } + // Mirror the Table's .dino-list-count so the count sits in the same position. .dino-map-count { display: flex; flex-flow: row wrap; justify-content: flex-end; - align-items: center; flex: 1 0 auto; + align-self: center; + position: relative; margin: auto; margin-right: 10px; - font-size: 13px; + + @media only screen and (min-width: 660px) { + bottom: 8px; + } .mat-icon { + opacity: 30%; margin-right: 4px; } } diff --git a/projects/material/list/src/list.html b/projects/material/list/src/list.html index d91ee3d12..029d97cc5 100644 --- a/projects/material/list/src/list.html +++ b/projects/material/list/src/list.html @@ -57,39 +57,6 @@ [aria-label]="checkboxLabel()" > - - - @@ -264,50 +231,6 @@ - - - -
- -
-
- -
- - - {{action.matIcon}} - - - - - more_horiz - - - -
-
-
- +
+
+ + check_box_outline_blank + {{'Select one or more rows to see the available actions'|transloco}} + + + + + + + + + + + + + + + +
+ +
+ +
+
+
diff --git a/projects/material/list/src/list.scss b/projects/material/list/src/list.scss index 5c4285ac9..a87967640 100644 --- a/projects/material/list/src/list.scss +++ b/projects/material/list/src/list.scss @@ -1,6 +1,95 @@ @use 'angular-material-css-vars' as mat-css-vars; dino-list { + .dino-selection-actions { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 4px 8px; + min-height: 48px; + + .dino-selection-actions-left { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + } + + .dino-selection-hint { + display: inline-flex; + align-items: center; + gap: 6px; + opacity: 0.55; + font-size: 13px; + font-style: italic; + .mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + + .dino-selection-actions-right { + display: flex; + align-items: center; + margin-left: auto; + } + + .dino-selection-clear .mat-icon, + .dino-selection-bulk .mat-icon, + .dino-columns-btn .mat-icon { + margin-right: 4px; + } + + // Pill-shaped (rounded) buttons, matching the filter toolbar. + .dino-selection-clear, + .dino-selection-bulk, + .dino-columns-btn, + .dino-selection-action { + border-radius: 20px; + .mat-mdc-button-persistent-ripple, + .mat-mdc-button-persistent-ripple::before, + .mdc-button__ripple { + border-radius: 20px; + } + } + + // Single-selection actions: icon only, expanding to a labelled icon on hover. + .dino-selection-action { + min-width: 0; + padding: 0 10px; + + // MDC text buttons shrink leading icons to 18px; keep the original 24px. + .mat-icon { + margin: 0; + font-size: 24px; + width: 24px; + height: 24px; + line-height: 24px; + } + + .dino-action-label { + display: inline-block; + max-width: 0; + margin-left: 0; + overflow: hidden; + white-space: nowrap; + vertical-align: middle; + opacity: 0; + transition: max-width 0.2s ease, opacity 0.2s ease, margin-left 0.2s ease; + } + + &:hover .dino-action-label, + &:focus-visible .dino-action-label { + max-width: 160px; + margin-left: 6px; + opacity: 1; + } + } + } + .dino-table-container { padding-bottom: 9vh; overflow-x: scroll; diff --git a/projects/material/list/src/list.ts b/projects/material/list/src/list.ts index afeb914ac..5d6af6c9a 100644 --- a/projects/material/list/src/list.ts +++ b/projects/material/list/src/list.ts @@ -346,6 +346,21 @@ export class SelectionList this._showColumnsSelector = exp; } + /** + * "shown/total" label for the Columns selector button (e.g. "10/12"). + */ + get columnsSelectedLabel(): string { + const headers = this.headers; + if (!headers || !headers.length) { + return ''; + } + const total = headers.length; + const shown = headers.filter( + h => (h.displayed || h.displayed === undefined) && !h.hidden, + ).length; + return `${shown}/${total}`; + } + /** * If true, the bulk action checkbox is available */ diff --git a/projects/material/search-filters-bar/src/search-filters-bar.html b/projects/material/search-filters-bar/src/search-filters-bar.html index 91c68424f..1189bf0cb 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.html +++ b/projects/material/search-filters-bar/src/search-filters-bar.html @@ -2,7 +2,7 @@
@@ -10,7 +10,7 @@ table_chart {{'Table'|transloco}} - + place {{'Map'|transloco}} @@ -18,18 +18,18 @@
- - - + + +
- - - - - {{'Filters'|transloco}} - -
- search -
-
- -
-
+ +
+
+
+ tune + {{'Filters'|transloco}} + + {{'Simple'|transloco}} + {{'Advanced'|transloco}} + +
+
+ + +
+
+ +
+
+ +
+ +
+
+ + {{'All'|transloco}} + {{'Any'|transloco}} + + +
+ + + + + + + +
+
+
+
diff --git a/projects/material/search-filters-bar/src/search-filters-bar.module.ts b/projects/material/search-filters-bar/src/search-filters-bar.module.ts index b658c1fd9..ef82fc806 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.module.ts +++ b/projects/material/search-filters-bar/src/search-filters-bar.module.ts @@ -40,12 +40,14 @@ import {MatListModule} from '@angular/material/list'; import {MatPaginatorModule} from '@angular/material/paginator'; import {MatSortModule} from '@angular/material/sort'; import {MatTableModule} from '@angular/material/table'; +import {MatTabsModule} from '@angular/material/tabs'; import {RouterModule} from '@angular/router'; import {BreakpointObserverModule} from '@dino/material/breakpoint-observer'; import {ExportListModule} from '@dino/material/export-list'; import {SearchFiltersChipsModule} from '@dino/material/search-filters-chips'; import {SearchFiltersDialogModule} from '@dino/material/search-filters-dialog'; import {SearchFiltersPresetManagerModule} from '@dino/material/search-filters-preset-manager'; +import {SearchFiltersWidgetModule} from '@dino/material/search-filters-widget'; import {IsFalseOrNullPipe} from './is-false-or-null.pipe'; import {SearchFiltersBar} from './search-filters-bar'; @@ -72,11 +74,13 @@ import {SearchFiltersBar} from './search-filters-bar'; MatBottomSheetModule, MatSortModule, MatTableModule, + MatTabsModule, ReactiveFormsModule, RouterModule, SearchFiltersChipsModule, SearchFiltersDialogModule, SearchFiltersPresetManagerModule, + SearchFiltersWidgetModule, ], declarations: [IsFalseOrNullPipe, SearchFiltersBar], exports: [SearchFiltersBar], diff --git a/projects/material/search-filters-bar/src/search-filters-bar.scss b/projects/material/search-filters-bar/src/search-filters-bar.scss index 6b87a1b54..7c77d4a69 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.scss +++ b/projects/material/search-filters-bar/src/search-filters-bar.scss @@ -1,3 +1,5 @@ +@use 'angular-material-css-vars' as mat-css-vars; + dino-search-filters-bar { .mat-icon { cursor: pointer; @@ -45,11 +47,27 @@ dino-search-filters-bar { gap: 8px; } + // Uniform 40px height for every toolbar button (Table/Map toggle, Add, Import, + // Filtri, Esporta), matching the Filtri button. + .dino-filters-toolbar { + .mat-mdc-button-base { + height: 40px; + } + } + .dino-view-switcher { + height: 40px; + .mat-button-toggle { + height: 40px; + } + .mat-button-toggle-button { + height: 40px; + } .mat-button-toggle-label-content { display: inline-flex; align-items: center; gap: 4px; + line-height: 40px; } .mat-icon { margin: 0; @@ -58,15 +76,38 @@ dino-search-filters-bar { } .dino-filters-toolbar-right { - .dino-filters-dialog-button, - .dino-export-button { - height: 40px; - .mat-icon { - margin: 0 4px 0 0; + .dino-filters-dialog-button .mat-icon, + .dino-export-button .mat-icon { + margin: 0 4px 0 0; + } + + // Pill-shaped (rounded) buttons on the right (Add, Import, Filtri, Esporta). + .mat-mdc-button-base { + border-radius: 20px; + .mat-mdc-button-persistent-ripple, + .mat-mdc-button-persistent-ripple::before, + .mdc-button__ripple { + border-radius: 20px; } } } + .dino-filters-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + margin-left: 6px; + padding: 0 6px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + line-height: 1; + background: mat-css-vars.mat-css-color-primary(500); + color: #fff; + } + .dino-filters-panel-header .dino-filters-panel-title { align-items: center; text-transform: uppercase; @@ -141,3 +182,96 @@ dino-search-filters-bar { display: none !important; } } + +// The unified Filters modal is rendered in a CDK overlay (outside the component), +// so its styles must live at the global level, not nested under the host selector. +.dino-filters-modal { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 20px 20px; + + .mat-icon { + cursor: pointer; + } + + .dino-filters-modal-header { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 4px 4px 12px; + } + + .dino-filters-modal-title { + display: flex; + align-items: center; + gap: 10px; + } + + .dino-filters-modal-heading { + font-size: 1.15rem; + font-weight: 500; + } + + .dino-filters-modal-actions { + display: flex; + align-items: center; + gap: 8px; + .mat-icon { + margin-right: 4px; + } + } + + .dino-filters-modal-body { + max-height: 72vh; + overflow: auto; + padding: 4px; + } + + // Simple tab: fixed 3-column grid (matching the reference mockup), collapsing + // to 2 then 1 column on narrower viewports. + .dino-filters-bar { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px 16px; + align-items: start; + + dino-search-filters-preset-manager { + grid-column: 1 / -1; + } + .mat-mdc-form-field { + width: 100%; + } + + @media only screen and (max-width: 992px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + @media only screen and (max-width: 599px) { + grid-template-columns: 1fr; + } + } + + .dino-filters-advanced-logic { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 8px; + } + + // Advanced tab: the SearchFiltersWidgets set their own `flex: 0 1 30%`, so the + // container must be a wrapping flex row for the 3-column layout to apply. + .dino-filters-advanced .mat-mdc-tab-body-content { + display: flex; + flex-flow: row wrap; + align-content: flex-start; + align-items: stretch; + overflow: hidden !important; + + @media only screen and (max-width: 768px) { + display: block; + } + } +} diff --git a/projects/material/search-filters-bar/src/search-filters-bar.ts b/projects/material/search-filters-bar/src/search-filters-bar.ts index b58729111..0853aefc2 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.ts +++ b/projects/material/search-filters-bar/src/search-filters-bar.ts @@ -20,6 +20,7 @@ * */ +import {AjfFieldType, AjfNodeType} from '@ajf/core/forms'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -30,9 +31,11 @@ import { OnInit, Optional, Output, + TemplateRef, + ViewChild, ViewEncapsulation, } from '@angular/core'; -import {UntypedFormGroup} from '@angular/forms'; +import {UntypedFormControl, UntypedFormGroup} from '@angular/forms'; import {MatBottomSheet} from '@angular/material/bottom-sheet'; import {MatDialog, MatDialogConfig, MatDialogRef} from '@angular/material/dialog'; import {ActivatedRoute, NavigationEnd, Router} from '@angular/router'; @@ -45,6 +48,7 @@ import { FilterItem, FilterListType, FiltersService, + NULL_OPERATORS, SearchFiltersComponent, } from '@dino/core/list'; import {LocationManager} from '@dino/core/locations'; @@ -53,9 +57,16 @@ import {ProjectManager} from '@dino/core/projects'; import {UserData, UserDataManager, UserGroup, UserGroupManager} from '@dino/core/users'; import {BreakpointObserverService} from '@dino/material/breakpoint-observer'; import {ExportBottomSheet} from '@dino/material/export-list'; -import {SearchFiltersDialog} from '@dino/material/search-filters-dialog'; import {isRxDocument, RxDocument} from 'rxdb'; -import {combineLatest, Observable, of as obsOf, Subject, Subscription, throwError} from 'rxjs'; +import { + BehaviorSubject, + combineLatest, + Observable, + of as obsOf, + Subject, + Subscription, + throwError, +} from 'rxjs'; import { catchError, debounceTime, @@ -115,10 +126,23 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, availableFormStatuses: Observable; /** - * If true, the Form Map button is displayed + * If true, the Map view is available for the current form schema (its location + * metric is active). When false, the Map toggle is shown but disabled. */ displayFormMapButton: Observable; + /** + * If true, the Table/Map view switcher is shown. It appears on any form-data + * list (a form schema is loaded), regardless of location support. + */ + displayViewSwitcher: Observable; + + /** + * Number of currently applied filters (basic + additional), shown as a badge + * on the Filtri button. + */ + appliedFiltersCount$: Observable; + /** * Emits true when the current route is the form Map view, false for the Table view. * Drives the active state of the Tabella/Mappa switcher. @@ -130,6 +154,49 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, */ generatedAdditionalFilters: Observable; + /** + * Template of the unified Filters modal (Simple + Advanced tabs). + */ + @ViewChild('filtersDialogTpl') filtersDialogTpl!: TemplateRef; + + /** + * The active tab of the Filters modal: 'simple' (basic filters) or + * 'advanced' (additional field-name filters). + */ + activeFilterTab: 'simple' | 'advanced' = 'simple'; + + /** + * Data of the additional filters shown in the Advanced tab of the modal. + */ + filterItemsData: Observable = obsOf([]); + + /** + * The And/Any logic toggle Form Control for the Advanced tab. + */ + logicAndOrToggle: UntypedFormControl = new UntypedFormControl('and'); + + /** + * The index of the currently displayed additional-filter group (Advanced tab). + */ + private _currentGroupId: BehaviorSubject = new BehaviorSubject(0); + + /** + * Reference to the currently open Filters modal. + */ + private _filtersDialogRef?: MatDialogRef; + + /** + * Subscribes to the And/Any logic toggle value changes while the modal is open. + */ + private _logicToggleSub: Subscription = Subscription.EMPTY; + + /** + * Public accessor to the FiltersService for the modal template. + */ + get fts(): FiltersService { + return this._fts; + } + /** * Date Picker input filtering methods. */ @@ -208,11 +275,6 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, this._filtersDialogWidth = w; } } - /** - * A reference to the MatDialog that contains the additionalFilters - */ - private _dialogRef?: MatDialogRef; - /** * Subscribes to the value returned by the MatDialog on its closing event */ @@ -280,12 +342,35 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, }), ); + // The view switcher is shown on any form-data list (a form schema loads), + // even when the schema has no location — in that case the Map toggle is disabled. + this.displayViewSwitcher = this._route.params.pipe( + switchMap(params => + params['form_schema_id'] ? this._fschm.get(params['form_schema_id']) : obsOf(null), + ), + map(schema => schema != null), + ); + this.isMapView$ = this._router.events.pipe( filter(e => e instanceof NavigationEnd), startWith(null), map(() => this._router.url.split('?')[0].endsWith('/map')), ); + // Count of applied filters (basic-with-value + additional), decoded from the + // FiltersService queryString which encodes exactly those active filter items. + this.appliedFiltersCount$ = this._fts.queryString.pipe( + map(qs => { + try { + const parsed: {filters?: FilterItem[]} = JSON.parse(decodeURI(atob(qs))); + return parsed.filters?.length ?? 0; + } catch { + return 0; + } + }), + startWith(0), + ); + this.minDatePicker = (d: Date | null): boolean => { const minDate = this.dateSearchFilters.get('dateStart')?.value ? new Date(this.dateSearchFilters.get('dateStart')?.value) @@ -330,28 +415,144 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, } /** - * Opens a dialog with dino-search-filters-dialog component. - * Aligns the temporary filters list to the additional filters list. - * Subscribes to Dialog closing event, updating the Additional Filters when - * the Dialog closing event value is true. + * Opens the unified Filters modal, with a Simple tab (basic filters, applied + * live) and an Advanced tab (additional field-name filters, staged in the + * temporary list). Pressing "Cerca" commits the Advanced filters; "Chiudi" + * discards uncommitted Advanced changes. */ - openDialog() { + openFiltersDialog(): void { this._fts.resetTemporaryFilters(); + this.activeFilterTab = 'simple'; + const currentLogic = this._fts.additionalFiltersLogic.value; + this.logicAndOrToggle = new UntypedFormControl(currentLogic); + this._fts.temporaryAdditionalFiltersLogic.next(currentLogic); + this._currentGroupId.next(0); + this._setupFilterItemsData(); + + this._logicToggleSub.unsubscribe(); + this._logicToggleSub = this.logicAndOrToggle.valueChanges.subscribe(res => + this._fts.temporaryAdditionalFiltersLogic.next(res), + ); + const dialogConfig = new MatDialogConfig(); dialogConfig.panelClass = 'dino-search-filters-dialog'; dialogConfig.minWidth = `${this._filtersDialogWidth}vw`; dialogConfig.maxWidth = `${this._filtersDialogWidth}vw`; - this._dialogRef = this.dialog.open(SearchFiltersDialog, dialogConfig); - this._dialogSub = this._dialogRef + dialogConfig.autoFocus = false; + this._filtersDialogRef = this.dialog.open(this.filtersDialogTpl, dialogConfig); + + this._dialogSub.unsubscribe(); + this._dialogSub = this._filtersDialogRef .afterClosed() - .pipe(catchError(err => throwError(() => err) as Observable)) - .subscribe((searchFilters: {search: boolean; logic?: 'and' | 'or'}) => { - if (searchFilters && searchFilters.search) { - this._fts.updateAdditionalFilters(searchFilters.logic); + .pipe(catchError(err => throwError(() => err) as Observable<{search?: boolean}>)) + .subscribe((res?: {search?: boolean}) => { + this._logicToggleSub.unsubscribe(); + if (res && res.search) { + this._fts.updateAdditionalFilters(this.logicAndOrToggle.value); } }); } + /** + * Closes the Filters modal and commits the staged Advanced filters. + */ + search(): void { + this._filtersDialogRef?.close({search: true}); + } + + /** + * Closes the Filters modal without committing the staged Advanced filters. + */ + closeFiltersDialog(): void { + this._filtersDialogRef?.close({search: false}); + } + + /** + * Sets the currently displayed additional-filter group (Advanced tab), and + * refreshes the widgets data for that group. + * @param id The group id (mat-tab index) + */ + setCurrentGroupId(id: number): void { + this._currentGroupId.next(id); + this._setupFilterItemsData(); + } + + /** + * Asks the FiltersService to add a FilterItem to the given filter list, + * skipping empty values (unless the operator is a NULL operator). + */ + addFilter(filterItem: FilterItem, listType: FilterListType): void { + const operatorValue = filterItem.operator?.value; + const isNullOperator = operatorValue && operatorValue in NULL_OPERATORS; + const hasValue = filterItem.value !== null && filterItem.value !== ''; + if (hasValue || isNullOperator) { + this._fts.addFilter(filterItem, listType); + } + } + + /** + * Removes a staged (temporary) additional filter, used by the Advanced tab chips. + */ + removeTemporaryFilter(filterItem: FilterItem, listType: FilterListType[] | FilterListType): void { + this._fts + .removeFilter(filterItem, listType) + .pipe( + take(1), + catchError(err => throwError(() => err) as Observable), + ) + .subscribe(); + } + + /** + * Builds the additional-filter items observable for the current Advanced group. + * Mirrors SearchFiltersDialog's setup. + */ + private _setupFilterItemsData(): void { + this.filterItemsData = this._fts.generatedFilters.pipe( + withLatestFrom(this._currentGroupId), + map(([groups, id]) => groups[id] as FilterGroup), + map(group => + group && group.filterGroupAdditionalFilters + ? group.filterGroupAdditionalFilters + .filter(ft => ft.fieldType !== AjfFieldType.Empty) + .map(flt => { + flt.isFilterItemDetails = group.isFilterGroupDetails; + return flt; + }) + : [], + ), + map(filters => filters.map(f => this._setupFilterItem(f))), + catchError(err => throwError(() => err) as Observable), + take(1), + ); + } + + /** + * Sets up a FilterItem, assigning default fallback values where necessary. + * Mirrors SearchFiltersDialog's setup. + */ + private _setupFilterItem(item: FilterItem): FilterItem { + return { + id: item.id ?? 10, + parent: 1, + parentNode: item.parentNode ?? 1, + choicesOrigin: item.choicesOrigin, + choicesOriginRef: item.choicesOrigin?.name, + name: item.name, + label: item.label ?? item.name.charAt(0).toUpperCase() + item.name.slice(1), + nodeType: AjfNodeType.AjfField, + fieldType: item.fieldType ? item.fieldType : AjfFieldType.String, + isAdditionalFilter: item.isAdditionalFilter, + editable: item.editable ?? true, + defaultValue: item.defaultValue ?? null, + size: item.size ?? 'normal', + validation: item.validation, + visibility: item.visibility != null ? item.visibility : {condition: 'true'}, + isFilterItemDetails: item.isFilterItemDetails, + isRepeatingSlideFilter: item.isRepeatingSlideFilter, + }; + } + /** * Open export dialog */ @@ -1125,6 +1326,8 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, ngOnDestroy() { this._dialogSub.unsubscribe(); + this._logicToggleSub.unsubscribe(); + this._currentGroupId.complete(); this._mainUnsubscribe.next(); this._mainUnsubscribe.complete(); } From 7de0ee7446881df2e640af1829736848f6bbd219 Mon Sep 17 00:00:00 2001 From: tulas Date: Fri, 7 Aug 2026 10:06:19 +0200 Subject: [PATCH 03/22] feat(datachat): AI tab in the form-data section, permanent chats, chat restyle Form-data view switcher: - rename the Tabella toggle to Dati ('Table' -> 'Data' key) and add a third AI toggle routing to /forms/:id/datachat, preserving the ?filters= param - hide Filtri, Esporta, the projected toolbar actions and the filter chips on the AI view; new viewSwitcherOnly input skips initFilters where no filter is displayed - the AI page mounts the switcher and drops its DataChat breadcrumb DataChat: - remove the start-up 'Here is your data!' table and the suggested_questions blob: the chat opens empty - create the PandasAI agent on the first question instead of on entering the chat, so that opening the AI view is not charged; keep it alive while the User stays inside the form section and destroy it (enddatachat) only when leaving it. Completion mode no longer posts enddatachat: it has no agent - conversations are stored permanently in a dedicated IndexedDB database (DataChatStore), scoped by user and form schema, outside RxDB: they are never synced with the backend and survive the logout collections wipe - new conversations sidebar (opt-in), welcome block with starter questions, and a send button beside the chat input - feedback is available in datachat mode too, the answer entry carries its question so the feedback request can quote it, and the source of the request tells the two chats apart (dinoapp-ragai / dinoapp-datachat) - chat entries restyled as answer cards with source cards, an actions row (rating, copy, regenerate) and suggested questions as rows Co-Authored-By: Claude Opus 5 (1M context) --- projects/core/translations/src/ar.ts | 23 +- projects/core/translations/src/eng.ts | 22 +- projects/core/translations/src/esp.ts | 23 +- projects/core/translations/src/fra.ts | 23 +- projects/core/translations/src/ita.ts | 22 +- projects/core/translations/src/prt.ts | 23 +- projects/core/translations/src/uga.ts | 23 +- projects/core/translations/src/ukr.ts | 23 +- .../components/datachat.component.html | 8 +- .../components/datachat.component.scss | 35 ++ .../datachat/components/datachat.component.ts | 1 + .../src/app/datachat/datachat.module.ts | 9 +- .../forms-collect-routing.module.ts | 2 +- .../src/app/gpt/components/gpt.component.html | 10 +- .../src/app/gpt/components/gpt.component.scss | 150 ++---- .../src/app/gpt/components/gpt.component.ts | 1 + .../material/datachat/src/datachat-entry.html | 133 +++-- .../material/datachat/src/datachat-entry.scss | 370 +++++++++++--- .../material/datachat/src/datachat-entry.ts | 119 ++++- .../datachat/src/datachat-session.service.ts | 374 ++++++++++++++ .../material/datachat/src/datachat-store.ts | 245 +++++++++ projects/material/datachat/src/datachat.html | 107 +++- .../datachat/src/datachat.interfaces.ts | 9 +- .../material/datachat/src/datachat.module.ts | 9 +- projects/material/datachat/src/datachat.scss | 363 +++++++++++-- projects/material/datachat/src/datachat.ts | 482 ++++++++++++++---- projects/material/datachat/src/public_api.ts | 3 + .../datachat/src/relative-date.pipe.ts | 59 +++ .../src/search-filters-bar.html | 11 +- .../src/search-filters-bar.spec.ts | 2 +- .../src/search-filters-bar.ts | 72 ++- 31 files changed, 2322 insertions(+), 434 deletions(-) create mode 100644 projects/dinoapp/src/app/datachat/components/datachat.component.scss create mode 100644 projects/material/datachat/src/datachat-session.service.ts create mode 100644 projects/material/datachat/src/datachat-store.ts create mode 100644 projects/material/datachat/src/relative-date.pipe.ts diff --git a/projects/core/translations/src/ar.ts b/projects/core/translations/src/ar.ts index 894fbe5ea..22add79db 100644 --- a/projects/core/translations/src/ar.ts +++ b/projects/core/translations/src/ar.ts @@ -217,8 +217,29 @@ export const AR: Translation = { 'Advanced filters': 'عوامل تصفية متقدمة', 'Simple': 'بسيط', 'Advanced': 'متقدم', - 'Table': 'جدول', + 'Data': 'بيانات', 'Map': 'خريطة', + 'Ask your data': 'اسأل بياناتك', + 'Ask a question in natural language about this form data': 'اطرح سؤالاً بلغة طبيعية حول بيانات هذا النموذج', + 'Write a question about the data...': 'اكتب سؤالاً عن البيانات...', + 'New chat': 'محادثة جديدة', + 'Chats': 'المحادثات', + 'Delete chat': 'حذف المحادثة', + 'Today': 'اليوم', + 'Yesterday': 'أمس', + '{{days}} days ago': 'قبل {{days}} أيام', + 'Last week': 'الأسبوع الماضي', + 'Good response': 'إجابة جيدة', + 'Bad response': 'إجابة سيئة', + 'Suggested questions': 'أسئلة مقترحة', + 'Copy': 'نسخ', + 'Copied': 'تم النسخ', + 'Regenerate answer': 'إعادة توليد الإجابة', + 'How many records were collected this month?': 'كم عدد السجلات التي تم جمعها هذا الشهر؟', + 'Summarize the collected notes': 'لخّص الملاحظات المجمعة', + 'Compare the activities by organization': 'قارن الأنشطة حسب المنظمة', + 'Which items have the lowest values?': 'ما العناصر ذات القيم الأدنى؟', + 'Send': 'إرسال', 'pin': 'علامات', 'selected': 'محدد', 'Columns': 'أعمدة', diff --git a/projects/core/translations/src/eng.ts b/projects/core/translations/src/eng.ts index cdbd8b613..61583a5d9 100644 --- a/projects/core/translations/src/eng.ts +++ b/projects/core/translations/src/eng.ts @@ -223,8 +223,28 @@ export const ENG: Translation = { 'Advanced filters': 'Advanced filters', 'Simple': 'Simple', 'Advanced': 'Advanced', - 'Table': 'Table', + 'Data': 'Data', 'Map': 'Map', + 'Ask your data': 'Ask your data', + 'Ask a question in natural language about this form data': 'Ask a question in natural language about this form data', + 'Write a question about the data...': 'Write a question about the data...', + 'New chat': 'New chat', + 'Chats': 'Chats', + 'Delete chat': 'Delete chat', + 'Today': 'Today', + 'Yesterday': 'Yesterday', + '{{days}} days ago': '{{days}} days ago', + 'Last week': 'Last week', + 'Good response': 'Good response', + 'Bad response': 'Bad response', + 'Suggested questions': 'Suggested questions', + 'Copy': 'Copy', + 'Copied': 'Copied', + 'Regenerate answer': 'Regenerate answer', + 'How many records were collected this month?': 'How many records were collected this month?', + 'Summarize the collected notes': 'Summarize the collected notes', + 'Compare the activities by organization': 'Compare the activities by organization', + 'Which items have the lowest values?': 'Which items have the lowest values?', 'pin': 'pin', 'selected': 'selected', 'Columns': 'Columns', diff --git a/projects/core/translations/src/esp.ts b/projects/core/translations/src/esp.ts index 4e458f444..e78f2eb73 100644 --- a/projects/core/translations/src/esp.ts +++ b/projects/core/translations/src/esp.ts @@ -222,8 +222,29 @@ export const ESP: Translation = { 'Advanced filters': 'Filtros avanzados', 'Simple': 'Simple', 'Advanced': 'Avanzado', - 'Table': 'Tabla', + 'Data': 'Datos', 'Map': 'Mapa', + 'Ask your data': 'Pregunta a tus datos', + 'Ask a question in natural language about this form data': 'Haz una pregunta en lenguaje natural sobre los datos de este formulario', + 'Write a question about the data...': 'Escribe una pregunta sobre los datos...', + 'New chat': 'Nuevo chat', + 'Chats': 'Chats', + 'Delete chat': 'Eliminar chat', + 'Today': 'Hoy', + 'Yesterday': 'Ayer', + '{{days}} days ago': 'Hace {{days}} días', + 'Last week': 'La semana pasada', + 'Good response': 'Respuesta útil', + 'Bad response': 'Respuesta no útil', + 'Suggested questions': 'Preguntas sugeridas', + 'Copy': 'Copiar', + 'Copied': 'Copiado', + 'Regenerate answer': 'Regenerar la respuesta', + 'How many records were collected this month?': '¿Cuántos datos se han recogido este mes?', + 'Summarize the collected notes': 'Resume las notas recogidas', + 'Compare the activities by organization': 'Compara las actividades por organización', + 'Which items have the lowest values?': '¿Qué elementos tienen los valores más bajos?', + 'Send': 'Enviar', 'pin': 'marcadores', 'selected': 'seleccionados', 'Columns': 'Columnas', diff --git a/projects/core/translations/src/fra.ts b/projects/core/translations/src/fra.ts index 08be61572..fedb732fc 100644 --- a/projects/core/translations/src/fra.ts +++ b/projects/core/translations/src/fra.ts @@ -205,8 +205,29 @@ export const FRA: Translation = { 'Advanced filters': 'Filtres avancés', 'Simple': 'Simple', 'Advanced': 'Avancé', - 'Table': 'Tableau', + 'Data': 'Données', 'Map': 'Carte', + 'Ask your data': 'Interrogez vos données', + 'Ask a question in natural language about this form data': 'Posez une question en langage naturel sur les données de ce formulaire', + 'Write a question about the data...': 'Écrivez une question sur les données...', + 'New chat': 'Nouveau chat', + 'Chats': 'Chats', + 'Delete chat': 'Supprimer le chat', + 'Today': 'Aujourd\'hui', + 'Yesterday': 'Hier', + '{{days}} days ago': 'Il y a {{days}} jours', + 'Last week': 'La semaine dernière', + 'Good response': 'Bonne réponse', + 'Bad response': 'Mauvaise réponse', + 'Suggested questions': 'Questions suggérées', + 'Copy': 'Copier', + 'Copied': 'Copié', + 'Regenerate answer': 'Régénérer la réponse', + 'How many records were collected this month?': 'Combien de données ont été collectées ce mois-ci ?', + 'Summarize the collected notes': 'Résume les notes collectées', + 'Compare the activities by organization': 'Compare les activités par organisation', + 'Which items have the lowest values?': 'Quels éléments ont les valeurs les plus basses ?', + 'Send': 'Envoyer', 'pin': 'repères', 'selected': 'sélectionnés', 'Columns': 'Colonnes', diff --git a/projects/core/translations/src/ita.ts b/projects/core/translations/src/ita.ts index b4e4a8d0b..77d44ac25 100644 --- a/projects/core/translations/src/ita.ts +++ b/projects/core/translations/src/ita.ts @@ -211,8 +211,28 @@ export const ITA: Translation = { 'Advanced filters': 'Filtri avanzati', 'Simple': 'Semplice', 'Advanced': 'Avanzati', - 'Table': 'Tabella', + 'Data': 'Dati', 'Map': 'Mappa', + 'Ask your data': 'Chiedi ai tuoi dati', + 'Ask a question in natural language about this form data': 'Fai una domanda in linguaggio naturale sui dati di questo form', + 'Write a question about the data...': 'Scrivi una domanda sui dati...', + 'New chat': 'Nuova chat', + 'Chats': 'Chat', + 'Delete chat': 'Elimina chat', + 'Today': 'Oggi', + 'Yesterday': 'Ieri', + '{{days}} days ago': '{{days}} giorni fa', + 'Last week': 'La scorsa settimana', + 'Good response': 'Risposta utile', + 'Bad response': 'Risposta non utile', + 'Suggested questions': 'Domande suggerite', + 'Copy': 'Copia', + 'Copied': 'Copiato', + 'Regenerate answer': 'Rigenera la risposta', + 'How many records were collected this month?': 'Quanti dati sono stati raccolti questo mese?', + 'Summarize the collected notes': 'Riassumi le note raccolte', + 'Compare the activities by organization': 'Confronta le attività per organizzazione', + 'Which items have the lowest values?': 'Quali elementi hanno i valori più bassi?', 'pin': 'pin', 'selected': 'selezionati', 'Columns': 'Colonne', diff --git a/projects/core/translations/src/prt.ts b/projects/core/translations/src/prt.ts index 57ac51989..ee014d6fd 100644 --- a/projects/core/translations/src/prt.ts +++ b/projects/core/translations/src/prt.ts @@ -221,8 +221,29 @@ export const PRT: Translation = { 'Advanced filters': 'Filtros avançados', 'Simple': 'Simples', 'Advanced': 'Avançado', - 'Table': 'Tabela', + 'Data': 'Dados', 'Map': 'Mapa', + 'Ask your data': 'Pergunte aos seus dados', + 'Ask a question in natural language about this form data': 'Faça uma pergunta em linguagem natural sobre os dados deste formulário', + 'Write a question about the data...': 'Escreva uma pergunta sobre os dados...', + 'New chat': 'Novo chat', + 'Chats': 'Chats', + 'Delete chat': 'Eliminar chat', + 'Today': 'Hoje', + 'Yesterday': 'Ontem', + '{{days}} days ago': 'Há {{days}} dias', + 'Last week': 'Na semana passada', + 'Good response': 'Resposta útil', + 'Bad response': 'Resposta não útil', + 'Suggested questions': 'Perguntas sugeridas', + 'Copy': 'Copiar', + 'Copied': 'Copiado', + 'Regenerate answer': 'Gerar novamente a resposta', + 'How many records were collected this month?': 'Quantos dados foram recolhidos este mês?', + 'Summarize the collected notes': 'Resuma as notas recolhidas', + 'Compare the activities by organization': 'Compare as atividades por organização', + 'Which items have the lowest values?': 'Que elementos têm os valores mais baixos?', + 'Send': 'Enviar', 'pin': 'marcadores', 'selected': 'selecionados', 'Columns': 'Colunas', diff --git a/projects/core/translations/src/uga.ts b/projects/core/translations/src/uga.ts index 80ce4b9c2..4c0d43f78 100644 --- a/projects/core/translations/src/uga.ts +++ b/projects/core/translations/src/uga.ts @@ -25,8 +25,29 @@ export const UGA: Translation = { 'Advanced filters': 'Advanced filters', 'Simple': 'Simple', 'Advanced': 'Advanced', - 'Table': 'Table', + 'Data': 'Data', 'Map': 'Map', + 'Ask your data': 'Ask your data', + 'Ask a question in natural language about this form data': 'Ask a question in natural language about this form data', + 'Write a question about the data...': 'Write a question about the data...', + 'New chat': 'New chat', + 'Chats': 'Chats', + 'Delete chat': 'Delete chat', + 'Today': 'Today', + 'Yesterday': 'Yesterday', + '{{days}} days ago': '{{days}} days ago', + 'Last week': 'Last week', + 'Good response': 'Good response', + 'Bad response': 'Bad response', + 'Suggested questions': 'Suggested questions', + 'Copy': 'Copy', + 'Copied': 'Copied', + 'Regenerate answer': 'Regenerate answer', + 'How many records were collected this month?': 'How many records were collected this month?', + 'Summarize the collected notes': 'Summarize the collected notes', + 'Compare the activities by organization': 'Compare the activities by organization', + 'Which items have the lowest values?': 'Which items have the lowest values?', + 'Send': 'Send', 'pin': 'pin', 'selected': 'selected', 'Columns': 'Columns', diff --git a/projects/core/translations/src/ukr.ts b/projects/core/translations/src/ukr.ts index bffbd57f5..b31694385 100644 --- a/projects/core/translations/src/ukr.ts +++ b/projects/core/translations/src/ukr.ts @@ -212,8 +212,29 @@ export const UKR: Translation = { 'Advanced filters': 'Розширені фільтри', 'Simple': 'Простий', 'Advanced': 'Розширений', - 'Table': 'Таблиця', + 'Data': 'Дані', 'Map': 'Карта', + 'Ask your data': 'Запитайте свої дані', + 'Ask a question in natural language about this form data': 'Поставте запитання природною мовою про дані цієї форми', + 'Write a question about the data...': 'Напишіть запитання про дані...', + 'New chat': 'Новий чат', + 'Chats': 'Чати', + 'Delete chat': 'Видалити чат', + 'Today': 'Сьогодні', + 'Yesterday': 'Вчора', + '{{days}} days ago': '{{days}} днів тому', + 'Last week': 'Минулого тижня', + 'Good response': 'Гарна відповідь', + 'Bad response': 'Погана відповідь', + 'Suggested questions': 'Пропоновані запитання', + 'Copy': 'Копіювати', + 'Copied': 'Скопійовано', + 'Regenerate answer': 'Згенерувати відповідь знову', + 'How many records were collected this month?': 'Скільки даних зібрано цього місяця?', + 'Summarize the collected notes': 'Підсумуйте зібрані нотатки', + 'Compare the activities by organization': 'Порівняйте діяльність за організаціями', + 'Which items have the lowest values?': 'Які елементи мають найнижчі значення?', + 'Send': 'Надіслати', 'pin': 'позначки', 'selected': 'вибрано', 'Columns': 'Стовпці', diff --git a/projects/dinoapp/src/app/datachat/components/datachat.component.html b/projects/dinoapp/src/app/datachat/components/datachat.component.html index 9102d6bbd..7f73a0640 100644 --- a/projects/dinoapp/src/app/datachat/components/datachat.component.html +++ b/projects/dinoapp/src/app/datachat/components/datachat.component.html @@ -1,8 +1,14 @@ +
+ +
+ + [conversationsSidebar]="true" + [showWelcome]="true" +>
diff --git a/projects/dinoapp/src/app/datachat/components/datachat.component.scss b/projects/dinoapp/src/app/datachat/components/datachat.component.scss new file mode 100644 index 000000000..5332eda86 --- /dev/null +++ b/projects/dinoapp/src/app/datachat/components/datachat.component.scss @@ -0,0 +1,35 @@ +dinoapp-datachat { + display: flex; + flex-direction: column; + height: calc(100vh - 73px); + + // Mirror the Table's .dino-list-toolbar and the Map's .dino-map-toolbar (same + // flex props + height) so the breadcrumb and the Dati/Mappa/AI switcher stay + // in the same place when toggling views. + .dinoapp-datachat-toolbar { + flex: 0 0 auto; + background: transparent; + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-content: center; + min-height: 56px; + } + + dino-search-filters-bar { + flex: 0 0 auto; + } + + // The chat owns its own internal layout: here it just fills whatever is left + // below the toolbar and the view switcher. + dino-datachat { + flex: 1 1 auto; + height: auto; + min-height: 0; + padding: 8px 5px 12px; + } + + @media only screen and (max-width: 599px) { + height: calc(100vh - 65px); + } +} diff --git a/projects/dinoapp/src/app/datachat/components/datachat.component.ts b/projects/dinoapp/src/app/datachat/components/datachat.component.ts index ca90d45ef..4de389bbb 100644 --- a/projects/dinoapp/src/app/datachat/components/datachat.component.ts +++ b/projects/dinoapp/src/app/datachat/components/datachat.component.ts @@ -5,6 +5,7 @@ import {acceptTermsContent, pandinoUrl} from '../conf'; @Component({ selector: 'dinoapp-datachat', templateUrl: './datachat.component.html', + styleUrls: ['./datachat.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) diff --git a/projects/dinoapp/src/app/datachat/datachat.module.ts b/projects/dinoapp/src/app/datachat/datachat.module.ts index efd282d04..9d87015c0 100644 --- a/projects/dinoapp/src/app/datachat/datachat.module.ts +++ b/projects/dinoapp/src/app/datachat/datachat.module.ts @@ -2,12 +2,19 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {BreadcrumbsModule as DinoBreadcrumbsModule} from '@dino/material/breadcrumbs'; import {DataChatModule as DinoDataChatModule} from '@dino/material/datachat'; +import {SearchFiltersBarModule} from '@dino/material/search-filters-bar'; import {DataChatRoutingModule} from './datachat-routing.module'; import {DataChatComponent} from './components/datachat.component'; @NgModule({ declarations: [DataChatComponent], - imports: [CommonModule, DinoBreadcrumbsModule, DinoDataChatModule, DataChatRoutingModule], + imports: [ + CommonModule, + DinoBreadcrumbsModule, + DinoDataChatModule, + DataChatRoutingModule, + SearchFiltersBarModule, + ], }) export class DataChatModule {} diff --git a/projects/dinoapp/src/app/forms-collect/forms-collect-routing.module.ts b/projects/dinoapp/src/app/forms-collect/forms-collect-routing.module.ts index f544d21be..84a31aa47 100644 --- a/projects/dinoapp/src/app/forms-collect/forms-collect-routing.module.ts +++ b/projects/dinoapp/src/app/forms-collect/forms-collect-routing.module.ts @@ -27,7 +27,7 @@ const routes: Routes = [ path: 'datachat', loadChildren: () => import('../datachat/datachat.module').then(m => m.DataChatModule), - data: {breadcrumbs: [{label: ':form_schema_id', parametrical: true}, {label: 'DataChat'}]}, + data: {breadcrumbs: [{label: ':form_schema_id', parametrical: true}]}, }, { path: 'view', diff --git a/projects/dinoapp/src/app/gpt/components/gpt.component.html b/projects/dinoapp/src/app/gpt/components/gpt.component.html index 15a61906b..c1a76654a 100644 --- a/projects/dinoapp/src/app/gpt/components/gpt.component.html +++ b/projects/dinoapp/src/app/gpt/components/gpt.component.html @@ -1,3 +1,6 @@ +
+ +
-
+ [conversationsSidebar]="true" +> +
+ +
+ visibility + + + + + +
+
+ +
+ info + {{qa.note}} +
+ +
+ +
+
diff --git a/projects/material/datachat/src/datachat-entry.scss b/projects/material/datachat/src/datachat-entry.scss index 39274dc98..f844c570c 100644 --- a/projects/material/datachat/src/datachat-entry.scss +++ b/projects/material/datachat/src/datachat-entry.scss @@ -160,6 +160,64 @@ dino-datachat-entry { overflow-x: auto; } + // ---- Preview banner and caveat -------------------------------------------- + .dino-datachat-preview, + .dino-datachat-note { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 12px 0; + padding: 10px 12px; + border-radius: 10px; + font-size: 13px; + line-height: 19px; + + .mat-icon { + flex: 0 0 auto; + width: 18px; + height: 18px; + font-size: 18px; + cursor: default; + } + } + + .dino-datachat-preview { + background: var(--dce-subtle); + border: 1px solid var(--dce-border); + color: var(--dce-muted); + + .dino-datachat-preview-text { + flex: 1 1 auto; + + span { + margin-right: 5px; + } + } + } + + // A caveat is not a value: it is set apart from the data it comes with. + .dino-datachat-note { + background: mat-css-vars.mat-css-color-warn(500, 0.06); + border: 1px solid mat-css-vars.mat-css-color-warn(500, 0.3); + border-left-width: 4px; + + .mat-icon { + color: mat-css-vars.mat-css-color-warn(600); + } + + span { + flex: 1 1 auto; + } + } + + // ---- Charts ---------------------------------------------------------------- + .dino-datachat-charts { + display: flex; + flex-flow: column nowrap; + gap: 16px; + margin-top: 16px; + } + // ---- Sources --------------------------------------------------------------- .dino-datachat-section-label { display: flex; @@ -282,6 +340,21 @@ dino-datachat-entry { background: var(--dce-border); } + // The export sits at the end of the actions row, away from the icon buttons. + .dino-datachat-download.mat-mdc-outlined-button { + height: 34px; + margin-left: auto; + border-radius: 999px; + font-size: 13px; + + .mat-icon { + width: 18px; + height: 18px; + margin-right: 6px; + font-size: 18px; + } + } + // ---- Suggested questions --------------------------------------------------- .dino-datachat-suggested { margin: 16px 0 0 46px; diff --git a/projects/material/datachat/src/datachat-entry.spec.ts b/projects/material/datachat/src/datachat-entry.spec.ts new file mode 100644 index 000000000..3415569a7 --- /dev/null +++ b/projects/material/datachat/src/datachat-entry.spec.ts @@ -0,0 +1,210 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {provideRouter} from '@angular/router'; +import {provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; +import {TableGenerator} from '@dino/material/table-generator'; +import {DataChatEntry} from './datachat-entry'; +import {DataChatModule} from './datachat.module'; +import {DataChatQA} from './datachat.interfaces'; + +describe('Data Chat Entry', () => { + let fixture: ComponentFixture; + let entry: DataChatEntry; + + const truncatedTable: DataChatQA = { + componentData: { + component: TableGenerator, + inputs: {setJsonData: [{txt: 'ottimo servizio', sentiment: 'positive'}]}, + }, + noPrompt: true, + truncated: true, + totalRows: 530, + totalColumns: 14, + previewRows: 20, + previewColumns: 2, + downloadUrl: '/datachat/export/b3e2ed0fd6c44683858ef641542b108b', + downloadFilename: 'sentiment_txt.csv', + note: '12 rows could not be analyzed: their sentiment is empty, not neutral.', + }; + + const completeTable: DataChatQA = { + componentData: {component: TableGenerator, inputs: {setJsonData: [{city: 'Roma', n: 1}]}}, + noPrompt: true, + truncated: false, + totalRows: 2, + totalColumns: 2, + previewRows: 2, + previewColumns: 2, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [BrowserAnimationsModule, DataChatModule], + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + provideRouter([]), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DataChatEntry); + entry = fixture.componentInstance; + }); + + it('should show the preview banner before the table', async () => { + entry.qa = truncatedTable; + await fixture.whenStable(); + fixture.detectChanges(); + + const banner = fixture.nativeElement.querySelector('.dino-datachat-preview'); + const table = fixture.nativeElement.querySelector('.dino-datachat-component'); + + expect(banner).toBeTruthy(); + expect(table).toBeTruthy(); + /* Node.DOCUMENT_POSITION_FOLLOWING: the table comes after the banner */ + expect(banner.compareDocumentPosition(table) & 4).toBeTruthy(); + }); + + it('should show no banner and no download button for a complete result', async () => { + entry.qa = completeTable; + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.dino-datachat-preview')).toBeNull(); + expect(fixture.nativeElement.querySelector('.dino-datachat-download')).toBeNull(); + expect(fixture.nativeElement.querySelector('.dino-datachat-note')).toBeNull(); + }); + + it('should render the note verbatim, even on a complete result', async () => { + const note = 'First line of the caveat.\nSecond line, not markup.'; + entry.qa = {...completeTable, note}; + await fixture.whenStable(); + fixture.detectChanges(); + + const noteEl = fixture.nativeElement.querySelector('.dino-datachat-note span:last-child'); + + expect(noteEl.textContent).toEqual(note); + expect(noteEl.querySelector('b')).toBeNull(); + }); + + it('should label the download button with the file name', async () => { + entry.qa = truncatedTable; + await fixture.whenStable(); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('.dino-datachat-download'); + + expect(button.textContent).toContain('sentiment_txt.csv'); + }); + + it('should emit the download url and file name on click', async () => { + entry.qa = truncatedTable; + await fixture.whenStable(); + fixture.detectChanges(); + let emitted: {url: string; filename: string} | null = null; + entry.downloadClick.subscribe(evt => (emitted = evt)); + + fixture.nativeElement.querySelector('.dino-datachat-download').click(); + + expect(emitted).not.toBeNull(); + expect(emitted!.url).toEqual(truncatedTable.downloadUrl!); + expect(emitted!.filename).toEqual('sentiment_txt.csv'); + }); + + it('should render one chart component per chart of the answer', async () => { + const chart = {type: 'bar', labels: ['1'], datasets: [{label: 'Risposte', data: [20]}]}; + entry.qa = {...completeTable, charts: [chart, {...chart, title: 'second'}]}; + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelectorAll('dino-datachat-chart').length).toEqual(2); + }); + + it('should render charts of a text answer that never mentions them', async () => { + const chart = {type: 'bar', labels: ['1'], datasets: [{label: 'Risposte', data: [20]}]}; + entry.qa = { + response: 'Questo dataset contiene 804 risposte.', + noPrompt: true, + charts: [chart], + }; + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelectorAll('dino-datachat-chart').length).toEqual(1); + }); + + it('should render charts of an answer with no prose at all', async () => { + const chart = {type: 'bar', labels: ['1'], datasets: [{label: 'Risposte', data: [20]}]}; + entry.qa = {noPrompt: true, charts: [chart]}; + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelectorAll('dino-datachat-chart').length).toEqual(1); + }); + + it('should render no chart component when the answer has no charts', async () => { + entry.qa = completeTable; + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelectorAll('dino-datachat-chart').length).toEqual(0); + }); + + it('should build the answer text of a table from its rows', () => { + /* A tabular answer has no prose of its own: without its rows there would be + * nothing to copy, and its rating would be sent with an empty answer. */ + entry.qa = { + ...truncatedTable, + tableData: [ + {txt: 'ottimo servizio', sentiment: 'positive'}, + {txt: 'mai piu', sentiment: null}, + ], + }; + + expect(entry.canCopy).toBeTrue(); + expect(entry.answerText).toEqual( + 'txt\tsentiment\nottimo servizio\tpositive\nmai piu\t', + ); + }); + + it('should rate a table answer with its rows, never with an empty answer', () => { + entry.qa = { + ...truncatedTable, + question: 'quante recensioni positive?', + feedbackEnabled: true, + tableData: [{txt: 'ottimo servizio', sentiment: 'positive'}], + }; + let emitted: {question: string; answer: string} | null = null; + entry.feedbackClick.subscribe(evt => (emitted = evt)); + + entry.onFeedbackClick(true); + + expect(emitted).not.toBeNull(); + expect(emitted!.question).toEqual('quante recensioni positive?'); + expect(emitted!.answer).toContain('ottimo servizio'); + }); + + it('should rate an answer made of charts alone with their titles', () => { + entry.qa = { + noPrompt: true, + feedbackEnabled: true, + charts: [ + {type: 'bar', labels: ['1'], datasets: [{data: [20]}], title: 'Soddisfazione'}, + {type: 'bar', labels: ['1'], datasets: [{data: [20]}], title: 'Media'}, + ], + }; + let emitted: {answer: string} | null = null; + entry.feedbackClick.subscribe(evt => (emitted = evt)); + + entry.onFeedbackClick(false); + + expect(emitted!.answer).toEqual('Soddisfazione, Media'); + }); + + it('should detect dropped columns only when some are missing', () => { + expect(entry.hasDroppedColumns(truncatedTable)).toBeTrue(); + expect(entry.hasDroppedColumns(completeTable)).toBeFalse(); + expect(entry.hasDroppedColumns({truncated: true})).toBeFalse(); + }); +}); diff --git a/projects/material/datachat/src/datachat-entry.ts b/projects/material/datachat/src/datachat-entry.ts index be173b5fb..5d6b94e16 100644 --- a/projects/material/datachat/src/datachat-entry.ts +++ b/projects/material/datachat/src/datachat-entry.ts @@ -39,6 +39,11 @@ import * as mrkd from 'marked'; */ const COPIED_FEEDBACK_TIME = 2000; +/** + * The maximum length of the answer sent along with a rating. + */ +const MAX_FEEDBACK_ANSWER_LENGTH = 2000; + /** * The ChatEntry component. * Displays a single chat question/response in the DataChat history @@ -85,6 +90,14 @@ export class DataChatEntry implements OnDestroy { */ @Output() regenerateClick: EventEmitter = new EventEmitter(); + /** + * Emitted when the download of the complete result is requested + */ + @Output() downloadClick: EventEmitter<{url: string; filename: string}> = new EventEmitter<{ + url: string; + filename: string; + }>(); + /** * True right after the answer has been copied to the clipboard */ @@ -138,10 +151,53 @@ export class DataChatEntry implements OnDestroy { (this.qa.response != null || this.qa.explanation != null || this.qa.imageData != null || - this.qa.componentData != null) + this.qa.componentData != null || + this.qa.note != null || + this.qa.truncated === true || + this.hasCharts) ); } + /** + * True when the entry carries charts to draw + */ + get hasCharts(): boolean { + return this.qa?.charts != null && this.qa.charts.length > 0; + } + + /** + * True when the complete result of this answer can be downloaded + */ + get canDownload(): boolean { + return this.qa?.downloadUrl != null && this.qa.downloadUrl.length > 0; + } + + /** + * True when the displayed table holds fewer columns than the complete result + * @param qa The datachat QA entry + */ + hasDroppedColumns(qa: DataChatQA): boolean { + return ( + qa.totalColumns != null && qa.previewColumns != null && qa.totalColumns > qa.previewColumns + ); + } + + /** + * True when the row counts of the preview banner are known + * @param qa The datachat QA entry + */ + hasRowCounts(qa: DataChatQA): boolean { + return qa.previewRows != null && qa.totalRows != null; + } + + onDownloadClick(): void { + if (!this.qa || !this.qa.downloadUrl) return; + this.downloadClick.emit({ + url: this.qa.downloadUrl, + filename: this.qa.downloadFilename ?? 'export.csv', + }); + } + /** * True when the User can rate this entry: either the backend returned a log * id for it (completion mode) or the chat marked it as rateable (datachat @@ -151,11 +207,25 @@ export class DataChatEntry implements OnDestroy { return this.qa != null && (this.qa.log_id != null || this.qa.feedbackEnabled === true); } + /** + * The answer as text: its prose and, when it is a table, its rows. + * A tabular answer has no prose of its own, so without its rows there would + * be nothing to copy and nothing to send along with its rating. + */ + get answerText(): string { + if (this.qa == null) { + return ''; + } + return [this.qa.explanation, this.qa.response, this._tableText()] + .filter(part => part) + .join('\n\n'); + } + /** * True when the answer holds some text to put in the clipboard. */ get canCopy(): boolean { - return this.qa != null && (this.qa.response != null || this.qa.explanation != null); + return this.answerText.length > 0; } /** @@ -170,7 +240,7 @@ export class DataChatEntry implements OnDestroy { * True when the answer displays its actions row. */ get showActions(): boolean { - return this.feedbackAvailable || this.canCopy || this.canRegenerate; + return this.feedbackAvailable || this.canCopy || this.canRegenerate || this.canDownload; } /** @@ -178,7 +248,7 @@ export class DataChatEntry implements OnDestroy { * couple of seconds. */ copyAnswer(): void { - const text = [this.qa?.explanation, this.qa?.response].filter(part => part).join('\n\n'); + const text = this.answerText; if (!text || typeof navigator === 'undefined' || navigator.clipboard == null) { return; } @@ -217,11 +287,49 @@ export class DataChatEntry implements OnDestroy { logId: this.qa.log_id ?? '', isPositive, question: this.qa.question ?? '', - answer: this.qa.response ?? this.qa.explanation ?? '', + answer: this._feedbackAnswer(), }); } } + /** + * The answer sent along with a rating. It is a log field, so a long table is + * cut, and an answer made of charts alone is described by their titles: an + * empty answer would say nothing about what the User rated. + */ + private _feedbackAnswer(): string { + const text = this.answerText; + if (text) { + return text.length > MAX_FEEDBACK_ANSWER_LENGTH + ? `${text.slice(0, MAX_FEEDBACK_ANSWER_LENGTH)}…` + : text; + } + return (this.qa?.charts ?? []) + .map(chart => chart.title) + .filter(title => title) + .join(', '); + } + + /** + * The rows of a tabular answer, as tab separated text: the format a + * spreadsheet understands when the answer is pasted into it. + * @returns The table as text, null when the answer holds no table + */ + private _tableText(): string | null { + const rows = Array.isArray(this.qa?.tableData) ? (this.qa?.tableData as unknown[]) : null; + const firstRow = rows?.length ? rows[0] : null; + if (firstRow == null || typeof firstRow !== 'object') { + return null; + } + const columns = Object.keys(firstRow); + const line = (values: unknown[]) => + values.map(value => (value == null ? '' : `${value}`)).join('\t'); + return [ + line(columns), + ...rows!.map(row => line(columns.map(column => (row as {[key: string]: unknown})[column]))), + ].join('\n'); + } + getFormattedResponse(qa: DataChatQA): string { if (!qa.response) return ''; return mrkd.parse(qa.response) as string; diff --git a/projects/material/datachat/src/datachat-session.service.ts b/projects/material/datachat/src/datachat-session.service.ts index b46868594..15e7c4113 100644 --- a/projects/material/datachat/src/datachat-session.service.ts +++ b/projects/material/datachat/src/datachat-session.service.ts @@ -277,7 +277,11 @@ export class DataChatSessionService { if (active == null) { return; } - active.messages = messages.map(qa => this._withoutField(qa, 'componentData')); + // The component instance of a table cannot be stored - its rows are, in + // tableData - and an export link dies with the chat session. + active.messages = messages.map(qa => + this._withoutFields(qa, ['componentData', 'downloadUrl', 'downloadFilename']), + ); this._cancelPendingSave(); this._saveTimeout = setTimeout(() => { this._saveTimeout = null; @@ -362,13 +366,15 @@ export class DataChatSessionService { } /** - * Returns a copy of the given chat entry without the given field. + * Returns a copy of the given chat entry without the given fields. * Dynamic components (the generated tables, the progress bar of a pending * answer) cannot be stored and are dropped this way. */ - private _withoutField(qa: DataChatQA, field: keyof DataChatQA): DataChatQA { + private _withoutFields(qa: DataChatQA, fields: (keyof DataChatQA)[]): DataChatQA { const copy: DataChatQA = {...qa}; - delete copy[field]; + for (const field of fields) { + delete copy[field]; + } return copy; } } diff --git a/projects/material/datachat/src/datachat.html b/projects/material/datachat/src/datachat.html index 3eedac5b0..86f176fb1 100644 --- a/projects/material/datachat/src/datachat.html +++ b/projects/material/datachat/src/datachat.html @@ -81,6 +81,7 @@

diff --git a/projects/material/datachat/src/datachat.interfaces.ts b/projects/material/datachat/src/datachat.interfaces.ts index f338433b4..7fdabc18b 100644 --- a/projects/material/datachat/src/datachat.interfaces.ts +++ b/projects/material/datachat/src/datachat.interfaces.ts @@ -38,6 +38,172 @@ export interface DataChatQA { * log id for it, as it happens for the datachat answers. */ feedbackEnabled?: boolean; + /** + * Number of rows of the complete result. The displayed table may hold fewer. + */ + totalRows?: number; + /** + * Number of columns of the complete result. The displayed table may hold fewer. + */ + totalColumns?: number; + /** + * Number of rows actually displayed + */ + previewRows?: number; + /** + * Number of columns actually displayed + */ + previewColumns?: number; + /** + * True when the displayed table is only a subset of the complete result + */ + truncated?: boolean; + /** + * Server relative path of the complete result, downloadable as a csv file. + * An export lives as long as the chat session, so it is never stored with + * the conversation. + */ + downloadUrl?: string; + /** + * Suggested file name for the downloaded complete result + */ + downloadFilename?: string; + /** + * A caveat about the result itself, to be displayed verbatim + */ + note?: string; + /** + * The charts to be displayed alongside the answer + */ + charts?: DataChatChartSpec[]; + /** + * The rows of a tabular answer. They are stored with the conversation, so + * that its table can be built again when the conversation is displayed + * again: a component instance cannot be stored. + */ + tableData?: unknown; +} + +/** + * The state of a chart specification, as resolved by DataChatChart + */ +export type DataChatChartStatus = 'ok' | 'invalid' | 'empty'; + +/** + * The response types returned by the DataChat API + */ +export type DataChatResponseType = + | 'str' + | 'dataframe' + | 'image' + | 'dict' + | 'text_and_image' + | 'chart'; + +/** + * A chart specification, in the Chart.js 'data' shape plus some semantic hints. + * The API never sends colors nor a Chart.js 'options' object: palette, fonts, legend + * and theming are up to the client. + */ +export interface DataChatChartSpec { + /** + * The chart type, i.e. bar | line | pie | doughnut | scatter + */ + type: string; + /** + * The category labels, one per point. Null for scatter charts. + */ + labels?: string[] | null; + datasets: DataChatChartDataset[]; + title?: string | null; + /** + * The x axis label. It may be a whole survey question, so expect very long strings. + */ + x_label?: string | null; + /** + * The y axis label, i.e. 'numero di risposte' + */ + y_label?: string | null; + /** + * True when a multi series bar chart reads better stacked + */ + stacked?: boolean; + /** + * True when the bars of a bar chart run left to right, which the API chooses for + * many categories or long labels. The categories keep the order they arrive in. + */ + horizontal?: boolean; + [key: string]: any; +} + +/** + * A single series of a chart specification + */ +export interface DataChatChartDataset { + label?: string | null; + /** + * The series values, parallel to the chart labels, or the {x, y} points of a + * scatter chart. A null value is a missing value, and must be displayed as a gap, + * never as a zero. + */ + data: (number | null)[] | {x: number; y: number}[]; + /** + * True for area charts + */ + fill?: boolean; + [key: string]: any; +} + +/** + * The 'response' object of a DataChat API reply. + * Additive fields may appear at any time, so unknown fields must be tolerated and + * a missing key and a null value always mean the same thing. + */ +export interface DataChatResponsePayload { + type: DataChatResponseType | string; + value: any; + /** + * Rows of the complete result + */ + total_rows?: number | null; + /** + * Columns of the complete result + */ + total_columns?: number | null; + /** + * Rows present in 'value' + */ + preview_rows?: number | null; + /** + * True when 'value' is a subset of the complete result. Absent means false. + */ + truncated?: boolean; + /** + * Server relative path of the complete result csv, i.e. /datachat/export/ + */ + download_url?: string | null; + /** + * Suggested file name of the complete result csv + */ + download_filename?: string | null; + /** + * A caveat about the result. Absent when there is none, never null. + */ + note?: string; + /** + * Zero or more charts to be displayed alongside the value. + * Absent when there are none, never null and never empty. + */ + charts?: DataChatChartSpec[]; +} + +/** + * A DataChat API reply + */ +export interface DataChatApiResponse { + response: DataChatResponsePayload; + explanation?: string | null; + log_id?: string | number | null; } export interface ComponentData { diff --git a/projects/material/datachat/src/datachat.module.ts b/projects/material/datachat/src/datachat.module.ts index 6ee6d1d80..2beef3a57 100644 --- a/projects/material/datachat/src/datachat.module.ts +++ b/projects/material/datachat/src/datachat.module.ts @@ -36,6 +36,7 @@ import {LoadingSpinnerModule as DinoLoadingSpinnerModule} from '@dino/material/l import {DataChat} from './datachat'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {TableGeneratorModule as DinoTableGeneratorModule} from '@dino/material/table-generator'; +import {DataChatChart} from './datachat-chart'; import {DataChatEntry} from './datachat-entry'; import {MatProgressBarModule} from '@angular/material/progress-bar'; import {ParagraphDialogComponent} from './paragraph-dialog.component'; @@ -71,7 +72,7 @@ import {BreakpointObserverModule} from '@dino/material/breakpoint-observer'; RouterModule, TranslocoModule, ], - declarations: [DataChat, DataChatEntry, ParagraphDialogComponent, RelativeDatePipe], - exports: [DataChat, DataChatEntry, ParagraphDialogComponent, RelativeDatePipe], + declarations: [DataChat, DataChatChart, DataChatEntry, ParagraphDialogComponent, RelativeDatePipe], + exports: [DataChat, DataChatChart, DataChatEntry, ParagraphDialogComponent, RelativeDatePipe], }) export class DataChatModule {} diff --git a/projects/material/datachat/src/datachat.spec.ts b/projects/material/datachat/src/datachat.spec.ts index a0d67915f..2fbddda9b 100644 --- a/projects/material/datachat/src/datachat.spec.ts +++ b/projects/material/datachat/src/datachat.spec.ts @@ -2,6 +2,8 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {DataChatModule} from './datachat.module'; import {DataChat} from './datachat'; +import {DataChatQA} from './datachat.interfaces'; +import {MatSnackBar} from '@angular/material/snack-bar'; import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; import {AUTH_SERVICE_CONFIG, AuthService, AuthServiceConfig} from '@dino/core/auth'; import {BehaviorSubject, of} from 'rxjs'; @@ -90,6 +92,8 @@ const authServiceMock = { resetEvt: of(false), logout: () => of(false), logoutEvt: new EventEmitter(), + tokenRefreshedEvt: new EventEmitter(), + hasValidAuthToken: () => true, _authConfig: new BehaviorSubject(authServiceConfig), authConfig: authServiceConfig, } as unknown as AuthService; @@ -156,4 +160,317 @@ describe('Data Chat', () => { expect(addHistorySpy).toHaveBeenCalledWith({question: 'test_question'}); expect(dataChat.history.length).toEqual(1); }); + + describe('preview and export info', () => { + const previewInfo = (response: any) => + (dataChat as any)._previewInfoFromResponse(response) as DataChatQA; + + it('should map the preview info of a truncated dataframe', () => { + const info = previewInfo({ + type: 'dataframe', + value: [ + {city: 'c0', n: 0}, + {city: 'c1', n: 1}, + ], + total_rows: 340, + total_columns: 2, + preview_rows: 20, + truncated: true, + download_url: '/datachat/export/bf80bb41d8214522b0d38bca61afbd26', + download_filename: 'cities.csv', + }); + + expect(info.truncated).toBeTrue(); + expect(info.totalRows).toEqual(340); + expect(info.totalColumns).toEqual(2); + expect(info.previewRows).toEqual(20); + expect(info.previewColumns).toEqual(2); + expect(info.downloadUrl).toEqual('/datachat/export/bf80bb41d8214522b0d38bca61afbd26'); + expect(info.downloadFilename).toEqual('cities.csv'); + expect(info.note).toBeUndefined(); + }); + + it('should map a complete dataframe with no export', () => { + const info = previewInfo({ + type: 'dataframe', + value: [{city: 'Roma', n: 1}], + total_rows: 2, + total_columns: 2, + preview_rows: 2, + truncated: false, + download_url: null, + download_filename: null, + }); + + expect(info.truncated).toBeFalse(); + expect(info.downloadUrl).toBeUndefined(); + expect(info.downloadFilename).toBeUndefined(); + }); + + it('should not infer the truncated flag from the row counts', () => { + const info = previewInfo({ + type: 'dataframe', + value: [{city: 'Roma'}], + total_rows: 500, + preview_rows: 20, + truncated: false, + }); + + expect(info.truncated).toBeFalse(); + }); + + it('should count the columns actually displayed', () => { + const info = previewInfo({ + type: 'dataframe', + value: [{txt: 'ottimo servizio', sentiment: 'positive', score: 0.95}], + total_columns: 14, + }); + + expect(info.previewColumns).toEqual(3); + expect(info.totalColumns).toEqual(14); + }); + + it('should keep a note of a complete result and tolerate unknown fields', () => { + const note = '12 rows could not be analyzed: their sentiment is empty, not neutral.'; + const info = previewInfo({ + type: 'dataframe', + value: [{txt: 'mai piu', sentiment: null}], + truncated: false, + note, + some_future_field: {nested: true}, + }); + + expect(info.note).toEqual(note); + expect(info.truncated).toBeFalse(); + }); + + it('should map an export of a text answer', () => { + const info = previewInfo({ + type: 'str', + value: 'Export pronto: 340 righe.', + download_url: '/datachat/export/abc123', + download_filename: 'dataset.csv', + }); + + expect(info.downloadUrl).toEqual('/datachat/export/abc123'); + expect(info.downloadFilename).toEqual('dataset.csv'); + expect(info.previewColumns).toBeUndefined(); + }); + + it('should default to no preview info for a plain text answer', () => { + const info = previewInfo({type: 'str', value: 'Questo dataset contiene...'}); + + expect(info.truncated).toBeFalse(); + expect(info.totalRows).toBeUndefined(); + expect(info.downloadUrl).toBeUndefined(); + expect(info.note).toBeUndefined(); + expect(info.charts).toBeUndefined(); + }); + }); + + describe('charts', () => { + const chartSpec = (label: string) => ({ + type: 'bar', + labels: ['1', '2'], + datasets: [{label, data: [20, 71]}], + title: label, + }); + const previewInfo = (response: any) => + (dataChat as any)._previewInfoFromResponse(response) as DataChatQA; + + it('should map the charts of a text answer', () => { + const info = previewInfo({ + type: 'str', + value: '### Analisi del dataset', + charts: [chartSpec('Soddisfazione'), chartSpec('Media per programma')], + }); + + expect(info.charts?.length).toEqual(2); + expect(info.charts![0].title).toEqual('Soddisfazione'); + }); + + it('should keep the further charts of a chart answer, and repeat none', async () => { + /* The value of a chart answer is the primary chart, and charts holds the ones + * the API did not put in it: dropping them would lose the answer's own extras. */ + await fixtureDataChat.whenStable(); + fixtureDataChat.detectChanges(); + dataChat.baseDataChatAPIurl = 'http://127.0.0.1:5000'; + dataChat.apiKey.next('key_code'); + spyOn(dataChat, '_ensureAgent').and.returnValue(of(true)); + spyOn(dataChat['_udm'], 'getActiveUserData').and.returnValue( + of({email: 'test@test.com'}), + ); + + dataChat.dataChat('due grafici'); + httpTestingController.expectOne('http://127.0.0.1:5000/datachat').flush({ + response: { + type: 'chart', + value: chartSpec('Primario'), + charts: [chartSpec('Secondario')], + }, + explanation: null, + }); + + const answer = dataChat.history[dataChat.history.length - 1]; + expect(answer.charts?.length).toEqual(2); + expect(answer.charts![0].title).toEqual('Primario'); + expect(answer.charts![1].title).toEqual('Secondario'); + }); + + it('should map the charts of a dataframe answer', () => { + const info = previewInfo({ + type: 'dataframe', + value: [{programma: 'INTELLIGENZA ARTIFICIALE', media: 3.573}], + total_rows: 8, + truncated: false, + charts: [chartSpec('Soddisfazione media')], + }); + + expect(info.charts?.length).toEqual(1); + expect(info.totalRows).toEqual(8); + }); + + it('should map unprompted charts, whatever the answer says', () => { + /* The API attaches every chart built during a run, even when the answer does not + * mention one: nothing here may depend on the text referring to a chart. */ + const info = previewInfo({ + type: 'str', + value: 'Questo dataset contiene 804 risposte.', + charts: [chartSpec('Soddisfazione')], + }); + const emptyText = previewInfo({type: 'str', value: '', charts: [chartSpec('Soddisfazione')]}); + + expect(info.charts?.length).toEqual(1); + expect(emptyText.charts?.length).toEqual(1); + }); + + it('should cap the charts of a single answer', () => { + const info = previewInfo({ + type: 'str', + value: 'many charts', + charts: Array.from({length: 8}, (_, idx) => chartSpec(`chart ${idx}`)), + }); + + expect(info.charts?.length).toEqual(6); + }); + + it('should drop what is not a chart and keep no empty list', () => { + const withoutDatasets = previewInfo({ + type: 'str', + value: 'x', + charts: [{type: 'bar', labels: ['1']}, null, 'not a chart'], + }); + const emptyList = previewInfo({type: 'str', value: 'x', charts: []}); + + expect(withoutDatasets.charts).toBeUndefined(); + expect(emptyList.charts).toBeUndefined(); + }); + }); + + describe('image base64', () => { + const clean = (value: any) => (dataChat as any)._cleanBase64(value) as string; + + it('should strip the python bytes repr wrapper', () => { + expect(clean("b'iVBORw0KAAA='")).toEqual('iVBORw0KAAA='); + expect(clean('b"iVBORw0KAAA="')).toEqual('iVBORw0KAAA='); + }); + + it('should leave a correctly encoded image untouched', () => { + expect(clean('iVBORw0KAAA=')).toEqual('iVBORw0KAAA='); + expect(clean('')).toEqual(''); + }); + }); + + describe('export download', () => { + const exportPath = '/datachat/export/bf80bb41d8214522b0d38bca61afbd26'; + let snackBarSpy: jasmine.Spy; + + beforeEach(() => { + dataChat.baseDataChatAPIurl = 'http://127.0.0.1:5000/'; + dataChat.apiKey.next('key_code'); + spyOn((dataChat as any)._udm, 'getActiveUserData').and.returnValue( + of({email: 'test@test.com'}), + ); + snackBarSpy = spyOn(TestBed.inject(MatSnackBar), 'open'); + }); + + it('should request the export with the api key and user email headers', () => { + dataChat.downloadExport(exportPath, 'cities.csv'); + + const req = httpTestingController.expectOne(`http://127.0.0.1:5000${exportPath}`); + + expect(req.request.method).toEqual('GET'); + expect(req.request.responseType).toEqual('blob'); + expect(req.request.headers.keys()).toEqual(['X-API-KEY', 'X-USER-EMAIL']); + expect(req.request.headers.get('X-USER-EMAIL')).toEqual('test@test.com'); + + req.flush(new Blob(['city,n\nRoma,1'], {type: 'text/csv'})); + }); + + it('should emit the downloaded file', () => { + let downloaded: {blob: Blob; filename: string} | null = null; + dataChat.exportDownload.subscribe(evt => (downloaded = evt)); + + dataChat.downloadExport(exportPath, 'cities.csv'); + httpTestingController + .expectOne(`http://127.0.0.1:5000${exportPath}`) + .flush(new Blob(['city,n\nRoma,1'], {type: 'text/csv'})); + + expect(downloaded).not.toBeNull(); + expect(downloaded!.filename).toEqual('cities.csv'); + expect(downloaded!.blob.type).toEqual('text/csv'); + }); + + it('should explain an expired export on 404 without emitting a file', () => { + let emitted = false; + dataChat.exportDownload.subscribe(() => (emitted = true)); + + dataChat.downloadExport(exportPath, 'cities.csv'); + httpTestingController + .expectOne(`http://127.0.0.1:5000${exportPath}`) + .flush(new Blob(['{"error":"Export not found or expired"}']), { + status: 404, + statusText: 'Not Found', + }); + + expect(emitted).toBeFalse(); + expect(snackBarSpy).toHaveBeenCalled(); + expect(snackBarSpy.calls.mostRecent().args[0]).toContain('no longer available'); + }); + + it('should explain an ended chat session on 400', () => { + dataChat.downloadExport(exportPath, 'cities.csv'); + httpTestingController + .expectOne(`http://127.0.0.1:5000${exportPath}`) + .flush(new Blob(['{"error":"Agent not active for this Api Key"}']), { + status: 400, + statusText: 'Bad Request', + }); + + expect(snackBarSpy.calls.mostRecent().args[0]).toContain('chat session has ended'); + }); + + it('should not parse the html body of a 403', () => { + dataChat.downloadExport(exportPath, 'cities.csv'); + + expect(() => + httpTestingController + .expectOne(`http://127.0.0.1:5000${exportPath}`) + .flush(new Blob(['Forbidden'], {type: 'text/html'}), { + status: 403, + statusText: 'Forbidden', + }), + ).not.toThrow(); + + expect(snackBarSpy).toHaveBeenCalled(); + }); + + it('should not request anything without an api key', () => { + dataChat.apiKey.next(null); + + dataChat.downloadExport(exportPath, 'cities.csv'); + + httpTestingController.expectNone(`http://127.0.0.1:5000${exportPath}`); + }); + }); }); diff --git a/projects/material/datachat/src/datachat.ts b/projects/material/datachat/src/datachat.ts index 480679cf9..08fd4de77 100644 --- a/projects/material/datachat/src/datachat.ts +++ b/projects/material/datachat/src/datachat.ts @@ -32,6 +32,7 @@ import { OnDestroy, OnInit, Optional, + Output, ViewChild, ViewEncapsulation, } from '@angular/core'; @@ -39,8 +40,16 @@ import {FormControl, FormGroup, Validators} from '@angular/forms'; import {ActivatedRoute, Router} from '@angular/router'; import {DataChatSessionService} from './datachat-session.service'; import {DataChatConversation} from './datachat-store'; -import {CompletionRequest, CompletionResponse, DataChatQA} from './datachat.interfaces'; -import {HttpClient} from '@angular/common/http'; +import { + CompletionRequest, + CompletionResponse, + ComponentData, + DataChatApiResponse, + DataChatChartSpec, + DataChatQA, + DataChatResponsePayload, +} from './datachat.interfaces'; +import {HttpBackend, HttpClient, HttpErrorResponse} from '@angular/common/http'; import {catchError, map, shareReplay, switchMap, take, takeUntil, tap} from 'rxjs/operators'; import {ErrorHandlerMessageService} from '@dino/core/error-handler'; import { @@ -82,6 +91,23 @@ import { */ const COMPLETION_CONVERSATIONS_KEY = 'completion'; +/** + * The maximum number of charts displayed for a single answer, as documented by the API. + * When the API has more charts than this, its response carries a note saying so. + */ +const MAX_CHARTS_PER_ANSWER = 6; + +/** + * The text displayed for a null cell of a generated table: a value that was not + * analyzed is not a value of its own. + */ +const EMPTY_CELL_PLACEHOLDER = '—'; + +/** + * The maximum number of rows displayed by a generated table + */ +const MAX_TABLE_ROWS = 50; + /** * The DataChat component. * The active User can chat with a LLM via Flask API (PanDino) to analyze @@ -125,6 +151,15 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { */ readonly apiKeyConfirmationEvt: EventEmitter = new EventEmitter(); + /** + * Emitted when a DataChat export has been downloaded, so that the host application + * can save it with the most appropriate strategy for its platform + */ + @Output() exportDownload: EventEmitter<{blob: Blob; filename: string}> = new EventEmitter<{ + blob: Blob; + filename: string; + }>(); + /** * The currently confirmed Api Key */ @@ -337,6 +372,14 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { */ private _nodesVisibility: Observable; + /** + * Http client used to download the exports, bypassing the interceptors. + * The export endpoint answers 400 when its agent is gone, and JWTInterceptor + * reads any 400 as an expired token: it would refresh the auth token, replay + * the request and possibly log the user out. + */ + private readonly _exportHttp: HttpClient; + /** * If present, terms of use for GPT have been accepted */ @@ -395,7 +438,9 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { @Optional() private _lc: LocationManager | null, @Optional() private _og: OrganizationManager | null, private _cdr: ChangeDetectorRef, + httpBackend: HttpBackend, ) { + this._exportHttp = new HttpClient(httpBackend); this.conversations = this._session.conversations; this.activeConversation = this._session.activeConversation; // The sidebar is closed by default on small screens, until the User @@ -537,7 +582,7 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { this._conversationKey = this.mode === 'datachat' ? this._schemaId : COMPLETION_CONVERSATIONS_KEY; if (this._conversationKey != null) { this._session.openScope(this._conversationKey).then(messages => { - this.history = messages; + this.history = this._restoreTables(messages); this._cdr.detectChanges(); this._scrollChatBottom(); }); @@ -565,6 +610,21 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { this._cdr.detectChanges(); } + /** + * Builds again the tables of the entries of a stored conversation: a table is + * a component instance, which cannot be stored, but its rows are. + * @param messages The chat entries of the conversation + * @returns The same entries, with their tables + */ + private _restoreTables(messages: DataChatQA[]): DataChatQA[] { + for (const qa of messages) { + if (qa.tableData != null && qa.componentData == null) { + qa.componentData = this._tableComponentData(qa.tableData); + } + } + return messages; + } + /** * Displays a stored conversation. The live agent is left untouched: the * restored entries are shown as they were, and any new question is answered @@ -577,7 +637,7 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { } this._session.openConversation(conversation.id).then(messages => { if (messages != null) { - this.history = messages; + this.history = this._restoreTables(messages); this._cdr.detectChanges(); this._scrollChatBottom(); } @@ -725,11 +785,7 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { const url = `${this.baseDataChatAPIurl}/${ this.endpointUrls?.dataChatEndpoint ?? 'datachat' }`; - return this._http.post<{ - explanation: string; - response: {type: string; value: any}; - log_id?: string | number; - }>(url, {'chat': text}, {headers}); + return this._http.post(url, {'chat': text}, {headers}); }), take(1), ) @@ -740,51 +796,51 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { } this._removeLastFromHistory(); if (res) { + // The question is kept on every answer entry too - hidden by + // noPrompt - so that the feedback request can quote it. + const answer: DataChatQA = { + ...this._previewInfoFromResponse(res.response), + question: text, + explanation: res.explanation ?? undefined, + noPrompt: true, + feedbackEnabled: true, + log_id: res.log_id ?? undefined, + }; switch (res.response.type) { case 'image': const base64string: string = res.response.value; - const base64imageData = `data:image/png;base64, ${base64string - .replace("b'", '') - .slice(0, -1)}`; this._addToHistory({ - // The question is kept on the answer entry too - hidden by - // noPrompt - so that the feedback request can quote it. - question: text, - explanation: res.explanation, - imageData: base64imageData, - noPrompt: true, - feedbackEnabled: true, - log_id: res.log_id, + ...answer, + imageData: `data:image/png;base64,${this._cleanBase64(base64string)}`, + }); + break; + case 'chart': + this._addToHistory({ + ...answer, + // The value is the primary chart and charts holds the further + // ones, which the API never repeats inside it. + charts: this._sanitizeCharts([ + res.response.value, + ...(res.response.charts ?? []), + ]), }); break; case 'dataframe': this._addToHistory({ - question: text, - explanation: res.explanation, - componentData: { - component: TableGenerator, - inputs: {maxRowsDisplayed: 50, setJsonData: res.response.value}, - }, - noPrompt: true, - feedbackEnabled: true, - log_id: res.log_id, + ...answer, + tableData: res.response.value, + componentData: this._tableComponentData(res.response.value), }); break; default: + const isTabular = typeof res.response.value === 'object'; this._addToHistory({ - question: text, - explanation: res.explanation, - response: typeof res.response.value === 'object' ? undefined : res.response.value, - componentData: - typeof res.response.value === 'object' - ? { - component: TableGenerator, - inputs: {maxRowsDisplayed: 50, setJsonData: res.response.value}, - } - : undefined, - noPrompt: true, - feedbackEnabled: true, - log_id: res.log_id, + ...answer, + response: isTabular ? undefined : res.response.value, + tableData: isTabular ? res.response.value : undefined, + componentData: isTabular + ? this._tableComponentData(res.response.value) + : undefined, }); break; } @@ -818,6 +874,161 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { }); } + /** + * Maps the additive fields of a DataChat response, i.e. the preview, export and + * chart info, onto a chat history entry. + * A missing field and a null field always mean the same thing. + * @param response The 'response' object of the DataChat reply + * @returns The preview, export and chart fields of the history entry + */ + private _previewInfoFromResponse(response: DataChatResponsePayload): Partial { + return { + truncated: response.truncated === true, + totalRows: response.total_rows ?? undefined, + totalColumns: response.total_columns ?? undefined, + previewRows: response.preview_rows ?? undefined, + previewColumns: this._previewColumnsCount(response.value), + downloadUrl: response.download_url ?? undefined, + downloadFilename: response.download_filename ?? undefined, + note: response.note ?? undefined, + charts: this._sanitizeCharts(response.charts), + }; + } + + /** + * Keeps the chart specifications that can be displayed, capped to the maximum + * number of charts of a single answer. + * Only what is not a chart at all is discarded here: a chart that cannot be drawn + * is displayed as such by DataChatChart, instead of disappearing silently. + * @param charts The charts of the DataChat reply + * @returns The charts to display, undefined if there are none + */ + private _sanitizeCharts(charts: any): DataChatChartSpec[] | undefined { + if (!Array.isArray(charts)) return undefined; + const valid = charts.filter( + chart => chart != null && typeof chart === 'object' && Array.isArray(chart.datasets), + ); + return valid.length ? valid.slice(0, MAX_CHARTS_PER_ANSWER) : undefined; + } + + /** + * Counts the columns actually displayed. TableGenerator builds its columns from the + * keys of the first row, so that is what the user sees. + * @param value The 'value' of the DataChat reply + * @returns The number of displayed columns, undefined if the value is not tabular + */ + private _previewColumnsCount(value: any): number | undefined { + const firstRow = Array.isArray(value) ? value[0] : value; + if (firstRow == null || typeof firstRow !== 'object') return undefined; + return Object.keys(firstRow).length; + } + + /** + * Builds the table displaying the rows of a tabular answer. + * @param rows The rows of the answer + * @returns The TableGenerator component data + */ + private _tableComponentData(rows: unknown): ComponentData { + return { + component: TableGenerator, + inputs: { + maxRowsDisplayed: MAX_TABLE_ROWS, + setJsonData: rows, + emptyCellPlaceholder: EMPTY_CELL_PLACEHOLDER, + }, + }; + } + + /** + * Strips the python bytes repr wrapper, i.e. b'...', from a base64 encoded image. + * A correctly encoded image is returned untouched, so that the API can stop + * wrapping its images at any time without breaking this client. + * @param value The image value of the DataChat reply + * @returns The base64 encoded image + */ + private _cleanBase64(value: string): string { + const trimmed = (value ?? '').trim(); + const wrapped = /^b(['"])([\s\S]*)\1$/.exec(trimmed); + return wrapped ? wrapped[2] : trimmed; + } + + /** + * Downloads the complete result of a DataChat answer as a csv file. + * The export endpoint is not publicly reachable, so it must be requested with the + * same headers as the 'datachat' endpoint. The downloaded file is emitted through + * the exportDownload event, to be saved by the host application. + * @param url The server relative path of the export, as received in the response + * @param filename The suggested file name of the export + */ + downloadExport(url: string, filename: string): void { + if (!this.baseDataChatAPIurl || !this.apiKey.value || !url) return; + this._udm + .getActiveUserData() + .pipe( + switchMap(activeUserData => { + if (!activeUserData || !this.apiKey.value) return obsOf(null); + const headers = {'X-API-KEY': this.apiKey.value, 'X-USER-EMAIL': activeUserData.email}; + return this._exportHttp.get(this._exportUrl(url), {headers, responseType: 'blob'}); + }), + take(1), + ) + .subscribe({ + next: blob => { + if (!blob) return; + this.exportDownload.emit({blob, filename}); + }, + error: (err: HttpErrorResponse) => this._handleExportError(err), + }); + } + + /** + * Joins the base DataChat url and the server relative export path. + * The export token is never parsed nor rebuilt. + * @param downloadUrl The server relative path of the export + * @returns The absolute export url + */ + private _exportUrl(downloadUrl: string): string { + const base = (this.baseDataChatAPIurl ?? '').replace(/\/+$/, ''); + return `${base}${downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`}`; + } + + /** + * Notifies the user of a failed export download. + * Exports live as long as the chat session, so an expired or unknown token is an + * expected outcome and is not reported as an error. + * The error body is not parsed: a blob response type leaves it as a Blob, and the + * 403 body is an html page. + * @param err The http error + */ + private _handleExportError(err: HttpErrorResponse): void { + let message: string; + switch (err.status) { + case 404: + message = 'This download is no longer available. Please run the query again'; + break; + case 400: + message = 'The chat session has ended. Please run the query again'; + break; + case 0: + case 401: + case 403: + message = 'DINO-AI is not responding at the moment. Please try later'; + break; + default: + message = 'Could not download the export file'; + if (isDevMode()) { + console.log(err); + } else { + this._ehms.captureErrorMessage( + `DINO-AI export download error: ${JSON.stringify(err)}`, + 'warning', + ); + } + break; + } + this._snackBar.open(this._ts.translate(message), 'OK', {duration: 5000}); + } + /** * Sends a message to the API 'agentchat' endpoint and adds the response * to the chat history diff --git a/projects/material/datachat/src/public_api.ts b/projects/material/datachat/src/public_api.ts index 0fc8ce7f5..398681aad 100644 --- a/projects/material/datachat/src/public_api.ts +++ b/projects/material/datachat/src/public_api.ts @@ -21,6 +21,7 @@ */ export * from './datachat'; +export * from './datachat-chart'; export * from './datachat-entry'; export * from './datachat-session.service'; export * from './datachat-store'; diff --git a/projects/material/package.json b/projects/material/package.json index b8a5ae4c8..dff239b6c 100644 --- a/projects/material/package.json +++ b/projects/material/package.json @@ -21,6 +21,7 @@ "@dino/core": "0.0.0-PLACEHOLDER", "@ngneat/transloco": "0.0.0-TRANSLOCO", "angular-material-css-vars": "0.0.0-AMCV", + "chart.js": "0.0.0-CHARTJS", "rxdb": "0.0.0-RXDB", "rxjs": "0.0.0-RXJS", "xlsx": "0.0.0-XLSX" diff --git a/projects/material/table-generator/src/table-generator.html b/projects/material/table-generator/src/table-generator.html index 19e357b8f..5723d896e 100644 --- a/projects/material/table-generator/src/table-generator.html +++ b/projects/material/table-generator/src/table-generator.html @@ -1,7 +1,7 @@ {{title[column]}} - {{element[column]}} + {{formatCell(element[column])}} diff --git a/projects/material/table-generator/src/table-generator.spec.ts b/projects/material/table-generator/src/table-generator.spec.ts index fd8b25f95..840f57e68 100644 --- a/projects/material/table-generator/src/table-generator.spec.ts +++ b/projects/material/table-generator/src/table-generator.spec.ts @@ -44,4 +44,31 @@ describe('Table Generator', () => { expect(handleDataSpy).toHaveBeenCalled(); }); + + it('should render null cells with the placeholder, without touching real values', async () => { + tableGenerator.emptyCellPlaceholder = '—'; + tableGenerator.setJsonData = [ + {sentiment: null, score: 0, flagged: false, txt: 'ottimo servizio', empty: ''}, + ] as any; + + await fixtureTableGenerator.whenStable(); + fixtureTableGenerator.detectChanges(); + + const cells = fixtureTableGenerator.nativeElement.querySelectorAll('mat-cell'); + const texts = Array.from(cells).map((cell: any) => cell.textContent.trim()); + + expect(texts).toEqual(['—', '0', 'false', 'ottimo servizio', '']); + expect(fixtureTableGenerator.nativeElement.textContent).not.toContain('null'); + }); + + it('should leave null cells blank when no placeholder is set', async () => { + tableGenerator.setJsonData = [{sentiment: null}] as any; + + await fixtureTableGenerator.whenStable(); + fixtureTableGenerator.detectChanges(); + + const cell = fixtureTableGenerator.nativeElement.querySelector('mat-cell'); + + expect(cell.textContent.trim()).toEqual(''); + }); }); diff --git a/projects/material/table-generator/src/table-generator.ts b/projects/material/table-generator/src/table-generator.ts index e009755e0..afc568b07 100644 --- a/projects/material/table-generator/src/table-generator.ts +++ b/projects/material/table-generator/src/table-generator.ts @@ -82,6 +82,12 @@ export class TableGenerator implements OnDestroy { @Input() set maxRowsDisplayed(max: number) { this._maxRowsDisplayed = max; } + + /** + * Text displayed for null or undefined cells. + * Empty by default, so that a missing value is simply rendered as a blank cell. + */ + @Input() emptyCellPlaceholder: string = ''; /** * All displayed columns */ @@ -113,6 +119,17 @@ export class TableGenerator implements OnDestroy { .subscribe(j => this._handleData(j)); } + /** + * Formats a cell value. A null or undefined value is a missing value, not a default + * one, and is never rendered as a plausible value of its own. + * @param value The cell value + * @returns The displayed cell text + */ + formatCell(value: unknown): string { + if (value === null || value === undefined) return this.emptyCellPlaceholder; + return String(value); + } + /** * Handles the data, parsing it * @param data diff --git a/scripts/utils/version-replacements.mjs b/scripts/utils/version-replacements.mjs index 4bf80f7f6..9f0069930 100644 --- a/scripts/utils/version-replacements.mjs +++ b/scripts/utils/version-replacements.mjs @@ -14,6 +14,7 @@ export const versionReplacements = packages => { ['angular-material-css-vars', 'AMCV'], ['apollo-angular', 'APOLLONG'], ['assert', 'ASSERT'], + ['chart.js', 'CHARTJS'], ['process', 'PROCESS'], ['rxdb', 'RXDB'], ['rxjs', 'RXJS'], From b10bfbeb1addc9f4a4987bc857fa96d553c9df5f Mon Sep 17 00:00:00 2001 From: tulas Date: Fri, 7 Aug 2026 12:56:50 +0200 Subject: [PATCH 05/22] style: theme the snackbars and unify the button radius - color every snackbar with the theme's primary, its message white and its action label white and bold. The snackbar styles are injected at runtime by the component and read the material tokens, which the theme sets on html: overriding them on the container is what recolors the action label. The import wizard keeps its own success and error panels - give the buttons of the form data section the radius of the other sections, i.e. the one of the import wizard: the view switcher and the toolbar buttons, the selection actions and the columns button of the list, and the buttons of the chat. A chip stays a pill Co-Authored-By: Claude Opus 5 (1M context) --- projects/dinoapp/src/styles.scss | 26 +++++++++++++++++++ .../material/datachat/src/datachat-entry.scss | 2 +- projects/material/datachat/src/datachat.scss | 4 +-- projects/material/list/src/list.scss | 6 ++--- .../src/search-filters-bar.scss | 7 ++--- 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/projects/dinoapp/src/styles.scss b/projects/dinoapp/src/styles.scss index 88ddbf1d4..ddf847ab7 100644 --- a/projects/dinoapp/src/styles.scss +++ b/projects/dinoapp/src/styles.scss @@ -190,6 +190,32 @@ body { url(assets/fonts/MaterialIcons-Regular.ttf) format('truetype'); } +// Snackbars are rendered in an overlay, outside of any component: they are +// themed here. The import wizard keeps its own success and error panels. +.mat-mdc-snack-bar-container:not(.dino-import-snack-success):not(.dino-import-snack-error) { + // The snackbar styles are injected at runtime by the component and read these + // tokens, which the theme sets on html: overriding them here is what recolors + // the action label. The declarations below cover the surface and the message. + --mdc-snackbar-container-color: #{mat-css-vars.mat-css-color-primary(500)}; + --mdc-snackbar-supporting-text-color: #fff; + --mat-snack-bar-button-color: #fff; + + .mdc-snackbar__surface { + background-color: mat-css-vars.mat-css-color-primary(500) !important; + } + + .mdc-snackbar__label { + color: #fff !important; + } + + // The action label is the one that names the outcome of the notification. + .mat-mdc-snack-bar-action.mat-mdc-button, + .mat-mdc-snack-bar-action .mdc-button__label { + color: #fff !important; + font-weight: 700; + } +} + .material-icons { font-family: 'Material Icons'; font-weight: normal; diff --git a/projects/material/datachat/src/datachat-entry.scss b/projects/material/datachat/src/datachat-entry.scss index f844c570c..5d08af4af 100644 --- a/projects/material/datachat/src/datachat-entry.scss +++ b/projects/material/datachat/src/datachat-entry.scss @@ -344,7 +344,7 @@ dino-datachat-entry { .dino-datachat-download.mat-mdc-outlined-button { height: 34px; margin-left: auto; - border-radius: 999px; + border-radius: 8px; font-size: 13px; .mat-icon { diff --git a/projects/material/datachat/src/datachat.scss b/projects/material/datachat/src/datachat.scss index 717a00634..a2b1a0a53 100644 --- a/projects/material/datachat/src/datachat.scss +++ b/projects/material/datachat/src/datachat.scss @@ -237,7 +237,7 @@ dino-datachat { .dino-chat-send { flex: 0 0 auto; height: 44px; - border-radius: 10px; + border-radius: 8px; .mat-icon { margin-right: 6px; @@ -279,7 +279,7 @@ dino-datachat { .dino-datachat-new { flex: 0 0 auto; height: 42px; - border-radius: 10px; + border-radius: 8px; margin-bottom: 14px; .mat-icon { diff --git a/projects/material/list/src/list.scss b/projects/material/list/src/list.scss index a87967640..abbcbc5af 100644 --- a/projects/material/list/src/list.scss +++ b/projects/material/list/src/list.scss @@ -43,16 +43,16 @@ dino-list { margin-right: 4px; } - // Pill-shaped (rounded) buttons, matching the filter toolbar. + // Same button radius as the filter toolbar and the other sections. .dino-selection-clear, .dino-selection-bulk, .dino-columns-btn, .dino-selection-action { - border-radius: 20px; + border-radius: 8px; .mat-mdc-button-persistent-ripple, .mat-mdc-button-persistent-ripple::before, .mdc-button__ripple { - border-radius: 20px; + border-radius: 8px; } } diff --git a/projects/material/search-filters-bar/src/search-filters-bar.scss b/projects/material/search-filters-bar/src/search-filters-bar.scss index 7c77d4a69..199883c00 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.scss +++ b/projects/material/search-filters-bar/src/search-filters-bar.scss @@ -57,6 +57,7 @@ dino-search-filters-bar { .dino-view-switcher { height: 40px; + border-radius: 8px; .mat-button-toggle { height: 40px; } @@ -81,13 +82,13 @@ dino-search-filters-bar { margin: 0 4px 0 0; } - // Pill-shaped (rounded) buttons on the right (Add, Import, Filtri, Esporta). + // Same button radius as the other sections, i.e. the import wizard. .mat-mdc-button-base { - border-radius: 20px; + border-radius: 8px; .mat-mdc-button-persistent-ripple, .mat-mdc-button-persistent-ripple::before, .mdc-button__ripple { - border-radius: 20px; + border-radius: 8px; } } } From 58e6d77ded099cad2248fe846e6668e1d4b9f3e6 Mon Sep 17 00:00:00 2001 From: tulas Date: Fri, 7 Aug 2026 13:13:42 +0200 Subject: [PATCH 06/22] feat(material/list): bring back the quick actions of a row The selection action row replaced them with the actions of the selected row, which costs a click and a checkbox to reach what used to be one hover away. - restore the actions column and its 'actions' displayed column: the main actions of a row, revealed when the row is hovered or focused - drop the three dots opening the actions dialog: every action of a row is still available from the selection row above, which lists them all - center the icons on the right edge of the row. The cell has no width of its own, so the icons are taken out of the flow as a group instead of being laid out one slot each, which left them at the bottom of the row - give them the icon size of the actions of the selection row Co-Authored-By: Claude Opus 5 (1M context) --- projects/core/list/src/list.ts | 2 ++ projects/material/list/src/list.html | 22 ++++++++++++++++++++++ projects/material/list/src/list.scss | 25 +++++++++++++++++++------ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/projects/core/list/src/list.ts b/projects/core/list/src/list.ts index c9db53c24..1a637b864 100644 --- a/projects/core/list/src/list.ts +++ b/projects/core/list/src/list.ts @@ -98,6 +98,8 @@ export abstract class List { ...headers .filter(header => (header.displayed || header.displayed === undefined) && !header.hidden) .map(header => header.column.toString()), + // The quick actions of a row, displayed when it is hovered + 'actions', ]; if (this._showCheckbox) { this._displayedColumns.unshift('select'); diff --git a/projects/material/list/src/list.html b/projects/material/list/src/list.html index 029d97cc5..9a1cfca14 100644 --- a/projects/material/list/src/list.html +++ b/projects/material/list/src/list.html @@ -271,6 +271,28 @@ + + + + + +
+ + + {{action.matIcon}} + + +
+
+
+ Date: Fri, 7 Aug 2026 13:20:48 +0200 Subject: [PATCH 07/22] perf(material/datachat): read the credits only when they can have changed Opening the chat validated the stored api key and asked the api for the credits of the user, which cannot have changed: nothing is spent until the agent is created, on the first question. Both the agent creation and every answer already refresh them. The credits of a key entered for the first time are still read once: without a stored key the api is not asked for them at all, so they would stay unknown. Co-Authored-By: Claude Opus 5 (1M context) --- projects/material/datachat/src/datachat.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/projects/material/datachat/src/datachat.ts b/projects/material/datachat/src/datachat.ts index 08fd4de77..ac8b4e661 100644 --- a/projects/material/datachat/src/datachat.ts +++ b/projects/material/datachat/src/datachat.ts @@ -719,9 +719,13 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { this._ts.translate('DINO-AI: AUTHENTICATION SUCCESSFUL!'), {duration: 10000}, ); + // The credits of a key just entered are unknown: they are read here + // once. Opening the chat spends nothing, so from then on they are + // refreshed by what does spend them, i.e. creating the agent and + // asking a question. + this._refreshAvailableTokens(); } this.apiKeyConfirmationEvt.emit(key); - this._refreshAvailableTokens(); if (isDevMode()) { console.log(res); } From 4d10ce2f1a99bd3f1657cf373ba6494c49e58c6d Mon Sep 17 00:00:00 2001 From: tulas Date: Fri, 7 Aug 2026 14:57:34 +0200 Subject: [PATCH 08/22] feat(material/list): resizable columns, and reset the customized ones Every column of a list takes the same share of the width, so a column of long answers is as wide as one of dates and the User cannot trade one for the other. Angular Material does not resize columns: only the experimental package does, which this drags a grip of its own instead of depending on. - add a grip on the right edge of a header cell. It swallows its own pointer events: that cell is the drag source reordering the columns, and the button sorting them - size the resized columns through a stylesheet of the list, rather than a binding on every cell: a cell is rendered by the table, in a view of its own, and a width must follow the pointer without waiting for a change detection and hold for the rows rendered later. The rules are marked important: the width of a column is otherwise given by selectors of a higher specificity, which would keep a column from being made narrower than the default - store the width of a column in its header, which the columns preset of the list already saves and reads back - let the text of a cell use the width of its column, instead of ellipsizing at 200px whatever the column: without this, widening a column shows nothing more - reset the columns from their dialog: the ones displayed, their order and their widths, dropping the preset of the User Two bugs found on the way, both older than this: - a column dropped in the wrong place. Only the columns of the data are dragged, so the indexes of the event count those alone, while they were applied to the displayed columns, which the checkbox and the actions belong to as well - the headers of the section were overwritten by the preferences of the User, as the list set them on itself through their own input. The input alone is a default now, the list applies its preferences through _applyHeaders, and a width is stored replacing a header instead of writing into it: with no preference stored, those are the very objects the section holds Narrow the checkbox column, which used to hold the bulk actions. Co-Authored-By: Claude Opus 5 (1M context) --- projects/core/list/src/list-header.ts | 5 + projects/core/list/src/list.ts | 36 ++++ projects/core/translations/src/ar.ts | 1 + projects/core/translations/src/eng.ts | 1 + projects/core/translations/src/esp.ts | 1 + projects/core/translations/src/fra.ts | 1 + projects/core/translations/src/ita.ts | 1 + projects/core/translations/src/prt.ts | 1 + projects/core/translations/src/uga.ts | 1 + projects/core/translations/src/ukr.ts | 1 + projects/material/list/src/column-resize.ts | 202 ++++++++++++++++++ .../material/list/src/columns-selector.html | 12 +- .../material/list/src/columns-selector.scss | 13 ++ .../material/list/src/columns-selector.ts | 8 + projects/material/list/src/list.html | 17 +- projects/material/list/src/list.module.ts | 2 + projects/material/list/src/list.scss | 74 ++++++- projects/material/list/src/list.ts | 154 ++++++++++++- projects/material/list/src/public_api.ts | 1 + 19 files changed, 517 insertions(+), 15 deletions(-) create mode 100644 projects/material/list/src/column-resize.ts diff --git a/projects/core/list/src/list-header.ts b/projects/core/list/src/list-header.ts index 346d4a5ed..e6a3e5792 100644 --- a/projects/core/list/src/list-header.ts +++ b/projects/core/list/src/list-header.ts @@ -75,6 +75,11 @@ export interface ListHeader { * Optional header icon identifier */ icon?: string; + /** + * The width of the column, in pixels, when the User has resized it. + * Unset means the column takes its share of the available width. + */ + width?: number; /** * Method needed to evaluate the editability of a cell. * If true and if the active user has the proper permissions, diff --git a/projects/core/list/src/list.ts b/projects/core/list/src/list.ts index 1a637b864..195a3b0a7 100644 --- a/projects/core/list/src/list.ts +++ b/projects/core/list/src/list.ts @@ -111,6 +111,12 @@ export abstract class List { */ protected _headers: BehaviorSubject[]> = new BehaviorSubject[]>([]); + /** + * The column headers as they are given to the list, before the columns + * preferences of the User are applied to them + */ + protected _defaultHeaders: ListHeader[] = []; + get headers(): ListHeader[] { return this._headers.value; } @@ -135,6 +141,25 @@ export abstract class List { */ @Input() set headers(headers: ListHeader[]) { + // The headers as the section defines them, kept so that the User can be + // given them back. Only the input is a default: the headers the list sets + // on itself carry the preferences of the User, and go through + // _applyHeaders. A width is never a default: it may have been written on + // these very objects, which the section can hold and give again. + this._defaultHeaders = headers.map(header => { + const defaultHeader = {...header}; + delete defaultHeader.width; + return defaultHeader; + }); + this._applyHeaders(headers); + } + + /** + * Displays the given headers, applying to them the columns preferences of + * the User: which columns are displayed, and in which order. + * @param headers The headers to display + */ + protected _applyHeaders(headers: ListHeader[]): void { const loadedPreset = this._loadColumnsSelectionPreset(); const loadedHeaders = loadedPreset?.columns.map(loadedHeader => { const defaultHeader = headers.find(h => h.column === loadedHeader.column); @@ -310,6 +335,17 @@ export abstract class List { return JSON.parse(b64_to_utf8(preset)); } + /** + * Drops the columns preset of the list from the localstorage, i.e. the + * displayed columns, their order and their widths. + */ + protected _clearColumnsSelectionPreset(): void { + const key = this._getColumnsSelectionPresetKey(); + if (key != null) { + localStorage.removeItem(key); + } + } + /** * Retrieves the list columns selection key in the localstorage * @returns The key, if present. diff --git a/projects/core/translations/src/ar.ts b/projects/core/translations/src/ar.ts index 0a4da8632..7c57097dc 100644 --- a/projects/core/translations/src/ar.ts +++ b/projects/core/translations/src/ar.ts @@ -251,6 +251,7 @@ export const AR: Translation = { 'pin': 'علامات', 'selected': 'محدد', 'Columns': 'أعمدة', + 'Reset columns': 'إعادة تعيين الأعمدة', 'Clear selection': 'مسح التحديد', 'Select one or more rows to see the available actions': 'حدد صفًا واحدًا أو أكثر لعرض الإجراءات المتاحة', diff --git a/projects/core/translations/src/eng.ts b/projects/core/translations/src/eng.ts index 6ae505aa9..718eec968 100644 --- a/projects/core/translations/src/eng.ts +++ b/projects/core/translations/src/eng.ts @@ -256,6 +256,7 @@ export const ENG: Translation = { 'pin': 'pin', 'selected': 'selected', 'Columns': 'Columns', + 'Reset columns': 'Reset columns', 'Clear selection': 'Clear selection', 'Select one or more rows to see the available actions': 'Select one or more rows to see the available actions', diff --git a/projects/core/translations/src/esp.ts b/projects/core/translations/src/esp.ts index 915539aa2..5235b0ec5 100644 --- a/projects/core/translations/src/esp.ts +++ b/projects/core/translations/src/esp.ts @@ -256,6 +256,7 @@ export const ESP: Translation = { 'pin': 'marcadores', 'selected': 'seleccionados', 'Columns': 'Columnas', + 'Reset columns': 'Restablecer las columnas', 'Clear selection': 'Borrar selección', 'Select one or more rows to see the available actions': 'Seleccione una o más filas para ver las acciones disponibles', diff --git a/projects/core/translations/src/fra.ts b/projects/core/translations/src/fra.ts index fe4345943..242947cde 100644 --- a/projects/core/translations/src/fra.ts +++ b/projects/core/translations/src/fra.ts @@ -239,6 +239,7 @@ export const FRA: Translation = { 'pin': 'repères', 'selected': 'sélectionnés', 'Columns': 'Colonnes', + 'Reset columns': 'Réinitialiser les colonnes', 'Clear selection': 'Effacer la sélection', 'Select one or more rows to see the available actions': 'Sélectionnez une ou plusieurs lignes pour voir les actions disponibles', diff --git a/projects/core/translations/src/ita.ts b/projects/core/translations/src/ita.ts index 47179b5e2..cbbd27751 100644 --- a/projects/core/translations/src/ita.ts +++ b/projects/core/translations/src/ita.ts @@ -244,6 +244,7 @@ export const ITA: Translation = { 'pin': 'pin', 'selected': 'selezionati', 'Columns': 'Colonne', + 'Reset columns': 'Ripristina le colonne', 'Clear selection': 'Deseleziona', 'Select one or more rows to see the available actions': 'Seleziona una o più righe per visualizzare le azioni', diff --git a/projects/core/translations/src/prt.ts b/projects/core/translations/src/prt.ts index 307527d71..ba8b8fdf8 100644 --- a/projects/core/translations/src/prt.ts +++ b/projects/core/translations/src/prt.ts @@ -255,6 +255,7 @@ export const PRT: Translation = { 'pin': 'marcadores', 'selected': 'selecionados', 'Columns': 'Colunas', + 'Reset columns': 'Repor as colunas', 'Clear selection': 'Limpar seleção', 'Select one or more rows to see the available actions': 'Selecione uma ou mais linhas para ver as ações disponíveis', diff --git a/projects/core/translations/src/uga.ts b/projects/core/translations/src/uga.ts index c3065896c..30e7f2574 100644 --- a/projects/core/translations/src/uga.ts +++ b/projects/core/translations/src/uga.ts @@ -59,6 +59,7 @@ export const UGA: Translation = { 'pin': 'pin', 'selected': 'selected', 'Columns': 'Columns', + 'Reset columns': 'Reset columns', 'Clear selection': 'Clear selection', 'Select one or more rows to see the available actions': 'Select one or more rows to see the available actions', diff --git a/projects/core/translations/src/ukr.ts b/projects/core/translations/src/ukr.ts index a692e8561..05b0bf961 100644 --- a/projects/core/translations/src/ukr.ts +++ b/projects/core/translations/src/ukr.ts @@ -246,6 +246,7 @@ export const UKR: Translation = { 'pin': 'позначки', 'selected': 'вибрано', 'Columns': 'Стовпці', + 'Reset columns': 'Скинути стовпці', 'Clear selection': 'Очистити вибір', 'Select one or more rows to see the available actions': 'Виберіть один або кілька рядків, щоб побачити доступні дії', diff --git a/projects/material/list/src/column-resize.ts b/projects/material/list/src/column-resize.ts new file mode 100644 index 000000000..f5845c083 --- /dev/null +++ b/projects/material/list/src/column-resize.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright (C) Gnucoop soc. coop. + * + * This file is part of the Dino (dino). + * + * Dino (dino) is free software: you can redistribute it and/or + * modify it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the License, + * or (at your option) any later version. + * + * Dino (dino) is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dino (dino). + * If not, see http://www.gnu.org/licenses/. + * + */ +import { + Directive, + ElementRef, + EventEmitter, + Input, + NgZone, + OnDestroy, + OnInit, + Output, + Renderer2, +} from '@angular/core'; + +/** + * The narrowest a column can be dragged to, in pixels. + */ +const MIN_COLUMN_WIDTH = 80; + +/** + * The class of the element dragged to resize a column. + */ +const GRIP_CLASS = 'dino-column-resize-grip'; + +/** + * The class set on the body while a column is being resized. + */ +const RESIZING_CLASS = 'dino-column-resizing'; + +/** + * The size of a column, as the User drags its grip. + */ +export interface ColumnResizeEvent { + /** + * The name of the resized column + */ + column: string; + /** + * The width of the column, in pixels + */ + width: number; +} + +/** + * Adds to a list header cell a grip that resizes its column. + * + * The grip swallows its own pointer events: the same header cell is a drag + * source, which reorders the columns, and may be a sort button. + * The directive does not size anything itself, it only tells how wide the + * column should be: the list binds the width of every cell of the column, so + * that a single writer decides it. + */ +@Directive({selector: '[dinoColumnResize]'}) +export class ColumnResize implements OnInit, OnDestroy { + /** + * The name of the column the header cell belongs to + */ + @Input('dinoColumnResize') column = ''; + + /** + * Emitted, at most once per frame, while the grip is dragged + */ + @Output() readonly columnResize: EventEmitter = + new EventEmitter(); + + /** + * Emitted with the final width, when the grip is released + */ + @Output() readonly columnResizeEnd: EventEmitter = + new EventEmitter(); + + /** + * The grip element added to the header cell + */ + private _grip: HTMLElement | null = null; + + /** + * Removes the listeners of the grip + */ + private _teardown: (() => void)[] = []; + + /** + * The pointer position and the column width when the drag started + */ + private _start: {x: number; width: number} | null = null; + + /** + * The width emitted on the next frame, and the frame waiting for it + */ + private _pendingWidth = 0; + private _pendingFrame: number | null = null; + + constructor( + private _el: ElementRef, + private _renderer: Renderer2, + private _zone: NgZone, + ) {} + + ngOnInit(): void { + const grip = this._renderer.createElement('span') as HTMLElement; + this._renderer.addClass(grip, GRIP_CLASS); + this._renderer.appendChild(this._el.nativeElement, grip); + this._grip = grip; + + // Dragging a grip fires an event per pointer move: it must not run a change + // detection of its own, the emissions are throttled to one per frame. + this._zone.runOutsideAngular(() => { + this._teardown.push( + this._renderer.listen(grip, 'pointerdown', (evt: PointerEvent) => this._onDown(evt)), + this._renderer.listen(grip, 'pointermove', (evt: PointerEvent) => this._onMove(evt)), + this._renderer.listen(grip, 'pointerup', (evt: PointerEvent) => this._onUp(evt)), + this._renderer.listen(grip, 'pointercancel', (evt: PointerEvent) => this._onUp(evt)), + // The header cell is a drag source and a sort button: a click on the + // grip is never one of the two. + this._renderer.listen(grip, 'click', (evt: MouseEvent) => evt.stopPropagation()), + this._renderer.listen(grip, 'mousedown', (evt: MouseEvent) => evt.stopPropagation()), + ); + }); + } + + ngOnDestroy(): void { + this._cancelPendingFrame(); + this._teardown.forEach(teardown => teardown()); + this._teardown = []; + if (this._grip) { + this._renderer.removeChild(this._el.nativeElement, this._grip); + this._grip = null; + } + this._renderer.removeClass(document.body, RESIZING_CLASS); + } + + /** + * Starts the resize, keeping the event away from the drag source and from + * the sort button of the header cell. + */ + private _onDown(evt: PointerEvent): void { + evt.preventDefault(); + evt.stopPropagation(); + this._start = {x: evt.clientX, width: this._el.nativeElement.offsetWidth}; + this._grip?.setPointerCapture(evt.pointerId); + this._renderer.addClass(document.body, RESIZING_CLASS); + } + + /** + * Follows the pointer, emitting the width of the column once per frame. + */ + private _onMove(evt: PointerEvent): void { + if (this._start == null) { + return; + } + evt.preventDefault(); + this._pendingWidth = Math.max(MIN_COLUMN_WIDTH, this._start.width + evt.clientX - this._start.x); + if (this._pendingFrame != null) { + return; + } + this._pendingFrame = requestAnimationFrame(() => { + this._pendingFrame = null; + this._zone.run(() => this.columnResize.emit({column: this.column, width: this._pendingWidth})); + }); + } + + /** + * Ends the resize with the width the column keeps. + */ + private _onUp(evt: PointerEvent): void { + if (this._start == null) { + return; + } + this._cancelPendingFrame(); + const width = Math.max(MIN_COLUMN_WIDTH, this._start.width + evt.clientX - this._start.x); + this._start = null; + this._grip?.releasePointerCapture(evt.pointerId); + this._renderer.removeClass(document.body, RESIZING_CLASS); + this._zone.run(() => this.columnResizeEnd.emit({column: this.column, width})); + } + + private _cancelPendingFrame(): void { + if (this._pendingFrame != null) { + cancelAnimationFrame(this._pendingFrame); + this._pendingFrame = null; + } + } +} diff --git a/projects/material/list/src/columns-selector.html b/projects/material/list/src/columns-selector.html index f7a88f5ed..6239ff8da 100644 --- a/projects/material/list/src/columns-selector.html +++ b/projects/material/list/src/columns-selector.html @@ -25,7 +25,13 @@

{{'Customize Table'|transloco}}


- +
+ + +
diff --git a/projects/material/list/src/columns-selector.scss b/projects/material/list/src/columns-selector.scss index efd9b1ca4..7c72aa84b 100644 --- a/projects/material/list/src/columns-selector.scss +++ b/projects/material/list/src/columns-selector.scss @@ -21,6 +21,19 @@ } } +.dino-selector-actions { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px; + .mat-mdc-button-base { + border-radius: 8px; + } + .dino-reset-columns-btn .mat-icon { + margin-right: 4px; + } +} + .dino-column-selector { display: flex; flex: 1 1; diff --git a/projects/material/list/src/columns-selector.ts b/projects/material/list/src/columns-selector.ts index 6fddd5b3e..a378c8228 100644 --- a/projects/material/list/src/columns-selector.ts +++ b/projects/material/list/src/columns-selector.ts @@ -123,4 +123,12 @@ export class ColumnsSelector implements OnInit { apply() { this.columns.pipe(take(1)).subscribe(columns => this.dialogRef.close(columns)); } + + /** + * Asks the list for the columns of its section: the ones displayed, their + * order and their widths, dropping everything the User has customized. + */ + resetColumns() { + this.dialogRef.close('reset'); + } } diff --git a/projects/material/list/src/list.html b/projects/material/list/src/list.html index 9a1cfca14..3be77312e 100644 --- a/projects/material/list/src/list.html +++ b/projects/material/list/src/list.html @@ -106,7 +106,14 @@ [matColumnDef]="header.column.toString()" > - + */ private _selectionChangedSub: Subscription = Subscription.EMPTY; + /** + * The column being resized and the width its grip is at, while it is dragged + */ + private _resizingColumn: ColumnResizeEvent | null = null; + + /** + * The stylesheet holding the widths of the resized columns of this list + */ + private _widthsStyle: HTMLStyleElement | null = null; + + /** + * The class identifying this list, so that its column widths are its own + */ + private readonly _listClass = `dino-list-${++listInstances}`; + + /** + * The host element, used to mark the list with its own class + */ + private readonly _elementRef = inject(ElementRef) as ElementRef; + constructor( @Inject(UI_TOUR_SERVICE_CONFIG) readonly uiServiceConfig: UITourConfig, cdr: ChangeDetectorRef, @@ -575,6 +608,11 @@ export class SelectionList } ngAfterViewInit(): void { + // The widths of the columns are written in a stylesheet of this list only. + this._renderer.addClass(this._elementRef.nativeElement, this._listClass); + this._headers.pipe(takeUntil(this._mainUnsubscribe)).subscribe(() => { + this._applyColumnWidths(); + }); if (this._dataSource && this._dataSource.dataResults != null) { this._dataSource.dataResults.pipe(takeUntil(this._mainUnsubscribe)).subscribe(() => { this.clearSelection(); @@ -838,12 +876,16 @@ export class SelectionList catchError(err => throwError(() => err) as Observable>), takeUntil(this._mainUnsubscribe), ) - .subscribe((columns: ListHeader[]) => { + .subscribe((columns: ListHeader[] | 'reset') => { if (!columns) { return; } + if (columns === 'reset') { + this.resetColumns(); + return; + } this._saveColumnsSelectionPreset({columns, displayedColumns: this._displayedColumns}); - this.headers = columns; + this._applyHeaders(columns); if (this.mainListContext != null) { this.mainListContext.headers.next(this.headers); this.mainListContext.displayedColumns?.next(this.displayedColumns); @@ -936,8 +978,110 @@ export class SelectionList * @param event the Cdk DragDrop event */ drop(event: CdkDragDrop): void { - moveItemInArray(this._displayedColumns, event.previousIndex, event.currentIndex); + // Only the columns of the data are dragged: the checkbox and the actions + // are not, and they keep their place at the two ends of the row. The + // indexes of the event count the dragged columns alone, so the move is + // applied to those and the row is rebuilt around them. + const draggable = this._displayedColumns.filter(column => !FIXED_COLUMNS.includes(column)); + moveItemInArray(draggable, event.previousIndex, event.currentIndex); + const reordered = [ + ...this._displayedColumns.filter(column => column === 'select'), + ...draggable, + ...this._displayedColumns.filter(column => column === 'actions'), + ]; + // The array is the one the table renders from: it is reordered in place. + this._displayedColumns.splice(0, this._displayedColumns.length, ...reordered); + this.mainListContext?.displayedColumns?.next(this._displayedColumns); this._saveColumnsSelectionPreset({columns: this._headers.value, displayedColumns: this._displayedColumns}); + this._cdr.markForCheck(); + } + + /** + * Gives the table back the columns of its section: the ones displayed, their + * order and their widths, dropping what the User has customized. + */ + resetColumns(): void { + this._clearColumnsSelectionPreset(); + // The preferences have just been dropped, so this displays the headers as + // the section defines them. + this._applyHeaders(this._defaultHeaders.map(header => ({...header}))); + if (this.mainListContext != null) { + this.mainListContext.headers.next(this.headers); + this.mainListContext.displayedColumns?.next(this.displayedColumns); + } + this._applyColumnWidths(); + this._cdr.markForCheck(); + } + + /** + * Follows the grip of a column while it is dragged. + * @param evt The column being resized and its current width + */ + resizeColumn(evt: ColumnResizeEvent): void { + this._resizingColumn = evt; + this._applyColumnWidths(); + } + + /** + * Stores the width a column has been resized to. + * @param evt The resized column and its width + */ + resizeColumnEnd(evt: ColumnResizeEvent): void { + this._resizingColumn = null; + const headers = this._headers.value; + const header = headers.find(h => h.column.toString() === evt.column); + if (header == null) { + return; + } + // The header is replaced, not written into: with no preference stored the + // headers are the ones the section holds, and a width is not one of theirs. + const resized = headers.map(h => + h.column.toString() === evt.column ? {...h, width: evt.width} : h, + ); + this._headers.next(resized); + this.mainListContext?.headers.next(resized); + this._applyColumnWidths(); + this._saveColumnsSelectionPreset({ + columns: resized, + displayedColumns: this._displayedColumns, + }); + } + + /** + * Sizes the resized columns through a stylesheet of this list, rather than + * through a binding on every cell: a cell is rendered by the table, in a view + * of its own, and the widths must follow the pointer without waiting for a + * change detection, and hold for the rows rendered later. + */ + protected _applyColumnWidths(): void { + const rules: string[] = []; + for (const header of this._headers.value) { + const width = + this._resizingColumn != null && this._resizingColumn.column === header.column.toString() + ? this._resizingColumn.width + : header.width; + if (width == null) { + continue; + } + // The table builds its column classes replacing whatever is not allowed + // in a css class name, as a column name is a field name. + const column = header.column.toString().replace(/[^a-z0-9_-]/gi, '-'); + // The default width of a column is given by selectors with a higher + // specificity than this one, i.e. the min-width of + // 'mat-cell:not(.mat-column-actions):not(.mat-column-select)...', which + // would keep a column from being made narrower than the default. + rules.push( + `.${this._listClass} .mat-column-${column}` + + `{flex:0 0 ${width}px!important;` + + `min-width:${width}px!important;` + + `max-width:${width}px!important;}`, + ); + } + if (this._widthsStyle == null) { + this._widthsStyle = this._renderer.createElement('style') as HTMLStyleElement; + this._renderer.appendChild(document.head, this._widthsStyle); + } + this._widthsStyle.textContent = rules.join('\n'); } /** @@ -1801,5 +1945,9 @@ export class SelectionList this._dialogSub.unsubscribe(); this._selectionChangedSub.unsubscribe(); this._dataSourceSub.unsubscribe(); + if (this._widthsStyle != null) { + this._renderer.removeChild(document.head, this._widthsStyle); + this._widthsStyle = null; + } } } diff --git a/projects/material/list/src/public_api.ts b/projects/material/list/src/public_api.ts index 8052438d3..cbce57eea 100644 --- a/projects/material/list/src/public_api.ts +++ b/projects/material/list/src/public_api.ts @@ -20,6 +20,7 @@ * */ +export * from './column-resize'; export * from './list'; export * from './list-cell-component'; export * from './list-context'; From 727e5b646d5c39361689a2e7fe81c804a1f05f7e Mon Sep 17 00:00:00 2001 From: tulas Date: Fri, 7 Aug 2026 15:44:19 +0200 Subject: [PATCH 09/22] feat(list sections): give back the keyword search, and move the actions into the toolbar The toolbar restyle of 0906a506 removed the keyword field of the filters bar, which the form data does not need, its filters being in the Filters modal. That bar is shared by every list, so the search went away from the sections that have nothing else: users, groups, notifications, reports, the aggregation list and every metric section. - display the keyword field again, beside the view switcher, through a keywordSearch input which defaults to true: the form list and the form map turn it off, everything else has it back. The AI view never displays it: it initializes no filter, so a field of its own would search nothing - only the field had been removed: the control of the bar, the filter item the FiltersService builds from it and the query the ListDataSource makes of it were all still there, so nothing else was needed - move the floating buttons of these sections into the toolbar, as the form list already does: Add, Import, Filters, Export. The credits a report costs are displayed on its button, as the floating one displayed them - the toolbar gives the keyword field all the width its actions leave The tour anchor of the report button is on a button of the section now, so its directive is always imported, as in the form list. Co-Authored-By: Claude Opus 5 (1M context) --- projects/core/translations/src/ar.ts | 1 + projects/core/translations/src/eng.ts | 1 + projects/core/translations/src/esp.ts | 1 + projects/core/translations/src/fra.ts | 1 + projects/core/translations/src/ita.ts | 1 + projects/core/translations/src/prt.ts | 1 + projects/core/translations/src/uga.ts | 1 + projects/core/translations/src/ukr.ts | 1 + .../aggregation-list.module.ts | 4 +-- .../aggregation-list.component.html | 20 +++++++----- .../components/forms-list.component.html | 1 + .../app/forms-map/components/forms-map.html | 1 + .../components/groups-list.component.html | 22 +++++++------ .../src/app/groups-list/groups-list.module.ts | 6 ++-- .../components/reports-list.component.html | 29 ++++++++++------- .../components/reports-list.component.scss | 20 ++++++++++++ .../components/reports-list.component.ts | 1 + .../app/reports-list/reports-list.module.ts | 9 +++--- .../components/users-list.component.html | 18 +++++++---- .../src/app/users-list/users-list.module.ts | 4 +-- .../metric-section/src/metric-section.html | 23 ++++++------- .../src/metric-section.module.ts | 4 +-- .../src/search-filters-bar.html | 21 ++++++++++++ .../src/search-filters-bar.scss | 32 +++++++++++++++++++ .../src/search-filters-bar.ts | 16 ++++++++++ 25 files changed, 178 insertions(+), 61 deletions(-) diff --git a/projects/core/translations/src/ar.ts b/projects/core/translations/src/ar.ts index 7c57097dc..3d953ff41 100644 --- a/projects/core/translations/src/ar.ts +++ b/projects/core/translations/src/ar.ts @@ -76,6 +76,7 @@ export const AR: Translation = { 'Add New {{schema}} Schema': 'إضافة مخطط {{schema}} جديد', 'Add New Report': 'إضافة تقرير جديد', 'Add New User': 'إضافة مستخدم جديد', + 'Add New Group': 'إضافة مجموعة جديدة', 'Add New User Permissions Group': 'إضافة مجموعة أذونات مستخدم جديدة', 'Add New form': 'إضافة نموذج جديد', 'Add filters': 'إضافة عوامل تصفية', diff --git a/projects/core/translations/src/eng.ts b/projects/core/translations/src/eng.ts index 718eec968..2d1c01907 100644 --- a/projects/core/translations/src/eng.ts +++ b/projects/core/translations/src/eng.ts @@ -82,6 +82,7 @@ export const ENG: Translation = { 'Add New {{schema}} Schema': 'Add new {{schema}} schema', 'Add New Report': 'Add new report', 'Add New User': 'Add new user', + 'Add New Group': 'Add new group', 'Add New User Permissions Group': 'Add new user permissions group', 'Add New form': 'Add new form', 'Add filters': 'Add filters', diff --git a/projects/core/translations/src/esp.ts b/projects/core/translations/src/esp.ts index 5235b0ec5..0afd6d81e 100644 --- a/projects/core/translations/src/esp.ts +++ b/projects/core/translations/src/esp.ts @@ -77,6 +77,7 @@ export const ESP: Translation = { 'Add New {{schema}} Schema': 'Agregar nuevo esquema de {{schema}}', 'Add New Report': 'Agregar nuevo informe', 'Add New User': 'Agregar nuevo usuario', + 'Add New Group': 'Añadir nuevo grupo', 'Add New User Permissions Group': 'Agregar nuevo grupo de permisos de usuario', 'Add New form': 'Agregar nuevo formulario', 'Add filters': 'Agregar filtros', diff --git a/projects/core/translations/src/fra.ts b/projects/core/translations/src/fra.ts index 242947cde..c9609c065 100644 --- a/projects/core/translations/src/fra.ts +++ b/projects/core/translations/src/fra.ts @@ -72,6 +72,7 @@ export const FRA: Translation = { 'Add New {{schema}} Schema': 'Ajouter un nouveau schéma {{schéma}}', 'Add New Report': 'Ajouter un nouveau rapport', 'Add New User': 'Ajouter un nouvel utilisateur', + 'Add New Group': 'Ajouter un nouveau groupe', 'Add New User Permissions Group': 'Ajouter un nouveau groupe d\'autorisations utilisateur', 'Add New form': 'Ajouter un nouveau formulaire', 'Add filters': 'Ajouter des filtres', diff --git a/projects/core/translations/src/ita.ts b/projects/core/translations/src/ita.ts index cbbd27751..11ff1de67 100644 --- a/projects/core/translations/src/ita.ts +++ b/projects/core/translations/src/ita.ts @@ -78,6 +78,7 @@ export const ITA: Translation = { 'Add New {{schema}} Schema': 'Aggiungi nuovo schema {{Schema}}', 'Add New Report': 'Aggiungi nuovo report', 'Add New User': 'Aggiungi nuovo utente', + 'Add New Group': 'Aggiungi nuovo gruppo', 'Add New User Permissions Group': 'Aggiungi nuovo gruppo di autorizzazioni utente', 'Add New form': 'Aggiungi nuovo form', 'Add filters': 'Aggiungi filtri', diff --git a/projects/core/translations/src/prt.ts b/projects/core/translations/src/prt.ts index ba8b8fdf8..b61524267 100644 --- a/projects/core/translations/src/prt.ts +++ b/projects/core/translations/src/prt.ts @@ -77,6 +77,7 @@ export const PRT: Translation = { 'Add New {{schema}} Schema': 'Adicionar novo esquema de {{schema}}', 'Add New Report': 'Adicionar novo relatório', 'Add New User': 'Adicionar novo usuário', + 'Add New Group': 'Adicionar novo grupo', 'Add New User Permissions Group': 'Adicionar novo grupo de permissões de usuário', 'Add New form': 'Adicionar novo formulário', 'Add filters': 'Adicionar filtros', diff --git a/projects/core/translations/src/uga.ts b/projects/core/translations/src/uga.ts index 30e7f2574..2f5a6d8ba 100644 --- a/projects/core/translations/src/uga.ts +++ b/projects/core/translations/src/uga.ts @@ -148,6 +148,7 @@ export const UGA: Translation = { 'Add New form': 'Add new form', 'Import forms': 'Import forms', 'Add New': 'Add new', + 'Add New Group': 'Add new group', 'Add New User Permissions Group': 'Add new user permissions group', 'Add New Report': 'Add new report', 'Add New User': 'Add new user', diff --git a/projects/core/translations/src/ukr.ts b/projects/core/translations/src/ukr.ts index 05b0bf961..d61b1b5a2 100644 --- a/projects/core/translations/src/ukr.ts +++ b/projects/core/translations/src/ukr.ts @@ -77,6 +77,7 @@ export const UKR: Translation = { 'Add New {{schema}} Schema': 'Додати нову схему {{schema}}', 'Add New Report': 'Додати новий звіт', 'Add New User': 'Додати нового користувача', + 'Add New Group': 'Додати нову групу', 'Add New User Permissions Group': 'Додати нову групу прав користувачів', 'Add New form': 'Додати нову форму', 'Add filters': 'Додати фільтри', diff --git a/projects/dinoapp/src/app/aggregation-list/aggregation-list.module.ts b/projects/dinoapp/src/app/aggregation-list/aggregation-list.module.ts index cd9dd7af2..3e2d7f39e 100644 --- a/projects/dinoapp/src/app/aggregation-list/aggregation-list.module.ts +++ b/projects/dinoapp/src/app/aggregation-list/aggregation-list.module.ts @@ -2,13 +2,13 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {MatDialogModule} from '@angular/material/dialog'; import {FormsModule as DinoFormsModule} from '@dino/core/forms'; -import {FloatingButtonModule} from '@dino/material/floating-button'; import {ListModule as DinoListModule} from '@dino/material/list'; import {SearchFiltersBarModule as DinoFiltersBarModule} from '@dino/material/search-filters-bar'; import {BreadcrumbsModule as DinoBreadcrumbsModule} from '@dino/material/breadcrumbs'; import {AggregationListComponent} from './components/aggregation-list.component'; import {AggregationListRoutingModule} from './aggregation-list-routing.module'; import {TranslocoModule} from '@ngneat/transloco'; +import {MatButtonModule} from '@angular/material/button'; @NgModule({ declarations: [AggregationListComponent], @@ -18,7 +18,7 @@ import {TranslocoModule} from '@ngneat/transloco'; DinoFiltersBarModule, DinoFormsModule, DinoListModule, - FloatingButtonModule, + MatButtonModule, MatDialogModule, TranslocoModule, AggregationListRoutingModule, diff --git a/projects/dinoapp/src/app/aggregation-list/components/aggregation-list.component.html b/projects/dinoapp/src/app/aggregation-list/components/aggregation-list.component.html index 54f77cd0e..33749779f 100644 --- a/projects/dinoapp/src/app/aggregation-list/components/aggregation-list.component.html +++ b/projects/dinoapp/src/app/aggregation-list/components/aggregation-list.component.html @@ -18,13 +18,17 @@ [additionalFilters]="false" [aggregationFilters]="true" [secondaryMetricFieldsDisplayed]="secondaryMetricFieldsDisplayed" - >
+ > + + - - diff --git a/projects/dinoapp/src/app/forms-list/components/forms-list.component.html b/projects/dinoapp/src/app/forms-list/components/forms-list.component.html index 32e099181..0163b39a9 100644 --- a/projects/dinoapp/src/app/forms-list/components/forms-list.component.html +++ b/projects/dinoapp/src/app/forms-list/components/forms-list.component.html @@ -21,6 +21,7 @@ - + + + - - diff --git a/projects/dinoapp/src/app/groups-list/groups-list.module.ts b/projects/dinoapp/src/app/groups-list/groups-list.module.ts index 615b1250d..f7677e907 100644 --- a/projects/dinoapp/src/app/groups-list/groups-list.module.ts +++ b/projects/dinoapp/src/app/groups-list/groups-list.module.ts @@ -2,7 +2,6 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {BreadcrumbsModule as DinoBreadcrumbsModule} from '@dino/material/breadcrumbs'; import {FormsModule as DinoFormsModule} from '@dino/core/forms'; -import {FloatingButtonModule} from '@dino/material/floating-button'; import {ListModule as DinoListModule} from '@dino/material/list'; import {MixedEditorModule as DinoMixedEditorModule} from '@dino/material/mixed-editor'; import {SearchFiltersBarModule as DinoFiltersBarModule} from '@dino/material/search-filters-bar'; @@ -11,6 +10,8 @@ import {TranslocoModule} from '@ngneat/transloco'; import {GroupsEditor} from './components/groups-editor.component'; import {GroupsList} from './components/groups-list.component'; import {GroupsListRoutingModule} from './groups-list-routing.module'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {MatButtonModule} from '@angular/material/button'; @NgModule({ declarations: [GroupsEditor, GroupsList], @@ -21,7 +22,8 @@ import {GroupsListRoutingModule} from './groups-list-routing.module'; DinoFormsModule, DinoListModule, DinoMixedEditorModule, - FloatingButtonModule, + MatButtonModule, + MatTooltipModule, GroupsListRoutingModule, TranslocoModule, ], diff --git a/projects/dinoapp/src/app/reports-list/components/reports-list.component.html b/projects/dinoapp/src/app/reports-list/components/reports-list.component.html index f2cc63264..ca62ae24d 100644 --- a/projects/dinoapp/src/app/reports-list/components/reports-list.component.html +++ b/projects/dinoapp/src/app/reports-list/components/reports-list.component.html @@ -13,16 +13,23 @@ filters [presetManager]="true" [secondaryMetricFieldsDisplayed]="secondaryMetricFieldsDisplayed" - > + > + + - - diff --git a/projects/dinoapp/src/app/reports-list/components/reports-list.component.scss b/projects/dinoapp/src/app/reports-list/components/reports-list.component.scss index e69de29bb..b1bce7f04 100644 --- a/projects/dinoapp/src/app/reports-list/components/reports-list.component.scss +++ b/projects/dinoapp/src/app/reports-list/components/reports-list.component.scss @@ -0,0 +1,20 @@ +dinoapp-reports-list { + // The credits a report costs, on the button adding one: it used to be the + // extended part of the floating button. + .dino-report-cost { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 8px; + padding-left: 8px; + border-left: 1px solid currentColor; + opacity: 0.85; + + .mat-icon { + width: 18px; + height: 18px; + margin: 0; + font-size: 18px; + } + } +} diff --git a/projects/dinoapp/src/app/reports-list/components/reports-list.component.ts b/projects/dinoapp/src/app/reports-list/components/reports-list.component.ts index 638cf0f62..4a8bdf7b0 100644 --- a/projects/dinoapp/src/app/reports-list/components/reports-list.component.ts +++ b/projects/dinoapp/src/app/reports-list/components/reports-list.component.ts @@ -15,6 +15,7 @@ import * as conf from '../conf'; @Component({ selector: 'dinoapp-reports-list', templateUrl: './reports-list.component.html', + styleUrls: ['./reports-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) diff --git a/projects/dinoapp/src/app/reports-list/reports-list.module.ts b/projects/dinoapp/src/app/reports-list/reports-list.module.ts index 6ec05f730..a54184a7a 100644 --- a/projects/dinoapp/src/app/reports-list/reports-list.module.ts +++ b/projects/dinoapp/src/app/reports-list/reports-list.module.ts @@ -2,7 +2,6 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {BreadcrumbsModule as DinoBreadcrumbsModule} from '@dino/material/breadcrumbs'; import {ReportsModule as DinoReportsModule} from '@dino/core/reports'; -import {FloatingButtonModule} from '@dino/material/floating-button'; import {ListModule as DinoListModule} from '@dino/material/list'; import {SearchFiltersBarModule as DinoFiltersBarModule} from '@dino/material/search-filters-bar'; import {TranslocoModule} from '@ngneat/transloco'; @@ -10,7 +9,8 @@ import {TranslocoModule} from '@ngneat/transloco'; import {ReportsListComponent} from './components/reports-list.component'; import {ReportsListRoutingModule} from './reports-list-routing.module'; import {TourMatMenuModule} from 'ngx-ui-tour-md-menu'; -import {environment} from 'src/environments/environment'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {MatButtonModule} from '@angular/material/button'; @NgModule({ declarations: [ReportsListComponent], @@ -20,10 +20,11 @@ import {environment} from 'src/environments/environment'; DinoFiltersBarModule, DinoReportsModule, DinoListModule, - FloatingButtonModule, + MatButtonModule, + MatTooltipModule, ReportsListRoutingModule, TranslocoModule, - environment.layoutConfig.uiTourConfig ? TourMatMenuModule : [], + TourMatMenuModule, ], providers: [], }) diff --git a/projects/dinoapp/src/app/users-list/components/users-list.component.html b/projects/dinoapp/src/app/users-list/components/users-list.component.html index c713991df..1de6de2a4 100644 --- a/projects/dinoapp/src/app/users-list/components/users-list.component.html +++ b/projects/dinoapp/src/app/users-list/components/users-list.component.html @@ -6,12 +6,16 @@ [additionalBasicFilters]="additionalBasicFilters" [onClickRowActions]="onClickRowActions" > - + + + - - diff --git a/projects/dinoapp/src/app/users-list/users-list.module.ts b/projects/dinoapp/src/app/users-list/users-list.module.ts index c3917f50b..aaf88fcbf 100644 --- a/projects/dinoapp/src/app/users-list/users-list.module.ts +++ b/projects/dinoapp/src/app/users-list/users-list.module.ts @@ -2,7 +2,6 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {BreadcrumbsModule as DinoBreadcrumbsModule} from '@dino/material/breadcrumbs'; import {FormsModule as DinoFormsModule} from '@dino/core/forms'; -import {FloatingButtonModule} from '@dino/material/floating-button'; import {ListModule as DinoListModule} from '@dino/material/list'; import {SearchFiltersBarModule as DinoFiltersBarModule} from '@dino/material/search-filters-bar'; import {UserEditorModule as DinoUserEditorModule} from '@dino/material/user-editor'; @@ -11,6 +10,7 @@ import {TranslocoModule} from '@ngneat/transloco'; import {UsersEditor} from './components/users-editor.component'; import {UsersList} from './components/users-list.component'; import {UsersListRoutingModule} from './users-list-routing.module'; +import {MatButtonModule} from '@angular/material/button'; @NgModule({ declarations: [UsersEditor, UsersList], @@ -21,7 +21,7 @@ import {UsersListRoutingModule} from './users-list-routing.module'; DinoFormsModule, DinoListModule, DinoUserEditorModule, - FloatingButtonModule, + MatButtonModule, UsersListRoutingModule, TranslocoModule, ], diff --git a/projects/material/metric-section/src/metric-section.html b/projects/material/metric-section/src/metric-section.html index b11cb13f7..8008f7ceb 100644 --- a/projects/material/metric-section/src/metric-section.html +++ b/projects/material/metric-section/src/metric-section.html @@ -13,18 +13,15 @@ [additionalFilters]="false" [exportable]="true" (exportEvt)="dinoList.export($event, 'metrics')" - > + > + + + - - - - diff --git a/projects/material/metric-section/src/metric-section.module.ts b/projects/material/metric-section/src/metric-section.module.ts index 6fa177264..8e0e9d2ee 100644 --- a/projects/material/metric-section/src/metric-section.module.ts +++ b/projects/material/metric-section/src/metric-section.module.ts @@ -26,13 +26,13 @@ import {MatButtonModule} from '@angular/material/button'; import {MatTooltipModule} from '@angular/material/tooltip'; import {FormsModule} from '@dino/core/forms'; import {BreakpointObserverModule} from '@dino/material/breakpoint-observer'; -import {FloatingButtonModule} from '@dino/material/floating-button'; import {ListModule} from '@dino/material/list'; import {MetricEditorModule} from '@dino/material/metric-editor'; import {SearchFiltersBarModule} from '@dino/material/search-filters-bar'; import {TranslocoModule} from '@ngneat/transloco'; import {MetricSection} from './metric-section'; +import {MatIconModule} from '@angular/material/icon'; @NgModule({ declarations: [MetricSection], @@ -40,9 +40,9 @@ import {MetricSection} from './metric-section'; BreakpointObserverModule, CommonModule, ListModule, - FloatingButtonModule, FormsModule, MatButtonModule, + MatIconModule, MatTooltipModule, MetricEditorModule, SearchFiltersBarModule, diff --git a/projects/material/search-filters-bar/src/search-filters-bar.html b/projects/material/search-filters-bar/src/search-filters-bar.html index 2b174035d..5b309e0d4 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.html +++ b/projects/material/search-filters-bar/src/search-filters-bar.html @@ -19,6 +19,27 @@ AI + + + + + search + close + +
diff --git a/projects/material/search-filters-bar/src/search-filters-bar.scss b/projects/material/search-filters-bar/src/search-filters-bar.scss index 199883c00..b306cbcda 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.scss +++ b/projects/material/search-filters-bar/src/search-filters-bar.scss @@ -47,6 +47,13 @@ dino-search-filters-bar { gap: 8px; } + // The left side takes whatever the actions on the right leave, so that the + // keyword field is as wide as it can be. + .dino-filters-toolbar-left { + flex: 1 1 auto; + min-width: 0; + } + // Uniform 40px height for every toolbar button (Table/Map toggle, Add, Import, // Filtri, Esporta), matching the Filtri button. .dino-filters-toolbar { @@ -55,6 +62,31 @@ dino-search-filters-bar { } } + // The keyword searching the whole list, beside the view switcher: the same + // height as the buttons of the toolbar, and no room reserved for a hint. + .mat-mdc-form-field.dino-filter-keyword { + flex: 1 1 280px; + min-width: 180px; + + .mat-mdc-text-field-wrapper { + border-radius: 8px; + } + + .mat-mdc-form-field-infix { + min-height: 40px; + padding-top: 8px; + padding-bottom: 8px; + } + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + .mdc-line-ripple { + display: none; + } + } + .dino-view-switcher { height: 40px; border-radius: 8px; diff --git a/projects/material/search-filters-bar/src/search-filters-bar.ts b/projects/material/search-filters-bar/src/search-filters-bar.ts index de165adf7..8911c58bb 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.ts +++ b/projects/material/search-filters-bar/src/search-filters-bar.ts @@ -269,6 +269,22 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, this._viewSwitcherOnly = state; } + /** + * If true, the keyword field searching the whole list is displayed. + * Defaults to true: the form data has its own filters, in the Filters modal, + * and turns it off. + */ + private _keywordSearch = true; + get keywordSearch(): boolean { + // The AI view displays the switcher alone and initializes no filter, so a + // keyword field of its own would search nothing. + return this._keywordSearch && !this._viewSwitcherOnly; + } + @Input() + set keywordSearch(state: boolean) { + this._keywordSearch = state; + } + private _exportable = false; get exportable() { return this._exportable; From 7894f9da9d4ddd39fef9f4e7de6ec30765321d67 Mon Sep 17 00:00:00 2001 From: tulas Date: Fri, 7 Aug 2026 16:49:58 +0200 Subject: [PATCH 10/22] feat(core/list): remember the filters of every section The filters lived in the url alone: leaving a section dropped them, and coming back displayed everything again. Whoever works with the same filters every day had to apply them every time, the only alternative being the named presets, which are global and have to be saved and loaded by hand. - store the filters of a section as they are applied, and apply them again when it is opened. What is stored is what the url carries, so the whole mechanism is the one already there: the filters are encoded where they were encoded, and applied through loadPreset, where a preset is applied - put the filters back in the url, replacing the entry: the section then reads, and is shared, exactly as if they had just been applied. A url carrying filters wins over the stored ones: a link must display what it says - forget a section when its last filter is removed: filtering nothing is a choice of its own - drop a stored value that cannot be read, which would otherwise break the loading of the filters of every section from then on - give the sections one way of identifying themselves, sectionStorageKey, which the columns preset already defined and now shares: the two cannot drift apart, and the map, which is not a list, can identify its form as its table does - azzera filtri: a button of the Filters modal dropping every filter of the section, the applied ones and the ones staged in the Advanced tab The FiltersService is a singleton holding the root route, so it cannot tell which section is displayed: the list and the map tell it. The filters of the sections are cleared on logout, as the columns and the presets already were. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/list/src/filters-service.spec.ts | 101 +++++++++++++++++- projects/core/list/src/filters.service.ts | 73 ++++++++++++- projects/core/list/src/list.ts | 17 +-- projects/core/list/src/public_api.ts | 1 + projects/core/list/src/section-storage-key.ts | 50 +++++++++ projects/core/translations/src/ar.ts | 1 + projects/core/translations/src/eng.ts | 1 + projects/core/translations/src/esp.ts | 1 + projects/core/translations/src/fra.ts | 1 + projects/core/translations/src/ita.ts | 1 + projects/core/translations/src/prt.ts | 1 + projects/core/translations/src/uga.ts | 1 + projects/core/translations/src/ukr.ts | 1 + .../src/app/forms-map/components/forms-map.ts | 14 ++- projects/material/list/src/list.ts | 6 ++ projects/material/main-nav/src/main-nav.ts | 3 +- .../src/search-filters-bar.html | 4 + .../src/search-filters-bar.scss | 4 + .../src/search-filters-bar.ts | 17 +++ 19 files changed, 277 insertions(+), 21 deletions(-) create mode 100644 projects/core/list/src/section-storage-key.ts diff --git a/projects/core/list/src/filters-service.spec.ts b/projects/core/list/src/filters-service.spec.ts index 0d05fe62b..649bc2dd9 100644 --- a/projects/core/list/src/filters-service.spec.ts +++ b/projects/core/list/src/filters-service.spec.ts @@ -1,13 +1,13 @@ import {AjfFieldType, AjfValidationGroup} from '@ajf/core/forms'; import {fakeAsync, flush, TestBed} from '@angular/core/testing'; import {UntypedFormControl, UntypedFormGroup} from '@angular/forms'; -import {ActivatedRoute} from '@angular/router'; +import {ActivatedRoute, Router} from '@angular/router'; import {RouterTestingModule} from '@angular/router/testing'; import {AUTH_SERVICE_CONFIG, AuthServiceConfig} from '@dino/core/auth'; import {DATA_SERVICE_CONFIG, DataServiceConfig} from '@dino/core/data'; import {getRxStorageMemory} from 'rxdb/plugins/storage-memory'; import {RxJsonSchema} from 'rxdb'; -import {of as obsOf} from 'rxjs'; +import {BehaviorSubject, of as obsOf} from 'rxjs'; import {AjfTranslocoModule} from '@ajf/core/transloco'; import {FilterItem, FiltersService} from './public_api'; @@ -241,3 +241,100 @@ describe('FiltersService', () => { expect(spyPropToFilterItem).toHaveBeenCalledTimes(1); }); }); + +describe('FiltersService filters memory', () => { + const storageKey = 'filters_test_section'; + const queryParams = new BehaviorSubject<{[key: string]: string}>({}); + let fts: FiltersService; + let router: Router; + + beforeEach(() => { + queryParams.next({}); + localStorage.removeItem(storageKey); + TestBed.configureTestingModule({ + imports: [AjfTranslocoModule, RouterTestingModule.withRoutes([])], + providers: [ + FiltersService, + {provide: DATA_SERVICE_CONFIG, useValue: dataServiceConfig()}, + {provide: AUTH_SERVICE_CONFIG, useValue: authServiceConfig}, + {provide: ActivatedRoute, useValue: {queryParams} as unknown as ActivatedRoute}, + ], + }); + fts = TestBed.inject(FiltersService); + router = TestBed.inject(Router); + }); + + afterEach(() => localStorage.removeItem(storageKey)); + + it('should store the filters of the section as they are applied', () => { + fts.storageKey = storageKey; + fts.queryString.subscribe(); + + fts.initializeFilters([fakeFormGroup]); + fts.loadPreset(fakeFiltersPreset_b); + + expect(localStorage.getItem(storageKey)).toEqual(fakeFiltersPreset_b); + }); + + it('should forget the section when its last filter is removed', () => { + localStorage.setItem(storageKey, fakeFiltersPreset_b); + fts.storageKey = storageKey; + fts.queryString.subscribe(); + + fts.initializeFilters([fakeFormGroup]); + /* No filter left is a choice of its own, not the absence of one. */ + fts.loadPreset(); + + expect(localStorage.getItem(storageKey)).toBeNull(); + }); + + it('should apply the stored filters when the url carries none', () => { + localStorage.setItem(storageKey, fakeFiltersPreset_b); + fts.storageKey = storageKey; + const spyLoadPreset = spyOn(fts, 'loadPreset').and.callThrough(); + const spyNavigate = spyOn(router, 'navigate').and.callThrough(); + + fts.initializeFilters([fakeFormGroup]); + + expect(spyLoadPreset).toHaveBeenCalledWith(fakeFiltersPreset_b); + /* The filters are put back in the url, without adding an entry to the history */ + expect(spyNavigate).toHaveBeenCalled(); + expect(spyNavigate.calls.mostRecent().args[1]?.queryParams).toEqual({ + 'filters': fakeFiltersPreset_b, + }); + expect(spyNavigate.calls.mostRecent().args[1]?.replaceUrl).toBeTrue(); + }); + + it('should let the filters of the url win over the stored ones', () => { + localStorage.setItem(storageKey, fakeFiltersPreset_b); + fts.storageKey = storageKey; + queryParams.next({'filters': fakeFiltersPreset}); + const spyLoadPreset = spyOn(fts, 'loadPreset').and.callThrough(); + + fts.initializeFilters([fakeFormGroup]); + + expect(spyLoadPreset).toHaveBeenCalledWith(fakeFiltersPreset); + }); + + it('should drop a stored value it cannot read', () => { + localStorage.setItem(storageKey, 'not a filters preset'); + fts.storageKey = storageKey; + const spyLoadPreset = spyOn(fts, 'loadPreset').and.callThrough(); + + fts.initializeFilters([fakeFormGroup]); + + expect(spyLoadPreset).toHaveBeenCalledWith(undefined); + expect(localStorage.getItem(storageKey)).toBeNull(); + }); + + it('should store nothing for a section with no key of its own', () => { + fts.storageKey = null; + fts.queryString.subscribe(); + const storedKeys = Object.keys(localStorage).length; + + fts.initializeFilters([fakeFormGroup]); + fts.loadPreset(fakeFiltersPreset_b); + + expect(Object.keys(localStorage).length).toEqual(storedKeys); + }); +}); diff --git a/projects/core/list/src/filters.service.ts b/projects/core/list/src/filters.service.ts index 7178b1cbc..b1d6920c7 100644 --- a/projects/core/list/src/filters.service.ts +++ b/projects/core/list/src/filters.service.ts @@ -226,6 +226,17 @@ export class FiltersService { */ private _loadPresetEvent: EventEmitter; + /** + * The key the filters of the section currently displayed are stored under. + * Null for a section with no identity of its own, whose filters are not + * remembered. The service is a singleton with the root route, so it cannot + * tell which section is displayed: the list tells it. + */ + private _storageKey: string | null = null; + set storageKey(key: string | null) { + this._storageKey = key; + } + get loadPresetEvent(): EventEmitter { return this._loadPresetEvent; } @@ -271,9 +282,22 @@ export class FiltersService { catchError(err => throwError(() => err) as Observable<[any, any]>), ) .subscribe(([loadEvent, preset]) => { - if (loadEvent) { - this.loadPreset(preset); + if (!loadEvent) { + return; + } + // A url carrying filters always wins: a link must display what it says. + const stored = preset == null ? this._loadStoredFilters() : null; + if (stored != null) { + // The filters of the section are put back in the url, replacing the + // entry, so that the section reads and is shared exactly as if they + // had just been applied. + this._router.navigate([], { + relativeTo: this._route, + queryParams: {'filters': stored}, + replaceUrl: true, + }); } + this.loadPreset(preset ?? stored ?? undefined); }); this._queryString = combineLatest([ @@ -805,9 +829,54 @@ export class FiltersService { queryParams: filterItems.length ? {'filters': queryString} : null, }); } + // Filtering nothing is a choice of its own: the section is then forgotten, + // and displays everything the next time it is opened. + this._saveStoredFilters(filterItems.length ? queryString : null); return queryString; } + /** + * Reads the filters stored for the section currently displayed. + * A value that cannot be decoded is dropped: it would break the loading of + * the filters of every section from then on. + * @returns The encoded filters, or null when there are none to apply + */ + private _loadStoredFilters(): string | null { + if (this._storageKey == null) { + return null; + } + try { + const stored = localStorage.getItem(this._storageKey); + if (stored == null) { + return null; + } + JSON.parse(decodeURI(atob(stored))); + return stored; + } catch { + this._saveStoredFilters(null); + return null; + } + } + + /** + * Stores the filters of the section currently displayed, or forgets them. + * @param queryString The encoded filters, null to forget them + */ + private _saveStoredFilters(queryString: string | null): void { + if (this._storageKey == null) { + return; + } + try { + if (queryString == null) { + localStorage.removeItem(this._storageKey); + } else { + localStorage.setItem(this._storageKey, queryString); + } + } catch { + // The storage is not available: the filters are simply not remembered. + } + } + /** * Updates the basic filters form values * @param filterItems The FilterItems used to update the form values diff --git a/projects/core/list/src/list.ts b/projects/core/list/src/list.ts index 195a3b0a7..07aff8cfe 100644 --- a/projects/core/list/src/list.ts +++ b/projects/core/list/src/list.ts @@ -30,6 +30,7 @@ import {ListHeader} from './list-header'; import {AdminUserInteractionsService} from './user-interactions'; import {b64_to_utf8, utf8_to_b64} from '@dino/core/auth'; import {NodeVisibility} from './node-visibility'; +import {sectionStorageKey} from './section-storage-key'; import {deepCopy} from '@ajf/core/utils'; /** @@ -351,21 +352,7 @@ export abstract class List { * @returns The key, if present. */ protected _getColumnsSelectionPresetKey(): string | null { - const snapshot = this._route.snapshot; - if (snapshot.data['isFormData']) { - return snapshot.params['form_schema_id'] - ? `columns_${snapshot.params['form_schema_id']}` - : null; - } else if (snapshot.data['isReportData']) { - return snapshot.params['report_schema_id'] - ? `columns_${snapshot.params['report_schema_id']}` - : null; - } else if (this._title) { - return `columns_${this._title}`; - } else if (snapshot.data['aggregation']) { - return `columns_aggregation`; - } - return null; + return sectionStorageKey('columns', this._route.snapshot, this._title); } /** diff --git a/projects/core/list/src/public_api.ts b/projects/core/list/src/public_api.ts index ce59d1731..09e1d971e 100644 --- a/projects/core/list/src/public_api.ts +++ b/projects/core/list/src/public_api.ts @@ -23,6 +23,7 @@ export * from './filters.service'; export * from './list'; export * from './list.module'; +export * from './section-storage-key'; export * from './list-header'; export * from './list-actions-interface'; export * from './list-filters-interfaces'; diff --git a/projects/core/list/src/section-storage-key.ts b/projects/core/list/src/section-storage-key.ts new file mode 100644 index 000000000..bb190ca58 --- /dev/null +++ b/projects/core/list/src/section-storage-key.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright (C) Gnucoop soc. coop. + * + * This file is part of the Dino (dino). + * + * Dino (dino) is free software: you can redistribute it and/or + * modify it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the License, + * or (at your option) any later version. + * + * Dino (dino) is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dino (dino). + * If not, see http://www.gnu.org/licenses/. + * + */ +import {ActivatedRouteSnapshot} from '@angular/router'; + +/** + * Builds the key the preferences of a list section are stored under, i.e. its + * columns and its filters, so that they always identify a section the same way. + * @param prefix What is stored, i.e. 'columns' or 'filters' + * @param snapshot The route snapshot of the section + * @param title The title of the list, for the sections that have one + * @returns The key, or null for a section that has no identity of its own and + * whose preferences are therefore not stored + */ +export function sectionStorageKey( + prefix: string, + snapshot: ActivatedRouteSnapshot, + title?: string, +): string | null { + if (snapshot.data['isFormData']) { + return snapshot.params['form_schema_id'] ? `${prefix}_${snapshot.params['form_schema_id']}` : null; + } else if (snapshot.data['isReportData']) { + return snapshot.params['report_schema_id'] + ? `${prefix}_${snapshot.params['report_schema_id']}` + : null; + } else if (title) { + return `${prefix}_${title}`; + } else if (snapshot.data['aggregation']) { + return `${prefix}_aggregation`; + } + return null; +} diff --git a/projects/core/translations/src/ar.ts b/projects/core/translations/src/ar.ts index 3d953ff41..f48db8599 100644 --- a/projects/core/translations/src/ar.ts +++ b/projects/core/translations/src/ar.ts @@ -253,6 +253,7 @@ export const AR: Translation = { 'selected': 'محدد', 'Columns': 'أعمدة', 'Reset columns': 'إعادة تعيين الأعمدة', + 'Reset filters': 'إعادة تعيين عوامل التصفية', 'Clear selection': 'مسح التحديد', 'Select one or more rows to see the available actions': 'حدد صفًا واحدًا أو أكثر لعرض الإجراءات المتاحة', diff --git a/projects/core/translations/src/eng.ts b/projects/core/translations/src/eng.ts index 2d1c01907..beb067588 100644 --- a/projects/core/translations/src/eng.ts +++ b/projects/core/translations/src/eng.ts @@ -258,6 +258,7 @@ export const ENG: Translation = { 'selected': 'selected', 'Columns': 'Columns', 'Reset columns': 'Reset columns', + 'Reset filters': 'Reset filters', 'Clear selection': 'Clear selection', 'Select one or more rows to see the available actions': 'Select one or more rows to see the available actions', diff --git a/projects/core/translations/src/esp.ts b/projects/core/translations/src/esp.ts index 0afd6d81e..bec3613df 100644 --- a/projects/core/translations/src/esp.ts +++ b/projects/core/translations/src/esp.ts @@ -258,6 +258,7 @@ export const ESP: Translation = { 'selected': 'seleccionados', 'Columns': 'Columnas', 'Reset columns': 'Restablecer las columnas', + 'Reset filters': 'Restablecer los filtros', 'Clear selection': 'Borrar selección', 'Select one or more rows to see the available actions': 'Seleccione una o más filas para ver las acciones disponibles', diff --git a/projects/core/translations/src/fra.ts b/projects/core/translations/src/fra.ts index c9609c065..d57220df3 100644 --- a/projects/core/translations/src/fra.ts +++ b/projects/core/translations/src/fra.ts @@ -241,6 +241,7 @@ export const FRA: Translation = { 'selected': 'sélectionnés', 'Columns': 'Colonnes', 'Reset columns': 'Réinitialiser les colonnes', + 'Reset filters': 'Réinitialiser les filtres', 'Clear selection': 'Effacer la sélection', 'Select one or more rows to see the available actions': 'Sélectionnez une ou plusieurs lignes pour voir les actions disponibles', diff --git a/projects/core/translations/src/ita.ts b/projects/core/translations/src/ita.ts index 11ff1de67..8afa61822 100644 --- a/projects/core/translations/src/ita.ts +++ b/projects/core/translations/src/ita.ts @@ -246,6 +246,7 @@ export const ITA: Translation = { 'selected': 'selezionati', 'Columns': 'Colonne', 'Reset columns': 'Ripristina le colonne', + 'Reset filters': 'Azzera filtri', 'Clear selection': 'Deseleziona', 'Select one or more rows to see the available actions': 'Seleziona una o più righe per visualizzare le azioni', diff --git a/projects/core/translations/src/prt.ts b/projects/core/translations/src/prt.ts index b61524267..d9b4100a1 100644 --- a/projects/core/translations/src/prt.ts +++ b/projects/core/translations/src/prt.ts @@ -257,6 +257,7 @@ export const PRT: Translation = { 'selected': 'selecionados', 'Columns': 'Colunas', 'Reset columns': 'Repor as colunas', + 'Reset filters': 'Repor os filtros', 'Clear selection': 'Limpar seleção', 'Select one or more rows to see the available actions': 'Selecione uma ou mais linhas para ver as ações disponíveis', diff --git a/projects/core/translations/src/uga.ts b/projects/core/translations/src/uga.ts index 2f5a6d8ba..5beafe5e4 100644 --- a/projects/core/translations/src/uga.ts +++ b/projects/core/translations/src/uga.ts @@ -60,6 +60,7 @@ export const UGA: Translation = { 'selected': 'selected', 'Columns': 'Columns', 'Reset columns': 'Reset columns', + 'Reset filters': 'Reset filters', 'Clear selection': 'Clear selection', 'Select one or more rows to see the available actions': 'Select one or more rows to see the available actions', diff --git a/projects/core/translations/src/ukr.ts b/projects/core/translations/src/ukr.ts index d61b1b5a2..0aa9fe469 100644 --- a/projects/core/translations/src/ukr.ts +++ b/projects/core/translations/src/ukr.ts @@ -248,6 +248,7 @@ export const UKR: Translation = { 'selected': 'вибрано', 'Columns': 'Стовпці', 'Reset columns': 'Скинути стовпці', + 'Reset filters': 'Скинути фільтри', 'Clear selection': 'Очистити вибір', 'Select one or more rows to see the available actions': 'Виберіть один або кілька рядків, щоб побачити доступні дії', diff --git a/projects/dinoapp/src/app/forms-map/components/forms-map.ts b/projects/dinoapp/src/app/forms-map/components/forms-map.ts index 83d34d239..004dd5309 100644 --- a/projects/dinoapp/src/app/forms-map/components/forms-map.ts +++ b/projects/dinoapp/src/app/forms-map/components/forms-map.ts @@ -16,7 +16,12 @@ import {Case, CaseManager} from '@dino/core/cases'; import {ActionTrigger, Metric} from '@dino/core/data'; import {ExportListData} from '@dino/core/exporter'; import {FormData, FormDataManager, FormInfo, FormSchema, FormSchemaManager} from '@dino/core/forms'; -import {FiltersService, ListHeader, NodeVisibility} from '@dino/core/list'; +import { + FiltersService, + ListHeader, + NodeVisibility, + sectionStorageKey, +} from '@dino/core/list'; import {Location, LocationManager} from '@dino/core/locations'; import {Organization, OrganizationManager} from '@dino/core/organizations'; import {Project, ProjectManager} from '@dino/core/projects'; @@ -388,6 +393,13 @@ export class FormsMapComponent implements AfterViewInit, OnDestroy { } } if (this.filtersBar != null) { + // The map displays the data of the form, so it filters it with the very + // filters of its table: same section, same key. + this._filtersService.storageKey = sectionStorageKey( + 'filters', + this._route.snapshot, + undefined, + ); this.filtersBar.initFilters(); } } diff --git a/projects/material/list/src/list.ts b/projects/material/list/src/list.ts index db2ca72fb..b5d910230 100644 --- a/projects/material/list/src/list.ts +++ b/projects/material/list/src/list.ts @@ -69,6 +69,7 @@ import { ListHeader, mainActions, SearchFiltersComponent, + sectionStorageKey, } from '@dino/core/list'; import {BreakpointObserverService} from '@dino/material/breakpoint-observer'; import {ExportList} from '@dino/material/export-list'; @@ -622,6 +623,11 @@ export class SelectionList } ngOnInit() { + // The filters of this section are read and stored under this key: the + // service is a singleton and cannot tell which section is displayed. It has + // to be set before the filters bar initializes its filters, which is what + // filling the data source ends up doing. + this._fts.storageKey = sectionStorageKey('filters', this._route.snapshot, this.title); if (this._dataSource) { this._fillDataSource(); } diff --git a/projects/material/main-nav/src/main-nav.ts b/projects/material/main-nav/src/main-nav.ts index d7b8510cf..4dcf519f7 100644 --- a/projects/material/main-nav/src/main-nav.ts +++ b/projects/material/main-nav/src/main-nav.ts @@ -931,7 +931,8 @@ export class MainNav implements AfterViewInit, OnDestroy { for (let key of Object.keys(localStorage)) { if ( key.includes('columns_') || - key.includes('filters_preset_') || + // The filters of every section, and the presets of the User + key.startsWith('filters_') || key === 'dino_new_version_ready' || key === 'pandas_dino_api_key' || key === 'dino_gpt_terms_accepted' diff --git a/projects/material/search-filters-bar/src/search-filters-bar.html b/projects/material/search-filters-bar/src/search-filters-bar.html index 5b309e0d4..1a15d3c45 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.html +++ b/projects/material/search-filters-bar/src/search-filters-bar.html @@ -90,6 +90,10 @@
+ + + +
+ {{'Select all Form fields'|transloco}} + {{'Label values'|transloco}} +
{{'Value format'|transloco}}
+ + {{'Default'|transloco}} + {{'Data Analysis format'|transloco}} + {{'Separate columns'|transloco}} + +
+
+
+
+ +
+ +
+
+

+ {{label|transloco}} +

+ + + search + + + +
+
+ +
+
+
+ + + - + diff --git a/projects/material/export-list/src/export-list.module.ts b/projects/material/export-list/src/export-list.module.ts index 09bc26738..6cd0925ca 100644 --- a/projects/material/export-list/src/export-list.module.ts +++ b/projects/material/export-list/src/export-list.module.ts @@ -21,33 +21,24 @@ */ import {AjfTranslocoModule} from '@ajf/core/transloco'; -import {BreakpointObserverModule} from '@dino/material/breakpoint-observer'; import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; import {MatButtonToggleModule} from '@angular/material/button-toggle'; import {MatCheckboxModule} from '@angular/material/checkbox'; -import {MatNativeDateModule} from '@angular/material/core'; -import {MatDatepickerModule} from '@angular/material/datepicker'; import {MatFormFieldModule} from '@angular/material/form-field'; import {MatIconModule} from '@angular/material/icon'; import {MatInputModule} from '@angular/material/input'; -import {MatListModule} from '@angular/material/list'; -import {MatSelectModule} from '@angular/material/select'; -import {MatTabsModule} from '@angular/material/tabs'; -import {MatToolbarModule} from '@angular/material/toolbar'; +import {MatMenuModule} from '@angular/material/menu'; +import {MatRadioModule} from '@angular/material/radio'; import {MatTooltipModule} from '@angular/material/tooltip'; import {ExportList} from './export-list'; import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; import {LoadingSpinnerModule as DinoLoadingSpinnerModule} from '@dino/material/loading-spinner'; -import {ExportBottomSheet} from './export-list-bottom-sheet'; -import {ToggleButtonComponent} from './toggle-button'; - @NgModule({ imports: [ - BreakpointObserverModule, CommonModule, DinoLoadingSpinnerModule, FormsModule, @@ -55,20 +46,15 @@ import {ToggleButtonComponent} from './toggle-button'; MatButtonModule, MatButtonToggleModule, MatCheckboxModule, - MatDatepickerModule, MatFormFieldModule, MatIconModule, MatInputModule, - MatListModule, - MatSelectModule, - MatNativeDateModule, + MatMenuModule, MatProgressSpinnerModule, - MatTabsModule, - MatToolbarModule, + MatRadioModule, MatTooltipModule, AjfTranslocoModule, ], - declarations: [ExportList, ToggleButtonComponent, ExportBottomSheet], - exports: [ToggleButtonComponent, ExportBottomSheet], + declarations: [ExportList], }) export class ExportListModule {} diff --git a/projects/material/export-list/src/export-list.scss b/projects/material/export-list/src/export-list.scss index 178615b2d..d53ae807e 100644 --- a/projects/material/export-list/src/export-list.scss +++ b/projects/material/export-list/src/export-list.scss @@ -1,4 +1,358 @@ +@use 'angular-material-css-vars' as mat-css-vars; + +@function exp-primary($shade: 500, $alpha: 1) { + @return mat-css-vars.mat-css-color-primary($shade, $alpha); +} + +// Dark-theme neutral overrides (`.isDarkTheme` is set on an ancestor by the app). +.isDarkTheme dino-export-list, +.isDarkTheme .dino-export-formats-menu { + --exp-page: #14171b; + --exp-surface: #23272d; + --exp-subtle: #1b1f24; + --exp-border: #363c44; + --exp-border-inner: #2f353c; + --exp-input-border: #3a424b; + --exp-heading: #e6e9ec; + --exp-text: #d4d9dd; + --exp-muted: #99a0a7; +} + +@mixin dino-export-tokens { + // Light-theme neutral defaults. + --exp-page: #eef1f4; + --exp-surface: #ffffff; + --exp-subtle: #f7f9fb; + --exp-border: #e2e8ee; + --exp-border-inner: #eef1f4; + --exp-input-border: #d7dee5; + --exp-heading: #12303f; + --exp-text: #2a3a45; + --exp-muted: #64798a; +} + dino-export-list { + @include dino-export-tokens; + + display: block; + height: 100%; + color: var(--exp-text); + + * { + box-sizing: border-box; + } + + .dino-export-dialog { + display: flex; + flex-direction: column; + height: 100%; + position: relative; + background: var(--exp-page); + } + + // --- Header ------------------------------------------------------------- + + .dino-export-header, + .dino-export-options, + .dino-export-footer { + background: var(--exp-surface); + padding: 12px 24px; + } + + .dino-export-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + border-bottom: 1px solid var(--exp-border); + } + + .dino-export-title { + display: flex; + align-items: center; + gap: 8px; + color: var(--exp-heading); + + mat-icon { + color: exp-primary(500); + } + + h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + white-space: nowrap; + } + } + + .dino-export-format { + margin-left: auto; + } + + .dino-export-options { + border-bottom: 1px solid var(--exp-border); + } + + // --- Fields and formats dropdown --------------------------------------- + + .dino-export-formats-trigger { + display: flex; + align-items: center; + gap: 12px; + min-width: 280px; + padding: 6px 12px; + border: 1px solid var(--exp-input-border); + border-radius: 8px; + background: var(--exp-surface); + color: var(--exp-text); + cursor: pointer; + text-align: left; + + &:hover { + border-color: exp-primary(500); + } + + mat-icon { + margin-left: auto; + color: var(--exp-muted); + } + } + + .dino-export-formats-text { + display: flex; + flex-direction: column; + overflow: hidden; + } + + .dino-export-formats-caption { + font-size: 11px; + color: var(--exp-muted); + } + + .dino-export-formats-summary { + font-size: 14px; + font-weight: 500; + color: var(--exp-heading); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + // --- Body --------------------------------------------------------------- + + .dino-export-body { + display: grid; + grid-template-columns: 260px 1fr; + gap: 16px; + flex: 1 1 auto; + min-height: 0; + padding: 16px 24px; + } + + .dino-export-sections, + .dino-export-fields { + display: flex; + flex-direction: column; + min-height: 0; + background: var(--exp-surface); + border: 1px solid var(--exp-border); + border-radius: 8px; + padding: 12px; + } + + .dino-export-sections-caption { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--exp-muted); + padding: 4px 8px 12px; + } + + .dino-export-sections-list { + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + min-height: 0; + } + + .dino-export-section { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 12px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--exp-text); + font-size: 14px; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--exp-subtle); + } + + &.dino-active { + background: exp-primary(500, 0.12); + color: exp-primary(500); + font-weight: 500; + } + } + + .dino-export-section-label { + flex: 1 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .dino-export-section-count { + flex: 0 0 auto; + padding: 2px 8px; + border-radius: 999px; + background: var(--exp-border-inner); + color: var(--exp-muted); + font-size: 12px; + } + + // --- Fields grid -------------------------------------------------------- + + .dino-export-fields-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding-bottom: 12px; + } + + .dino-export-fields-title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--exp-heading); + } + + .dino-export-field-search { + flex: 1 1 220px; + max-width: 340px; + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + .mdc-line-ripple { + display: none; + } + + .mat-mdc-text-field-wrapper { + height: 40px; + border-radius: 8px; + border: 1px solid var(--exp-input-border); + background: var(--exp-surface); + } + + .mat-mdc-form-field-infix { + min-height: 40px; + padding: 8px 0; + } + } + + .dino-export-fields-header button { + height: 40px; + border-radius: 8px; + white-space: nowrap; + } + + .dino-export-fields-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + align-content: start; + overflow-y: auto; + min-height: 0; + flex: 1 1 auto; + padding: 4px; + } + + .dino-export-field { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 8px 8px 14px; + border: 1px solid var(--exp-border); + border-radius: 8px; + background: var(--exp-surface); + cursor: pointer; + + &:hover { + border-color: exp-primary(500); + } + } + + .dino-export-field-label { + flex: 1 1 auto; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // --- Footer ------------------------------------------------------------- + + .dino-export-footer { + display: flex; + align-items: center; + gap: 12px; + border-top: 1px solid var(--exp-border); + } + + .dino-export-count { + font-size: 14px; + color: var(--exp-muted); + + strong { + color: var(--exp-heading); + } + } + + .dino-export-spacer { + flex: 1 1 auto; + } + + .dino-export-footer button { + height: 44px; + border-radius: 8px; + } + + // --- Shared control styling -------------------------------------------- + + .mat-button-toggle-group { + border-radius: 8px; + border-color: var(--exp-border); + + .mat-button-toggle { + font-size: 14px; + background: var(--exp-surface); + color: var(--exp-text); + } + + .mat-button-toggle-checked { + background: exp-primary(500, 0.12); + color: exp-primary(500); + } + + .mat-button-toggle-button { + height: 40px; + } + + .mat-button-toggle-label-content { + line-height: 40px; + } + } + mat-spinner { display: block !important; position: absolute !important; @@ -6,85 +360,91 @@ dino-export-list { left: 45%; z-index: 2; } - mat-toolbar { - .mat-mdc-form-field { - min-width: 100px; + + @media only screen and (max-width: 992px) { + .dino-export-header, + .dino-export-options, + .dino-export-footer { + padding: 12px 16px; } - .dino-export-toolbar-spacer { - flex: 1 1 auto; + + .dino-export-format { + margin-left: 0; } - .dino-spacer { - flex: 1 1 auto; + + .dino-export-body { + grid-template-columns: 1fr; + padding: 12px 16px; } - dino-toggle-button { - width: auto; - margin-right: 10px; + + .dino-export-sections { + padding: 8px; } - mat-button-toggle { - font-size: 16px; - @media only screen and (max-width: 992px) { - font-size: 14px; - } + + .dino-export-sections-caption { + display: none; } - height: auto !important; - min-height: 64px; - button { - min-height: 50px; + .dino-export-sections-list { + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; } - &.dino-export-options { - .mat-button-toggle-group { - flex: 1 1 auto; - .mat-button-toggle { - flex: 1 1 auto; - } - } + .dino-export-section { + width: auto; + flex: 0 0 auto; } - @media only screen and (max-width: 992px) { - &.dino-export-options { - flex-wrap: wrap; - padding-bottom: 10px; - } + .dino-export-fields-grid { + grid-template-columns: 1fr; + } + + .dino-export-field-search { + max-width: none; } } - .dino-checked { - background-color: red; - } - .dino-export-translate-btn { - max-width: 110px; - } - .dino-export-toolbar-count { - margin-right: 10px; +} + +// The dialog panel and the dropdown overlay live outside the host element: +// with ViewEncapsulation.None their styles must sit at the top level of the file. +.dino-export-dialog-panel { + .mat-mdc-dialog-surface { + padding: 0; + overflow: hidden; } - mat-selection-list { - width: 100%; - overflow-y: scroll; - max-height: 50vh; +} + +.dino-export-formats-menu { + @include dino-export-tokens; + + .mat-mdc-menu-content { + padding: 0; } - mat-icon { - margin-left: 10px; + .dino-export-menu-body { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + min-width: 260px; + background: var(--exp-surface); + color: var(--exp-text); } - .dino-button-focus-action { - .mat-button-toggle-checked, - .dino-toggle-button { - background-color: #7f50b8; - } - .dino-export-button { - background-color: #7f50b8; - margin-left: 25px; - @media only screen and (max-width: 992px) { - .mat-icon { - margin: auto; - } - } - } + .dino-export-menu-caption { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--exp-muted); + padding-top: 4px; + border-top: 1px solid var(--exp-border-inner); } -} -.mat-mdc-dialog-surface { - padding: 24px; + .dino-export-menu-radios { + display: flex; + flex-direction: column; + gap: 4px; + } } diff --git a/projects/material/export-list/src/export-list.spec.ts b/projects/material/export-list/src/export-list.spec.ts index cee9de521..92a09fb07 100644 --- a/projects/material/export-list/src/export-list.spec.ts +++ b/projects/material/export-list/src/export-list.spec.ts @@ -168,9 +168,9 @@ describe('Export Forms', () => { fixtureImportForm.detectChanges(); const spyExportCsv = spyOn(exportForm, '_buildCsv').and.callFake(() => {}); - spyOn(exportForm, '_getFieldsFromTabs').and.callFake(() => { - return testAjfSchema.nodes[0].nodes as unknown[] as AjfField[]; - }); + const selectedFields = testAjfSchema.nodes[0].nodes as unknown[] as AjfField[]; + spyOn(exportForm, '_getSelectedFields').and.callFake(() => selectedFields); + spyOn(exportForm, '_getSectionFields').and.callFake(() => selectedFields); exportForm.data = formData; exportForm.export(); diff --git a/projects/material/export-list/src/export-list.ts b/projects/material/export-list/src/export-list.ts index b2a0f5f80..de97170b8 100644 --- a/projects/material/export-list/src/export-list.ts +++ b/projects/material/export-list/src/export-list.ts @@ -33,7 +33,6 @@ import { import {TranslocoService} from '@ajf/core/transloco'; import {deepCopy} from '@ajf/core/utils'; import { - AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -43,18 +42,22 @@ import { OnDestroy, Optional, Output, - QueryList, - ViewChildren, ViewEncapsulation, } from '@angular/core'; -import {MatSelectionList} from '@angular/material/list'; -import {MatTabChangeEvent} from '@angular/material/tabs'; -import {BehaviorSubject, forkJoin, isObservable, Observable, of as obsOf, Subscription} from 'rxjs'; -import {filter, map, switchMap, take, tap, withLatestFrom} from 'rxjs/operators'; +import {UntypedFormControl} from '@angular/forms'; +import { + BehaviorSubject, + combineLatest, + forkJoin, + isObservable, + Observable, + of as obsOf, + Subscription, +} from 'rxjs'; +import {filter, map, startWith, switchMap, take, tap, withLatestFrom} from 'rxjs/operators'; import * as XLSX from 'xlsx'; import {FormSchema} from '@dino/core/forms'; -import {ToggleButtonComponent} from './toggle-button'; import {AreaManager} from '@dino/core/areas'; import {CaseManager} from '@dino/core/cases'; @@ -63,9 +66,7 @@ import {OrganizationManager} from '@dino/core/organizations'; import {ProjectManager} from '@dino/core/projects'; import {ActionTrigger, DataModelManager} from '@dino/core/data'; import {MatDialogRef, MAT_DIALOG_DATA} from '@angular/material/dialog'; -import {BreakpointObserverService} from '@dino/material/breakpoint-observer'; import {RxDocument} from 'rxdb'; -import {MatSelectChange} from '@angular/material/select'; import { AjfField, Context, @@ -75,11 +76,36 @@ import { ExportFormat, MAX_SHEETNAME_LENGTH, ExportModel, - SelOption, ExportListData, Exporter, } from '@dino/core/exporter'; +/** + * A group of exportable fields, as shown in the "Sections" sidebar of the dialog. + * The position in the `sections` array is the slide index used by the export engine. + */ +export interface ExportSection { + label: string; + fields: AjfField[]; +} + +/** A section as rendered in the sidebar, with its live selection count. */ +export interface ExportSectionView extends ExportSection { + index: number; + active: boolean; + selected: number; + total: number; +} + +/** A field as rendered in the fields grid, with its live selection state. */ +export interface ExportFieldView { + field: AjfField; + selected: boolean; +} + +/** The way form values are laid out in the exported file. */ +export type ExportValueFormat = 'default' | 'data_analysis' | 'separate_columns'; + // @TODO: Use Exporter Class and remove all duplicated methods from here @Component({ selector: 'dino-export-list', @@ -88,12 +114,9 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) -export class ExportList implements AfterViewInit, OnDestroy { +export class ExportList implements OnDestroy { disableExport$: BehaviorSubject = new BehaviorSubject(true); exportFilters: ExportFilters = 'displayed'; - @ViewChildren(ToggleButtonComponent) - toggleButtons!: QueryList; - @ViewChildren(MatSelectionList) fields!: QueryList; /** * Event emitted as an Action hook @@ -101,9 +124,50 @@ export class ExportList implements AfterViewInit, OnDestroy { @Output() readonly emitExportActionTrigger: EventEmitter = new EventEmitter(); - readonly availableFieldsAndFormats: SelOption[] = []; - public selectedFieldsAndFormats: string[] = ['all_form_fields']; - readonly availableFilters: SelOption[] = []; + /** The exportable sections, in the same order as the slides of the export model */ + sections: ExportSection[] = []; + + /** If true, every field of every section is selected */ + selectAllFormFields = true; + + /** If true, export translated labels instead of raw values */ + labelValues = false; + + /** The layout of the values in the exported file */ + valueFormat: ExportValueFormat = 'default'; + + /** The section currently shown in the fields grid */ + readonly activeSectionIndex$: BehaviorSubject = new BehaviorSubject(0); + + /** Keyword filter applied to the fields of the active section */ + readonly fieldSearch: UntypedFormControl = new UntypedFormControl(''); + + /** Emits every time the field selection changes */ + readonly selectionChanged$: BehaviorSubject = new BehaviorSubject(undefined); + + readonly sectionsView$: Observable; + readonly activeSectionLabel$: Observable; + readonly visibleFields$: Observable; + readonly selectedCount$: Observable; + readonly totalCount$: Observable; + + /** The summary line shown in the "Fields and formats" dropdown trigger */ + get fieldsAndFormatsSummary(): string { + const valueFormatLabels: {[format in ExportValueFormat]: string} = { + default: 'Default', + data_analysis: 'Data Analysis format', + separate_columns: 'Separate columns', + }; + const parts = [ + this._ts.translate(this.selectAllFormFields ? 'All fields' : 'Selected fields'), + this.labelValues ? this._ts.translate('Label values') : null, + this._ts.translate(valueFormatLabels[this.valueFormat]), + ]; + return parts.filter(part => part != null).join(' · '); + } + + /** The names of the selected fields, one Set per section */ + private _selection: Set[] = []; readonly exportDataList$: BehaviorSubject = new BehaviorSubject([]); @@ -148,7 +212,6 @@ export class ExportList implements AfterViewInit, OnDestroy { ); private _exportedDataListPopulated$: Observable; - private _currentTabIndex$: BehaviorSubject = new BehaviorSubject(0); /** * Additional properties to be added to the export, external to the form schema @@ -186,8 +249,6 @@ export class ExportList implements AfterViewInit, OnDestroy { private _exportedNamesBySlide: {[index: number]: string[]} = {}; private _loading$: BehaviorSubject = new BehaviorSubject(false); - private _selectAllFieldsofCurrentSlideEvt: EventEmitter = new EventEmitter(); - private _selectAllSub: Subscription = Subscription.EMPTY; /** If true, export use translated labels instead values */ private _translate$: BehaviorSubject = new BehaviorSubject(false); @@ -247,7 +308,6 @@ export class ExportList implements AfterViewInit, OnDestroy { constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public dialogData: ExportListData, - readonly breakpointObserver: BreakpointObserverService, private _ts: TranslocoService, private _cdr: ChangeDetectorRef, @Optional() private _ar: AreaManager | null, @@ -274,25 +334,55 @@ export class ExportList implements AfterViewInit, OnDestroy { this._dinoFields = []; } - this.availableFieldsAndFormats = [ - {value: 'all_form_fields', label: 'Select all Form fields'}, - {value: 'label_values', label: 'Label values'}, - {value: 'data_analysis', label: 'Data Analysis format'}, - {value: 'separate_columns', label: 'Separate columns'}, - ]; + const selectionState$ = combineLatest([this.selectionChanged$, this.activeSectionIndex$]); + + this.sectionsView$ = selectionState$.pipe( + map(([_, activeIndex]) => + this.sections.map((section, index) => ({ + ...section, + index, + active: index === activeIndex, + selected: this._selection[index] != null ? this._selection[index].size : 0, + total: section.fields.length, + })), + ), + ); - this.availableFilters = [{value: 'displayed', label: 'Items in page'}]; + this.activeSectionLabel$ = this.activeSectionIndex$.pipe( + map(index => (this.sections[index] != null ? this.sections[index].label : null)), + ); - this._selectAllSub = (this._selectAllFieldsofCurrentSlideEvt as Observable) - .pipe(withLatestFrom(this._currentTabIndex$)) - .subscribe(([checked, tabIndex]) => { - const selectionList = this.fields.toArray()[tabIndex]; - if (checked) { - selectionList.selectAll(); - } else { - selectionList.deselectAll(); + this.visibleFields$ = combineLatest([ + selectionState$, + this.fieldSearch.valueChanges.pipe(startWith('')), + ]).pipe( + map(([[_, activeIndex], search]) => { + const section = this.sections[activeIndex]; + if (section == null) { + return []; } - }); + const selected = this._selection[activeIndex] ?? new Set(); + const keyword = `${search ?? ''}`.trim().toLowerCase(); + return section.fields + .filter( + field => + keyword === '' || + this._ts + .translate(field.label ?? '') + .toLowerCase() + .includes(keyword), + ) + .map(field => ({field, selected: selected.has(field.name)})); + }), + ); + + this.selectedCount$ = selectionState$.pipe( + map(() => this._selection.reduce((count, names) => count + names.size, 0)), + ); + + this.totalCount$ = selectionState$.pipe( + map(() => this.sections.reduce((count, section) => count + section.fields.length, 0)), + ); this._exportedDataListPopulated$ = this.exportDataList$.pipe( switchMap(expData => { @@ -399,8 +489,8 @@ export class ExportList implements AfterViewInit, OnDestroy { withLatestFrom(slideNodes$), map(([ctxList, slideNodes]) => { let fields: AjfField[] = []; - const fieldsFromTab: AjfField[] = this._getFieldsFromTabs(); - const fieldsFromTabNames: string[] = this._getFieldsFromTabs().map(f => f.name); + const fieldsFromTab: AjfField[] = this._getSelectedFields(); + const fieldsFromTabNames: string[] = fieldsFromTab.map(f => f.name); if (ctxList.length > 0) { slideNodes.forEach(slideNode => { if ((slideNode.nodeType as AjfNodeType) === AjfNodeType.AjfRepeatingSlide) { @@ -611,7 +701,7 @@ export class ExportList implements AfterViewInit, OnDestroy { field.slideName != null && exportCtx[field.slideName] == null ) { - const fieldsFromTab: AjfField[] = this._getFieldsFromTabs(field.slideIndex); + const fieldsFromTab: AjfField[] = this._getSectionFields(field.slideIndex); exportCtx[field.slideName] = ctx[field.slideName] ? ctx[field.slideName] : this._countNumberOfInstanceInContext(fieldsFromTab, ctx); @@ -737,32 +827,6 @@ export class ExportList implements AfterViewInit, OnDestroy { // this._ExporterReadyEvt.emit(); // } - ngAfterViewInit(): void { - if (this.filtersCount > 0) { - const numFilters = `${this._ts.translate('All items')} / ${ - this.filtersCount - } ${this._ts.translate('filters')}`; - this.availableFilters.push({value: 'filtered', label: numFilters}); - } else { - this.availableFilters.push({value: 'filtered', label: 'Add filters'}); - } - this.availableFilters.push({value: 'not-filtered', label: 'All items'}); - - if (this.dialogData) { - if (this.dialogData.selectAll) { - this.selectAll(true); - if (this.toggleButtons.first != null && this.toggleButtons.first.group === 'fields') { - this.toggleButtons.first.toggle(); - } - } - if (this.dialogData.exportFormat) { - this.exportFormat = this.dialogData.exportFormat; - } - } - - this._cdr.detectChanges(); - } - /** * It builds a csv file and download it from browser. * the csv contains all the selected fields. @@ -811,7 +875,6 @@ export class ExportList implements AfterViewInit, OnDestroy { ngOnDestroy(): void { this.schema$.complete(); this.exportModel$.complete(); - this._selectAllSub.unsubscribe(); this._exportSub.unsubscribe(); this._downloadSub.unsubscribe(); this._ctxValuesSub.unsubscribe(); @@ -822,71 +885,87 @@ export class ExportList implements AfterViewInit, OnDestroy { } /** - * Update form filters options - * @param evt + * Shows the fields of the section with the given index and clears the keyword filter. + * @param index the position of the section in the sections sidebar */ - updateFilters(evt: MatSelectChange) { - if (evt.value) { - if (evt.value === 'filtered') { - if (this.filtersCount === 0) { - this.closeDialog(); - } - } + setActiveSection(index: number): void { + if (index === this.activeSectionIndex$.value) { + return; } + this.fieldSearch.setValue(''); + this.activeSectionIndex$.next(index); } /** - * Update fields and formats options - * @param evt + * Selects or deselects a single field of the active section. + * @param field the toggled field + * @param checked the new selection state */ - updateFieldsAndFormats(evt: MatSelectChange) { - if (evt.value) { - this.selectedFieldsAndFormats = evt.value; - if (evt.value.includes('all_form_fields')) { - this.selectAll(true); - } else { - this.selectAll(false); - } - - if (evt.value.includes('label_values')) { - this.setTranslation(true); - } else { - this.setTranslation(false); - } - - if (evt.value.includes('data_analysis')) { - this.setDataAnalysisFormat(true); - } else { - this.setDataAnalysisFormat(false); - } + toggleField(field: AjfField, checked: boolean): void { + const selection = this._selection[this.activeSectionIndex$.value]; + if (selection == null) { + return; + } + if (checked) { + selection.add(field.name); + } else { + selection.delete(field.name); + } + this._onSelectionChanged(); + } - if (evt.value.includes('separate_columns')) { - this.setSeparateColumns(true); - } else { - this.setSeparateColumns(false); - } + /** + * Selects or deselects every field of the active section. + * @param checked the new selection state + */ + setActiveSectionSelection(checked: boolean): void { + const index = this.activeSectionIndex$.value; + const section = this.sections[index]; + if (section == null) { + return; } + this._selection[index] = new Set(checked ? section.fields.map(f => f.name) : []); + this._onSelectionChanged(); } /** - * If checked true, select all fields in all slides + * If checked true, select all fields in all sections * @param checked */ selectAll(checked: boolean): void { - this.toggleButtons - .filter(button => button.group != null && button.group === 'tab') - .forEach(button => button.setChecked(checked)); - this.fields.forEach(field => (checked ? field.selectAll() : field.deselectAll())); - this.updateExportDisable(); + this._selection = this.sections.map( + section => new Set(checked ? section.fields.map(f => f.name) : []), + ); + this._onSelectionChanged(); } /** - * If checkd true, select all fields in the current slide + * Handler of the "Select all Form fields" option of the fields and formats dropdown * @param checked */ - selectAllfieldSlides(checked: boolean): void { - this._selectAllFieldsofCurrentSlideEvt.next(checked); - this.updateExportDisable(); + setSelectAllFormFields(checked: boolean): void { + this.selectAllFormFields = checked; + this.selectAll(checked); + } + + /** + * Handler of the "Label values" option of the fields and formats dropdown + * @param checked + */ + setLabelValues(checked: boolean): void { + this.labelValues = checked; + this.setTranslation(checked); + } + + /** + * Handler of the value format radio group of the fields and formats dropdown. + * The three formats are mutually exclusive. + * @param format + */ + setValueFormat(format: ExportValueFormat): void { + this.valueFormat = format; + this.setDataAnalysisFormat(format === 'data_analysis'); + this.setSeparateColumns(format === 'separate_columns'); } /** @@ -919,12 +998,8 @@ export class ExportList implements AfterViewInit, OnDestroy { } } - tabChange(ev: MatTabChangeEvent): void { - this._currentTabIndex$.next(ev.index); - } - updateExportDisable(): void { - const countSelectedFields = this._getFieldsFromTabs().length; + const countSelectedFields = this._getSelectedFields().length; if (countSelectedFields > 0 || !this.schema$.value?.schema.nodes?.length) { this.disableExport$.next(false); } else { @@ -1005,7 +1080,7 @@ export class ExportList implements AfterViewInit, OnDestroy { baseField.name = baseFieldName; this._evaluateContext(baseField, baseExportCtx, {}); if (field.slideName != null && baseExportCtx[field.slideName] == null) { - const fieldsFromTab: AjfField[] = this._getFieldsFromTabs(field.slideIndex); + const fieldsFromTab: AjfField[] = this._getSectionFields(field.slideIndex); const numberOfInstanceInContext = ctx[field.slideName] ? ctx[field.slideName] : this._countNumberOfInstanceInContext(fieldsFromTab, ctx); @@ -1104,6 +1179,19 @@ export class ExportList implements AfterViewInit, OnDestroy { const slideLabels: string[] = slideNodes.map(slide => slide.label); const slides = slideNodes.map(slide => slide.nodes); this.exportModel$.next({schemaName, slideLabels, slides}); + + this.sections = slideNodes.map(slide => ({ + label: slide.label, + fields: slide.nodes as AjfField[], + })); + this._selection = this.sections.map(() => new Set()); + this.activeSectionIndex$.next(0); + + this.selectAll(this.dialogData.selectAll === true); + if (this.dialogData.exportFormat) { + this.exportFormat = this.dialogData.exportFormat; + } + this._cdr.markForCheck(); }); } @@ -1339,27 +1427,45 @@ export class ExportList implements AfterViewInit, OnDestroy { } /** - * @return The list of ajfField of the selected fields contained in the tabs. + * @return The list of ajfField selected by the User, across all the sections. */ - private _getFieldsFromTabs(idx?: number): AjfField[] { + private _getSelectedFields(): AjfField[] { const fields: AjfField[] = []; - const tabs = this.fields != null ? this.fields.toArray() : []; - if (idx != null && tabs[idx] != null) { - tabs[idx].options.forEach(option => { - fields.push(option.value); - }); - } else { - tabs.forEach(tab => { - if (tab.selectedOptions != null && tab.selectedOptions.selected != null) { - tab.selectedOptions.selected - .filter(selected => selected != null && selected.value != null) - .forEach(selected => fields.push(selected.value)); - } - }); - } + this.sections.forEach((section, index) => { + const selection = this._selection[index]; + if (selection == null) { + return; + } + section.fields + .filter(field => selection.has(field.name)) + .forEach(field => fields.push(field)); + }); return fields; } + /** + * @param idx the position of the section in the sections sidebar + * @return All the ajfField of the section, selected or not. + */ + private _getSectionFields(idx?: number): AjfField[] { + if (idx == null || this.sections[idx] == null) { + return []; + } + return this.sections[idx].fields; + } + + /** + * Notifies the view of a selection change and refreshes the state of the export button. + */ + private _onSelectionChanged(): void { + this.selectAllFormFields = this.sections.every( + (section, index) => + this._selection[index] != null && this._selection[index].size === section.fields.length, + ); + this.selectionChanged$.next(); + this.updateExportDisable(); + } + /** * @param ctxList is the list of ajf contexts. * @param names is the list of field names. diff --git a/projects/material/export-list/src/public_api.ts b/projects/material/export-list/src/public_api.ts index 2f89d68b9..abb1ed2bf 100644 --- a/projects/material/export-list/src/public_api.ts +++ b/projects/material/export-list/src/public_api.ts @@ -22,5 +22,3 @@ export * from './export-list'; export * from './export-list.module'; -export * from './export-list-bottom-sheet'; -export * from './toggle-button'; diff --git a/projects/material/export-list/src/toggle-button.html b/projects/material/export-list/src/toggle-button.html deleted file mode 100644 index bed8ff498..000000000 --- a/projects/material/export-list/src/toggle-button.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/projects/material/export-list/src/toggle-button.scss b/projects/material/export-list/src/toggle-button.scss deleted file mode 100644 index bdf7e1868..000000000 --- a/projects/material/export-list/src/toggle-button.scss +++ /dev/null @@ -1,17 +0,0 @@ -dino-toggle-button { - height: 100%; - width: 100%; - max-height: 50px; - display: block; - button { - height: 100%; - width: 100%; - min-height: 50px; - text-align: center; - vertical-align: middle; - /* TODO(mdc-migration): The following rule targets internal classes of button that may no longer apply for the MDC version. */ - .mat-button-wrapper > * { - margin: 0; - } - } -} diff --git a/projects/material/export-list/src/toggle-button.ts b/projects/material/export-list/src/toggle-button.ts deleted file mode 100644 index ab928354e..000000000 --- a/projects/material/export-list/src/toggle-button.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @license - * Copyright (C) Gnucoop soc. coop. - * - * This file is part of the Dino (dino). - * - * Dino (dino) is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the License, - * or (at your option) any later version. - * - * Dino (dino) is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero - * General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Dino (dino). - * If not, see http://www.gnu.org/licenses/. - * - */ - -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - EventEmitter, - Input, - Output, - ViewEncapsulation, -} from '@angular/core'; - -/** Change event object emitted by ToggleButtonComponent. */ -export interface ExportSelectAllChange { - /** The source ToggleButtonComponent of the event. */ - source: ToggleButtonComponent; - /** The new `checked` value of the checkbox. */ - checked: boolean; -} - -@Component({ - selector: 'dino-toggle-button', - templateUrl: 'toggle-button.html', - styleUrls: ['toggle-button.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, -}) -export class ToggleButtonComponent { - checked: boolean = false; - - private _groupName: string | null = null; - get group(): string | null { - return this._groupName; - } - @Input() - set group(name: string | null) { - this._groupName = name; - } - - @Output() - readonly change: EventEmitter = new EventEmitter(); - constructor(private _cdr: ChangeDetectorRef) {} - - toggle(): void { - this.checked = !this.checked; - const event: ExportSelectAllChange = { - source: this, - checked: this.checked, - }; - this.change.emit(event); - } - - setChecked(val: boolean): void { - this.checked = val; - this._cdr.detectChanges(); - } -} diff --git a/projects/material/list/src/list.ts b/projects/material/list/src/list.ts index b5d910230..e39126794 100644 --- a/projects/material/list/src/list.ts +++ b/projects/material/list/src/list.ts @@ -1912,6 +1912,11 @@ export class SelectionList * @param dialogConfig The dialog configuration */ private _openExportDialog(dialogConfig: MatDialogConfig): void { + dialogConfig.panelClass = 'dino-export-dialog-panel'; + dialogConfig.width = 'min(1200px, 92vw)'; + dialogConfig.maxWidth = '92vw'; + dialogConfig.height = '85vh'; + dialogConfig.autoFocus = false; let dialogRef = this.dialog.open(ExportList, dialogConfig); dialogRef.componentInstance.emitExportActionTrigger .pipe(take(1)) diff --git a/projects/material/search-filters-bar/src/search-filters-bar.module.ts b/projects/material/search-filters-bar/src/search-filters-bar.module.ts index ef82fc806..f5aa00146 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.module.ts +++ b/projects/material/search-filters-bar/src/search-filters-bar.module.ts @@ -25,7 +25,6 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {ReactiveFormsModule} from '@angular/forms'; import {MatAutocompleteModule} from '@angular/material/autocomplete'; -import {MatBottomSheetModule} from '@angular/material/bottom-sheet'; import {MatButtonModule} from '@angular/material/button'; import {MatButtonToggleModule} from '@angular/material/button-toggle'; import {MatCheckboxModule} from '@angular/material/checkbox'; @@ -71,7 +70,6 @@ import {SearchFiltersBar} from './search-filters-bar'; MatListModule, MatNativeDateModule, MatPaginatorModule, - MatBottomSheetModule, MatSortModule, MatTableModule, MatTabsModule, diff --git a/projects/material/search-filters-bar/src/search-filters-bar.ts b/projects/material/search-filters-bar/src/search-filters-bar.ts index 48f44524f..4eaab0fb2 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.ts +++ b/projects/material/search-filters-bar/src/search-filters-bar.ts @@ -36,7 +36,6 @@ import { ViewEncapsulation, } from '@angular/core'; import {UntypedFormControl, UntypedFormGroup} from '@angular/forms'; -import {MatBottomSheet} from '@angular/material/bottom-sheet'; import {MatDialog, MatDialogConfig, MatDialogRef} from '@angular/material/dialog'; import {ActivatedRoute, NavigationEnd, Router} from '@angular/router'; import {AreaManager} from '@dino/core/areas'; @@ -56,7 +55,6 @@ import {OrganizationManager} from '@dino/core/organizations'; import {ProjectManager} from '@dino/core/projects'; import {UserData, UserDataManager, UserGroup, UserGroupManager} from '@dino/core/users'; import {BreakpointObserverService} from '@dino/material/breakpoint-observer'; -import {ExportBottomSheet} from '@dino/material/export-list'; import {isRxDocument, RxDocument} from 'rxdb'; import { BehaviorSubject, @@ -334,7 +332,6 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, public dialog: MatDialog, private _fsm: FormStatusManager, private _cdr: ChangeDetectorRef, - private _bottomSheet: MatBottomSheet, private _route: ActivatedRoute, private _fschm: FormSchemaManager, private _udm: UserDataManager, @@ -633,20 +630,6 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, this.exportEvt.emit('CSV'); } - /** - * Open bottom sheet with export options - */ - openExportBottomSheet(): void { - this._bottomSheet - .open(ExportBottomSheet) - .afterDismissed() - .subscribe((ev: 'XLSX' | 'CSV' | 'dialog' | null) => { - if (ev != null) { - this.exportEvt.emit(ev); - } - }); - } - /** * Switches between the Data, Map and AI views of the current form schema, * preserving the active filters (carried in the `?filters=` query param). From 551b3133739e8bf38eaf14b034e952b3bf64373d Mon Sep 17 00:00:00 2001 From: tulas Date: Sun, 9 Aug 2026 16:45:20 +0200 Subject: [PATCH 12/22] style(search-filters-bar): align the Filters modal header with the export dialog The two modals open from the same toolbar and read as one screen, but their headers had drifted: the Filtri title was lighter and a hair smaller than Esporta dati, and its Semplice/Avanzati toggles kept the Material defaults, taller than the segmented controls of the export header and grey where those are tinted with the primary colour. - give the heading the same 18px 600 of the export title - give the tabs the same segmented control: 40px tall, 8px radius, the selected one tinted with the primary colour Co-Authored-By: Claude Opus 5 (1M context) --- .../src/search-filters-bar.scss | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/projects/material/search-filters-bar/src/search-filters-bar.scss b/projects/material/search-filters-bar/src/search-filters-bar.scss index 0f88ae64a..81090fe53 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.scss +++ b/projects/material/search-filters-bar/src/search-filters-bar.scss @@ -244,8 +244,27 @@ dino-search-filters-bar { } .dino-filters-modal-heading { - font-size: 1.15rem; - font-weight: 500; + font-size: 18px; + font-weight: 600; + } + + // Same segmented control as the header of the export dialog. + .dino-filters-modal-tabs { + border-radius: 8px; + + .mat-button-toggle { + font-size: 14px; + } + .mat-button-toggle-checked { + background: mat-css-vars.mat-css-color-primary(500, 0.12); + color: mat-css-vars.mat-css-color-primary(500); + } + .mat-button-toggle-button { + height: 40px; + } + .mat-button-toggle-label-content { + line-height: 40px; + } } .dino-filters-modal-actions { From 0d0649754412c1ff6f72700bd0731780d7a182ad Mon Sep 17 00:00:00 2001 From: tulas Date: Sun, 9 Aug 2026 16:50:00 +0200 Subject: [PATCH 13/22] style(search-filters-bar): the All/Any logic toggle as the other segmented controls The Tutti/Qualsiasi toggle of the Advanced tab was the last one left on the Material defaults, taller and grey beside the Semplice/Avanzati tabs right above it. Co-Authored-By: Claude Opus 5 (1M context) --- .../material/search-filters-bar/src/search-filters-bar.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/projects/material/search-filters-bar/src/search-filters-bar.scss b/projects/material/search-filters-bar/src/search-filters-bar.scss index 81090fe53..eaca31ab3 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.scss +++ b/projects/material/search-filters-bar/src/search-filters-bar.scss @@ -248,8 +248,10 @@ dino-search-filters-bar { font-weight: 600; } - // Same segmented control as the header of the export dialog. - .dino-filters-modal-tabs { + // Same segmented control as the header of the export dialog, for the + // Simple/Advanced tabs and for the All/Any logic of the Advanced tab. + .dino-filters-modal-tabs, + .dino-filters-advanced-logic .mat-button-toggle-group { border-radius: 8px; .mat-button-toggle { From c92baa93f27e4c3d9ebe45bf96cd4c04a45e870a Mon Sep 17 00:00:00 2001 From: tulas Date: Sun, 9 Aug 2026 17:13:33 +0200 Subject: [PATCH 14/22] fix(material/export-list): let the metrics export dialog take the height it needs Metrics have no slides, so the dialog is its header and its footer alone, but it was opened at the same fixed 85vh as the form export: below the two rows lay an empty grey area taller than the whole modal. - open the dialog at auto height when there are no fields to pick, keeping 85vh as the ceiling of the form one, which fills it with the sections and the grid Co-Authored-By: Claude Opus 5 (1M context) --- projects/dinoapp/src/app/forms-map/components/forms-map.ts | 1 + projects/material/list/src/list.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/projects/dinoapp/src/app/forms-map/components/forms-map.ts b/projects/dinoapp/src/app/forms-map/components/forms-map.ts index 795016c1d..4fe14c1f3 100644 --- a/projects/dinoapp/src/app/forms-map/components/forms-map.ts +++ b/projects/dinoapp/src/app/forms-map/components/forms-map.ts @@ -367,6 +367,7 @@ export class FormsMapComponent implements AfterViewInit, OnDestroy { dialogConfig.width = 'min(1200px, 92vw)'; dialogConfig.maxWidth = '92vw'; dialogConfig.height = '85vh'; + dialogConfig.maxHeight = '85vh'; dialogConfig.autoFocus = false; const dialogRef = this._dialog.open(ExportList, dialogConfig); dialogRef.componentInstance.emitExportActionTrigger diff --git a/projects/material/list/src/list.ts b/projects/material/list/src/list.ts index e39126794..51bf0ca13 100644 --- a/projects/material/list/src/list.ts +++ b/projects/material/list/src/list.ts @@ -1912,10 +1912,14 @@ export class SelectionList * @param dialogConfig The dialog configuration */ private _openExportDialog(dialogConfig: MatDialogConfig): void { + // Metrics have no slides to pick fields from: the dialog is its header and + // its footer alone, and takes only the height they need. + const compact = (dialogConfig.data as ExportListData | undefined)?.listType === 'metrics'; dialogConfig.panelClass = 'dino-export-dialog-panel'; dialogConfig.width = 'min(1200px, 92vw)'; dialogConfig.maxWidth = '92vw'; - dialogConfig.height = '85vh'; + dialogConfig.height = compact ? 'auto' : '85vh'; + dialogConfig.maxHeight = '85vh'; dialogConfig.autoFocus = false; let dialogRef = this.dialog.open(ExportList, dialogConfig); dialogRef.componentInstance.emitExportActionTrigger From 2d0b5d7e8819702db542ba2f7aedb877103a8a80 Mon Sep 17 00:00:00 2001 From: tulas Date: Sun, 9 Aug 2026 17:16:39 +0200 Subject: [PATCH 15/22] feat(material/export-list): the whole label of a field on hover A field card is one cell of the grid: a long label is ellipsized, and long labels are the rule in a form of any size, so choosing what to export meant guessing between two fields cut at the same word. - give every field card the tooltip of its label, as the sections of the sidebar already had, and let both wrap up to 420px instead of being ellipsized in turn Co-Authored-By: Claude Opus 5 (1M context) --- projects/material/export-list/src/export-list.html | 14 ++++++++++++-- projects/material/export-list/src/export-list.scss | 10 ++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/projects/material/export-list/src/export-list.html b/projects/material/export-list/src/export-list.html index 65ac4ecf4..fe8653c15 100644 --- a/projects/material/export-list/src/export-list.html +++ b/projects/material/export-list/src/export-list.html @@ -99,7 +99,10 @@

{{'Export data'|transloco}}

[class.dino-active]="section.active" (click)="setActiveSection(section.index)" > - {{section.selected}}/{{section.total}} @@ -126,7 +129,14 @@

-