Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0906a50
feat(forms-map): unify Table/Map search filter and restyle filter too…
tulas75 Jul 22, 2026
9dd9440
feat(list/search-filters-bar): form-list toolbar, unified Filtri moda…
tulas75 Jul 22, 2026
7de0ee7
feat(datachat): AI tab in the form-data section, permanent chats, cha…
tulas75 Aug 7, 2026
a0333a6
feat(material/datachat): table previews, csv export and charts
tulas75 Aug 7, 2026
b10bfbe
style: theme the snackbars and unify the button radius
tulas75 Aug 7, 2026
58e6d77
feat(material/list): bring back the quick actions of a row
tulas75 Aug 7, 2026
801b006
perf(material/datachat): read the credits only when they can have cha…
tulas75 Aug 7, 2026
4d10ce2
feat(material/list): resizable columns, and reset the customized ones
tulas75 Aug 7, 2026
727e5b6
feat(list sections): give back the keyword search, and move the actio…
tulas75 Aug 7, 2026
7894f9d
feat(core/list): remember the filters of every section
tulas75 Aug 7, 2026
d13039b
feat(material/export-list): redesign the export dialog
tulas75 Aug 9, 2026
551b313
style(search-filters-bar): align the Filters modal header with the ex…
tulas75 Aug 9, 2026
0d06497
style(search-filters-bar): the All/Any logic toggle as the other segm…
tulas75 Aug 9, 2026
c92baa9
fix(material/export-list): let the metrics export dialog take the hei…
tulas75 Aug 9, 2026
2d0b5d7
feat(material/export-list): the whole label of a field on hover
tulas75 Aug 9, 2026
09caf0e
fix(material/list): keep the row status out of the checkbox column
tulas75 Sep 6, 2026
022100e
feat(material/search-filters-bar): a chip for every applied filter, s…
tulas75 Sep 8, 2026
d88fff4
style(material/search-filters-bar): the names of a multiple metric se…
tulas75 Sep 8, 2026
b6e04c1
fix(material/search-filters-bar): let a field with an option already …
tulas75 Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@
"options": {
"serviceWorker": true,
"allowedCommonJsDependencies": [
"chart.js",
"crypto-js/aes",
"crypto-js/enc-utf8",
"deep-equal",
Expand Down
101 changes: 99 additions & 2 deletions projects/core/list/src/filters-service.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});
73 changes: 71 additions & 2 deletions projects/core/list/src/filters.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,17 @@ export class FiltersService<T extends Model = Model> {
*/
private _loadPresetEvent: EventEmitter<boolean>;

/**
* 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<boolean> {
return this._loadPresetEvent;
}
Expand Down Expand Up @@ -271,9 +282,22 @@ export class FiltersService<T extends Model = Model> {
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([
Expand Down Expand Up @@ -805,9 +829,54 @@ export class FiltersService<T extends Model = Model> {
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
Expand Down
5 changes: 5 additions & 0 deletions projects/core/list/src/list-filters-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ export interface FilterItem extends Partial<AjfBaseField> {
* 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
*/
Expand Down
5 changes: 5 additions & 0 deletions projects/core/list/src/list-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export interface ListHeader<T> {
* 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,
Expand Down
54 changes: 39 additions & 15 deletions projects/core/list/src/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -98,6 +99,7 @@ export abstract class List<T extends Model = Model, AD extends Model = Model> {
...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) {
Expand All @@ -110,6 +112,12 @@ export abstract class List<T extends Model = Model, AD extends Model = Model> {
*/
protected _headers: BehaviorSubject<ListHeader<T>[]> = new BehaviorSubject<ListHeader<T>[]>([]);

/**
* The column headers as they are given to the list, before the columns
* preferences of the User are applied to them
*/
protected _defaultHeaders: ListHeader<T>[] = [];

get headers(): ListHeader<T>[] {
return this._headers.value;
}
Expand All @@ -134,6 +142,25 @@ export abstract class List<T extends Model = Model, AD extends Model = Model> {
*/
@Input()
set headers(headers: ListHeader<T>[]) {
// 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<T>[]): void {
const loadedPreset = this._loadColumnsSelectionPreset();
const loadedHeaders = loadedPreset?.columns.map(loadedHeader => {
const defaultHeader = headers.find(h => h.column === loadedHeader.column);
Expand Down Expand Up @@ -309,26 +336,23 @@ export abstract class List<T extends Model = Model, AD extends Model = Model> {
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);
}

/**
Expand Down
1 change: 1 addition & 0 deletions projects/core/list/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 50 additions & 0 deletions projects/core/list/src/section-storage-key.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading