diff --git a/cypress/e2e/datasets/datasets-general.cy.js b/cypress/e2e/datasets/datasets-general.cy.js index 49dd5e485b..9ba4674077 100644 --- a/cypress/e2e/datasets/datasets-general.cy.js +++ b/cypress/e2e/datasets/datasets-general.cy.js @@ -495,27 +495,21 @@ describe("Datasets general", () => { }); cy.readFile("CI/e2e/frontend.config.e2e.json").then((baseConfig) => { - const relationsToTest = [ - { relation: "GREATER_THAN", rhs: 1 }, - { relation: "LESS_THAN", rhs: 3 }, - { relation: "EQUAL_TO_NUMERIC", rhs: 2 }, - { relation: "GREATER_THAN_OR_EQUAL", rhs: 2 }, - { relation: "LESS_THAN_OR_EQUAL", rhs: 2 }, - { relation: "RANGE", rhs: [1, 3] }, - ]; const testConfig = { ...baseConfig, defaultDatasetsListSettings: { ...baseConfig.defaultDatasetsListSettings, - conditions: relationsToTest.map(({ relation, rhs }) => ({ - condition: { - lhs: "extra_entry_end_time", - relation, - rhs, - unit: "", + conditions: [ + { + condition: { + lhs: "extra_entry_end_time", + relation: "GREATER_THAN", + rhs: 1, + unit: "", + }, + enabled: true, }, - enabled: true, - })), + ], }, }; @@ -536,20 +530,16 @@ describe("Datasets general", () => { cy.get(".dataset-table mat-row").first().click(); cy.get(".metadataTable", { timeout: 10000 }).scrollIntoView(); - cy.get(".metadataTable mat-row").within(() => { - cy.get(".mat-column-human_name label") - .invoke("text") - .then((fieldName) => { - if (fieldName && fieldName.trim() === "Extra Entry End Time") { - cy.get(".mat-column-value label") - .invoke("text") - .then((valueText) => { - const value = parseFloat(valueText.trim()); - expect(value).to.be.greaterThan(1); - }); - } - }); - }); + cy.get(".metadataTable mat-row .mat-column-value label") + .invoke("text") + .then((valueText) => { + const value = parseFloat(valueText.trim()); + expect(value).to.be.greaterThan(1); + }); + + cy.visit("/datasets"); + cy.get(".condition-panel").first().click(); + cy.get('[data-cy="remove-condition-button"]').click(); }); }); @@ -651,7 +641,7 @@ describe("Datasets general", () => { cy.visit("/datasets"); - cy.wait("@getConfig", { timeout: 10000 }); + cy.wait("@getConfig", { timeout: 20000 }); cy.finishedLoading(); }); @@ -670,4 +660,67 @@ describe("Datasets general", () => { .should("be.visible"); }); }); + + describe("Conditions in multiple pages", () => { + beforeEach(() => { + cy.login(Cypress.env("username"), Cypress.env("password")); + }); + + it("should preverse dataset conditions when clearing sample conditions", () => { + + cy.createDataset({ + type: "raw", + scientificMetadata: { + extra_entry_end_time: { type: "number", value: 5, unit: "" }, + }, + }); + const sampleId = Math.floor(100000 + Math.random() * 900000).toString(); + cy.createSample({...testData.sample, sampleId}); + + cy.visit("/datasets"); + cy.finishedLoading(); + + cy.get('[data-cy="add-condition-button"]').click(); + cy.get('input[name="lhs"]').type("extra_entry_end_time"); + cy.get("mat-dialog-container").find('button[type="submit"]').click(); + + cy.get(".condition-panel").should("have.length", 1); + + cy.visit("/samples"); + cy.finishedLoading(); + + cy.get('[data-cy="add-condition-button"]').click(); + cy.get('input[name="lhs"]').type("test_characteristic"); + cy.get("mat-dialog-container").find('button[type="submit"]').click(); + + cy.get(".condition-panel").should("have.length", 1); + + cy.get(".condition-panel").first().click(); + cy.get(".condition-panel") + .first() + .within(() => { + cy.get("input[matInput]").eq(0).clear().type("10"); + }); + cy.get('[data-cy="samples-filters-search-button"]').click(); + + // Clear conditions on samples page + cy.get('[data-cy="samples-filters-clear-button"]').click(); + + cy.get(".condition-panel").should("have.length", 0); + + // Navigate back to datasets and verify condition is still there + cy.visit("/datasets"); + cy.finishedLoading(); + + cy.get(".condition-panel").should("have.length", 1); + cy.get(".condition-panel").should("contain.text", "extra_entry_end_time"); + + cy.get(".condition-panel").first().click(); + cy.get('[data-cy="remove-condition-button"]').click(); + }); + afterEach(() => { + cy.removeSamples(); + }) + }); + }); diff --git a/cypress/fixtures/testData.js b/cypress/fixtures/testData.js index 0d44199aae..47812207dd 100644 --- a/cypress/fixtures/testData.js +++ b/cypress/fixtures/testData.js @@ -70,6 +70,21 @@ export const testData = { main_user: "ESS", }, }, + sample: { + ownerGroup: "ess", + accessGroups: ["string"], + instrumentGroup: "string", + sampleId: "string", + owner: "string", + description: "Cypress Sample", + type: "string", + proposalId: "cypress", + parentSampleId: "string", + sampleCharacteristics: { + test_characteristic: { type: "number", value: 10, unit: "" }, + }, + isPublished: false, + }, rawDataset: { principalInvestigator: "string", endTime: "2019-10-31T14:44:46.143Z", diff --git a/cypress/support/commands.js b/cypress/support/commands.js index fe83913f91..38034a3efd 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -215,6 +215,28 @@ Cypress.Commands.add("createInstrument", (instrument) => { }); }); +Cypress.Commands.add("createSample", (sample) => { + return cy.getCookie("user").then((userCookie) => { + const user = JSON.parse(decodeURIComponent(userCookie.value)); + + cy.getToken().then((token) => { + cy.log("Sample: " + JSON.stringify(sample, null, 2)); + cy.log("User: " + JSON.stringify(user, null, 2)); + + cy.request({ + method: "POST", + url: lbBaseUrl + "/Samples", + headers: { + Authorization: token, + Accept: "application/json", + "Content-Type": "application/json", + }, + body: sample, + }); + }); + }); +}); + Cypress.Commands.add("updateProposal", (proposalId, updateProposalDto) => { return cy.getCookie("user").then((userCookie) => { const user = JSON.parse(decodeURIComponent(userCookie.value)); diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.html b/src/app/datasets/datasets-filter/datasets-filter.component.html index fb6bebb561..d35d48af82 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.html +++ b/src/app/datasets/datasets-filter/datasets-filter.component.html @@ -61,308 +61,19 @@ (numericRangeChange)="numericRangeChange(filter.key, $event)" > -
-
- Conditions - -
- - - - -
-
- -
- -
- {{ getHumanName(conditionConfig.condition.lhs) }} -
-
- {{ conditionConfig.condition.lhs }} -
-
- -
- {{ conditionConfig.condition.lhs }} -
-
-
- - - - - - Name Details - -
- - Human readable name: - {{ getHumanName(conditionConfig.condition.lhs) }} -
- Metadata key: - {{ conditionConfig.condition.lhs }} -
-
- - Metadata key: {{ conditionConfig.condition.lhs }} - -
-
-
-
- {{ - getConditionDisplayText(conditionConfig.condition, i) - }} -
-
-
- - {{ getConditionDisplayText(conditionConfig.condition, i) }} - -
- -
- - Operator - - - - {{ - conditionConfig.condition.relation === "GREATER_THAN" - ? "is greater than" - : conditionConfig.condition.relation === "LESS_THAN" - ? "is less than" - : getOperatorUIValue( - conditionConfig.condition.relation - ) === "EQUAL_TO" - ? "is equal to" - : conditionConfig.condition.relation === - "GREATER_THAN_OR_EQUAL" - ? "is greater than or equal to" - : conditionConfig.condition.relation === - "LESS_THAN_OR_EQUAL" - ? "is less than or equal to" - : conditionConfig.condition.relation === "RANGE" - ? "is in range" - : "" - }} - - {{ - conditionConfig.condition.relation === "GREATER_THAN" - ? "( > )" - : conditionConfig.condition.relation === "LESS_THAN" - ? "( < )" - : getOperatorUIValue( - conditionConfig.condition.relation - ) === "EQUAL_TO" - ? "( = )" - : conditionConfig.condition.relation === - "GREATER_THAN_OR_EQUAL" - ? "( ≥ )" - : conditionConfig.condition.relation === - "LESS_THAN_OR_EQUAL" - ? "( ≤ )" - : conditionConfig.condition.relation === - "RANGE" - ? "( <-> )" - : "" - }} - - - - - - is equal to - ( = ) - is greater than - ( > ) - is less than - ( < ) - is greater than or equal to - ( ≥ ) - is less than or equal to - ( ≤ ) - is in range - ( <-> ) - - - - - - - Value - - - - - Min - - - - Max - - - - - - Unit - - - - {{ unit }} - - - - - -
- - {{ conditionConfig.enabled ? "Enabled" : "Disabled" }} - - - -
-
-
-
-
+ [showConditions]="true" + [metadataKeys]="metadataKeys$ | async" + [unitsEnabled]="appConfig.scienceSearchUnitsEnabled" + [showConditionToggle]="true" + [conditionType]="'datasets'" + [addConditionAction]="addCondition" + [removeConditionAction]="removeCondition" + >
- - - - {{ characteristic.lhs }} - - -  =  - - -  =  - - -  <  - - -  >  - - - {{ - characteristic.relation === "EQUAL_TO_STRING" - ? '"' + characteristic.rhs + '"' - : characteristic.rhs - }} - {{ characteristic.unit | prettyUnit }} - - cancel - - - + search + Apply +
diff --git a/src/app/samples/sample-dashboard/sample-dashboard.component.scss b/src/app/samples/sample-dashboard/sample-dashboard.component.scss index 4b7b58306a..2cf1e3fd74 100644 --- a/src/app/samples/sample-dashboard/sample-dashboard.component.scss +++ b/src/app/samples/sample-dashboard/sample-dashboard.component.scss @@ -57,3 +57,13 @@ } } } + +.section-container { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + + button { + flex: 1; + } +} diff --git a/src/app/samples/sample-dashboard/sample-dashboard.component.ts b/src/app/samples/sample-dashboard/sample-dashboard.component.ts index 600dd454ea..92a5951be9 100644 --- a/src/app/samples/sample-dashboard/sample-dashboard.component.ts +++ b/src/app/samples/sample-dashboard/sample-dashboard.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { Component, OnInit, OnDestroy, ViewChild } from "@angular/core"; import { Store } from "@ngrx/store"; import { SampleClass } from "@scicatproject/scicat-sdk-ts-angular"; import { @@ -8,8 +8,6 @@ import { setTextFilterAction, prefillFiltersAction, fetchMetadataKeysAction, - addCharacteristicsFilterAction, - removeCharacteristicsFilterAction, } from "state-management/actions/samples.actions"; import { BehaviorSubject, combineLatest, Subscription } from "rxjs"; import { selectSampleDashboardPageViewModel } from "state-management/selectors/samples.selectors"; @@ -21,7 +19,6 @@ import { SampleDialogComponent } from "samples/sample-dialog/sample-dialog.compo import deepEqual from "deep-equal"; import { filter, map, distinctUntilChanged, take } from "rxjs/operators"; import { SampleFilters } from "state-management/models"; -import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; import { AppConfigService } from "app-config.service"; import { TableField } from "shared/modules/dynamic-material-table/models/table-field.model"; import { @@ -39,10 +36,19 @@ import { TableEventType, TableSelectionMode, } from "shared/modules/dynamic-material-table/models/table-row.model"; -import { updateUserSettingsAction } from "state-management/actions/user.actions"; +import { + updateConditionsConfigs, + updateUserSettingsAction, +} from "state-management/actions/user.actions"; import { Sort } from "@angular/material/sort"; import { TableConfigService } from "shared/services/table-config.service"; import { actionMenu } from "shared/modules/dynamic-material-table/utilizes/default-table-settings"; +import { SharedFilterComponent } from "shared/modules/shared-filter/shared-filter.component"; +import { + addCharacteristicsFilterAction, + removeCharacteristicsFilterAction, +} from "state-management/actions/samples.actions"; +import { ScientificCondition } from "state-management/models"; @Component({ selector: "sample-dashboard", @@ -53,6 +59,8 @@ import { actionMenu } from "shared/modules/dynamic-material-table/utilizes/defau export class SampleDashboardComponent implements OnInit, OnDestroy { vm$ = this.store.select(selectSampleDashboardPageViewModel); + @ViewChild("conditionFilter") conditionFilter: SharedFilterComponent; + tableDefaultSettingsConfig: ITableSetting = { visibleActionMenu: actionMenu, settingList: [ @@ -136,11 +144,11 @@ export class SampleDashboardComponent implements OnInit, OnDestroy { constructor( private appConfigService: AppConfigService, private datePipe: DatePipe, - public dialog: MatDialog, private route: ActivatedRoute, private router: Router, private store: Store, private tableConfigService: TableConfigService, + public dialog: MatDialog, ) {} ngOnInit() { @@ -208,6 +216,18 @@ export class SampleDashboardComponent implements OnInit, OnDestroy { ); } + addCondition = (condition: ScientificCondition) => { + this.store.dispatch( + addCharacteristicsFilterAction({ characteristic: condition }), + ); + }; + + removeCondition = (condition: ScientificCondition) => { + this.store.dispatch( + removeCharacteristicsFilterAction({ lhs: condition.lhs }), + ); + }; + formatTableData(samples: SampleClass[]): any { if (samples) { return samples.map((sample) => ({ @@ -233,24 +253,24 @@ export class SampleDashboardComponent implements OnInit, OnDestroy { } } - openSearchParametersDialog() { - this.dialog - .open(SearchParametersDialogComponent, { - data: { parameterKeys: this.metadataKeys }, - }) - .afterClosed() - .subscribe((res) => { - if (res) { - const { data } = res; - this.store.dispatch( - addCharacteristicsFilterAction({ characteristic: data }), - ); - } - }); + applyFilters() { + if (this.conditionFilter) { + this.conditionFilter.applyConditions(); + } + this.store.dispatch(fetchSamplesAction()); } - removeCharacteristic(index: number) { - this.store.dispatch(removeCharacteristicsFilterAction({ index })); + reset() { + this.store.dispatch(setTextFilterAction({ text: "" })); + + this.vm$.pipe(take(1)).subscribe((vm) => { + vm.characteristicsFilter?.forEach((c) => { + this.store.dispatch(removeCharacteristicsFilterAction({ lhs: c.lhs })); + }); + }); + this.conditionFilter?.clearConditions(); + + this.store.dispatch(fetchSamplesAction()); } getTableSort(): ITableSetting["tableSort"] { diff --git a/src/app/samples/samples.module.ts b/src/app/samples/samples.module.ts index e5479b0525..286c5dcb37 100644 --- a/src/app/samples/samples.module.ts +++ b/src/app/samples/samples.module.ts @@ -21,6 +21,8 @@ import { NgxJsonViewerModule } from "ngx-json-viewer"; import { SharedScicatFrontendModule } from "shared/shared.module"; import { MatChipsModule } from "@angular/material/chips"; import { FileSizePipe } from "shared/pipes/filesize.pipe"; +import { MatExpansionModule } from "@angular/material/expansion"; +import { SharedFilterModule } from "shared/modules/shared-filter/shared-filter.module"; @NgModule({ imports: [ @@ -41,6 +43,8 @@ import { FileSizePipe } from "shared/pipes/filesize.pipe"; ReactiveFormsModule, SharedScicatFrontendModule, StoreModule.forFeature("samples", samplesReducer), + MatExpansionModule, + SharedFilterModule, ], exports: [SampleDetailComponent, SampleDialogComponent], declarations: [ diff --git a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.ts b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.ts index 758400dd73..8811739e0c 100644 --- a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.ts +++ b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.ts @@ -56,7 +56,7 @@ export class SearchParametersDialogComponent { Validators.required, Validators.minLength(9), ]), - rhs: new FormControl( + rhs: new FormControl( this.data.condition?.rhs || "", [Validators.required, Validators.minLength(1)], ), diff --git a/src/app/shared/modules/shared-filter/shared-filter.component.html b/src/app/shared/modules/shared-filter/shared-filter.component.html index 1ae0204785..55f0357195 100644 --- a/src/app/shared/modules/shared-filter/shared-filter.component.html +++ b/src/app/shared/modules/shared-filter/shared-filter.component.html @@ -1,5 +1,5 @@ -
+ @@ -111,3 +111,300 @@
+ + + + +
+ Conditions + +
+ + + + +
+
+ +
+ +
+ {{ getHumanName(conditionConfig.condition.lhs) }} +
+
+ {{ conditionConfig.condition.lhs }} +
+
+ +
+ {{ conditionConfig.condition.lhs }} +
+
+
+ + + + Name Details +
+ + Human readable name: {{ getHumanName(conditionConfig.condition.lhs) }}
+ Metadata key: + {{ conditionConfig.condition.lhs }} +
+
+ + Metadata key: {{ + conditionConfig.condition.lhs + }} + +
+
+
+
+ {{ + getConditionDisplayText(conditionConfig.condition, i) + }} +
+
+
+ + {{ getConditionDisplayText(conditionConfig.condition, i) }} + +
+ +
+ + Operator + + + + {{ + conditionConfig.condition.relation === "GREATER_THAN" + ? "is greater than" + : conditionConfig.condition.relation === "LESS_THAN" + ? "is less than" + : getOperatorUIValue( + conditionConfig.condition.relation + ) === "EQUAL_TO" + ? "is equal to" + : conditionConfig.condition.relation === + "GREATER_THAN_OR_EQUAL" + ? "is greater than or equal to" + : conditionConfig.condition.relation === + "LESS_THAN_OR_EQUAL" + ? "is less than or equal to" + : conditionConfig.condition.relation === "RANGE" + ? "is in range" + : "" + }} + + {{ + conditionConfig.condition.relation === "GREATER_THAN" + ? "( > )" + : conditionConfig.condition.relation === "LESS_THAN" + ? "( < )" + : getOperatorUIValue( + conditionConfig.condition.relation + ) === "EQUAL_TO" + ? "( = )" + : conditionConfig.condition.relation === + "GREATER_THAN_OR_EQUAL" + ? "( ≥ )" + : conditionConfig.condition.relation === + "LESS_THAN_OR_EQUAL" + ? "( ≤ )" + : conditionConfig.condition.relation === "RANGE" + ? "( <-> )" + : "" + }} + + + + + + is equal to ( = ) + is greater than + ( > ) + is less than + ( < ) + is greater than or equal to + ( ≥ ) + is less than or equal to + ( ≤ ) + is in range + ( <-> ) + + + + + + + Value + + + + + Min + + + + Max + + + + + + Unit + + + {{ unit }} + + + + +
+ + {{ conditionConfig.enabled ? "Enabled" : "Disabled" }} + + + +
+
+
+
+
diff --git a/src/app/shared/modules/shared-filter/shared-filter.component.scss b/src/app/shared/modules/shared-filter/shared-filter.component.scss index a8d25346d4..d661186062 100644 --- a/src/app/shared/modules/shared-filter/shared-filter.component.scss +++ b/src/app/shared/modules/shared-filter/shared-filter.component.scss @@ -73,4 +73,188 @@ mat-divider { font-size: 0.75rem; display: inline-flex; align-items: center; +} + +// Condition filter styles +.section-container { + font-size: 1.25rem; + font-weight: 425; + margin-bottom: 0.5rem; + position: relative; + margin-top: 1rem; +} + +.conditions-header { + display: flex; + align-items: center; + justify-content: space-between; + + .condition-button { + display: flex; + align-items: center; + justify-content: end; + } +} + +.condition-title-section { + display: flex; + flex-direction: column; + width: 100%; + + .condition-field-name { + font-weight: 500; + font-size: 1rem; + margin-bottom: 0.25rem; + display: flex; + align-items: center; + + .cell-icon { + color: var(--theme-primary-default); + flex-shrink: 0; + font-size: 19px; + transform: translateY(4px); + } + + .field-name-wrapper { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + + .primary-name { + font-size: 1rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + } + + .secondary-name { + font-size: 0.875rem; + font-style: italic; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + .condition-description { + font-size: 0.9em; + color: rgba(0, 0, 0, 0.6); + + .condition-chip { + display: block; + font-size: 0.85rem; + font-weight: 500; + padding: 2px 8px; + background: transparent; + border: 1px solid var(--theme-primary-default); + color: var(--theme-primary-default); + border-radius: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} + +.mat-accordion .mat-expansion-panel { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.condition-panel { + &.disabled { + background-color: #f5f5f5; + opacity: 0.7; + } + + .mat-expansion-panel-header { + height: auto !important; + padding: 0.5rem !important; + align-items: flex-start !important; + + .mat-expansion-panel-header-title { + white-space: normal !important; + overflow: visible !important; + flex: 1; + min-width: 0; + } + + .mat-expansion-panel-header-description { + display: none !important; + } + } + + .condition-details { + padding: 0.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + + .condition-actions-section { + display: flex; + align-items: center; + justify-content: center; + margin-top: -0.5rem; + margin-bottom: -0.5rem; + + .condition-toggle { + margin: 0; + } + + .condition-remove { + display: flex; + align-items: center; + margin-left: 1rem; + color: #d32f2f; + font-size: 1rem; + line-height: 1; + + mat-icon { + margin-right: 4px; + } + + &:hover { + background-color: #ffebee; + color: #b71c1c; + } + } + } + + .condition-fields { + margin-left: -20px; + width: calc(100% + 40px); + @include mat.form-field-density(-5); + } + + .operator-symbol { + color: gray; + margin-left: 8px; + font-size: 1.1em; + vertical-align: middle; + } + } +} + +.hover-card { + min-width: 300px; + max-width: 620px; +} + +.hover-card-title { + padding: 0 16px; + font-size: 14px; + border-radius: 4px 4px 0 0; + color: white; + background: var(--theme-header-1-lighter); +} + +.hover-card-content { + padding: 10px 16px; + background: white; + color: var(--theme-header-1-lighter-contrast); + border-radius: 0 0 4px 4px; + white-space: pre-line; + word-wrap: break-word; } \ No newline at end of file diff --git a/src/app/shared/modules/shared-filter/shared-filter.component.spec.ts b/src/app/shared/modules/shared-filter/shared-filter.component.spec.ts index a8f3aa947a..9ad867044e 100644 --- a/src/app/shared/modules/shared-filter/shared-filter.component.spec.ts +++ b/src/app/shared/modules/shared-filter/shared-filter.component.spec.ts @@ -5,16 +5,48 @@ import { MatDatepickerInputEvent } from "@angular/material/datepicker"; import { DateTime } from "luxon"; import { SharedFilterComponent } from "./shared-filter.component"; import { of } from "rxjs"; +import { Store } from "@ngrx/store"; +import { MatDialog } from "@angular/material/dialog"; +import { MatSnackBar } from "@angular/material/snack-bar"; +import { AsyncPipe } from "@angular/common"; +import { UnitsService } from "shared/services/units.service"; +import { UnitsOptionsService } from "shared/services/units-options.service"; describe("SharedFilterComponent", () => { let component: SharedFilterComponent; let fixture: ComponentFixture; + let mockStore: jasmine.SpyObj; + let mockDialog: jasmine.SpyObj; + let mockSnackBar: jasmine.SpyObj; + let mockUnitsService: jasmine.SpyObj; + let mockUnitsOptionsService: jasmine.SpyObj; + beforeEach(async () => { + mockStore = jasmine.createSpyObj("Store", ["select", "dispatch"]); + mockStore.select.and.returnValue(of([])); + + mockDialog = jasmine.createSpyObj("MatDialog", ["open"]); + mockSnackBar = jasmine.createSpyObj("MatSnackBar", ["open"]); + mockUnitsService = jasmine.createSpyObj("UnitsService", ["getUnits"]); + mockUnitsOptionsService = jasmine.createSpyObj("UnitsOptionsService", [ + "getUnitsOptions", + "setUnitsOptions", + "clearUnitsOptions", + ]); + await TestBed.configureTestingModule({ declarations: [SharedFilterComponent], imports: [ReactiveFormsModule], schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: Store, useValue: mockStore }, + { provide: MatDialog, useValue: mockDialog }, + { provide: MatSnackBar, useValue: mockSnackBar }, + { provide: AsyncPipe, useClass: AsyncPipe }, + { provide: UnitsService, useValue: mockUnitsService }, + { provide: UnitsOptionsService, useValue: mockUnitsOptionsService }, + ], }).compileComponents(); fixture = TestBed.createComponent(SharedFilterComponent); diff --git a/src/app/shared/modules/shared-filter/shared-filter.component.ts b/src/app/shared/modules/shared-filter/shared-filter.component.ts index f459879a40..1cd8b76a3c 100644 --- a/src/app/shared/modules/shared-filter/shared-filter.component.ts +++ b/src/app/shared/modules/shared-filter/shared-filter.component.ts @@ -7,30 +7,52 @@ import { Output, OnChanges, SimpleChanges, + OnInit, + OnDestroy, } from "@angular/core"; import { FormControl, FormGroup } from "@angular/forms"; import { MatDatepickerInputEvent } from "@angular/material/datepicker"; import { DateTime } from "luxon"; -import { Observable } from "rxjs"; +import { Observable, Subscription } from "rxjs"; +import { map, take } from "rxjs/operators"; import { DateRange } from "state-management/state/proposals.store"; import { MultiSelectFilterValue } from "../filters/multiselect-filter.component"; import { INumericRange } from "../numeric-range/form/model/numeric-range-field.model"; -import { FilterType } from "state-management/state/user.store"; +import { FilterType, ConditionConfig } from "state-management/state/user.store"; import { toIsoUtc } from "../filters/utils"; -import { orderBy } from "lodash-es"; +import { orderBy, isEqual } from "lodash-es"; +import { ScientificCondition } from "state-management/models"; +import { ConnectedPosition } from "@angular/cdk/overlay"; +import { Store } from "@ngrx/store"; +import { MatDialog } from "@angular/material/dialog"; +import { MatSnackBar } from "@angular/material/snack-bar"; +import { AsyncPipe } from "@angular/common"; +import { UnitsService } from "shared/services/units.service"; +import { UnitsOptionsService } from "shared/services/units-options.service"; +import { + SearchParametersDialogComponent, + SearchParametersDialogData, +} from "../search-parameters-dialog/search-parameters-dialog.component"; +import { + updateConditionsConfigs, + updateUserSettingsAction, + selectColumnAction, + deselectColumnAction, +} from "state-management/actions/user.actions"; +import { selectConditions } from "state-management/selectors/user.selectors"; type FacetItem = { _id: string; label?: string; count: number }; + @Component({ selector: "shared-filter", templateUrl: "./shared-filter.component.html", styleUrls: ["./shared-filter.component.scss"], standalone: false, }) -export class SharedFilterComponent implements OnChanges { - private dateRange: DateRange = { - begin: null, - end: null, - }; +export class SharedFilterComponent implements OnChanges, OnInit, OnDestroy { + private subscriptions: Subscription[] = []; + private dateRange: DateRange = { begin: null, end: null }; + checkboxDisplaylimit = 10; searchInputDisplayThreshold = 10; checkboxFacetCounts: FacetItem[] = []; @@ -78,6 +100,14 @@ export class SharedFilterComponent implements OnChanges { | undefined | null; @Input() showBadge = false; + // Condition filter inputs + @Input() showConditions = false; + @Input() metadataKeys: string[] = []; + @Input() unitsEnabled = false; + @Input() showConditionToggle = false; + @Input() conditionType: "datasets" | "samples" = "datasets"; + @Input() addConditionAction: (condition: ScientificCondition) => void; + @Input() removeConditionAction: (condition: ScientificCondition) => void; @Output() textChange = new EventEmitter(); @Output() checkBoxChange = new EventEmitter(); @@ -87,14 +117,58 @@ export class SharedFilterComponent implements OnChanges { begin?: string; end?: string; }>(); + @Output() conditionsApplied = new EventEmitter(); + + allConditions$ = this.store.select(selectConditions); + + conditionConfigs$ = this.allConditions$.pipe( + map((configs) => + (configs || []).filter((c) => c.conditionType === this.conditionType), + ), + ); + + humanNameMap: { [key: string]: string } = {}; + fieldTypeMap: { [key: string]: string } = {}; + tempConditionValues: string[] = []; + hoverKey: string | null = null; + overlayPositions: ConnectedPosition[] = [ + { + originX: "end", + originY: "center", + overlayX: "start", + overlayY: "center", + offsetX: 8, + }, + { + originX: "center", + originY: "center", + overlayX: "end", + overlayY: "top", + offsetY: 8, + }, + ]; + + constructor( + private store: Store, + private dialog: MatDialog, + private snackBar: MatSnackBar, + private asyncPipe: AsyncPipe, + private unitsService: UnitsService, + private unitsOptionsService: UnitsOptionsService, + ) {} - constructor() {} ngOnInit() { // Reset display limit whenever the text search changes this.filterForm.get("textField")!.valueChanges.subscribe(() => { this.checkboxDisplaylimit = 10; }); + + if (this.showConditions) { + this.buildMetadataMaps(); + this.applyEnabledConditions(); + } } + ngOnChanges(changes: SimpleChanges) { if (this.checkboxFacetCounts.length > this.searchInputDisplayThreshold) { this.showCheckboxSearch = true; @@ -237,4 +311,440 @@ export class SharedFilterComponent implements OnChanges { } /** Checkbox filter helpers END*/ + + /** Condition filter helpers and methods START */ + + // Helper to get all conditions and update store + updateStore(updatedConditions: ConditionConfig[]) { + this.store.dispatch( + updateConditionsConfigs({ conditionConfigs: updatedConditions }), + ); + this.store.dispatch( + updateUserSettingsAction({ property: { conditions: updatedConditions } }), + ); + } + + buildMetadataMaps() { + this.conditionConfigs$.pipe(take(1)).subscribe((conditionConfigs) => { + (conditionConfigs || []).forEach((config) => { + const { lhs, type, human_name } = config.condition; + if (lhs && type) this.fieldTypeMap[lhs] = type; + if (lhs && human_name) this.humanNameMap[lhs] = human_name; + }); + }); + } + + applyEnabledConditions() { + this.allConditions$.pipe(take(1)).subscribe((allConditions) => { + const needsUpdate = (allConditions || []).some((c) => !c.conditionType); + + if (needsUpdate) { + const updatedConditions = (allConditions || []).map((c) => ({ + ...c, + conditionType: c.conditionType || this.conditionType, + })); + this.updateStore(updatedConditions); + } + + const myConditions = (allConditions || []).filter( + (c) => !c.conditionType || c.conditionType === this.conditionType, + ); + + myConditions.forEach((config) => { + this.applyUnitsOptions(config.condition); + if (config.enabled && config.condition.lhs && config.condition.rhs) { + this.addConditionAction?.(config.condition); + } + }); + }); + } + + applyUnitsOptions(condition: ScientificCondition): void { + const lhs = condition?.lhs; + const unitsOptions = condition?.unitsOptions; + if (lhs && unitsOptions?.length) { + this.unitsOptionsService.setUnitsOptions(lhs, unitsOptions); + } + } + + trackByCondition(index: number, conditionConfig: ConditionConfig): string { + return `${conditionConfig.condition.lhs}-${index}`; + } + + getHumanName(key: string): string { + return this.humanNameMap[key]; + } + + getOperatorUIValue(relation: string): string { + return relation === "EQUAL_TO_NUMERIC" || relation === "EQUAL_TO_STRING" + ? "EQUAL_TO" + : relation; + } + + getAllowedOperators(key: string): string[] { + const type = this.fieldTypeMap[key]; + if (type === "string") return ["EQUAL_TO"]; + return [ + "EQUAL_TO", + "GREATER_THAN", + "LESS_THAN", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "RANGE", + ]; + } + + getUnits(parameterKey: string): string[] { + const stored = this.unitsOptionsService.getUnitsOptions(parameterKey); + if (stored?.length) return stored; + return this.unitsService.getUnits(parameterKey); + } + + getConditionDisplayText( + condition: ScientificCondition, + index?: number, + ): string { + if (condition.relation === "RANGE") { + if (!condition.lhs || !condition.rhs) return "Configure condition..."; + const rangeValues = Array.isArray(condition.rhs) + ? condition.rhs + : [undefined, undefined]; + const min = rangeValues[0] !== undefined ? rangeValues[0] : "?"; + const max = rangeValues[1] !== undefined ? rangeValues[1] : "?"; + const unit = condition.unit ? ` ${condition.unit}` : ""; + return `${min} <-> ${max}${unit}`; + } + + const rhsValue = + this.tempConditionValues[index] != undefined + ? this.tempConditionValues[index] + : condition.rhs; + if (!condition.lhs || rhsValue == null || rhsValue === "") + return "Configure condition..."; + + let relationSymbol = ""; + switch (condition.relation) { + case "EQUAL_TO_NUMERIC": + case "EQUAL_TO_STRING": + case "EQUAL_TO": + relationSymbol = "="; + break; + case "LESS_THAN": + relationSymbol = "<"; + break; + case "GREATER_THAN": + relationSymbol = ">"; + break; + case "GREATER_THAN_OR_EQUAL": + relationSymbol = "≥"; + break; + case "LESS_THAN_OR_EQUAL": + relationSymbol = "≤"; + break; + } + + const unit = condition.unit ? ` ${condition.unit}` : ""; + return `${relationSymbol} ${rhsValue}${unit}`; + } + + addCondition() { + this.buildMetadataMaps(); + + this.allConditions$.pipe(take(1)).subscribe((allConditions) => { + const myConditions = (allConditions || []).filter( + (c) => c.conditionType === this.conditionType, + ); + const usedFields = myConditions.map((config) => config.condition.lhs); + const availableKeys = (this.metadataKeys || []).filter( + (key) => !usedFields.includes(key), + ); + + this.dialog + .open( + SearchParametersDialogComponent, + { + data: { + usedFields: usedFields, + parameterKeys: availableKeys, + humanNameMap: this.humanNameMap, + }, + restoreFocus: false, + }, + ) + .afterClosed() + .subscribe((res) => { + if (res) { + const { data } = res; + + const existingConditionIndex = myConditions.findIndex((config) => + isEqual(this.humanNameMap[config.condition.lhs], data.lhs), + ); + + if (existingConditionIndex !== -1) { + this.snackBar.open("Condition already exists", "Close", { + duration: 2000, + panelClass: ["snackbar-warning"], + }); + return; + } + + const newCondition: ConditionConfig = { + condition: { + ...data, + rhs: "", + type: this.fieldTypeMap[data.lhs], + human_name: this.humanNameMap[data.lhs], + }, + enabled: true, + conditionType: this.conditionType, + }; + + this.updateStore([...(allConditions || []), newCondition]); + this.store.dispatch( + selectColumnAction({ name: data.lhs, columnType: "custom" }), + ); + + this.snackBar.open("Condition added successfully", "Close", { + duration: 2000, + panelClass: ["snackbar-success"], + }); + } + }); + }); + } + + removeCondition(condition: ConditionConfig, index: number) { + this.allConditions$.pipe(take(1)).subscribe((allConditions) => { + const actualIndex = (allConditions || []).findIndex( + (c) => + c.condition.lhs === condition.condition.lhs && + c.conditionType === this.conditionType, + ); + + if (actualIndex === -1) return; + + const updatedConditions = [...(allConditions || [])]; + updatedConditions.splice(actualIndex, 1); + this.tempConditionValues.splice(index, 1); + + if (condition.enabled) { + this.removeConditionAction?.(condition.condition); + this.store.dispatch( + deselectColumnAction({ + name: condition.condition.lhs, + columnType: "custom", + }), + ); + } + + if (condition.condition.lhs) { + this.unitsOptionsService.clearUnitsOptions(condition.condition.lhs); + } + + this.updateStore(updatedConditions); + }); + } + + updateConditionField(index: number, updates: Partial) { + this.allConditions$.pipe(take(1)).subscribe((allConditions) => { + const myConditions = (allConditions || []).filter( + (c) => c.conditionType === this.conditionType, + ); + + if (!myConditions[index]) return; + + const actualIndex = (allConditions || []).findIndex( + (c) => + c.condition.lhs === myConditions[index].condition.lhs && + c.conditionType === this.conditionType, + ); + + if (actualIndex === -1) return; + + const updatedConditions = [...(allConditions || [])]; + const conditionConfig = updatedConditions[actualIndex]; + + updatedConditions[actualIndex] = { + ...conditionConfig, + condition: { + ...conditionConfig.condition, + ...updates, + type: this.fieldTypeMap[conditionConfig.condition.lhs], + human_name: this.humanNameMap[conditionConfig.condition.lhs], + }, + }; + + this.store.dispatch( + updateConditionsConfigs({ conditionConfigs: updatedConditions }), + ); + }); + } + + updateConditionOperator( + index: number, + newOperator: ScientificCondition["relation"], + ) { + delete this.tempConditionValues[index]; + this.updateConditionField(index, { + relation: newOperator, + rhs: newOperator === "RANGE" ? [undefined, undefined] : "", + unit: newOperator === "EQUAL_TO_STRING" ? "" : undefined, + }); + } + + updateConditionValue(index: number, event: Event) { + const newValue = (event.target as HTMLInputElement).value; + this.tempConditionValues[index] = newValue; + } + + updateConditionRangeValue(index: number, event: Event, rangeIndex: 0 | 1) { + const newValue = (event.target as HTMLInputElement).value; + const currentRhs = this.asyncPipe.transform(this.conditionConfigs$)?.[index] + ?.condition.rhs; + const rhs = Array.isArray(currentRhs) + ? [...currentRhs] + : [undefined, undefined]; + rhs[rangeIndex] = Number(newValue); + this.updateConditionField(index, { rhs }); + } + + updateConditionUnit(index: number, event: any) { + const newUnit = event.target + ? (event.target as HTMLInputElement).value + : event.option.value; + this.updateConditionField(index, { unit: newUnit || undefined }); + } + + toggleConditionEnabled(index: number, enabled: boolean) { + this.allConditions$.pipe(take(1)).subscribe((allConditions) => { + const myConditions = (allConditions || []).filter( + (c) => c.conditionType === this.conditionType, + ); + + if (!myConditions[index]) return; + + const actualIndex = (allConditions || []).findIndex( + (c) => + c.condition.lhs === myConditions[index].condition.lhs && + c.conditionType === this.conditionType, + ); + + if (actualIndex === -1) return; + + const updatedConditions = [...(allConditions || [])]; + updatedConditions[actualIndex] = { + ...updatedConditions[actualIndex], + enabled, + }; + const condition = updatedConditions[actualIndex].condition; + + if (enabled && condition.lhs && condition.rhs) { + this.addConditionAction?.(condition); + this.store.dispatch( + selectColumnAction({ name: condition.lhs, columnType: "custom" }), + ); + } else { + this.removeConditionAction?.(condition); + this.store.dispatch( + deselectColumnAction({ name: condition.lhs, columnType: "custom" }), + ); + } + + this.updateStore(updatedConditions); + }); + } + + applyConditions() { + this.allConditions$.pipe(take(1)).subscribe((allConditions) => { + const myConditions = (allConditions || []).filter( + (c) => c.conditionType === this.conditionType, + ); + const otherConditions = (allConditions || []).filter( + (c) => c.conditionType !== this.conditionType, + ); + + const updatedMyConditions = myConditions.map((config, i) => { + const lhs = config.condition.lhs; + const baseCondition = { + ...config.condition, + type: this.fieldTypeMap[lhs], + human_name: this.humanNameMap[lhs], + }; + + if (this.tempConditionValues[i] !== undefined) { + const value = this.tempConditionValues[i]; + const fieldType = this.fieldTypeMap[config.condition.lhs]; + const isNumeric = value !== "" && !isNaN(Number(value)); + + if (config.condition.relation === "EQUAL_TO") { + return { + ...config, + condition: { + ...baseCondition, + rhs: isNumeric ? Number(value) : value, + relation: + fieldType === "string" || !isNumeric + ? ("EQUAL_TO_STRING" as ScientificCondition["relation"]) + : ("EQUAL_TO_NUMERIC" as ScientificCondition["relation"]), + }, + }; + } else { + return { + ...config, + condition: { + ...baseCondition, + rhs: isNumeric ? Number(value) : value, + }, + }; + } + } + return { ...config, condition: baseCondition }; + }); + + // Removes old conditions for this type + updatedMyConditions.forEach((c) => + this.removeConditionAction?.(c.condition), + ); + + // Adds updated conditions for this type + updatedMyConditions.forEach((config) => { + if ( + config.enabled && + config.condition.lhs && + config.condition.rhs != null && + config.condition.rhs !== "" + ) { + this.addConditionAction?.(config.condition); + } + }); + + // Merges other conditions with updated conditions for this type + this.updateStore([...otherConditions, ...updatedMyConditions]); + this.tempConditionValues = []; + this.conditionsApplied.emit(); + }); + } + + clearConditions() { + this.allConditions$.pipe(take(1)).subscribe((allConditions) => { + const myConditions = (allConditions || []).filter( + (c) => c.conditionType === this.conditionType, + ); + + myConditions.forEach((config) => + this.removeConditionAction?.(config.condition), + ); + + const updatedConditions = (allConditions || []).filter( + (c) => c.conditionType !== this.conditionType, + ); + this.updateStore(updatedConditions); + }); + } + + ngOnDestroy() { + this.subscriptions.forEach((sub) => sub.unsubscribe()); + } + + /** Condition filter helpers and methods END */ } diff --git a/src/app/shared/modules/shared-filter/shared-filter.module.ts b/src/app/shared/modules/shared-filter/shared-filter.module.ts index 7120ad3b58..55abee60b0 100644 --- a/src/app/shared/modules/shared-filter/shared-filter.module.ts +++ b/src/app/shared/modules/shared-filter/shared-filter.module.ts @@ -2,7 +2,7 @@ import { NgModule } from "@angular/core"; import { MatInputModule } from "@angular/material/input"; import { MatDatepickerModule } from "@angular/material/datepicker"; -import { CommonModule } from "@angular/common"; +import { CommonModule, AsyncPipe } from "@angular/common"; import { MatIconModule } from "@angular/material/icon"; import { MatTooltipModule } from "@angular/material/tooltip"; import { SharedFilterComponent } from "./shared-filter.component"; @@ -19,6 +19,13 @@ import { MatButtonModule } from "@angular/material/button"; import { PipesModule } from "shared/pipes/pipes.module"; import { provideLuxonDateAdapter } from "@angular/material-luxon-adapter"; import { AppConfigService } from "app-config.service"; +import { MatExpansionModule } from "@angular/material/expansion"; +import { MatCardModule } from "@angular/material/card"; +import { MatSelectModule } from "@angular/material/select"; +import { MatSlideToggleModule } from "@angular/material/slide-toggle"; +import { OverlayModule } from "@angular/cdk/overlay"; +import { MatDialogModule } from "@angular/material/dialog"; +import { MatSnackBarModule } from "@angular/material/snack-bar"; @NgModule({ declarations: [SharedFilterComponent, MultiSelectFilterComponent], @@ -39,11 +46,19 @@ import { AppConfigService } from "app-config.service"; PipesModule, MatChipsModule, NgxNumericRangeFormFieldModule, + MatExpansionModule, + MatCardModule, + MatSelectModule, + MatSlideToggleModule, + OverlayModule, + MatDialogModule, + MatSnackBarModule, ], // Force shared-filter to use the Luxon adapter. // Needed because JsonFormsAngularMaterialModule brings in MatNativeDateModule, // which otherwise switches the DateAdapter to the native one. providers: [ + AsyncPipe, provideLuxonDateAdapter(), { provide: MAT_DATE_FORMATS, diff --git a/src/app/shared/services/units-options.service.ts b/src/app/shared/services/units-options.service.ts index 4e19f07243..8f9791216a 100644 --- a/src/app/shared/services/units-options.service.ts +++ b/src/app/shared/services/units-options.service.ts @@ -4,8 +4,6 @@ import { Injectable } from "@angular/core"; providedIn: "root", }) export class UnitsOptionsService { - // private unitsOptionsMap: Record = {}; - setUnitsOptions(lhs: string, unitsOptions: string[]) { localStorage.setItem(`unitsOptions_${lhs}`, JSON.stringify(unitsOptions)); } diff --git a/src/app/state-management/actions/samples.actions.spec.ts b/src/app/state-management/actions/samples.actions.spec.ts index a8287abd3d..fedd15a058 100644 --- a/src/app/state-management/actions/samples.actions.spec.ts +++ b/src/app/state-management/actions/samples.actions.spec.ts @@ -435,11 +435,11 @@ describe("Sample Actions", () => { describe("removeCharacteristicsFilterAction", () => { it("should create an action", () => { - const index = 0; - const action = fromActions.removeCharacteristicsFilterAction({ index }); + const lhs = "lhsTest"; + const action = fromActions.removeCharacteristicsFilterAction({ lhs }); expect({ ...action }).toEqual({ type: "[Sample] Remove Characteristics Filter", - index, + lhs, }); }); }); diff --git a/src/app/state-management/actions/samples.actions.ts b/src/app/state-management/actions/samples.actions.ts index d360c236d3..392ed83e6b 100644 --- a/src/app/state-management/actions/samples.actions.ts +++ b/src/app/state-management/actions/samples.actions.ts @@ -178,7 +178,7 @@ export const addCharacteristicsFilterAction = createAction( export const removeCharacteristicsFilterAction = createAction( "[Sample] Remove Characteristics Filter", - props<{ index: number }>(), + props<{ lhs: string }>(), ); export const clearSamplesStateAction = createAction("[Sample] Clear State"); diff --git a/src/app/state-management/models/index.ts b/src/app/state-management/models/index.ts index d73572d0dd..0a0fc27ac8 100644 --- a/src/app/state-management/models/index.ts +++ b/src/app/state-management/models/index.ts @@ -142,7 +142,7 @@ type ScientificConditionRelation = export interface ScientificCondition { lhs: string; relation: ScientificConditionRelation; - rhs: string | number | number[]; + rhs: string | number | (string | number)[]; unit: string; unitsOptions?: string[]; type?: string; diff --git a/src/app/state-management/reducers/samples.reducer.spec.ts b/src/app/state-management/reducers/samples.reducer.spec.ts index 0025d441ae..0aa7c3c9ed 100644 --- a/src/app/state-management/reducers/samples.reducer.spec.ts +++ b/src/app/state-management/reducers/samples.reducer.spec.ts @@ -241,9 +241,9 @@ describe("SamplesReducer", () => { characteristic, ); - const index = 0; + const lhs = "lhsTest"; - const action = fromActions.removeCharacteristicsFilterAction({ index }); + const action = fromActions.removeCharacteristicsFilterAction({ lhs }); const state = samplesReducer(initialSampleState, action); expect(state.sampleFilters.characteristics).not.toContain(characteristic); diff --git a/src/app/state-management/reducers/samples.reducer.ts b/src/app/state-management/reducers/samples.reducer.ts index acc1e8cac1..6125fb94e7 100644 --- a/src/app/state-management/reducers/samples.reducer.ts +++ b/src/app/state-management/reducers/samples.reducer.ts @@ -164,10 +164,11 @@ const reducer = createReducer( on( fromActions.removeCharacteristicsFilterAction, - (state, { index }): SampleState => { + (state, { lhs }): SampleState => { const currentFilters = state.sampleFilters; - const characteristics = [...currentFilters.characteristics]; - characteristics.splice(index, 1); + const characteristics = currentFilters.characteristics.filter( + (c) => c.lhs !== lhs, + ); const sampleFilters = { ...currentFilters, characteristics }; return { ...state, sampleFilters }; }, diff --git a/src/app/state-management/selectors/samples.selectors.spec.ts b/src/app/state-management/selectors/samples.selectors.spec.ts index bc92bf1aff..a8d00f426d 100644 --- a/src/app/state-management/selectors/samples.selectors.spec.ts +++ b/src/app/state-management/selectors/samples.selectors.spec.ts @@ -206,6 +206,7 @@ describe("Sample Selectors", () => { initialSampleState.sampleFilters.characteristics, initialUserState.tablesSettings, initialSampleState.samplesCount, + false, //hasAppliedFilters ), ).toEqual({ samples: [], @@ -221,6 +222,7 @@ describe("Sample Selectors", () => { characteristicsFilter: [], tableSettings: {}, count: 0, + hasAppliedFilters: false, }); }); }); diff --git a/src/app/state-management/selectors/samples.selectors.ts b/src/app/state-management/selectors/samples.selectors.ts index 50b8b333bb..909a2b2d80 100644 --- a/src/app/state-management/selectors/samples.selectors.ts +++ b/src/app/state-management/selectors/samples.selectors.ts @@ -98,6 +98,13 @@ export const selectSamplesPagination = createSelector( }), ); +export const selectHasAppliedFilters = createSelector( + selectFilters, + (filters) => + filters.text !== "" || + (filters.characteristics && filters.characteristics.length > 0), +); + export const selectSampleDashboardPageViewModel = createSelector( selectSamples, selectSamplesPagination, @@ -108,6 +115,7 @@ export const selectSampleDashboardPageViewModel = createSelector( selectCharacteristicsFilter, selectTablesSettings, selectSamplesCount, + selectHasAppliedFilters, ( samples, samplesPagination, @@ -118,6 +126,7 @@ export const selectSampleDashboardPageViewModel = createSelector( characteristicsFilter, tableSettings, count, + hasAppliedFilters, ) => ({ samples, samplesPagination, @@ -128,6 +137,7 @@ export const selectSampleDashboardPageViewModel = createSelector( characteristicsFilter, tableSettings, count, + hasAppliedFilters, }), ); diff --git a/src/app/state-management/state/user.store.ts b/src/app/state-management/state/user.store.ts index eb9e33e22e..e7a910aff3 100644 --- a/src/app/state-management/state/user.store.ts +++ b/src/app/state-management/state/user.store.ts @@ -24,6 +24,7 @@ export interface FilterConfig { export interface ConditionConfig { condition: ScientificCondition; enabled: boolean; + conditionType?: "datasets" | "samples"; } // NOTE It IS ok to make up a state of other sub states