diff --git a/angular.json b/angular.json index 953f26f6d..4bbcf2670 100644 --- a/angular.json +++ b/angular.json @@ -377,6 +377,7 @@ "options": { "serviceWorker": true, "allowedCommonJsDependencies": [ + "chart.js", "crypto-js/aes", "crypto-js/enc-utf8", "deep-equal", 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..9596104e7 100644 --- a/projects/core/list/src/filters.service.ts +++ b/projects/core/list/src/filters.service.ts @@ -38,7 +38,15 @@ import { Subscription, throwError, } from 'rxjs'; -import {catchError, debounceTime, map, skip, take, withLatestFrom} from 'rxjs/operators'; +import { + catchError, + debounceTime, + map, + shareReplay, + skip, + take, + withLatestFrom, +} from 'rxjs/operators'; import { DEFAULT_MODEL_KEYS, @@ -226,6 +234,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 +290,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([ @@ -294,6 +326,11 @@ export class FiltersService { return this._updateQueryString(transformedFilters); }), catchError(err => throwError(() => err) as Observable), + // Encoding the filters also writes them in the url and stores them for + // the section: it has to happen once, and not once per subscriber. The + // reference count frees the chain when the section is left, so that the + // next one starts from its own filters and not from the last ones. + shareReplay({bufferSize: 1, refCount: true}), ); } @@ -805,9 +842,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-filters-interfaces.ts b/projects/core/list/src/list-filters-interfaces.ts index be1bc7529..bac3fba74 100644 --- a/projects/core/list/src/list-filters-interfaces.ts +++ b/projects/core/list/src/list-filters-interfaces.ts @@ -91,6 +91,11 @@ export interface FilterItem extends Partial { * Specifies if this filter refers to a field belonging to a Repeating Slide */ isRepeatingSlideFilter?: boolean; + /** + * Specifies if this is a basic filter, one of those displayed by the main + * filters component (eg. date, metric, user filters) + */ + isBasicFilter?: boolean; /** * States the validation state of the filter */ 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 c90078c67..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'; /** @@ -98,6 +99,7 @@ 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) { @@ -110,6 +112,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; } @@ -134,6 +142,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); @@ -309,26 +336,23 @@ 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. */ 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 f2204c5d5..e42a36957 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': 'إضافة عوامل تصفية', @@ -214,6 +215,49 @@ export const AR: Translation = { 'Filter': 'تصفية', 'Filter condition': 'شرط التصفية', 'Filters': 'عوامل التصفية', + 'Advanced filters': 'عوامل تصفية متقدمة', + 'Applied filters': 'الفلاتر المطبقة', + 'Simple': 'بسيط', + 'Advanced': 'متقدم', + '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': 'إعادة توليد الإجابة', + 'Could not download the export file': 'تعذر تنزيل ملف التصدير', + 'The chat session has ended. Please run the query again': 'انتهت جلسة المحادثة. من فضلك أعد طرح السؤال', + 'This download is no longer available. Please run the query again': 'لم يعد هذا التنزيل متاحًا. من فضلك أعد طرح السؤال', + 'Showing the first {{preview}} of {{total}} rows': 'يتم عرض أول {{preview}} صفوف من أصل {{total}}', + 'Not all rows are shown': 'لا يتم عرض جميع الصفوف', + 'Only {{preview}} of {{total}} columns are shown': 'يتم عرض {{preview}} أعمدة فقط من أصل {{total}}', + 'This chart could not be displayed': 'تعذر عرض هذا الرسم البياني', + 'No data to display': 'لا توجد بيانات للعرض', + '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': 'أعمدة', + 'Reset columns': 'إعادة تعيين الأعمدة', + 'Reset filters': 'إعادة تعيين عوامل التصفية', + 'Clear selection': 'مسح التحديد', + 'Select one or more rows to see the available actions': + 'حدد صفًا واحدًا أو أكثر لعرض الإجراءات المتاحة', 'First page': 'الصفحة الأولى', 'Forgot your password?': 'نسيت كلمة المرور؟', 'Form Fields': 'حقول النموذج', @@ -671,4 +715,19 @@ export const AR: Translation = { 'سيتم احتساب الدرجات بناءً على معيارين:\n\n
  • نقطة واحدة: شرح السلوك.\n\n
  • نقطة واحدة: الإشارة إلى أن الأمر كان متكررًا وأن إلياس لم يستطع الدفاع عن نفسه.\n\n
', 'Quand une personne peut-elle être considérée comme dépendante ?': 'متى يمكن اعتبار شخص ما مدمنًا؟', + 'Export data': 'تصدير البيانات', + 'With active filters': 'مع عوامل التصفية النشطة', + 'Sections': 'الأقسام', + 'All fields': 'كل الحقول', + 'Selected fields': 'الحقول المحددة', + 'Value format': 'تنسيق القيم', + 'Search field...': 'ابحث عن حقل...', + 'Select all': 'تحديد الكل', + 'Deselect': 'إلغاء التحديد', + 'fields selected out of': 'حقول محددة من أصل', + 'Cancel': 'إلغاء', + 'Select all Form fields': 'تحديد كل حقول النموذج', + 'Label values': 'قيم التسميات', + 'Data Analysis format': 'تنسيق تحليل البيانات', + 'Separate columns': 'أعمدة منفصلة', }; diff --git a/projects/core/translations/src/eng.ts b/projects/core/translations/src/eng.ts index bb063b3b4..87d8f883c 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', @@ -220,6 +221,48 @@ export const ENG: Translation = { 'Filter': 'Filter', 'Filter condition': 'Filter condition', 'Filters': 'Filters', + 'Advanced filters': 'Advanced filters', + 'Applied filters': 'Applied filters', + 'Simple': 'Simple', + 'Advanced': 'Advanced', + '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', + 'Could not download the export file': 'Could not download the export file', + 'The chat session has ended. Please run the query again': 'The chat session has ended. Please run the query again', + 'This download is no longer available. Please run the query again': 'This download is no longer available. Please run the query again', + 'Showing the first {{preview}} of {{total}} rows': 'Showing the first {{preview}} of {{total}} rows', + 'Not all rows are shown': 'Not all rows are shown', + 'Only {{preview}} of {{total}} columns are shown': 'Only {{preview}} of {{total}} columns are shown', + 'This chart could not be displayed': 'This chart could not be displayed', + 'No data to display': 'No data to display', + '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', + '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', 'First page': 'First page', 'Forgot your password?': 'Forgot your password?', 'Form Fields': 'Form Fields', @@ -515,4 +558,19 @@ export const ENG: Translation = { 'the selected schema': 'the selected schema', 'update:': 'update:', 'xlsx': 'xlsx', + 'Export data': 'Export data', + 'With active filters': 'With active filters', + 'Sections': 'Sections', + 'All fields': 'All fields', + 'Selected fields': 'Selected fields', + 'Value format': 'Value format', + 'Search field...': 'Search field...', + 'Select all': 'Select all', + 'Deselect': 'Deselect', + 'fields selected out of': 'fields selected out of', + 'Cancel': 'Cancel', + 'Select all Form fields': 'Select all Form fields', + 'Label values': 'Label values', + 'Data Analysis format': 'Data Analysis format', + 'Separate columns': 'Separate columns', }; diff --git a/projects/core/translations/src/esp.ts b/projects/core/translations/src/esp.ts index 5a506e7d0..aba36ffa0 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', @@ -219,6 +220,49 @@ export const ESP: Translation = { 'Filter': 'Filtrar', 'Filter condition': 'Condición de filtro', 'Filters': 'Filtros', + 'Advanced filters': 'Filtros avanzados', + 'Applied filters': 'Filtros aplicados', + 'Simple': 'Simple', + 'Advanced': 'Avanzado', + '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', + 'Could not download the export file': 'No se ha podido descargar el archivo exportado', + 'The chat session has ended. Please run the query again': 'La sesión de chat ha terminado. Por favor, repite la pregunta', + 'This download is no longer available. Please run the query again': 'Esta descarga ya no está disponible. Por favor, repite la pregunta', + 'Showing the first {{preview}} of {{total}} rows': 'Se muestran las primeras {{preview}} filas de {{total}}', + 'Not all rows are shown': 'No se muestran todas las filas', + 'Only {{preview}} of {{total}} columns are shown': 'Solo se muestran {{preview}} columnas de {{total}}', + 'This chart could not be displayed': 'No se ha podido mostrar este gráfico', + 'No data to display': 'No hay datos que mostrar', + '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', + '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', 'First page': 'Primera página', 'Forgot your password?': '¿Olvidaste tu contraseña?', 'Form Fields': 'Campos de formulario', @@ -516,4 +560,19 @@ export const ESP: Translation = { 'the selected schema': 'el esquema seleccionado', 'update:': 'actualizado:', 'xlsx': 'xlsx', + 'Export data': 'Exportar datos', + 'With active filters': 'Con filtros activos', + 'Sections': 'Secciones', + 'All fields': 'Todos los campos', + 'Selected fields': 'Campos seleccionados', + 'Value format': 'Formato de valores', + 'Search field...': 'Buscar campo...', + 'Select all': 'Seleccionar todo', + 'Deselect': 'Deseleccionar', + 'fields selected out of': 'campos seleccionados de', + 'Cancel': 'Cancelar', + 'Select all Form fields': 'Seleccionar todos los campos del formulario', + 'Label values': 'Valores de etiqueta', + 'Data Analysis format': 'Formato Data Analysis', + 'Separate columns': 'Columnas separadas', }; diff --git a/projects/core/translations/src/fra.ts b/projects/core/translations/src/fra.ts index b84139bf6..144f2c60e 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', @@ -202,6 +203,49 @@ export const FRA: Translation = { 'Filter': 'Filtre', 'Filter condition': 'Condition de filtre', 'Filters': 'Filtres', + 'Advanced filters': 'Filtres avancés', + 'Applied filters': 'Filtres appliqués', + 'Simple': 'Simple', + 'Advanced': 'Avancé', + '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', + 'Could not download the export file': 'Impossible de télécharger le fichier exporté', + 'The chat session has ended. Please run the query again': 'La session de chat est terminée. Veuillez répéter la question', + 'This download is no longer available. Please run the query again': 'Ce téléchargement n\'est plus disponible. Veuillez répéter la question', + 'Showing the first {{preview}} of {{total}} rows': 'Les {{preview}} premières lignes sur {{total}} sont affichées', + 'Not all rows are shown': 'Toutes les lignes ne sont pas affichées', + 'Only {{preview}} of {{total}} columns are shown': 'Seules {{preview}} colonnes sur {{total}} sont affichées', + 'This chart could not be displayed': 'Ce graphique n\'a pas pu être affiché', + 'No data to display': 'Aucune donnée à afficher', + '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', + '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', 'First page': 'Première page', 'Forgot your password?': 'Votre mot de passe oublié?', 'Form Fields': 'Champs de formulaires', @@ -469,5 +513,20 @@ export const FRA: Translation = { 'the selected items': 'les éléments sélectionnés', 'the selected schema': 'le schéma sélectionné', 'update:': 'mise à jour:', - 'xlsx': 'xlsx' + 'xlsx': 'xlsx', + 'Export data': 'Exporter les données', + 'With active filters': 'Avec filtres actifs', + 'Sections': 'Sections', + 'All fields': 'Tous les champs', + 'Selected fields': 'Champs sélectionnés', + 'Value format': 'Format des valeurs', + 'Search field...': 'Rechercher un champ...', + 'Select all': 'Tout sélectionner', + 'Deselect': 'Désélectionner', + 'fields selected out of': 'champs sélectionnés sur', + 'Cancel': 'Annuler', + 'Select all Form fields': 'Sélectionner tous les champs du formulaire', + 'Label values': 'Valeurs des libellés', + 'Data Analysis format': 'Format Data Analysis', + 'Separate columns': 'Colonnes séparées', }; diff --git a/projects/core/translations/src/ita.ts b/projects/core/translations/src/ita.ts index 5a5793178..3fb4c08e7 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', @@ -208,6 +209,48 @@ export const ITA: Translation = { 'Filter': 'Filtra', 'Filter condition': 'Condizione di filtro', 'Filters': 'Filtri', + 'Advanced filters': 'Filtri avanzati', + 'Applied filters': 'Filtri applicati', + 'Simple': 'Semplice', + 'Advanced': 'Avanzati', + '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', + 'Could not download the export file': 'Impossibile scaricare il file esportato', + 'The chat session has ended. Please run the query again': 'La sessione di chat è terminata. Per favore, ripeti la domanda', + 'This download is no longer available. Please run the query again': 'Questo download non è più disponibile. Per favore, ripeti la domanda', + 'Showing the first {{preview}} of {{total}} rows': 'Sono mostrate le prime {{preview}} righe su {{total}}', + 'Not all rows are shown': 'Non tutte le righe sono mostrate', + 'Only {{preview}} of {{total}} columns are shown': 'Sono mostrate solo {{preview}} colonne su {{total}}', + 'This chart could not be displayed': 'Non è stato possibile visualizzare questo grafico', + 'No data to display': 'Nessun dato da visualizzare', + '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', + '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', 'First page': 'Prima pagina', 'Forgot your password?': 'Ti sei dimenticato la tua password?', 'Form Fields': 'Campi del Form', @@ -480,5 +523,20 @@ export const ITA: Translation = { 'the selected items': 'per gli elementi selezionati', 'the selected schema': 'lo schema selezionato', 'update:': 'aggiorna:', - 'xlsx': 'xlsx' + 'xlsx': 'xlsx', + 'Export data': 'Esporta dati', + 'With active filters': 'Con filtri attivi', + 'Sections': 'Sezioni', + 'All fields': 'Tutti i campi', + 'Selected fields': 'Campi selezionati', + 'Value format': 'Formato valori', + 'Search field...': 'Cerca campo...', + 'Select all': 'Seleziona tutti', + 'Deselect': 'Deseleziona', + 'fields selected out of': 'campi selezionati su', + 'Cancel': 'Annulla', + 'Select all Form fields': 'Seleziona tutti i campi Form', + 'Label values': 'Valori etichetta', + 'Data Analysis format': 'Formato Data Analysis', + 'Separate columns': 'Colonne separate', }; diff --git a/projects/core/translations/src/prt.ts b/projects/core/translations/src/prt.ts index 735f2e065..0a040c2a5 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', @@ -218,6 +219,49 @@ export const PRT: Translation = { 'Filter': 'Filtrar', 'Filter condition': 'Condição de filtro', 'Filters': 'Filtros', + 'Advanced filters': 'Filtros avançados', + 'Applied filters': 'Filtros aplicados', + 'Simple': 'Simples', + 'Advanced': 'Avançado', + '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', + 'Could not download the export file': 'Não foi possível descarregar o ficheiro exportado', + 'The chat session has ended. Please run the query again': 'A sessão de chat terminou. Por favor, repita a pergunta', + 'This download is no longer available. Please run the query again': 'Esta transferência já não está disponível. Por favor, repita a pergunta', + 'Showing the first {{preview}} of {{total}} rows': 'São mostradas as primeiras {{preview}} linhas de {{total}}', + 'Not all rows are shown': 'Nem todas as linhas são mostradas', + 'Only {{preview}} of {{total}} columns are shown': 'São mostradas apenas {{preview}} colunas de {{total}}', + 'This chart could not be displayed': 'Não foi possível mostrar este gráfico', + 'No data to display': 'Não há dados para mostrar', + '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', + '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', 'First page': 'Primeira página', 'Forgot your password?': 'Esqueceu sua senha?', 'Form Fields': 'Campos do formulário', @@ -516,4 +560,19 @@ export const PRT: Translation = { 'the selected schema': 'o esquema selecionado', 'update:': 'atualizado:', 'xlsx': 'xlsx', + 'Export data': 'Exportar dados', + 'With active filters': 'Com filtros ativos', + 'Sections': 'Secções', + 'All fields': 'Todos os campos', + 'Selected fields': 'Campos selecionados', + 'Value format': 'Formato dos valores', + 'Search field...': 'Procurar campo...', + 'Select all': 'Selecionar tudo', + 'Deselect': 'Desselecionar', + 'fields selected out of': 'campos selecionados de', + 'Cancel': 'Cancelar', + 'Select all Form fields': 'Selecionar todos os campos do formulário', + 'Label values': 'Valores das etiquetas', + 'Data Analysis format': 'Formato Data Analysis', + 'Separate columns': 'Colunas separadas', }; diff --git a/projects/core/translations/src/uga.ts b/projects/core/translations/src/uga.ts index 15d69fe98..4125282f7 100644 --- a/projects/core/translations/src/uga.ts +++ b/projects/core/translations/src/uga.ts @@ -22,6 +22,49 @@ import {Translation} from '@ajf/core/transloco'; // tslint:disable:max-line-length export const UGA: Translation = { + 'Advanced filters': 'Advanced filters', + 'Applied filters': 'Applied filters', + 'Simple': 'Simple', + 'Advanced': 'Advanced', + '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', + 'Could not download the export file': 'Could not download the export file', + 'The chat session has ended. Please run the query again': 'The chat session has ended. Please run the query again', + 'This download is no longer available. Please run the query again': 'This download is no longer available. Please run the query again', + 'Showing the first {{preview}} of {{total}} rows': 'Showing the first {{preview}} of {{total}} rows', + 'Not all rows are shown': 'Not all rows are shown', + 'Only {{preview}} of {{total}} columns are shown': 'Only {{preview}} of {{total}} columns are shown', + 'This chart could not be displayed': 'This chart could not be displayed', + 'No data to display': 'No data to display', + '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', + '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', '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.', @@ -107,6 +150,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', @@ -191,4 +235,20 @@ export const UGA: Translation = { 'Visibility': 'Visibility', 'Private': 'Private', 'Public': 'Public', + 'Export data': 'Export data', + 'With active filters': 'With active filters', + 'Sections': 'Sections', + 'All fields': 'All fields', + 'Selected fields': 'Selected fields', + 'Value format': 'Value format', + 'Default': 'Default', + 'Search field...': 'Search field...', + 'Select all': 'Select all', + 'Deselect': 'Deselect', + 'fields selected out of': 'fields selected out of', + 'Cancel': 'Cancel', + 'Select all Form fields': 'Select all Form fields', + 'Label values': 'Label values', + 'Data Analysis format': 'Data Analysis format', + 'Separate columns': 'Separate columns', }; diff --git a/projects/core/translations/src/ukr.ts b/projects/core/translations/src/ukr.ts index 661f4b3f7..994945b39 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': 'Додати фільтри', @@ -209,6 +210,49 @@ export const UKR: Translation = { 'Filter': 'Фільтр', 'Filter condition': 'Умова фільтра', 'Filters': 'Фільтри', + 'Advanced filters': 'Розширені фільтри', + 'Applied filters': 'Застосовані фільтри', + 'Simple': 'Простий', + 'Advanced': 'Розширений', + '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': 'Згенерувати відповідь знову', + 'Could not download the export file': 'Не вдалося завантажити експортований файл', + 'The chat session has ended. Please run the query again': 'Сесію чату завершено. Будь ласка, повторіть запитання', + 'This download is no longer available. Please run the query again': 'Це завантаження більше недоступне. Будь ласка, повторіть запитання', + 'Showing the first {{preview}} of {{total}} rows': 'Показано перші {{preview}} рядків із {{total}}', + 'Not all rows are shown': 'Показано не всі рядки', + 'Only {{preview}} of {{total}} columns are shown': 'Показано лише {{preview}} стовпців із {{total}}', + 'This chart could not be displayed': 'Не вдалося показати цю діаграму', + 'No data to display': 'Немає даних для показу', + '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': 'Стовпці', + 'Reset columns': 'Скинути стовпці', + 'Reset filters': 'Скинути фільтри', + 'Clear selection': 'Очистити вибір', + 'Select one or more rows to see the available actions': + 'Виберіть один або кілька рядків, щоб побачити доступні дії', 'First page': 'Перша сторінка', 'Forgot your password?': 'Забули пароль?', 'Form Fields': 'Поля форми', @@ -580,4 +624,19 @@ export const UKR: Translation = { 'Hide empty rows': 'Приховати порожні рядки', 'Table definition': 'Визначення таблиці', 'Value must not be empty': 'Значення не повинно бути порожнім', + 'Export data': 'Експортувати дані', + 'With active filters': 'З активними фільтрами', + 'Sections': 'Розділи', + 'All fields': 'Усі поля', + 'Selected fields': 'Вибрані поля', + 'Value format': 'Формат значень', + 'Search field...': 'Пошук поля...', + 'Select all': 'Вибрати все', + 'Deselect': 'Зняти вибір', + 'fields selected out of': 'полів вибрано з', + 'Cancel': 'Скасувати', + 'Select all Form fields': 'Вибрати всі поля форми', + 'Label values': 'Значення міток', + 'Data Analysis format': 'Формат Data Analysis', + 'Separate columns': 'Окремі стовпці', }; 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/datachat/components/datachat.component.html b/projects/dinoapp/src/app/datachat/components/datachat.component.html index 9102d6bbd..bc77b092a 100644 --- a/projects/dinoapp/src/app/datachat/components/datachat.component.html +++ b/projects/dinoapp/src/app/datachat/components/datachat.component.html @@ -1,8 +1,15 @@ +
+ +
+ + [conversationsSidebar]="true" + [showWelcome]="true" + (exportDownload)="saveExport($event.blob, $event.filename)" +>
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..490afee09 100644 --- a/projects/dinoapp/src/app/datachat/components/datachat.component.ts +++ b/projects/dinoapp/src/app/datachat/components/datachat.component.ts @@ -1,10 +1,18 @@ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {TranslocoService} from '@ajf/core/transloco'; +import {Capacitor} from '@capacitor/core'; +import {Directory} from '@capacitor/filesystem'; +import write_blob from 'capacitor-blob-writer'; +import {from} from 'rxjs'; +import {take} from 'rxjs/operators'; import {ajfCommonFunctions} from '../../../ajf-functions/ajf-functions.common'; import {acceptTermsContent, pandinoUrl} from '../conf'; @Component({ selector: 'dinoapp-datachat', templateUrl: './datachat.component.html', + styleUrls: ['./datachat.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) @@ -22,5 +30,41 @@ export class DataChatComponent { dataChatEndpoint: this.dataChatEndpoint, }; acceptTermsContent = acceptTermsContent; - constructor() {} + + constructor(private _snackBar: MatSnackBar, private _ts: TranslocoService) {} + + /** + * Saves a DataChat export file, downloaded by the DataChat component. + * @param blob The downloaded file + * @param filename The file name suggested by the DataChat API + */ + saveExport(blob: Blob, filename: string): void { + if (Capacitor.getPlatform() !== 'web') { + from( + write_blob({ + path: filename, + directory: Directory.Documents, + blob, + on_fallback(error) { + console.error(error); + }, + }), + ) + .pipe(take(1)) + .subscribe(() => + this._snackBar.open( + this._ts.translate('Export file saved in your Documents folder'), + 'EXPORT SAVED', + {duration: 10000}, + ), + ); + } else { + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + link.click(); + setTimeout(() => window.URL.revokeObjectURL(objectUrl)); + } + } } 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/forms-list/components/forms-list.component.html b/projects/dinoapp/src/app/forms-list/components/forms-list.component.html index 0b08a6cdb..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,24 +21,32 @@ + > + + + - - - - 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.html b/projects/dinoapp/src/app/forms-map/components/forms-map.html index b5e0f1768..9e848c7a7 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,19 @@ -
-
- - 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..7aa7772eb 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,53 @@ dinoapp-forms-map { display: flex; - align-items: flex-start; - - #mapContainer { - overflow: hidden; - flex-grow: 1; - height: calc(100vh - 73px); - min-width: 300px; - min-height: 300px; - } - - .leaflet-popup-content { - margin: 7px 10px; + 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; + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-content: center; + min-height: 56px; } - - #filtersContainer { - width: 330px; - padding-left: 5px; - display: grid; - gap: 5px; - grid-template-columns: 1fr; - overflow-y: auto; - mat-form-field { - height: 50px; + // 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; + flex: 1 0 auto; + align-self: center; + position: relative; + margin: auto; + margin-right: 10px; + + @media only screen and (min-width: 660px) { + bottom: 8px; } - mat-label { - font-size: 13px; + .mat-icon { + opacity: 30%; + margin-right: 4px; } } - #applyFilters { - height: 50px; - display: flex; - 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); - - #mapContainer { - width: 100%; - height: unset; - } - - #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..d9c4a63ac 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,48 @@ -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, + 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'; +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 +55,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 +103,378 @@ 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(); + this._filtersService.storageKey = null; + 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, + }; + } + dialogConfig.panelClass = 'dino-export-dialog-panel'; + 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 + .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) { + // 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(); + } + } + + /** + * 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/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}} +
+ +
+ +
+ +
+ -
-
- {{'Suggested questions'|transloco}} - - {{question}} - -
+
+
-
- + + + + + + - + +
- - -
- error - Error: -
-
- -
- smart_toy - +
+ +
+
+ error
- - -
- +
+
+ +
+ - + +
diff --git a/projects/material/datachat/src/datachat-entry.scss b/projects/material/datachat/src/datachat-entry.scss index a056b6527..5d08af4af 100644 --- a/projects/material/datachat/src/datachat-entry.scss +++ b/projects/material/datachat/src/datachat-entry.scss @@ -1,118 +1,399 @@ @use 'angular-material-css-vars' as mat-css-vars; +@function dce-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-datachat-entry { + --dce-surface: #23272d; + --dce-subtle: #1b1f24; + --dce-border: #363c44; + --dce-heading: #e6e9ec; + --dce-text: #d4d9dd; + --dce-muted: #99a0a7; + --dce-hover: #2b3037; +} + dino-datachat-entry { - margin-right: auto; - margin-left: auto; - margin-bottom: 20px; - max-width: 95%; - width: 95%; + // Light-theme neutral defaults. + --dce-surface: #ffffff; + --dce-subtle: #f7f9fb; + --dce-border: #e2e8ee; + --dce-heading: #12303f; + --dce-text: #2a3a45; + --dce-muted: #64798a; + --dce-hover: #f1f5f9; - .dino-datachat-entry { - border-radius: 5px; - padding: 10px; + display: block; + width: 100%; + margin-bottom: 18px; + color: var(--dce-text); - &:not(.dino-datachat-entry.dino-datachat-entry-component-response) { - display: flex; - align-items: center; - flex-flow: row wrap; + * { + box-sizing: border-box; + } + + // ---- Message: avatar + bubble --------------------------------------------- + .dino-datachat-message { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; + gap: 12px; + + & + .dino-datachat-message { + margin-top: 12px; } } - .dino-datachat-response-container { + .dino-datachat-avatar { + flex: 0 0 auto; display: flex; - flex-direction: row; align-items: center; - /* Center align items vertically */ - width: 100%; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 10px; + background: dce-primary(500, 0.12); + color: dce-primary(600); + + .mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + cursor: default; + } + + &.dino-datachat-avatar-user { + background: var(--dce-hover); + color: var(--dce-muted); + } + + &.dino-datachat-avatar-error { + background: mat-css-vars.mat-css-color-warn(500, 0.12); + color: mat-css-vars.mat-css-color-warn(600); + } + } + + .dino-datachat-bubble { + flex: 1 1 auto; + min-width: 0; + padding: 18px 20px; + border: 1px solid var(--dce-border); + border-radius: 12px; + background: var(--dce-surface); + font-size: 14px; + line-height: 22px; + } + + .dino-datachat-message-question .dino-datachat-bubble { + background: var(--dce-subtle); + padding: 12px 16px; + color: var(--dce-heading); + font-weight: 600; + } + + .dino-datachat-message-error .dino-datachat-bubble { + border-color: mat-css-vars.mat-css-color-warn(500, 0.4); + background: mat-css-vars.mat-css-color-warn(500, 0.06); + } + + // ---- Answer content -------------------------------------------------------- + .dino-datachat-explanation { + margin: 0 0 10px; + + &:last-child { + margin-bottom: 0; + } + } + + .dino-datachat-answer { + p { + margin: 0 0 12px; - .dino-datachat-entry.dino-datachat-entry-response { - flex: 1; - /* Take available space */ - max-width: fit-content; - } - - .dino-datachat-feedback { - margin-left: 10px; - display: flex; - flex-direction: row; - /* Horizontal alignment */ - /* Stack icons vertically or keep horizontal depending on preference, user said "besides(right)" */ - width: auto; - margin-top: 0; - - button { - margin-left: 5px; - /* Add spacing between buttons */ - margin-bottom: 0px; + &:last-child { + margin-bottom: 0; } + } - .active-feedback { - color: mat-css-vars.mat-css-color-primary(100); - animation: pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + ul, + ol { + margin: 0 0 12px; + padding-left: 22px; + } + + table { + width: 100%; + border-collapse: collapse; + margin-bottom: 12px; + + th, + td { + padding: 6px 10px; + border: 1px solid var(--dce-border); + text-align: left; } } - &.dino-datachat-entry-question { - background-color: mat-css-vars.mat-css-color-accent(100, 0.2); + code { + padding: 1px 5px; + border-radius: 4px; + background: var(--dce-subtle); + font-size: 13px; } - &.dino-datachat-entry-error { - background-color: mat-css-vars.mat-css-color-warn(100, 0.2); + pre { + padding: 12px; + border-radius: 8px; + background: var(--dce-subtle); + overflow-x: auto; } + } + + .dino-datachat-image { + display: block; + max-width: 100%; + border-radius: 8px; + } + + .dino-datachat-component { + 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; - &.dino-datachat-entry-response, - &.dino-datachat-entry-component-response, - &.dino-datachat-entry-image-response, - &.dino-datachat-entry-image-error { - background-color: mat-css-vars.mat-css-color-primary(100, 0.2); + .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-entry-question, - &.dino-datachat-entry-response, - &.dino-datachat-entry-image-response, - &.dino-datachat-entry-image-error { - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: flex-start; + .dino-datachat-preview-text { + flex: 1 1 auto; - span, - img { - flex: 1 0 90%; + span { + margin-right: 5px; } } + } - &.dino-datachat-entry-image-response img { - max-width: 700px; + // 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; - @media only screen and (max-width: 768px) { - max-width: 90%; - } + .mat-icon { + color: mat-css-vars.mat-css-color-warn(600); } - .dino-datachat-sources-chips { - margin-top: 10px; + span { + flex: 1 1 auto; + } + } - .dino-datachat-sources-chips-span { - margin-right: 10px; - } + // ---- Charts ---------------------------------------------------------------- + .dino-datachat-charts { + display: flex; + flex-flow: column nowrap; + gap: 16px; + margin-top: 16px; + } - .mat-mdc-chip { - background-color: mat-css-vars.mat-css-color-primary(100, 0.4); - margin-right: 5px; - cursor: pointer; + // ---- Sources --------------------------------------------------------------- + .dino-datachat-section-label { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--dce-muted); + + .mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + cursor: default; + } + } - span { - font-size: 12px; - cursor: pointer; - } + .dino-datachat-sources { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--dce-border); + } + + .dino-datachat-source-list { + display: flex; + flex-flow: row wrap; + gap: 10px; + } + + .dino-datachat-source { + display: flex; + align-items: center; + gap: 10px; + max-width: 100%; + padding: 10px 14px; + border: 1px solid var(--dce-border); + border-radius: 10px; + background: var(--dce-surface); + font-family: inherit; + text-align: left; + cursor: pointer; + + &:hover { + background: var(--dce-hover); + border-color: dce-primary(500, 0.35); + } + + > .mat-icon { + flex: 0 0 auto; + width: 30px; + height: 30px; + padding: 5px; + border-radius: 8px; + font-size: 20px; + background: dce-primary(500, 0.1); + color: dce-primary(600); + cursor: pointer; + } + } + + .dino-datachat-source-info { + display: flex; + flex-flow: column; + min-width: 0; + } + + .dino-datachat-source-name { + font-size: 13px; + font-weight: 600; + color: var(--dce-heading); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .dino-datachat-source-page { + font-size: 12px; + color: var(--dce-muted); + } + + // ---- Actions --------------------------------------------------------------- + .dino-datachat-actions { + display: flex; + align-items: center; + gap: 2px; + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid var(--dce-border); + + .mat-mdc-icon-button { + width: 34px; + height: 34px; + padding: 5px; + color: var(--dce-muted); + + .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; } + + &:hover { + color: var(--dce-heading); + } + } + + .active-feedback { + color: dce-primary(500); + animation: pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + } + + .dino-datachat-actions-divider { + width: 1px; + height: 18px; + margin: 0 8px; + 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: 8px; + 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; + @media only screen and (max-width: 768px) { + margin-left: 0; + } } - .dino-datachat-entry-icon { - margin-right: 10px; + .dino-datachat-suggestion { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + margin-bottom: 8px; + padding: 12px 16px; + border: 1px solid var(--dce-border); + border-radius: 10px; + background: var(--dce-surface); + color: var(--dce-text); + font-family: inherit; + font-size: 14px; + text-align: left; + cursor: pointer; + + &:hover { + background: var(--dce-hover); + border-color: dce-primary(500, 0.35); + } + + .mat-icon { + flex: 0 0 auto; + width: 18px; + height: 18px; + font-size: 18px; + color: var(--dce-muted); + cursor: pointer; + } } } @@ -128,4 +409,4 @@ dino-datachat-entry { 100% { transform: scale(1); } -} \ No newline at end of file +} 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 4ee9bc9e7..5d6b94e16 100644 --- a/projects/material/datachat/src/datachat-entry.ts +++ b/projects/material/datachat/src/datachat-entry.ts @@ -34,6 +34,16 @@ import {MatDialog} from '@angular/material/dialog'; import {ParagraphDialogComponent} from './paragraph-dialog.component'; import * as mrkd from 'marked'; +/** + * How long the copy button confirms the copy, in milliseconds. + */ +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 @@ -63,11 +73,40 @@ export class DataChatEntry implements OnDestroy { * Emitted when feedback is clicked */ @Output() feedbackClick: EventEmitter<{ - logId: string; + logId: string | number; isPositive: boolean; question: string; answer: string; - }> = new EventEmitter<{logId: string; isPositive: boolean; question: string; answer: string}>(); + }> = new EventEmitter<{ + logId: string | number; + isPositive: boolean; + question: string; + answer: string; + }>(); + + /** + * Emitted, with the question of this entry, when its answer must be asked + * again + */ + @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 + */ + copied = false; + + /** + * Resets the copy confirmation + */ + private _copiedTimeout: ReturnType | null = null; constructor(private _dialog: MatDialog, private _cdr: ChangeDetectorRef) {} @@ -102,27 +141,203 @@ export class DataChatEntry implements OnDestroy { this.followUpClick.emit(question); } + /** + * True when the entry holds something the assistant answered: a text, an + * image, a generated component, or the explanation of one of them. + */ + get hasAnswer(): boolean { + return ( + this.qa != null && + (this.qa.response != null || + this.qa.explanation != null || + this.qa.imageData != 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 + * mode, where the answer may be an image or a table). + */ + get feedbackAvailable(): boolean { + 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.answerText.length > 0; + } + + /** + * True when the question that produced this answer is known, and can + * therefore be asked again. + */ + get canRegenerate(): boolean { + return this.qa?.question != null && this.qa.question.length > 0 && this.hasAnswer; + } + + /** + * True when the answer displays its actions row. + */ + get showActions(): boolean { + return this.feedbackAvailable || this.canCopy || this.canRegenerate || this.canDownload; + } + + /** + * Copies the answer to the clipboard, confirming it on the button for a + * couple of seconds. + */ + copyAnswer(): void { + const text = this.answerText; + if (!text || typeof navigator === 'undefined' || navigator.clipboard == null) { + return; + } + navigator.clipboard.writeText(text).then( + () => { + this.copied = true; + this._cdr.markForCheck(); + this._copiedTimeout = setTimeout(() => { + this.copied = false; + this._cdr.markForCheck(); + }, COPIED_FEEDBACK_TIME); + }, + () => { + // The clipboard is not available (insecure context, denied permission): + // nothing to confirm. + }, + ); + } + + /** + * Asks the question of this entry again. + */ + onRegenerateClick(): void { + if (this.qa?.question) { + this.regenerateClick.emit(this.qa.question); + } + } + onFeedbackClick(isPositive: boolean) { - if (this.qa && this.qa.log_id) { + if (this.qa && this.feedbackAvailable) { if (this.qa.userIsHappy === isPositive) { return; } this.qa.userIsHappy = isPositive; this.feedbackClick.emit({ - logId: this.qa.log_id, + logId: this.qa.log_id ?? '', isPositive, question: this.qa.question ?? '', - answer: this.qa.response ?? '', + 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; } ngOnDestroy(): void { - return; + if (this._copiedTimeout != null) { + clearTimeout(this._copiedTimeout); + } } } diff --git a/projects/material/datachat/src/datachat-session.service.ts b/projects/material/datachat/src/datachat-session.service.ts new file mode 100644 index 000000000..c2c880ed1 --- /dev/null +++ b/projects/material/datachat/src/datachat-session.service.ts @@ -0,0 +1,389 @@ +/** + * @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 {HttpClient} from '@angular/common/http'; +import {Injectable, isDevMode} from '@angular/core'; +import {NavigationEnd, Router} from '@angular/router'; +import {AuthService} from '@dino/core/auth'; +import {BehaviorSubject} from 'rxjs'; +import {filter, take} from 'rxjs/operators'; + +import {DataChatConversation, DataChatStore} from './datachat-store'; +import {DataChatQA} from './datachat.interfaces'; + +/** + * The maximum length of a conversation title, built from its first question. + */ +const TITLE_MAX_LENGTH = 60; + +/** + * How long to wait, after the last chat entry, before writing the conversation + * to the store. Collapses the several entries of a single answer into one write. + */ +const SAVE_DEBOUNCE = 300; + +/** + * A live PandasAI agent, kept alive while the User navigates inside the form + * section it belongs to. + */ +export interface DataChatLiveSession { + /** + * The Form Schema the agent was created for + */ + schemaId: string; + /** + * The validated DataChat api key + */ + apiKey: string; + /** + * The base url of the DataChat (Pandino) API + */ + baseUrl: string; + /** + * The name of the agent destruction endpoint + */ + endEndpoint: string; + /** + * The name of the User the agent was created for + */ + userName: string; + /** + * The email of the User the agent was created for + */ + userEmail: string; +} + +/** + * Owns everything of a DataChat conversation that must outlive the DataChat + * component instance: + * - the live PandasAI agent, so that moving between the Data, Map and AI views + * of a form does not destroy and recreate it (which would re-upload the whole + * csv and consume credits). The agent is destroyed as soon as the User leaves + * the form section it belongs to; + * - the conversations of the current Form Schema, permanently stored by the + * DataChatStore, and the one currently displayed. + */ +@Injectable({providedIn: 'root'}) +export class DataChatSessionService { + /** + * The currently live agent, if any. + */ + private _live: DataChatLiveSession | null = null; + + /** + * The api key of the currently live agent. + */ + get apiKey(): string | null { + return this._live?.apiKey ?? null; + } + + /** + * The live agent a new chat inherits, with the User it was created for. + */ + get liveSession(): DataChatLiveSession | null { + return this._live; + } + + /** + * The conversations of the currently open scope, most recent first. + */ + readonly conversations: BehaviorSubject = new BehaviorSubject< + DataChatConversation[] + >([]); + + /** + * The conversation currently displayed by the chat. + */ + readonly activeConversation: BehaviorSubject = + new BehaviorSubject(null); + + /** + * The scope (`|`) of the currently open conversations. + */ + private _scope: string | null = null; + + /** + * Pending debounced save of the active conversation. + */ + private _saveTimeout: ReturnType | null = null; + + constructor( + private _http: HttpClient, + private _router: Router, + private _auth: AuthService, + private _store: DataChatStore, + ) { + this._router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => { + if (this._live && !this.isInsideForm(this._router.url, this._live.schemaId)) { + this.endSession(); + } + }); + } + + /** + * Returns true if the given url belongs to the section of the given Form Schema. + * @param url The url to test + * @param schemaId The Form Schema id + */ + isInsideForm(url: string, schemaId: string): boolean { + const path = url.split('?')[0]; + return path === `/forms/${schemaId}` || path.startsWith(`/forms/${schemaId}/`); + } + + /** + * Returns true if an agent created for the given Form Schema is still alive. + * @param schemaId The Form Schema id + */ + isAliveFor(schemaId: string): boolean { + return this._live != null && this._live.schemaId === schemaId; + } + + /** + * Registers an agent as live, so that it is not destroyed when the DataChat + * component is destroyed. + * @param session The live session data + */ + keepAlive(session: DataChatLiveSession): void { + this._live = session; + } + + /** + * Destroys the live agent, if any. The stored conversations are kept: + * they are permanent and are restored on the next visit. + * + * The User the agent belongs to is read from the session and not from the + * local data: the session also ends on logout, when that data is being + * destroyed, and an agent left alive keeps consuming the credits of the User. + */ + endSession(): void { + const session = this._live; + this._live = null; + if (session == null) { + return; + } + const headers = { + 'X-API-KEY': session.apiKey, + 'X-USER-NAME': session.userName, + 'X-USER-EMAIL': session.userEmail, + }; + this._http + .post(`${session.baseUrl}/${session.endEndpoint}`, {}, {headers}) + .pipe(take(1)) + .subscribe({ + next: res => { + if (isDevMode()) { + console.log(res); + } + }, + error: err => { + if (isDevMode()) { + console.log(err); + } + }, + }); + } + + /** + * Loads the conversations of the active User on a Form Schema and activates + * the most recent one, or a new empty conversation if there is none. + * @param schemaId The Form Schema id + * @returns The chat entries of the activated conversation + */ + async openScope(schemaId: string): Promise { + const userId = this._auth.getUserInfo()?.id ?? 'anonymous'; + const scope = this._store.scope(`${userId}`, schemaId); + if (this._scope === scope && this.activeConversation.value != null) { + return this.activeConversation.value.messages; + } + this._scope = scope; + const conversations = await this._store.list(scope); + this.conversations.next(conversations); + const active = conversations.length ? conversations[0] : this._createConversation(scope); + this.activeConversation.next(active); + return active.messages; + } + + /** + * Activates a new, empty conversation. It is stored only once it holds a + * message, so that repeatedly asking for a new chat does not fill the list. + * @returns The chat entries of the new conversation (always empty) + */ + newConversation(): DataChatQA[] { + if (this._scope == null) { + return []; + } + const active = this.activeConversation.value; + if (active != null && !this._hasQuestions(active)) { + return active.messages; + } + const conversation = this._createConversation(this._scope); + this.activeConversation.next(conversation); + return conversation.messages; + } + + /** + * Activates a stored conversation. + * @param id The conversation id + * @returns Its chat entries, or null if it is gone + */ + async openConversation(id: string): Promise { + const conversation = await this._store.get(id); + if (conversation == null) { + return null; + } + this.activeConversation.next(conversation); + return conversation.messages; + } + + /** + * Deletes a stored conversation. When it is the active one, a new empty + * conversation - or the most recent of the remaining ones - is activated. + * @param id The conversation id + * @returns The chat entries to display after the deletion + */ + async removeConversation(id: string): Promise { + this._cancelPendingSave(); + await this._store.remove(id); + const conversations = this._scope != null ? await this._store.list(this._scope) : []; + this.conversations.next(conversations); + if (this.activeConversation.value?.id !== id) { + return this.activeConversation.value?.messages ?? []; + } + const active = + conversations.length > 0 + ? conversations[0] + : this._createConversation(this._scope ?? 'anonymous'); + this.activeConversation.next(active); + return active.messages; + } + + /** + * Stores the chat entries of the active conversation, giving it a title on + * its first question. Debounced: the several entries of a single answer + * result in a single write. + * @param messages The current chat entries + */ + saveActive(messages: DataChatQA[]): void { + const active = this.activeConversation.value; + if (active == null) { + return; + } + // 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; + this._flush(active); + }, SAVE_DEBOUNCE); + } + + /** + * Writes a conversation and refreshes the conversations list. + * A conversation is stored only once the User has asked something, so that + * merely opening the chat - or the greeting of the completion mode - does + * not fill the conversations list with empty entries. + * @param conversation The conversation to write + */ + private async _flush(conversation: DataChatConversation): Promise { + if (!this._hasQuestions(conversation)) { + return; + } + if (!conversation.title) { + conversation.title = this._buildTitle(conversation.messages); + } + conversation.updatedAt = new Date().getTime(); + await this._store.put(conversation); + if (this._scope != null) { + this.conversations.next(await this._store.list(this._scope)); + } + } + + /** + * Cancels a pending debounced save, if any. + */ + private _cancelPendingSave(): void { + if (this._saveTimeout != null) { + clearTimeout(this._saveTimeout); + this._saveTimeout = null; + } + } + + /** + * Returns true if the User has asked something in the given conversation. + * @param conversation The conversation to check + */ + private _hasQuestions(conversation: DataChatConversation): boolean { + return conversation.messages.some(qa => qa.question); + } + + /** + * Builds a new, empty conversation of a scope. + * @param scope The conversations scope + */ + private _createConversation(scope: string): DataChatConversation { + const now = new Date().getTime(); + return { + id: this._createId(), + scope, + title: '', + createdAt: now, + updatedAt: now, + messages: [], + }; + } + + /** + * Builds a conversation title from its first question. + * @param messages The chat entries of the conversation + */ + private _buildTitle(messages: DataChatQA[]): string { + const firstQuestion = messages.find(qa => qa.question)?.question ?? ''; + return firstQuestion.length > TITLE_MAX_LENGTH + ? `${firstQuestion.slice(0, TITLE_MAX_LENGTH).trim()}…` + : firstQuestion; + } + + /** + * Builds a unique conversation id. + */ + private _createId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${new Date().getTime()}-${Math.round(Math.random() * 1e9)}`; + } + + /** + * 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 _withoutFields(qa: DataChatQA, fields: (keyof DataChatQA)[]): DataChatQA { + const copy: DataChatQA = {...qa}; + for (const field of fields) { + delete copy[field]; + } + return copy; + } +} diff --git a/projects/material/datachat/src/datachat-store.ts b/projects/material/datachat/src/datachat-store.ts new file mode 100644 index 000000000..b37e3dd6a --- /dev/null +++ b/projects/material/datachat/src/datachat-store.ts @@ -0,0 +1,245 @@ +/** + * @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 {Injectable} from '@angular/core'; + +import {DataChatQA} from './datachat.interfaces'; + +/** + * The name of the IndexedDB database holding the DataChat conversations. + * It is a standalone database, unrelated to the RxDB one: it is never + * registered as a DataService collection, so it is never synced with the + * backend and it is not destroyed when the User logs out. + */ +const DB_NAME = 'dino_datachat'; + +/** + * The version of the IndexedDB database. + */ +const DB_VERSION = 1; + +/** + * The name of the object store holding the conversations. + */ +const STORE_NAME = 'conversations'; + +/** + * The name of the index used to list the conversations of a scope. + */ +const SCOPE_INDEX = 'by_scope'; + +/** + * A stored DataChat conversation. + */ +export interface DataChatConversation { + /** + * The unique id of the conversation + */ + id: string; + /** + * The `|` the conversation belongs to + */ + scope: string; + /** + * The title of the conversation, built from its first question. + * Empty until the User asks something. + */ + title: string; + /** + * Creation timestamp + */ + createdAt: number; + /** + * Last update timestamp, used to sort the conversations list + */ + updatedAt: number; + /** + * The chat entries of the conversation + */ + messages: DataChatQA[]; +} + +/** + * Persists the DataChat conversations in a dedicated IndexedDB database, so + * that a User can leave the AI view - or close the browser - and find the + * conversations again. + * + * This store is deliberately kept outside of the RxDB database: + * - it is not a DataService collection, so it is never picked up by the + * GraphQL replication and never leaves the browser; + * - it is not destroyed by DataService.destroyAllCollections() on logout, + * which is what makes the history permanent. Conversations are scoped by + * user id so that two Users of the same browser never see each other's chats. + * + * When IndexedDB is unavailable (private browsing, tests) the store silently + * degrades to an in-memory map: the chat keeps working, the history just does + * not survive the reload. + */ +@Injectable({providedIn: 'root'}) +export class DataChatStore { + /** + * The database connection, opened lazily. + */ + private _db: Promise | null = null; + + /** + * In-memory fallback, used when IndexedDB is not available. + */ + private _memory: Map = new Map(); + + /** + * Builds the scope of the conversations of a User on a Form Schema. + * @param userId The active user id + * @param schemaId The Form Schema id + */ + scope(userId: string, schemaId: string): string { + return `${userId}|${schemaId}`; + } + + /** + * Lists the conversations of a scope, most recently updated first. + * @param scope The conversations scope + */ + async list(scope: string): Promise { + const db = await this._open(); + if (db == null) { + return [...this._memory.values()] + .filter(conv => conv.scope === scope) + .sort((a, b) => b.updatedAt - a.updatedAt); + } + const conversations = await this._request(store => + store.index(SCOPE_INDEX).getAll(scope), + ); + return (conversations ?? []).sort((a, b) => b.updatedAt - a.updatedAt); + } + + /** + * Reads a single conversation. + * @param id The conversation id + */ + async get(id: string): Promise { + const db = await this._open(); + if (db == null) { + return this._memory.get(id) ?? null; + } + const conversation = await this._request(store => + store.get(id), + ); + return conversation ?? null; + } + + /** + * Inserts or updates a conversation. + * @param conversation The conversation to store + */ + async put(conversation: DataChatConversation): Promise { + const db = await this._open(); + if (db == null) { + this._memory.set(conversation.id, conversation); + return; + } + await this._request(store => store.put(conversation), 'readwrite'); + } + + /** + * Deletes a conversation. + * @param id The conversation id + */ + async remove(id: string): Promise { + const db = await this._open(); + if (db == null) { + this._memory.delete(id); + return; + } + await this._request(store => store.delete(id), 'readwrite'); + } + + /** + * Deletes every conversation of a scope. + * @param scope The conversations scope + */ + async clearScope(scope: string): Promise { + const conversations = await this.list(scope); + for (const conversation of conversations) { + await this.remove(conversation.id); + } + } + + /** + * Opens the database, creating the object store on first use. + * Resolves to null when IndexedDB is not usable: every public method then + * falls back to the in-memory map. + */ + private _open(): Promise { + if (this._db != null) { + return this._db; + } + this._db = new Promise(resolve => { + if (typeof indexedDB === 'undefined') { + resolve(null); + return; + } + try { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, {keyPath: 'id'}); + store.createIndex(SCOPE_INDEX, 'scope', {unique: false}); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + request.onblocked = () => resolve(null); + } catch { + resolve(null); + } + }); + return this._db; + } + + /** + * Runs an operation on the conversations object store. + * @param operation The operation to run + * @param mode The transaction mode + * @returns The result of the operation, or null on failure + */ + private async _request( + operation: (store: IDBObjectStore) => IDBRequest, + mode: IDBTransactionMode = 'readonly', + ): Promise { + const db = await this._open(); + if (db == null) { + return null; + } + return new Promise(resolve => { + try { + const transaction = db.transaction(STORE_NAME, mode); + const request = operation(transaction.objectStore(STORE_NAME)); + request.onsuccess = () => resolve(request.result as T); + request.onerror = () => resolve(null); + transaction.onabort = () => resolve(null); + } catch { + resolve(null); + } + }); + } +} diff --git a/projects/material/datachat/src/datachat.html b/projects/material/datachat/src/datachat.html index 0adcecc31..86f176fb1 100644 --- a/projects/material/datachat/src/datachat.html +++ b/projects/material/datachat/src/datachat.html @@ -41,31 +41,113 @@ - - +
+
+
+ + {{(activeConversation|async)?.title || ('New chat'|transloco)}} + + +
+
+
+

+ auto_awesome + {{welcomeTitle ?? ('Ask your data'|transloco)}} +

+

+ {{welcomeSubtitle ?? ('Ask a question in natural language about this form data'|transloco)}} +

+
+ +
+
+
- - -
+
- {{'Namespace'|transloco}} + folder {{namespace || 'default'}} - + + +
+
+ + +
@@ -98,4 +180,4 @@

smart_toy

- \ No newline at end of file + diff --git a/projects/material/datachat/src/datachat.interfaces.ts b/projects/material/datachat/src/datachat.interfaces.ts index 0268e83f9..7fdabc18b 100644 --- a/projects/material/datachat/src/datachat.interfaces.ts +++ b/projects/material/datachat/src/datachat.interfaces.ts @@ -32,7 +32,178 @@ export interface DataChatQA { vectors?: CompletionVector[]; userIsHappy?: boolean; followUpQuestions?: string[]; - log_id?: string; + log_id?: string | number; + /** + * True when the User can rate the entry even if the backend returned no + * 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 { @@ -54,7 +225,7 @@ export interface CompletionResponse { answer?: string; vectors?: CompletionVector[]; follow_ups?: string[]; - log_id?: string; + log_id?: string | number; } export interface QA extends CompletionResponse { diff --git a/projects/material/datachat/src/datachat.module.ts b/projects/material/datachat/src/datachat.module.ts index 2f1b60d7f..2beef3a57 100644 --- a/projects/material/datachat/src/datachat.module.ts +++ b/projects/material/datachat/src/datachat.module.ts @@ -36,16 +36,21 @@ 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'; +import {RelativeDatePipe} from './relative-date.pipe'; import {MatDialogModule} from '@angular/material/dialog'; import {MatSelectModule} from '@angular/material/select'; import {MatChipsModule} from '@angular/material/chips'; import {MatCardModule} from '@angular/material/card'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {BreakpointObserverModule} from '@dino/material/breakpoint-observer'; @NgModule({ imports: [ + BreakpointObserverModule, CommonModule, DinoLoadingSpinnerModule, DinoTableGeneratorModule, @@ -62,11 +67,12 @@ import {MatCardModule} from '@angular/material/card'; MatProgressSpinnerModule, MatSelectModule, MatSnackBarModule, + MatTooltipModule, ReactiveFormsModule, RouterModule, TranslocoModule, ], - declarations: [DataChat, DataChatEntry, ParagraphDialogComponent], - exports: [DataChat, DataChatEntry, ParagraphDialogComponent], + declarations: [DataChat, DataChatChart, DataChatEntry, ParagraphDialogComponent, RelativeDatePipe], + exports: [DataChat, DataChatChart, DataChatEntry, ParagraphDialogComponent, RelativeDatePipe], }) export class DataChatModule {} diff --git a/projects/material/datachat/src/datachat.scss b/projects/material/datachat/src/datachat.scss index 0218cbd43..a2b1a0a53 100644 --- a/projects/material/datachat/src/datachat.scss +++ b/projects/material/datachat/src/datachat.scss @@ -1,16 +1,54 @@ @use 'angular-material-css-vars' as mat-css-vars; +@function dc-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-datachat { + --dc-page: #14171b; + --dc-surface: #23272d; + --dc-subtle: #1b1f24; + --dc-border: #363c44; + --dc-heading: #e6e9ec; + --dc-text: #d4d9dd; + --dc-muted: #99a0a7; + --dc-hover: #2b3037; +} + dino-datachat { + // Light-theme neutral defaults. + --dc-page: #eef1f4; + --dc-surface: #ffffff; + --dc-subtle: #f7f9fb; + --dc-border: #e2e8ee; + --dc-heading: #12303f; + --dc-text: #2a3a45; + --dc-muted: #64798a; + --dc-hover: #f1f5f9; + + display: flex; + flex-direction: column; + min-height: 0; + color: var(--dc-text); + + // The chat fills the space its page gives it: both hosting pages are flex + // columns that set `flex: 1 1 auto` on it. + flex: 1 1 auto; + .dino-datachat-container { display: flex; flex-flow: column; - justify-content: center; + flex: 1 1 auto; + min-height: 0; align-items: center; + .dino-apikey-field { - flex: 1 0 auto; + flex: 0 0 auto; min-width: 300px; position: relative; top: 30vh; + .dino-apikey-field-fab { right: 10px; max-width: 32px; @@ -18,47 +56,296 @@ dino-datachat { } } - .dino-chat-history-container { - display: flex; - flex-flow: column; - flex: 1 0 auto; - width: 85vw; - @media only screen and (max-width: 768px) { - width: 95vw; + .dino-communicating-spinner { + position: relative; + right: 10px; + } + } + + // ---- Two columns: the chat card and the conversations sidebar ------------- + .dino-datachat-layout { + display: flex; + flex-flow: row nowrap; + align-items: stretch; + gap: 16px; + flex: 1 1 auto; + min-height: 420px; + width: 100%; + } + + .dino-datachat-panel { + display: flex; + flex-flow: column; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + background: var(--dc-surface); + border: 1px solid var(--dc-border); + border-radius: 12px; + overflow: hidden; + } + + .dino-datachat-panel-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 18px; + border-bottom: 1px solid var(--dc-border); + font-size: 14px; + font-weight: 700; + color: var(--dc-heading); + } + + .dino-datachat-panel-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // ---- Messages ------------------------------------------------------------- + .dino-chat-history-container { + display: flex; + flex-flow: column; + flex: 1 1 auto; + min-height: 0; + width: auto; + padding: 18px; + overflow-y: auto; + scroll-behavior: smooth; + background: transparent; + } + + .dino-datachat-welcome { + flex: 0 0 auto; + padding: 4px 4px 12px; + } + + .dino-datachat-welcome-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 6px; + font-size: 18px; + font-weight: 700; + color: var(--dc-heading); + + .mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + cursor: default; + } + } + + .dino-datachat-welcome-subtitle { + margin: 0 0 16px; + max-width: 640px; + font-size: 14px; + line-height: 20px; + color: var(--dc-muted); + } + + .dino-datachat-starters { + display: flex; + flex-flow: row wrap; + gap: 10px; + } + + .dino-datachat-starter { + padding: 8px 16px; + border: 1px solid var(--dc-border); + border-radius: 999px; + background: var(--dc-subtle); + color: var(--dc-text); + font-family: inherit; + font-size: 13px; + line-height: 18px; + text-align: left; + cursor: pointer; + + &:hover { + background: dc-primary(500, 0.08); + border-color: dc-primary(500, 0.35); + } + } + + // ---- Input row ------------------------------------------------------------ + .dino-chat-input-container { + flex: 0 0 auto; + position: static; + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: 10px; + width: auto; + padding: 12px 18px; + border-top: 1px solid var(--dc-border); + + .dino-chat-text-input { + flex: 1 1 auto; + min-width: 0; + + // Rounded, borderless field: the surrounding row is the visible control. + .mat-mdc-text-field-wrapper { + border-radius: 10px; + } + + .mdc-line-ripple { + display: none; } - padding: 6px; - height: 74vh; - max-height: 74vh; - overflow-y: auto; - border-radius: 4px; - scroll-behavior: smooth; - background-color: var(--mdc-filled-text-field-container-color); } - .dino-chat-input-container { - display: flex; - flex-flow: row wrap; - justify-content: space-between; - position: absolute; - bottom: 80px; - width: 86vw; - @media only screen and (max-width: 768px) { - width: 100vw; + + // Compact pill holding the namespace, with its folder icon. + .dino-chat-namespace-select { + flex: 0 0 auto; + width: 150px; + + .mat-mdc-text-field-wrapper { + border-radius: 10px; + padding-left: 10px; + } + + .mat-mdc-form-field-infix { + width: auto; + min-height: 44px; + padding-top: 10px; + padding-bottom: 10px; } - .dino-chat-text-input { - flex: 1 0 85%; - @media only screen and (max-width: 768px) { - flex: 1 0 auto; - } + + .mat-mdc-form-field-icon-prefix > .mat-icon { + width: 18px; + height: 18px; + margin-right: 6px; + padding: 0; + font-size: 18px; + color: var(--dc-muted); } - .dino-chat-namespace-select { - flex: 1 0 15%; + + .mdc-line-ripple { + display: none; } } - .dino-communicating-spinner { - position: relative; - right: 10px; + + // The Material form fields carry their own bottom subscript spacing, which + // would misalign the send button in this compact row. + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + .dino-chat-send { + flex: 0 0 auto; + height: 44px; + border-radius: 8px; + + .mat-icon { + margin-right: 6px; + } } } + + // ---- Conversations sidebar ------------------------------------------------ + .dino-datachat-conversations { + flex: 0 0 300px; + display: flex; + flex-flow: column; + min-height: 0; + padding: 14px; + background: var(--dc-surface); + border: 1px solid var(--dc-border); + border-radius: 12px; + + @media only screen and (max-width: 768px) { + flex-basis: 240px; + } + } + + .dino-datachat-conversations-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + } + + .dino-datachat-conversations-title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--dc-muted); + } + + .dino-datachat-new { + flex: 0 0 auto; + height: 42px; + border-radius: 8px; + margin-bottom: 14px; + + .mat-icon { + margin-right: 6px; + } + } + + .dino-datachat-conversations-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + } + + .dino-datachat-conversation { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + + &:hover { + background: var(--dc-hover); + + .dino-datachat-conversation-delete { + visibility: visible; + } + } + + &.dino-datachat-conversation-active { + background: dc-primary(500, 0.1); + } + } + + .dino-datachat-conversation-info { + display: flex; + flex-flow: column; + min-width: 0; + } + + .dino-datachat-conversation-title { + font-size: 14px; + color: var(--dc-heading); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .dino-datachat-conversation-date { + font-size: 12px; + color: var(--dc-muted); + } + + .dino-datachat-conversation-delete { + flex: 0 0 auto; + visibility: hidden; + + .mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + + // ---- Gates (terms, no data, no credits) ----------------------------------- .dino-no-items-message { display: flex; flex-flow: row wrap; @@ -67,19 +354,23 @@ dino-datachat { height: 85vh; opacity: 0.4; } + .dino-no-tokens-message { position: relative; top: 30%; text-align: center; margin: auto; opacity: 0.6; + .dino-no-tokens-message-row { display: flex; justify-content: center; align-items: center; + .dino-no-tokens-icon { margin-left: 10px; } + &.dino-no-tokens-message-row-buy { cursor: pointer; color: mat-css-vars.mat-css-color-primary(500); @@ -91,12 +382,14 @@ dino-datachat { max-height: 70vh; overflow-y: auto; } + .dino-gpt-accept-box { margin-top: 40px; display: flex; justify-content: center; align-items: center; } + .dino-loading { position: unset !important; width: unset !important; 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 022bea47b..8eaab88a5 100644 --- a/projects/material/datachat/src/datachat.ts +++ b/projects/material/datachat/src/datachat.ts @@ -32,14 +32,25 @@ import { OnDestroy, OnInit, Optional, + Output, ViewChild, ViewEncapsulation, } from '@angular/core'; import {FormControl, FormGroup, Validators} from '@angular/forms'; -import {ActivatedRoute} from '@angular/router'; -import {CompletionRequest, CompletionResponse, DataChatQA} from './datachat.interfaces'; -import {HttpClient} from '@angular/common/http'; -import {map, switchMap, take} from 'rxjs/operators'; +import {ActivatedRoute, Router} from '@angular/router'; +import {DataChatSessionService} from './datachat-session.service'; +import {DataChatConversation} from './datachat-store'; +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 { BehaviorSubject, @@ -47,6 +58,7 @@ import { forkJoin, Observable, of as obsOf, + Subject, Subscription, } from 'rxjs'; import {UserDataManager, UserGroupManager} from '@dino/core/users'; @@ -66,12 +78,36 @@ import {MatProgressBar} from '@angular/material/progress-bar'; import {MatSnackBar} from '@angular/material/snack-bar'; import {AuthService, User} from '@dino/core/auth'; import {MatSelectChange} from '@angular/material/select'; +import {BreakpointObserverService} from '@dino/material/breakpoint-observer'; import { STRIPE_PAYMENT_CONFIG, StripePaymentConfig, TokensService, } from '@dino/material/stripe-payment'; +/** + * The conversations key used in completion mode, where the chat is not bound + * to a Form Schema. + */ +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 @@ -115,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 */ @@ -163,6 +208,48 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { */ @Input() spinnerImagePath: string | undefined; + /** + * If true, the conversations sidebar is displayed beside the chat, allowing + * the User to switch between the stored conversations of the Form Schema. + * Only meaningful in 'datachat' mode. + */ + @Input() conversationsSidebar = false; + + /** + * If true, an empty chat displays a welcome block with the starter questions. + */ + @Input() showWelcome = false; + + /** + * The title of the welcome block. Defaults to a translated label. + */ + @Input() welcomeTitle: string | null = null; + + /** + * The subtitle of the welcome block. Defaults to a translated label. + */ + @Input() welcomeSubtitle: string | null = null; + + /** + * The questions suggested by the welcome block. Default to translated ones. + */ + @Input() starterQuestions: string[] | null = null; + + /** + * The `source` sent along with the feedback of an answer, telling the two + * chats apart in the backend logs. Defaults to the chat mode. + */ + private _feedbackSource: string | null = null; + get feedbackSource(): string { + return ( + this._feedbackSource ?? (this.mode === 'completion' ? 'dinoapp-ragai' : 'dinoapp-datachat') + ); + } + @Input() + set feedbackSource(source: string | null) { + this._feedbackSource = source; + } + /** * The Ajf functions used to evaluate relevant permissions. * This input is unnecessary and should be removed, as dino custom @@ -190,6 +277,53 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { */ history: DataChatQA[] = []; + /** + * The stored conversations of the current Form Schema, most recent first. + */ + readonly conversations: Observable; + + /** + * The conversation currently displayed. + */ + readonly activeConversation: BehaviorSubject; + + /** + * True when the conversations sidebar is closed. + */ + readonly sidebarCollapsed: BehaviorSubject = new BehaviorSubject(false); + + /** + * True once the User has opened or closed the sidebar: from then on the + * screen size does not change its state anymore. + */ + private _sidebarToggledByUser = false; + + /** + * Unsubscribes the component subscriptions on destroy. + */ + private _unsubscribe: Subject = new Subject(); + + /** + * The default starter questions of the welcome block. + */ + private readonly _defaultStarterQuestions: string[] = [ + 'How many records were collected this month?', + 'Summarize the collected notes', + 'Compare the activities by organization', + 'Which items have the lowest values?', + ]; + + /** + * The questions displayed by the welcome block, translated when they are the + * default ones. + */ + get starters(): string[] { + if (this.starterQuestions != null) { + return this.starterQuestions; + } + return this._defaultStarterQuestions.map(question => this._ts.translate(question)); + } + /** * Currently selected Chat namespace */ @@ -238,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 */ @@ -246,6 +388,41 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { return this._termsAccepted; } + /** + * The Form Schema id of the current chat, in datachat mode. + */ + private _schemaId: string | null = null; + + /** + * The key the stored conversations of this chat are grouped by. + */ + private _conversationKey: string | null = null; + + /** + * True if a PandasAI agent of a previous visit of this Form Schema is still + * alive: in that case no api key validation, csv export or agent creation + * is performed, and the chat history is restored from the session. + */ + private _agentAlive = false; + + /** + * True once this component has created its own agent. + */ + private _agentReady = false; + + /** + * The User the live agent belongs to, kept from the moment the agent is + * created: destroying it also happens on logout, when the local data of the + * User is being destroyed and can no longer be read. + */ + private _agentUser: {name: string; email: string} | null = null; + + /** + * The agent creation currently in flight, shared by the questions asked + * while it is running. + */ + private _agentCreation: Observable | null = null; + constructor( @Optional() @Inject(STRIPE_PAYMENT_CONFIG) readonly config: StripePaymentConfig | null, private _route: ActivatedRoute, @@ -259,13 +436,27 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { private _snackBar: MatSnackBar, private _ts: TranslocoService, private _tokensService: TokensService, + private _session: DataChatSessionService, + private _router: Router, + private _breakpointObserver: BreakpointObserverService, @Optional() private _ar: AreaManager | null, @Optional() private _cs: CaseManager | null, @Optional() private _pj: ProjectManager | null, @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 + // explicitly opens or closes it. + this._breakpointObserver.large.pipe(takeUntil(this._unsubscribe)).subscribe(isLarge => { + if (!this._sidebarToggledByUser) { + this.sidebarCollapsed.next(!isLarge); + } + }); this._exporter = null; this._formSchema$ = obsOf(null); this._formDataList$ = obsOf([]); @@ -275,6 +466,11 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { ngAfterViewInit(): void { if (this.mode === 'datachat') { + if (this._agentAlive) { + // The agent of a previous visit is still alive: its data has already + // been uploaded, there is nothing to export nor to create. + return; + } this._exporter = this._createExporter(); this._formSchema$ = this._fsm.get(this._route.snapshot.params['form_schema_id']); @@ -331,14 +527,14 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { const confirmedKey = res[2]; this.apiKey.next(confirmedKey); if (this._exporter && data && data.length) { + // Only the csv is prepared here: it is built locally and costs + // nothing. The agent - which is paid - is created on the first + // question, so that merely opening the chat is free. this._exporter.export(); - this._createAgent(confirmedKey); - } else { - if (!data || !data.length) { - this.noData.next(true); - } - this.isLoading.next(false); + } else if (!data || !data.length) { + this.noData.next(true); } + this.isLoading.next(false); this._cdr.detectChanges(); }, error: (err: any) => { @@ -364,7 +560,9 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { next: (res: string) => { const confirmedKey = res; this.apiKey.next(confirmedKey); - this._addToHistory({response: 'Hello! How can I help you?', noPrompt: true}); + if (this.history.length === 0) { + this._addToHistory({response: 'Hello! How can I help you?', noPrompt: true}); + } this.isLoading.next(false); this._cdr.detectChanges(); }, @@ -383,6 +581,29 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { } ngOnInit(): void { + if (this.mode === 'datachat') { + this._schemaId = this._route.snapshot.params['form_schema_id'] ?? null; + } + // In datachat mode the conversations belong to the Form Schema, in + // completion mode they are the single chat of the AI section. + this._conversationKey = this.mode === 'datachat' ? this._schemaId : COMPLETION_CONVERSATIONS_KEY; + if (this._conversationKey != null) { + this._session.openScope(this._conversationKey).then(messages => { + this.history = this._restoreTables(messages); + this._cdr.detectChanges(); + this._scrollChatBottom(); + }); + } + if (this.mode === 'datachat' && this._schemaId != null) { + const liveApiKey = this._session.apiKey; + if (this._session.isAliveFor(this._schemaId) && liveApiKey != null) { + this._agentAlive = true; + const live = this._session.liveSession; + this._agentUser = live != null ? {name: live.userName, email: live.userEmail} : null; + this.apiKey.next(liveApiKey); + return; + } + } const storedApiKey = localStorage.getItem('pandas_dino_api_key'); const storedAcceptTerms = localStorage.getItem('pandas_dino_api_key_accept_terms'); if (storedApiKey && storedAcceptTerms) { @@ -390,6 +611,81 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { } } + /** + * Activates a new, empty conversation. + */ + newConversation(): void { + this.history = this._session.newConversation(); + 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 + * by the current agent. + * @param conversation The conversation to display + */ + openConversation(conversation: DataChatConversation): void { + if (conversation.id === this.activeConversation.value?.id) { + return; + } + this._session.openConversation(conversation.id).then(messages => { + if (messages != null) { + this.history = this._restoreTables(messages); + this._cdr.detectChanges(); + this._scrollChatBottom(); + } + }); + } + + /** + * Deletes a stored conversation. + * @param conversation The conversation to delete + * @param evt The click event, stopped so that the conversation is not opened + */ + deleteConversation(conversation: DataChatConversation, evt: Event): void { + evt.stopPropagation(); + this._session.removeConversation(conversation.id).then(messages => { + this.history = messages; + this._cdr.detectChanges(); + }); + } + + /** + * Opens or closes the conversations sidebar. + */ + toggleSidebar(): void { + this._sidebarToggledByUser = true; + this.sidebarCollapsed.next(!this.sidebarCollapsed.value); + } + + /** + * Sends the content of the chat input and clears it. + * @param input The chat input element + */ + sendPrompt(input: HTMLTextAreaElement): void { + const text = input.value; + input.value = ''; + this.chatPromptText = ''; + this.chatInputFormGrop.get('chatInputControl')?.setValue(''); + this.chat(text); + } + /** * Opens a Stripe Payment dialog */ @@ -432,9 +728,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); } @@ -484,22 +784,21 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { */ dataChat(text: string): void { this._addToHistory({question: text}); - this._udm - .getActiveUserData() + this._addToHistory({ + componentData: {component: MatProgressBar, inputs: {mode: 'indeterminate'}}, + }); + // The agent is created here, on the first question, and reused by the + // following ones. + this._ensureAgent() .pipe( + switchMap(agentReady => (agentReady ? this._udm.getActiveUserData() : obsOf(null))), switchMap(activeUserData => { if (!activeUserData || !this.apiKey.value) return obsOf(null); const headers = {'X-API-KEY': this.apiKey.value, 'X-USER-EMAIL': activeUserData.email}; const url = `${this.baseDataChatAPIurl}/${ this.endpointUrls?.dataChatEndpoint ?? 'datachat' }`; - this._addToHistory({ - componentData: {component: MatProgressBar, inputs: {mode: 'indeterminate'}}, - }); - return this._http.post<{ - explanation: string; - response: {type: string; value: any}; - }>(url, {'chat': text}, {headers}); + return this._http.post(url, {'chat': text}, {headers}); }), take(1), ) @@ -510,40 +809,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({ - explanation: res.explanation, - imageData: base64imageData, - noPrompt: true, + ...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({ - explanation: res.explanation, - componentData: { - component: TableGenerator, - inputs: {maxRowsDisplayed: 50, setJsonData: res.response.value}, - }, - noPrompt: true, + ...answer, + tableData: res.response.value, + componentData: this._tableComponentData(res.response.value), }); break; default: + const isTabular = typeof res.response.value === 'object'; this._addToHistory({ - 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, + ...answer, + response: isTabular ? undefined : res.response.value, + tableData: isTabular ? res.response.value : undefined, + componentData: isTabular + ? this._tableComponentData(res.response.value) + : undefined, }); break; } @@ -577,6 +887,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 @@ -681,8 +1146,11 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { * @param answer qa answer * @returns */ - sendFeedback(logId: string, feedback: boolean, question: string, answer: string) { + sendFeedback(logId: string | number, feedback: boolean, question: string, answer: string) { if (!this.apiKey.value) return; + // The entry has just flagged itself as rated: keep that in the stored + // conversation too. + this._persistHistory(); const url = `${this.baseDataChatAPIurl}/feedback`; const headers = {'X-API-KEY': this.apiKey.value}; const userInfo = this._auth.getUserInfo(); @@ -693,7 +1161,7 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { answer, feedback: feedback ? 'positive' : 'negative', log_id: logId, - source: 'dinoapp', + source: this.feedbackSource, }; this._http .post(url, body, {headers}) @@ -763,89 +1231,104 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { } /** - * Sends an agent creation request to the API 'startdatachat' endpoint - * and adds to chat history the default table, generated by TableGenerator with - * the exported csv file + * Sends an agent creation request to the API 'startdatachat' endpoint, + * uploading the exported csv file the agent will analyze * @param apiKey */ - private _createAgent(apiKey: string) { - combineLatest([this._udm.getActiveUserData(), this._exportedFile$]) - .pipe( - switchMap(([activeUserData, exportedFile]) => { - if (!activeUserData || !exportedFile) return obsOf(null); - const headers = { - 'X-API-KEY': apiKey, - 'X-USER-NAME': activeUserData.full_name, - 'X-USER-EMAIL': activeUserData.email, - }; - const url = `${this.baseDataChatAPIurl}/${ - this.endpointUrls?.startEndpoint ?? 'startdatachat' - }`; - const formData = new FormData(); - formData.append('file', exportedFile); - const currentLang = this._ts.getActiveLang(); - formData.append('lang', currentLang); - return this._http.post(url, formData, {headers}).pipe( - map(response => { - return { - response, - exportedFile, - }; - }), + private _createAgent(apiKey: string): Observable { + return combineLatest([this._udm.getActiveUserData(), this._exportedFile$]).pipe( + switchMap(([activeUserData, exportedFile]) => { + if (!activeUserData || !exportedFile) return obsOf(null); + // The agent belongs to this User from now on, and is destroyed in its + // name even when the local data is gone. + this._agentUser = {name: activeUserData.full_name, email: activeUserData.email}; + const headers = { + 'X-API-KEY': apiKey, + 'X-USER-NAME': activeUserData.full_name, + 'X-USER-EMAIL': activeUserData.email, + }; + const url = `${this.baseDataChatAPIurl}/${ + this.endpointUrls?.startEndpoint ?? 'startdatachat' + }`; + const formData = new FormData(); + formData.append('file', exportedFile); + const currentLang = this._ts.getActiveLang(); + formData.append('lang', currentLang); + return this._http.post(url, formData, {headers}); + }), + take(1), + map(res => { + if (res) { + this._refreshAvailableTokens(); + } + if (isDevMode()) { + console.log(res); + } + return res != null; + }), + catchError(err => { + // Not enough tokens response from Pandino + if (err && err.error && err.error.error === 'Not enough tokens') { + this.noTokens.next(true); + this._snackBar.open( + this._ts.translate( + 'Not enough credits! Please add more DINO-AI Credits to your account to use this feature', + ), + 'OOPS!', + {duration: 10000}, ); - }), - take(1), - ) - .subscribe({ - next: res => { - if (res) { - this._addToHistory([ - {response: 'Here is your data!', noPrompt: true}, - { - componentData: { - component: TableGenerator, - inputs: {maxRowsDisplayed: 50, setCsvFile: res.exportedFile}, - }, - }, - ]); - if (res.response.suggested_questions) { - this._addToHistory({ - response: res.response.suggested_questions, - noPrompt: true, - }); - } - this._refreshAvailableTokens(); - } - + } else { if (isDevMode()) { - console.log(res); - } - this.isLoading.next(false); - }, - error: err => { - // Not enough tokens response from Pandino - if (err && err.error && err.error.error === 'Not enough tokens') { - this.noTokens.next(true); - this.isLoading.next(false); - this._snackBar.open( - this._ts.translate( - 'Not enough credits! Please add more DINO-AI Credits to your account to use this feature', - ), - 'OOPS!', - {duration: 10000}, - ); + console.log(err); } else { - if (isDevMode()) { - console.log(err); - } else { - this._ehms.captureErrorMessage( - `DINO-AI agent creation error: ${JSON.stringify(err)}`, - 'warning', - ); - } + this._ehms.captureErrorMessage( + `DINO-AI agent creation error: ${JSON.stringify(err)}`, + 'warning', + ); } - }, - }); + } + return obsOf(false); + }), + ); + } + + /** + * Creates the PandasAI agent if it does not exist yet. + * The agent creation uploads the whole dataset and is charged to the User, + * so it is deferred to the first question instead of being performed when + * the chat is opened. + * @returns True as soon as an agent is available + */ + private _ensureAgent(): Observable { + if (this._agentAlive || this._agentReady) { + return obsOf(true); + } + if (this._agentCreation != null) { + return this._agentCreation; + } + const apiKey = this.apiKey.value; + if (apiKey == null) { + return obsOf(false); + } + this._agentCreation = this._createAgent(apiKey).pipe( + tap(created => { + this._agentReady = created; + this._agentCreation = null; + }), + shareReplay(1), + ); + return this._agentCreation; + } + + /** + * The url the application is navigating to, or the current one when the + * component is not being destroyed by a navigation. + */ + private _nextUrl(): string { + const navigation = this._router.getCurrentNavigation(); + return navigation?.finalUrl != null + ? this._router.serializeUrl(navigation.finalUrl) + : this._router.url; } /** @@ -853,23 +1336,17 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { * @param apiKey The api key */ private _destroyAgent(apiKey: string) { - this._udm - .getActiveUserData() - .pipe( - switchMap(activeUserData => { - if (!activeUserData) return obsOf(null); - const headers = { - 'X-API-KEY': apiKey, - 'X-USER-NAME': activeUserData.full_name, - 'X-USER-EMAIL': activeUserData.email, - }; - const url = `${this.baseDataChatAPIurl}/${ - this.endpointUrls?.endEndpoint ?? 'enddatachat' - }`; - return this._http.post(url, {}, {headers}); - }), - take(1), - ) + // The User is the one the agent was created for, not the one the local data + // holds: this also runs on logout, while that data is being destroyed. + const headers = { + 'X-API-KEY': apiKey, + 'X-USER-NAME': this._agentUser?.name ?? '', + 'X-USER-EMAIL': this._agentUser?.email ?? '', + }; + const url = `${this.baseDataChatAPIurl}/${this.endpointUrls?.endEndpoint ?? 'enddatachat'}`; + this._http + .post(url, {}, {headers}) + .pipe(take(1)) .subscribe(res => { if (isDevMode()) { console.log(res); @@ -888,6 +1365,7 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { } else { this.history.push(qa); } + this._persistHistory(); this._cdr.detectChanges(); this._scrollChatBottom(); } @@ -897,6 +1375,18 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { */ private _removeLastFromHistory(): void { this.history.splice(-1, 1); + this._persistHistory(); + } + + /** + * Saves the chat history in the session, so that it can be restored when the + * AI view of this Form Schema is entered again. + */ + private _persistHistory(): void { + if (this._conversationKey == null) { + return; + } + this._session.saveActive(this.history); } /** @@ -943,13 +1433,38 @@ export class DataChat implements AfterViewInit, OnDestroy, OnInit { } ngOnDestroy(): void { - if (this.apiKey.value) { - this._destroyAgent(this.apiKey.value); + // Only the datachat mode creates a PandasAI agent, and only on the first + // question: there is nothing to keep alive nor to destroy in completion + // mode, or when no question was ever asked. + const hasAgent = this.mode === 'datachat' && (this._agentAlive || this._agentReady); + if (this.apiKey.value && hasAgent) { + if (this._schemaId != null) { + // The agent is kept alive while the User stays inside the form section, + // so that moving between the Data, Map and AI views does not destroy it + // and does not re-upload its data. + this._session.keepAlive({ + schemaId: this._schemaId, + apiKey: this.apiKey.value, + baseUrl: this.baseDataChatAPIurl ?? '', + endEndpoint: this.endpointUrls?.endEndpoint ?? 'enddatachat', + userName: this._agentUser?.name ?? '', + userEmail: this._agentUser?.email ?? '', + }); + if (!this._session.isInsideForm(this._nextUrl(), this._schemaId)) { + this._session.endSession(); + } + } else { + // A datachat outside of a form section (no schema id in the route): + // its agent has no section to stay alive for. + this._destroyAgent(this.apiKey.value); + } } if (this._exporter) { this._exporter.ngOnDestroy(); } this._apiKeyConfirmationSub.unsubscribe(); + this._unsubscribe.next(); + this._unsubscribe.complete(); this.apiKey.complete(); this.isLoading.complete(); } diff --git a/projects/material/datachat/src/public_api.ts b/projects/material/datachat/src/public_api.ts index bc7a8bffa..398681aad 100644 --- a/projects/material/datachat/src/public_api.ts +++ b/projects/material/datachat/src/public_api.ts @@ -21,7 +21,11 @@ */ export * from './datachat'; +export * from './datachat-chart'; export * from './datachat-entry'; +export * from './datachat-session.service'; +export * from './datachat-store'; +export * from './relative-date.pipe'; export * from './datachat.interfaces'; export * from './datachat.module'; export * from './paragraph-dialog.component'; diff --git a/projects/material/datachat/src/relative-date.pipe.ts b/projects/material/datachat/src/relative-date.pipe.ts new file mode 100644 index 000000000..886d8e93e --- /dev/null +++ b/projects/material/datachat/src/relative-date.pipe.ts @@ -0,0 +1,59 @@ +/** + * @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 {Pipe, PipeTransform} from '@angular/core'; +import {TranslocoService} from '@ajf/core/transloco'; +import {transformDateByLocale} from '@dino/core/langs'; +import {differenceInCalendarDays} from 'date-fns'; + +/** + * Formats a timestamp as a short, human readable distance from today: + * 'Today', 'Yesterday', '3 days ago', 'Last week', or the localized short date + * for anything older. + */ +@Pipe({name: 'dinoRelativeDate', pure: false}) +export class RelativeDatePipe implements PipeTransform { + constructor(private _ts: TranslocoService) {} + + transform(value: number | string | Date | null | undefined): string { + if (value == null) { + return ''; + } + const date = value instanceof Date ? value : new Date(value); + if (isNaN(date.getTime())) { + return ''; + } + const days = differenceInCalendarDays(new Date(), date); + if (days <= 0) { + return this._ts.translate('Today'); + } + if (days === 1) { + return this._ts.translate('Yesterday'); + } + if (days < 7) { + return this._ts.translate('{{days}} days ago', {days}); + } + if (days < 14) { + return this._ts.translate('Last week'); + } + return transformDateByLocale(date, this._ts.getActiveLang(), 'shortDate'); + } +} diff --git a/projects/material/export-list/src/export-list-bottom-sheet.ts b/projects/material/export-list/src/export-list-bottom-sheet.ts deleted file mode 100644 index d06d0bb9c..000000000 --- a/projects/material/export-list/src/export-list-bottom-sheet.ts +++ /dev/null @@ -1,51 +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, Component, ViewEncapsulation} from '@angular/core'; -import {MatBottomSheetRef} from '@angular/material/bottom-sheet'; - -@Component({ - selector: 'dino-export-form-bottom-sheet', - template: ` - - - {{ 'XLSX'|transloco }} - - - - {{ 'CSV'|transloco }} - - - {{ 'Select fields'|transloco }} - - - `, - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, -}) -export class ExportBottomSheet { - constructor(private _bottomSheetRef: MatBottomSheetRef) {} - - export(ev: string): void { - this._bottomSheetRef.dismiss(ev); - } -} diff --git a/projects/material/export-list/src/export-list.html b/projects/material/export-list/src/export-list.html index ad3c0620e..fe8653c15 100644 --- a/projects/material/export-list/src/export-list.html +++ b/projects/material/export-list/src/export-list.html @@ -1,115 +1,174 @@ - - - - +
+
+ cloud_download +

{{'Export data'|transloco}}

+
+ + + {{'Items in page'|transloco}} + {{'With active filters'|transloco}} ({{filtersCount}}) - {{'Items in page'|transloco}} - {{'All items'|transloco}} / {{filtersCount}} {{'filters'|transloco}} - - {{'Add filters'|transloco}} - + {{'All items'|transloco}}{{'Add filters'|transloco}} - - - - - {{'Filters'|transloco}} - + {{'All items'|transloco}} + + + + {{'csv'|transloco}} + {{'xlsx'|transloco}} + {{'splitted xlsx'|transloco}} + +
+ +
+ + + +
+ {{'Select all Form fields'|transloco}} + {{'Label values'|transloco}} +
{{'Value format'|transloco}}
+ + {{'Default'|transloco}} + {{'Data Analysis format'|transloco}} + {{'Separate columns'|transloco}} + +
+
+
+
+ +
+ + +
+
+

+ {{label|transloco}} +

+ + + search + + + +
+
+ +
+
+
+
+ + {{selectedCount$|async}} + {{'fields selected out of'|transloco}} {{totalCount$|async}} + + + - - - - {{'Fields and formats'|transloco}} - - - {{choice.label| transloco}} - - - - - - {{'csv'|transloco}} - {{'xlsx'|transloco}} - {{'splitted xlsx'|transloco}} - - - - - - {{'Select all fields of'|transloco}} {{label| transloco}} - - - - {{field.label| transloco}} - - - - +
+ -
+
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..250745480 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,101 @@ 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-format { + margin-left: 0; } - .dino-export-toolbar-spacer { - flex: 1 1 auto; + + .dino-export-body { + grid-template-columns: 1fr; + padding: 12px 16px; } - .dino-spacer { - flex: 1 1 auto; + + .dino-export-sections { + padding: 8px; } - dino-toggle-button { - width: auto; - margin-right: 10px; + + .dino-export-sections-caption { + display: none; } - mat-button-toggle { - font-size: 16px; - @media only screen and (max-width: 992px) { - font-size: 14px; - } + + .dino-export-sections-list { + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; } - height: auto !important; - min-height: 64px; - button { - min-height: 50px; + .dino-export-section { + width: auto; + flex: 0 0 auto; } - &.dino-export-options { - .mat-button-toggle-group { - flex: 1 1 auto; - .mat-button-toggle { - flex: 1 1 auto; - } - } + .dino-export-fields-grid { + grid-template-columns: 1fr; } - @media only screen and (max-width: 992px) { - &.dino-export-options { - flex-wrap: wrap; - padding-bottom: 10px; - } + .dino-export-field-search { + max-width: none; } } - .dino-checked { - background-color: red; - } - .dino-export-translate-btn { - max-width: 110px; +} + +// 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; } - .dino-export-toolbar-count { - margin-right: 10px; +} + +// Field and section labels are ellipsized in place: their tooltip carries the +// whole label, so it is given room to wrap instead of being ellipsized in turn. +.dino-export-label-tooltip { + .mdc-tooltip__surface { + max-width: 420px; + white-space: normal; + word-break: break-word; } - 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/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 d91ee3d12..5bc69798a 100644 --- a/projects/material/list/src/list.html +++ b/projects/material/list/src/list.html @@ -57,39 +57,6 @@ [aria-label]="checkboxLabel()" > - - - @@ -102,33 +69,41 @@ [aria-label]="checkboxLabel(row)" > - warning + + + warning - - - - cloud_upload - - + + + + cloud_upload + + + @@ -139,7 +114,14 @@ [matColumnDef]="header.column.toString()" > - + edit_note @@ -264,50 +251,6 @@ - - - -
- -
-
- -
- - - {{action.matIcon}} - - - - - more_horiz - - - -
-
-
- + + + + + +
+ + + {{action.matIcon}} + + +
+
+
+ +
+
+ + check_box_outline_blank + {{'Select one or more rows to see the available actions'|transloco}} + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
diff --git a/projects/material/list/src/list.module.ts b/projects/material/list/src/list.module.ts index 1ec1a0867..791e26b1c 100644 --- a/projects/material/list/src/list.module.ts +++ b/projects/material/list/src/list.module.ts @@ -67,6 +67,7 @@ import {LogViewer} from './log-viewer'; import {ImagePreview} from './image-preview'; import {AsListCellActionsPipe} from './list-action-pipe'; import {LangsModule} from '@dino/material/langs'; +import {ColumnResize} from './column-resize'; import {ListCellComponent} from './list-cell-component'; import {ActionsModal} from './actions-modal'; import {TourMatMenuModule} from 'ngx-ui-tour-md-menu'; @@ -109,6 +110,7 @@ import {TourMatMenuModule} from 'ngx-ui-tour-md-menu'; declarations: [ AsListCellActionsPipe, ActionsModal, + ColumnResize, ColumnsSelector, ImagePreview, ListCell, diff --git a/projects/material/list/src/list.scss b/projects/material/list/src/list.scss index 5c4285ac9..fc8a2d5a8 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; + } + + // 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: 8px; + .mat-mdc-button-persistent-ripple, + .mat-mdc-button-persistent-ripple::before, + .mdc-button__ripple { + border-radius: 8px; + } + } + + // 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; @@ -115,6 +204,13 @@ dino-list { } } + // A row with something to report says so in its bar as well as in its badges: + // the bar is legible the whole way down the list, a badge only once the eye is + // already on the row. + .mat-mdc-row.dino-row-flagged .mat-mdc-cell:first-of-type { + border-left-color: mat-css-vars.mat-css-color-warn(500); + } + .mat-mdc-cell:first-of-type.dino-aggregation, .mat-mdc-header-cell:first-of-type.dino-aggregation { display: none; @@ -131,6 +227,9 @@ dino-list { min-height: 70px; height: 100%; display: inline-flex; + // The anchor of `.dino-row-status`: on a compact viewport the first cell is + // `display: contents` and generates no box for the badges to hang from. + position: relative; min-width: 100%; transition: box-shadow ease-in-out 0.25s; -webkit-transition: box-shadow ease-in-out 0.25s; @@ -141,15 +240,14 @@ dino-list { align-items: center; } dino-list-cell { + // The text follows the width of its column, which the User can resize: + // a fixed truncation width would make a widened column show no more + // text than a narrow one. .dino-text-cell-long { - max-width: 100px; + max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - // Wider truncation width on non-mobile viewports. - @media only screen and (min-width: 769px) { - max-width: 200px; - } } .dino-link-cell { color: mat-css-vars.mat-css-color-accent(700); @@ -194,9 +292,63 @@ dino-list { } } + // ---- Column resizing -------------------------------------------------------- + // The grip sits on the right edge of a header cell. It is invisible until the + // header is hovered, so that it does not compete with the column labels. + .dino-column-resize-grip { + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 7px; + cursor: col-resize; + // The pointer events of the grip are its own: the header cell is a drag + // source and, when sortable, a button. + touch-action: none; + z-index: 1; + + &::after { + content: ''; + position: absolute; + top: 25%; + bottom: 25%; + right: 3px; + width: 1px; + background: mat-css-vars.mat-css-color-accent(700, 0.4); + opacity: 0; + transition: opacity 0.15s ease; + } + + &:hover::after { + opacity: 1; + background: mat-css-vars.mat-css-color-accent(700); + } + + @media only screen and (max-width: 768px) { + display: none; + } + } + + .mat-mdc-header-row:hover .dino-column-resize-grip::after { + opacity: 1; + } + + // The checkbox column is the width of its checkbox and no more. The status of + // a row does not stand beside it - see `.dino-row-status` below - because a + // column has one width for every row, and a width kept for the two status + // icons would be empty space on every row that has nothing to report. + // A cell is `box-sizing: border-box`, so the border and the padding come out + // of that width: 3px border + 14px + 40px checkbox + 4px = 61px. The 14px is + // what keeps the checkbox clear of an 18px badge sitting on the border. .mat-column-select { - flex: 0 0 2% !important; - min-width: 143px; + flex: 0 0 61px !important; + min-width: 61px; + max-width: 61px; + padding-left: 14px; + padding-right: 4px; + .mat-mdc-checkbox.dino-row-actions { + flex: 0 0 auto; + } } .mat-mdc-form-field.dino-details-search { margin-top: 25px !important; @@ -227,18 +379,28 @@ dino-list { max-width: 0px; background-color: transparent !important; right: 25px !important; + // The cell has no width of its own, so the icons are taken out of the flow + // and centered on the right edge of the row. .dino-row-actions-container { + position: absolute; + top: 50%; + right: 4px; + transform: translateY(-50%); display: flex; justify-content: flex-end; + align-items: center; + gap: 4px; min-width: auto; + // Same icon size as the actions of the selection row above. .mat-icon { - height: 16px; - width: 16px; - font-size: 16px; + height: 24px; + width: 24px; + font-size: 24px; + line-height: 24px; background-color: mat-css-vars.mat-css-color-accent(100); border-radius: 100%; padding: 5px; - margin: 1px; + margin: 0; } } } @@ -260,11 +422,14 @@ dino-list { display: contents; } + // A keyboard user never hovers a row: its actions appear when one is focused. + .mat-mdc-row:not(.dino-row-details) .mat-column-actions:focus-within .dino-action-icon { + visibility: visible; + } + mat-cell:last-of-type.mat-column-actions { .dino-action-icon { visibility: hidden; - position: absolute; - right: calc((4 + (var(--position) * 28)) * 1px); } padding: 5px; cursor: pointer; @@ -396,44 +561,69 @@ dino-list { background: transparent !important; opacity: 1; } - .dino-invalid-form-icon, - .dino-upload-files-icon { - transition: all ease-in-out 0.25s; - -webkit-transition: all ease-in-out 0.25s; - font-size: 22px; - position: relative; - top: 2px; - &:hover { - background: transparent !important; - opacity: 1; - } - @media only screen and (max-width: 768px) { - position: absolute; - z-index: 1; + // The status of a row - it is invalid, it still has files to send - stacked on + // its left edge, out of the flow, so that it costs the checkbox column no + // width at all. There are at most two, and a row is 70px tall at its + // shortest, so the stack always has the room for them. + .dino-row-status { + position: absolute; + // Flush with the inner edge of the accent border, and no further left: the + // table scrolls horizontally, and anything before the row would be cut off + // by `.dino-table-container` rather than drawn over the border. + left: 0; + top: 50%; + transform: translateY(-50%); + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + // The stack is only as clickable as its badges: an empty one, or the gap + // between two, belongs to the checkbox behind it and to the row. + pointer-events: none; + > * { + pointer-events: auto; + } + } + + // Scoped to `.mat-mdc-table` to outweigh the `padding: 5px` that + // `_table-base.scss` gives every icon in a cell: a badge is an 18px box, and + // that padding would make it a 28px one. + .mat-mdc-table .dino-row-status { + .dino-invalid-form-icon, + .dino-upload-files-icon, + .dino-upload-files-spinner { + box-sizing: border-box; + flex: 0 0 auto; + } + + .dino-invalid-form-icon, + .dino-upload-files-icon { + transition: all ease-in-out 0.25s; + -webkit-transition: all ease-in-out 0.25s; + padding: 0; + width: 18px; + height: 18px; font-size: 18px; - left: 4px; - top: 2px; + line-height: 18px; + &:hover { + background: transparent !important; + opacity: 1; + } } - } - .dino-invalid-form-icon ~ .dino-upload-files-icon, - .dino-invalid-form-icon ~ .dino-upload-files-spinner { - @media only screen and (max-width: 768px) { - left: 26px; + .dino-invalid-form-icon { + cursor: default; } - } - - .dino-invalid-form-icon { - cursor: default; - } - .dino-upload-files-icon { - cursor: pointer; - &.dino-upload-files-icon--offline { - cursor: default; - opacity: 0.4; - &:hover { + .dino-upload-files-icon { + cursor: pointer; + &.dino-upload-files-icon--offline { + cursor: default; opacity: 0.4; + &:hover { + opacity: 0.4; + } } } } @@ -492,7 +682,9 @@ dino-list { @media only screen and (max-width: 768px) { .mat-column-select { + flex: 0 0 auto !important; min-width: 5px; + max-width: none; } .dino-row-actions { .dino-columns-sel-btn { @@ -580,3 +772,14 @@ dino-list { .isLightTheme .mat-mdc-header-row { background: var(--mat-sidenav-content-background-color) !important; } + +// While a column is resized the pointer keeps its cursor wherever it goes, and +// dragging over the table must not select its text. +body.dino-column-resizing { + cursor: col-resize; + user-select: none; + + * { + cursor: col-resize !important; + } +} diff --git a/projects/material/list/src/list.ts b/projects/material/list/src/list.ts index afeb914ac..7567a5f54 100644 --- a/projects/material/list/src/list.ts +++ b/projects/material/list/src/list.ts @@ -32,6 +32,7 @@ import { ContentChildren, ElementRef, EventEmitter, + inject, Inject, Input, OnDestroy, @@ -68,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'; @@ -94,6 +96,7 @@ import { withLatestFrom, } from 'rxjs/operators'; +import {ColumnResizeEvent} from './column-resize'; import {ColumnsSelector} from './columns-selector'; import {ListCell} from './list-cell'; import {ListContext} from './list-context'; @@ -115,6 +118,17 @@ import {BrowserDetectorService} from '@dino/material/browser-detector'; import {CdkDragDrop, moveItemInArray} from '@angular/cdk/drag-drop'; import {UI_TOUR_SERVICE_CONFIG, UITourConfig} from '@dino/material/ui-tour-service'; +/** + * Counts the lists, so that each one sizes the columns of its own table. + */ +let listInstances = 0; + +/** + * The columns that are not part of the data and keep their place at the ends + * of a row: they are neither dragged nor resized. + */ +const FIXED_COLUMNS: string[] = ['select', 'actions']; + /** * The material List component with row selection, extending the core List. * It is populated with data by its associated ListDataSource. @@ -346,6 +360,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 */ @@ -364,6 +393,19 @@ export class SelectionList @Input() bulkDeleteAction?: (row: any) => void; + /** + * Whether the selection row has anything to offer: the actions of a row live + * there and nowhere else, so it is displayed on a list without bulk actions + * too. It takes a list where a row can be selected - by its checkbox or by + * clicking it - and something to perform on the selection. + */ + get selectionActionsAvailable(): boolean { + const selectable = this.bulkActions || this._onClickRowActions.includes('select'); + const hasActions = + this.listRowActions.length > 0 || (this.bulkActions && !!this.bulkActionsAvailable?.length); + return selectable && hasActions; + } + /** * Non default table cell templates */ @@ -502,6 +544,26 @@ export class SelectionList */ 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, @@ -560,6 +622,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(); @@ -569,6 +636,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(); } @@ -642,10 +714,11 @@ export class SelectionList } /** - * Selects all the currently displayed items + * Selects all the currently displayed items. + * A list without bulk actions selects one row at a time. */ selectAll(): void { - if (this.dataSource == null) { + if (this.dataSource == null || !this.bulkActions) { return; } this.getDisplayedItems().forEach(row => this.selection.select(row)); @@ -771,7 +844,13 @@ export class SelectionList */ rowToggle(row: T): void { if (this._onClickRowActions.some(act => act === 'select')) { - this.selection.toggle(row); + if (this.bulkActions || this.selection.isSelected(row)) { + this.selection.toggle(row); + } else { + // Without the bulk actions there is nothing to perform on several rows + // at once: a click moves the selection instead of adding to it. + this.selection.setSelection(row); + } } if (this._onClickRowActions.some(act => act === 'expand')) { this.expansionRowsUpdate(row); @@ -823,12 +902,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); @@ -921,8 +1004,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'); } /** @@ -1361,6 +1546,31 @@ export class SelectionList return this._uploadingFilesRows.has((row as {[key: string]: any})['id']); } + /** + * The form behind the row was saved with missing or invalid answers. + */ + isInvalidRow(row: T): boolean { + const data = (row as {data?: {[key: string]: any}}).data; + return data != null && (data['dinoinvalid'] === true || data['$invalid'] === true); + } + + /** + * The row still holds files that never reached the server. + */ + hasFilesToUploadRow(row: T): boolean { + const data = (row as {data?: {[key: string]: any}}).data; + return data != null && data['dino_filestoupload'] === true; + } + + /** + * Whether the row has anything to report at all - which is what its bar and + * its badges are for. Kept here, and not spelled out in the template, so that + * the bar and the badges can never disagree on what counts. + */ + hasStatusRow(row: T): boolean { + return this.isInvalidRow(row) || this.hasFilesToUploadRow(row); + } + /** * Called when a row is edited inline (eg. a boolean toggle) */ @@ -1747,6 +1957,15 @@ 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 = compact ? 'auto' : '85vh'; + dialogConfig.maxHeight = '85vh'; + dialogConfig.autoFocus = false; let dialogRef = this.dialog.open(ExportList, dialogConfig); dialogRef.componentInstance.emitExportActionTrigger .pipe(take(1)) @@ -1783,8 +2002,13 @@ export class SelectionList } this._fts.clearModelFilters(); this._fts.clearCustomFilters(); + this._fts.storageKey = null; 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'; 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/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/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/search-filters-bar/src/search-filters-bar.html b/projects/material/search-filters-bar/src/search-filters-bar.html index ef3e54cfb..d1bb72310 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.html +++ b/projects/material/search-filters-bar/src/search-filters-bar.html @@ -1,45 +1,145 @@ - - - - - - -
- search -
-
- -
-
- - - - +
+ - - search - close + table_chart + {{'Data'|transloco}} + + + place + {{'Map'|transloco}} + + + auto_awesome + AI + + + + + + + search + close + + +
+ +
+ + + + + + + +
+ + + +
+
+
+ tune + {{'Filters'|transloco}} + + {{'Simple'|transloco}} + {{'Advanced'|transloco}} + +
+
+ + + +
+
+ +
+
+ +
+ +
+
+ + {{'All'|transloco}} + {{'Any'|transloco}} + + +
+ + + + + + + +
+
+
@@ -207,45 +307,13 @@ >
- - - - - - - - - - - - - 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..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,8 +25,8 @@ 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'; import {MatNativeDateModule} from '@angular/material/core'; import {MatDatepickerModule} from '@angular/material/datepicker'; @@ -39,12 +39,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'; @@ -57,6 +59,7 @@ import {SearchFiltersBar} from './search-filters-bar'; ExportListModule, MatAutocompleteModule, MatButtonModule, + MatButtonToggleModule, MatCheckboxModule, MatDatepickerModule, MatDialogModule, @@ -67,14 +70,15 @@ import {SearchFiltersBar} from './search-filters-bar'; MatListModule, MatNativeDateModule, MatPaginatorModule, - 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 c44491637..eaca31ab3 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; @@ -23,24 +25,127 @@ dino-search-filters-bar { margin: auto; } } - .dino-filters-dialog-button, - .dino-form-map-button, - .dino-export-button { - flex: 1 1 auto; - height: 44px; - margin-right: 5px; - .mat-icon { - margin-left: 0px; - margin-right: 0px; - 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; + } + + // 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 { + .mat-mdc-button-base { + height: 40px; + } + } + + // 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; + .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; + cursor: pointer; + } + } + + .dino-filters-toolbar-right { + .dino-filters-dialog-button .mat-icon, + .dino-export-button .mat-icon { + margin: 0 4px 0 0; + } + + // Same button radius as the other sections, i.e. the import wizard. + .mat-mdc-button-base { + border-radius: 8px; + .mat-mdc-button-persistent-ripple, + .mat-mdc-button-persistent-ripple::before, + .mdc-button__ripple { + border-radius: 8px; + } + } + } + + .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; + } + .dino-open-aggregation-filters { display: flex; flex: 1 0 auto; @@ -65,19 +170,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 +186,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; + } } } } @@ -123,3 +215,121 @@ 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: 18px; + font-weight: 600; + } + + // 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 { + 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 { + display: flex; + align-items: center; + gap: 8px; + .mat-icon { + margin-right: 4px; + } + // Same radius as every other button of the section. + .mat-mdc-button-base { + border-radius: 8px; + } + } + + .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.spec.ts b/projects/material/search-filters-bar/src/search-filters-bar.spec.ts index 16e986e03..01253b3c5 100644 --- a/projects/material/search-filters-bar/src/search-filters-bar.spec.ts +++ b/projects/material/search-filters-bar/src/search-filters-bar.spec.ts @@ -71,7 +71,7 @@ describe('Search filters Bar', () => { const spyOpenDialog = spyOn(bar.dialog, 'open').and.callThrough(); fixtureBar.detectChanges(); - bar.openDialog(); + bar.openFiltersDialog(); expect(spyFtsResetTemp).toHaveBeenCalled(); expect(spyOpenDialog).toHaveBeenCalled(); 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..308e26077 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,12 +31,13 @@ import { OnInit, Optional, Output, + TemplateRef, + ViewChild, ViewEncapsulation, } from '@angular/core'; -import {UntypedFormGroup} from '@angular/forms'; -import {MatBottomSheet} from '@angular/material/bottom-sheet'; +import {UntypedFormControl, UntypedFormGroup} from '@angular/forms'; 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'; @@ -45,6 +47,7 @@ import { FilterItem, FilterListType, FiltersService, + NULL_OPERATORS, SearchFiltersComponent, } from '@dino/core/list'; import {LocationManager} from '@dino/core/locations'; @@ -52,13 +55,21 @@ 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 {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, + defer, + Observable, + of as obsOf, + Subject, + Subscription, + throwError, +} from 'rxjs'; import { catchError, debounceTime, + filter, map, startWith, switchMap, @@ -67,6 +78,12 @@ import { withLatestFrom, } from 'rxjs/operators'; +/** + * The available views of a form-data section: the list (Dati), the map (Mappa) + * and the DataChat (AI). + */ +export type FormDataView = 'table' | 'map' | 'ai'; + /** * Opt-in component that handles all SelectionList filters. * The filters are obtained by parsing the RxJsonSchema of the model and the ajfFormSchema, @@ -114,15 +131,90 @@ 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 Data/Map/AI 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; + + /** + * The form-data view of the current route: 'table' (Dati), 'map' (Mappa) or + * 'ai' (DataChat). Drives the active state of the view switcher. + */ + currentView$: Observable; + + /** + * Emits true when the current route is the form AI (DataChat) view. + * On that view only the switcher is displayed: filters, export and the + * projected toolbar actions are hidden. + */ + isAiView$: Observable; + /** * The Filter Service Generated filters */ 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'; + + /** + * The basic filters that get no chip: the keyword field is always visible in + * the bar, displaying its own value with a button to clear it. + */ + readonly chipsHiddenFilters: string[] = ['keyword']; + + /** + * 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. */ @@ -169,6 +261,35 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, this._presetManager = state; } + /** + * If true, only the view switcher (Dati/Mappa/AI) is displayed and no filter + * is initialized. Used by the AI (DataChat) view, which has no filterable list. + */ + private _viewSwitcherOnly = false; + get viewSwitcherOnly(): boolean { + return this._viewSwitcherOnly; + } + @Input() + set viewSwitcherOnly(state: boolean) { + 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; @@ -201,11 +322,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 */ @@ -223,7 +339,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, @@ -273,6 +388,46 @@ 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.currentView$ = this._router.events.pipe( + filter(e => e instanceof NavigationEnd), + startWith(null), + map(() => { + const path = this._router.url.split('?')[0]; + if (path.endsWith('/map')) { + return 'map'; + } + if (path.endsWith('/datachat')) { + return 'ai'; + } + return 'table'; + }), + ); + + this.isAiView$ = this.currentView$.pipe(map(view => view === 'ai')); + + // 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) @@ -299,6 +454,9 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, >(); ngOnInit() { + if (this._viewSwitcherOnly) { + return; + } this.initFilters(); } @@ -317,28 +475,178 @@ 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}); + } + + /** + * Drops every filter of the section: the simple ones, the advanced ones and + * the ones staged in the Advanced tab. The modal stays open, with its fields + * empty, so that new filters can be set right away. + */ + resetFilters(): void { + // The fields are emptied without notifying: every filter they stand for is + // dropped right after, at once. + this.basicFilters.forEach(group => group.reset({}, {emitEvent: false})); + this._fts.additionalFiltersLogic.next('and'); + this.logicAndOrToggle.setValue('and', {emitEvent: false}); + // Empties the applied filters, which clears the url and the filters stored + // for this section. + this._fts.loadPreset(); + this._fts.resetTemporaryFilters(); + } + + /** + * 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 the filter a chip stands for: a basic one by emptying the field it + * comes from, so that the field and its chip are cleared together, an + * additional one by dropping it from the applied filters. + * @param filterItem The filter of the chip being removed + */ + removeChip(filterItem: FilterItem): void { + if (filterItem.isBasicFilter) { + const group = this.basicFilters.find(fg => fg.get(filterItem.name) != null); + if (group != null) { + this.clearFilter(filterItem.name, group); + return; + } + } + this.removeFilter(filterItem, ['additional']); + } + + /** + * 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 */ @@ -347,26 +655,57 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, } /** - * Open bottom sheet with export options + * Switches between the Data, Map and AI views of the current form schema, + * preserving the active filters (carried in the `?filters=` query param). */ - openExportBottomSheet(): void { - this._bottomSheet - .open(ExportBottomSheet) - .afterDismissed() - .subscribe((ev: 'XLSX' | 'CSV' | 'dialog' | null) => { - if (ev != null) { - this.exportEvt.emit(ev); - } - }); + switchView(view: FormDataView): void { + if (view === 'map') { + this.viewMap(); + } else if (view === 'ai') { + this.viewDataChat(); + } else { + this.viewList(); + } } /** - * Redirects to the forms' View Map component + * 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', + }); + } + }); + } + + /** + * Redirects to the forms' DataChat (AI) component. + * Preserves the `?filters=` query param so the current filter transfers. + */ + viewDataChat(): void { + this._route.params.pipe(take(1)).subscribe(params => { + if (params['form_schema_id']) { + this._router.navigate(['forms', params['form_schema_id'], 'datachat'], { + queryParamsHandling: 'preserve', + }); } }); } @@ -590,6 +929,40 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, } } + /** + * The terms an options field (a metric, a status, a user, a user group) is + * searched by: what the user types in it and, on every new subscription, the + * term it holds right away. + * + * The Filters dialog is built anew each time it is opened, so its option + * lists subscribe again from scratch: fed by the value changes alone they + * would stay empty until the field was edited, and a field displaying an + * option already chosen - which is an object, not a term - would list nothing + * at all and could not be used any more. Such a field searches the whole + * list instead. + * @param inputControl The FormGroup of the field + * @param controlName The name of the field control + * @param debounce The milliseconds the typing is debounced by. Defaults to 0, + * no debounce. The term the field already holds is never debounced: the + * options are listed as soon as the field is displayed. + * @returns The terms the field is searched by, null if the field is missing + */ + private _searchTerms( + inputControl: UntypedFormGroup | undefined, + controlName: string, + debounce: number = 0, + ): Observable | null { + const control = inputControl?.get(controlName); + if (control == null) { + return null; + } + return defer(() => { + const typed = + debounce > 0 ? control.valueChanges.pipe(debounceTime(debounce)) : control.valueChanges; + return typed.pipe(startWith(typeof control.value === 'string' ? control.value : '')); + }); + } + /** * Populates the autocomplete panels of metric filters with options * @param metricType The type of metric @@ -605,14 +978,9 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, return; } const inputControl = this.additionalBasicFilters.find(group => group.get(metricType) != null); - const inputStartingValue = inputControl?.get(metricType)?.value; - let inputValue = inputControl?.get(metricType)?.valueChanges; - if (typeof inputStartingValue === 'string') { - inputValue = inputValue?.pipe(startWith(inputStartingValue)); - } + const inputValue = this._searchTerms(inputControl, metricType, 800); if (inputValue != null) { this.metricFiltersOptions[metricType] = inputValue.pipe( - debounceTime(800), switchMap(metricValue => { if (typeof metricValue === 'string') { let mtQuery: DataQueryOptions = { @@ -721,7 +1089,7 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, const inputControl = this.additionalBasicFilters.find( group => group.get('form_status') != null, ); - const inputValue = inputControl?.get('form_status')?.valueChanges; + const inputValue = this._searchTerms(inputControl, 'form_status'); if (inputValue != null) { this.formStatusFilterOptions = combineLatest([inputValue, this.availableFormStatuses]).pipe( switchMap(([inputVal, options]) => { @@ -747,7 +1115,7 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, */ private _populateUserDataOptions(): void { const inputControl = this.additionalBasicFilters.find(group => group.get('user_data') != null); - const inputValue = inputControl?.get('user_data')?.valueChanges; + const inputValue = this._searchTerms(inputControl, 'user_data'); if (inputValue != null) { this.usersFilterOptions = inputValue.pipe( switchMap(inputVal => { @@ -776,7 +1144,7 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, */ private _populateUserGroupOptions(): void { const inputControl = this.additionalBasicFilters.find(group => group.get('user_group') != null); - const inputValue = inputControl?.get('user_group')?.valueChanges; + const inputValue = this._searchTerms(inputControl, 'user_group'); if (inputValue != null) { this.userGroupsFilterOptions = inputValue.pipe( switchMap(inputVal => { @@ -847,7 +1215,7 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, inputControl.setValue({ [metricType]: { id: [...new Set([parentMetric.id, ...allDescendants, ...multipleIds])], - name: multipleName.join(), + name: multipleName.join(', '), secondary: this.getMetricDataSecondaryAttribute( parentMetric, this.secondaryMetricFieldsDisplayed, @@ -866,7 +1234,7 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, inputControl.setValue({ [metricType]: { id: [...multipleIds], - name: multipleName.join(), + name: multipleName.join(', '), secondary: null, }, [`${metricType}_multiple`]: [...filteredMultiple], @@ -1083,6 +1451,8 @@ export class SearchFiltersBar extends SearchFiltersComponent implements OnInit, ngOnDestroy() { this._dialogSub.unsubscribe(); + this._logicToggleSub.unsubscribe(); + this._currentGroupId.complete(); this._mainUnsubscribe.next(); this._mainUnsubscribe.complete(); } diff --git a/projects/material/search-filters-chips/src/search-filters-chips.html b/projects/material/search-filters-chips/src/search-filters-chips.html index 792b881dd..f05eabfc2 100644 --- a/projects/material/search-filters-chips/src/search-filters-chips.html +++ b/projects/material/search-filters-chips/src/search-filters-chips.html @@ -1,14 +1,21 @@ - + - - {{ (item.label ? item.label : item.name)|transloco|uppercase|slice:0:30 }} + + {{ chipLabel(item)|transloco|uppercase|slice:0:30 }} - {{ item?.operator?.label ? item?.operator?.label: ' '|transloco}} - - {{ item.value|dinoDateValue|json }} + + {{ chipValue(item)|dinoDateValue }} + + {{ item?.operator?.label ? item?.operator?.label: ' '|transloco}} + + + {{ item.value|dinoDateValue|json }} + + + cancel - \ No newline at end of file + diff --git a/projects/material/search-filters-chips/src/search-filters-chips.scss b/projects/material/search-filters-chips/src/search-filters-chips.scss index 24182f280..76a078bd5 100644 --- a/projects/material/search-filters-chips/src/search-filters-chips.scss +++ b/projects/material/search-filters-chips/src/search-filters-chips.scss @@ -1,3 +1,9 @@ dino-search-filters-chips { min-width: 100%; + + // The field a chip filters on, told apart from the value it is filtered by. + .dino-chip-field { + margin-right: 5px; + font-weight: 600; + } } diff --git a/projects/material/search-filters-chips/src/search-filters-chips.ts b/projects/material/search-filters-chips/src/search-filters-chips.ts index 549a871f5..c65c92540 100644 --- a/projects/material/search-filters-chips/src/search-filters-chips.ts +++ b/projects/material/search-filters-chips/src/search-filters-chips.ts @@ -56,6 +56,13 @@ export class SearchFiltersChips implements OnInit { */ @Input() chipsType: FilterListType = 'basic'; + /** + * The names of the basic filters that get no chip, because the component + * hosting the chips already displays them (eg. the keyword field of the + * filters bar, always visible with its own value and clear button). + */ + @Input() hiddenFilterNames: string[] = []; + /** * Event emitted when a chip is deleted. */ @@ -71,7 +78,7 @@ export class SearchFiltersChips implements OnInit { ngOnInit() { switch (this.chipsType) { case 'basic': - this.chipsFilters = this._fts.basicFilters; + this.chipsFilters = this._fts.basicFilters.pipe(map(basic => this._markBasicFilters(basic))); break; case 'additional': this.chipsFilters = this._fts.additionalFilters; @@ -84,17 +91,104 @@ export class SearchFiltersChips implements OnInit { this.chipsFilters = combineLatest([ this._fts.basicFilters, this._fts.additionalFilters, - ]).pipe(map(([basic, additional]) => basic.concat(additional))); + ]).pipe(map(([basic, additional]) => this._markBasicFilters(basic).concat(additional))); break; } // Here we make sure that invalid filters or filters with null / empty values // are not displayed as chips this.chipsFilters = this.chipsFilters.pipe( - map(filters => filters.filter(cf => cf.isValid)), + map(filters => filters.filter(cf => this._isDisplayed(cf))), catchError(err => throwError(() => err) as Observable), ); } + /** + * The label displayed by a chip: the name of the field the filter comes from. + * @param filterItem The filter item of the chip + * @returns The label to display, still to be translated + */ + chipLabel(filterItem: FilterItem): string { + if (!filterItem.isBasicFilter) { + return filterItem.label ? filterItem.label : filterItem.name; + } + if (filterItem.name === 'dateStart') { + return 'From date'; + } + if (filterItem.name === 'dateEnd') { + return 'To date'; + } + // The same transformation the filters bar applies to the placeholders of the + // basic filter fields: 'user_data' reads 'User', 'form_status' reads + // 'Form status'. + return ( + filterItem.name.charAt(0).toUpperCase() + + filterItem.name.slice(1).replace('_', ' ').replace('data', '') + ).trim(); + } + + /** + * The value displayed by the chip of a basic filter: the same the field it + * comes from displays, since that field is not visible once the filters + * dialog is closed. + * @param filterItem The basic filter item of the chip + * @returns The value to display, a Date when the filter is a date one + */ + chipValue(filterItem: FilterItem): any { + const value = filterItem.value; + if (value == null) { + return ''; + } + if (typeof value !== 'object' || value instanceof Date) { + return value; + } + const item = value as {[key: string]: any}; + // Form statuses + if (item['label'] && item['name'] && item['id']) { + return item['label']; + } + // Users + if (item['full_name']) { + return item['full_name']; + } + // User groups + if (item['groupName']) { + return item['groupName']; + } + // Metrics, either a single option or a multiple selection + if (item['name']) { + return item['secondary'] ? `${item['name']} - (${item['secondary']})` : item['name']; + } + return ''; + } + + /** + * Marks the filters of the basic list, whose chips are labelled and valued + * after the field they come from, dropping the ones the host component + * displays on its own. + * @param filters The basic filters + * @returns The basic filters to be displayed as chips + */ + private _markBasicFilters(filters: FilterItem[]): FilterItem[] { + return filters + .filter(ft => this.hiddenFilterNames.indexOf(ft.name) < 0) + .map(ft => ({...ft, isBasicFilter: true})); + } + + /** + * Checks if a filter is to be displayed as a chip. A basic filter keeps its + * place in the list once its field has been used, so only the ones actually + * carrying a value get a chip. + * @param filterItem The filter item + * @returns True if the filter is to be displayed + */ + private _isDisplayed(filterItem: FilterItem): boolean { + if (!filterItem.isBasicFilter) { + return filterItem.isValid === true; + } + const value = this.chipValue(filterItem); + return value !== '' && value != null; + } + /** * Checks if the value is an array with null * @param value The value to check 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'],