From 6381d95cb1d18e9a19e270108909d080c574788e Mon Sep 17 00:00:00 2001 From: Abdi Abdulle Date: Wed, 10 Dec 2025 16:19:48 +0100 Subject: [PATCH 1/9] moved condition filter logic to shared-filter component so it can be used by both datasets and samples --- .../datasets-filter.component.html | 312 +--------- .../datasets-filter.component.scss | 174 +----- .../datasets-filter.component.ts | 543 +----------------- .../sample-dashboard.component.html | 71 +-- .../sample-dashboard.component.scss | 10 + .../sample-dashboard.component.ts | 70 ++- src/app/samples/samples.module.ts | 4 + .../search-parameters-dialog.component.ts | 2 +- .../shared-filter.component.html | 299 +++++++++- .../shared-filter.component.scss | 184 ++++++ .../shared-filter/shared-filter.component.ts | 518 ++++++++++++++++- .../shared-filter/shared-filter.module.ts | 17 +- .../actions/samples.actions.ts | 2 +- src/app/state-management/models/index.ts | 2 +- .../reducers/samples.reducer.ts | 7 +- .../selectors/samples.selectors.ts | 10 + src/app/state-management/state/user.store.ts | 1 + 17 files changed, 1152 insertions(+), 1074 deletions(-) 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..cf5308887f 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,18 @@ 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; + + addCondition = (condition: ScientificCondition) => { + this.store.dispatch( + addCharacteristicsFilterAction({ characteristic: condition }), + ); + }; + + removeCondition = (condition: ScientificCondition) => { + this.store.dispatch(removeCharacteristicsFilterAction({ lhs: condition.lhs })); + }; + tableDefaultSettingsConfig: ITableSetting = { visibleActionMenu: actionMenu, settingList: [ @@ -233,24 +251,34 @@ 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.store.dispatch( + updateConditionsConfigs({ + conditionConfigs: [], + }), + ); + this.store.dispatch( + updateUserSettingsAction({ + property: { conditions: [] }, + }), + ); + + // Clear all characteristics + this.vm$.pipe(take(1)).subscribe((vm) => { + vm.characteristicsFilter?.forEach((c) => { + this.store.dispatch(removeCharacteristicsFilterAction({ lhs: c.lhs })); + }); + }); + + 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 de47860caa..e1d33e6e71 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.ts b/src/app/shared/modules/shared-filter/shared-filter.component.ts index f459879a40..a2cda9afac 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[] = []; @@ -48,7 +70,7 @@ export class SharedFilterComponent implements OnChanges { }); @ViewChild("input", { static: true }) input!: ElementRef; - + @Input() key = ""; @Input() label = "Filter"; @Input() tooltip = ""; @@ -78,6 +100,15 @@ 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 +118,60 @@ export class SharedFilterComponent implements OnChanges { begin?: string; end?: string; }>(); + @Output() conditionsApplied = new EventEmitter(); + + private 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 +314,425 @@ export class SharedFilterComponent implements OnChanges { } /** Checkbox filter helpers END*/ + + /** Condition filter helpers and methods START */ + + // Helper to get all conditions and update store + private 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(); + }); + } + + 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/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.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.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..2e77292a49 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 From 46899ec800a4a2f8dd688975f8ddf26759bcdc2a Mon Sep 17 00:00:00 2001 From: Abdi Abdulle Date: Wed, 10 Dec 2025 16:57:04 +0100 Subject: [PATCH 2/9] eslint fix --- .../datasets-filter.component.ts | 8 +++---- .../sample-dashboard.component.ts | 22 ++++++++++--------- .../shared-filter/shared-filter.component.ts | 11 +++------- src/app/state-management/state/user.store.ts | 2 +- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.ts b/src/app/datasets/datasets-filter/datasets-filter.component.ts index 0145c3a847..f6c64d8702 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.ts +++ b/src/app/datasets/datasets-filter/datasets-filter.component.ts @@ -59,14 +59,14 @@ export class DatasetsFilterComponent implements OnInit, OnDestroy { metadataKeys$ = this.store.select(selectMetadataKeys); - @ViewChild('conditionFilter') conditionFilter: SharedFilterComponent; + @ViewChild("conditionFilter") conditionFilter: SharedFilterComponent; addCondition = (condition: ScientificCondition) => { - this.store.dispatch(addScientificConditionAction({ condition })) + this.store.dispatch(addScientificConditionAction({ condition })); }; removeCondition = (condition: ScientificCondition) => { - this.store.dispatch(removeScientificConditionAction({ condition })) + this.store.dispatch(removeScientificConditionAction({ condition })); }; constructor( @@ -355,4 +355,4 @@ export class DatasetsFilterComponent implements OnInit, OnDestroy { this.store.dispatch(clearFacetsAction()); this.subscriptions.forEach((subscription) => subscription.unsubscribe()); } -} \ No newline at end of file +} diff --git a/src/app/samples/sample-dashboard/sample-dashboard.component.ts b/src/app/samples/sample-dashboard/sample-dashboard.component.ts index cf5308887f..59539d651e 100644 --- a/src/app/samples/sample-dashboard/sample-dashboard.component.ts +++ b/src/app/samples/sample-dashboard/sample-dashboard.component.ts @@ -61,16 +61,6 @@ export class SampleDashboardComponent implements OnInit, OnDestroy { @ViewChild("conditionFilter") conditionFilter: SharedFilterComponent; - addCondition = (condition: ScientificCondition) => { - this.store.dispatch( - addCharacteristicsFilterAction({ characteristic: condition }), - ); - }; - - removeCondition = (condition: ScientificCondition) => { - this.store.dispatch(removeCharacteristicsFilterAction({ lhs: condition.lhs })); - }; - tableDefaultSettingsConfig: ITableSetting = { visibleActionMenu: actionMenu, settingList: [ @@ -151,6 +141,18 @@ export class SampleDashboardComponent implements OnInit, OnDestroy { tablesSettings: object; + addCondition = (condition: ScientificCondition) => { + this.store.dispatch( + addCharacteristicsFilterAction({ characteristic: condition }), + ); + }; + + removeCondition = (condition: ScientificCondition) => { + this.store.dispatch( + removeCharacteristicsFilterAction({ lhs: condition.lhs }), + ); + }; + constructor( private appConfigService: AppConfigService, private datePipe: DatePipe, 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 a2cda9afac..2f2dd74e67 100644 --- a/src/app/shared/modules/shared-filter/shared-filter.component.ts +++ b/src/app/shared/modules/shared-filter/shared-filter.component.ts @@ -70,7 +70,7 @@ export class SharedFilterComponent implements OnChanges, OnInit, OnDestroy { }); @ViewChild("input", { static: true }) input!: ElementRef; - + @Input() key = ""; @Input() label = "Filter"; @Input() tooltip = ""; @@ -109,7 +109,6 @@ export class SharedFilterComponent implements OnChanges, OnInit, OnDestroy { @Input() addConditionAction: (condition: ScientificCondition) => void; @Input() removeConditionAction: (condition: ScientificCondition) => void; - @Output() textChange = new EventEmitter(); @Output() checkBoxChange = new EventEmitter(); @Output() selectionChange = new EventEmitter(); @@ -124,9 +123,7 @@ export class SharedFilterComponent implements OnChanges, OnInit, OnDestroy { conditionConfigs$ = this.allConditions$.pipe( map((configs) => - (configs || []).filter( - (c) => c.conditionType === this.conditionType, - ), + (configs || []).filter((c) => c.conditionType === this.conditionType), ), ); @@ -337,9 +334,8 @@ export class SharedFilterComponent implements OnChanges, OnInit, OnDestroy { }); } - applyEnabledConditions() { + applyEnabledConditions() { this.allConditions$.pipe(take(1)).subscribe((allConditions) => { - const needsUpdate = (allConditions || []).some((c) => !c.conditionType); if (needsUpdate) { @@ -519,7 +515,6 @@ export class SharedFilterComponent implements OnChanges, OnInit, OnDestroy { removeCondition(condition: ConditionConfig, index: number) { this.allConditions$.pipe(take(1)).subscribe((allConditions) => { - const actualIndex = (allConditions || []).findIndex( (c) => c.condition.lhs === condition.condition.lhs && diff --git a/src/app/state-management/state/user.store.ts b/src/app/state-management/state/user.store.ts index 2e77292a49..e7a910aff3 100644 --- a/src/app/state-management/state/user.store.ts +++ b/src/app/state-management/state/user.store.ts @@ -24,7 +24,7 @@ export interface FilterConfig { export interface ConditionConfig { condition: ScientificCondition; enabled: boolean; - conditionType?: "datasets" | "samples"; + conditionType?: "datasets" | "samples"; } // NOTE It IS ok to make up a state of other sub states From 20c69a77cf59742b07878a9362848cc901b936da Mon Sep 17 00:00:00 2001 From: Abdi Abdulle Date: Wed, 10 Dec 2025 17:11:02 +0100 Subject: [PATCH 3/9] fixed tests --- src/app/state-management/actions/samples.actions.spec.ts | 4 ++-- src/app/state-management/reducers/samples.reducer.spec.ts | 4 ++-- src/app/state-management/selectors/samples.selectors.spec.ts | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/state-management/actions/samples.actions.spec.ts b/src/app/state-management/actions/samples.actions.spec.ts index a8287abd3d..17ac642725 100644 --- a/src/app/state-management/actions/samples.actions.spec.ts +++ b/src/app/state-management/actions/samples.actions.spec.ts @@ -435,8 +435,8 @@ 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, 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/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, }); }); }); From 4284c886fefbc12c41def70cdf741e6a8527b40d Mon Sep 17 00:00:00 2001 From: Abdi Abdulle Date: Wed, 10 Dec 2025 17:23:05 +0100 Subject: [PATCH 4/9] eslint & test fix --- .../datasets-filter.component.ts | 4 +-- .../sample-dashboard.component.ts | 26 +++++++++---------- .../shared-filter/shared-filter.component.ts | 4 +-- .../actions/samples.actions.spec.ts | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.ts b/src/app/datasets/datasets-filter/datasets-filter.component.ts index f6c64d8702..8bb30de092 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.ts +++ b/src/app/datasets/datasets-filter/datasets-filter.component.ts @@ -70,12 +70,12 @@ export class DatasetsFilterComponent implements OnInit, OnDestroy { }; constructor( - public appConfigService: AppConfigService, - public dialog: MatDialog, private store: Store, private asyncPipe: AsyncPipe, private route: ActivatedRoute, private router: Router, + public appConfigService: AppConfigService, + public dialog: MatDialog, ) {} ngOnInit() { diff --git a/src/app/samples/sample-dashboard/sample-dashboard.component.ts b/src/app/samples/sample-dashboard/sample-dashboard.component.ts index 59539d651e..14a85e7588 100644 --- a/src/app/samples/sample-dashboard/sample-dashboard.component.ts +++ b/src/app/samples/sample-dashboard/sample-dashboard.component.ts @@ -141,26 +141,14 @@ export class SampleDashboardComponent implements OnInit, OnDestroy { tablesSettings: object; - addCondition = (condition: ScientificCondition) => { - this.store.dispatch( - addCharacteristicsFilterAction({ characteristic: condition }), - ); - }; - - removeCondition = (condition: ScientificCondition) => { - this.store.dispatch( - removeCharacteristicsFilterAction({ lhs: condition.lhs }), - ); - }; - 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() { @@ -228,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) => ({ 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 2f2dd74e67..1d8541b46e 100644 --- a/src/app/shared/modules/shared-filter/shared-filter.component.ts +++ b/src/app/shared/modules/shared-filter/shared-filter.component.ts @@ -119,7 +119,7 @@ export class SharedFilterComponent implements OnChanges, OnInit, OnDestroy { }>(); @Output() conditionsApplied = new EventEmitter(); - private allConditions$ = this.store.select(selectConditions); + allConditions$ = this.store.select(selectConditions); conditionConfigs$ = this.allConditions$.pipe( map((configs) => @@ -315,7 +315,7 @@ export class SharedFilterComponent implements OnChanges, OnInit, OnDestroy { /** Condition filter helpers and methods START */ // Helper to get all conditions and update store - private updateStore(updatedConditions: ConditionConfig[]) { + updateStore(updatedConditions: ConditionConfig[]) { this.store.dispatch( updateConditionsConfigs({ conditionConfigs: updatedConditions }), ); diff --git a/src/app/state-management/actions/samples.actions.spec.ts b/src/app/state-management/actions/samples.actions.spec.ts index 17ac642725..fedd15a058 100644 --- a/src/app/state-management/actions/samples.actions.spec.ts +++ b/src/app/state-management/actions/samples.actions.spec.ts @@ -439,7 +439,7 @@ describe("Sample Actions", () => { const action = fromActions.removeCharacteristicsFilterAction({ lhs }); expect({ ...action }).toEqual({ type: "[Sample] Remove Characteristics Filter", - index, + lhs, }); }); }); From 51f44fe8a966c95e4b2cdf75cb2fcbdc22dc458f Mon Sep 17 00:00:00 2001 From: Abdi Abdulle Date: Thu, 11 Dec 2025 16:09:04 +0100 Subject: [PATCH 5/9] updated tests --- .../datasets-filter.component.spec.ts | 2 +- .../datasets-filter.component.ts | 16 +++++----- .../shared-filter.component.spec.ts | 32 +++++++++++++++++++ .../shared-filter/shared-filter.component.ts | 2 +- .../shared/services/units-options.service.ts | 1 - 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.spec.ts b/src/app/datasets/datasets-filter/datasets-filter.component.spec.ts index cab9947c9d..8eb42e7053 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.spec.ts +++ b/src/app/datasets/datasets-filter/datasets-filter.component.spec.ts @@ -224,7 +224,7 @@ describe("DatasetsFilterComponent", () => { component.reset(); - expect(dispatchSpy).toHaveBeenCalledTimes(6); + expect(dispatchSpy).toHaveBeenCalledTimes(5); expect(dispatchSpy).toHaveBeenCalledWith(clearFacetsAction()); expect(dispatchSpy).toHaveBeenCalledWith(fetchDatasetsAction()); expect(dispatchSpy).toHaveBeenCalledWith(fetchFacetCountsAction()); diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.ts b/src/app/datasets/datasets-filter/datasets-filter.component.ts index 8bb30de092..063644f64a 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.ts +++ b/src/app/datasets/datasets-filter/datasets-filter.component.ts @@ -61,14 +61,6 @@ export class DatasetsFilterComponent implements OnInit, OnDestroy { @ViewChild("conditionFilter") conditionFilter: SharedFilterComponent; - addCondition = (condition: ScientificCondition) => { - this.store.dispatch(addScientificConditionAction({ condition })); - }; - - removeCondition = (condition: ScientificCondition) => { - this.store.dispatch(removeScientificConditionAction({ condition })); - }; - constructor( private store: Store, private asyncPipe: AsyncPipe, @@ -78,6 +70,14 @@ export class DatasetsFilterComponent implements OnInit, OnDestroy { public dialog: MatDialog, ) {} + addCondition = (condition: ScientificCondition) => { + this.store.dispatch(addScientificConditionAction({ condition })); + }; + + removeCondition = (condition: ScientificCondition) => { + this.store.dispatch(removeScientificConditionAction({ condition })); + }; + ngOnInit() { this.subscriptions.push( this.filterConfigs$.subscribe((filterConfigs) => { 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 1d8541b46e..82352097da 100644 --- a/src/app/shared/modules/shared-filter/shared-filter.component.ts +++ b/src/app/shared/modules/shared-filter/shared-filter.component.ts @@ -315,7 +315,7 @@ export class SharedFilterComponent implements OnChanges, OnInit, OnDestroy { /** Condition filter helpers and methods START */ // Helper to get all conditions and update store - updateStore(updatedConditions: ConditionConfig[]) { + updateStore(updatedConditions: ConditionConfig[]) { this.store.dispatch( updateConditionsConfigs({ conditionConfigs: updatedConditions }), ); diff --git a/src/app/shared/services/units-options.service.ts b/src/app/shared/services/units-options.service.ts index 4e19f07243..814010753b 100644 --- a/src/app/shared/services/units-options.service.ts +++ b/src/app/shared/services/units-options.service.ts @@ -4,7 +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)); From 97e2ffdaba56cd36bc443076fea7e84b2ca19b96 Mon Sep 17 00:00:00 2001 From: Abdi Abdulle Date: Thu, 11 Dec 2025 16:12:35 +0100 Subject: [PATCH 6/9] eslint fix --- src/app/shared/services/units-options.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/shared/services/units-options.service.ts b/src/app/shared/services/units-options.service.ts index 814010753b..8f9791216a 100644 --- a/src/app/shared/services/units-options.service.ts +++ b/src/app/shared/services/units-options.service.ts @@ -4,7 +4,6 @@ import { Injectable } from "@angular/core"; providedIn: "root", }) export class UnitsOptionsService { - setUnitsOptions(lhs: string, unitsOptions: string[]) { localStorage.setItem(`unitsOptions_${lhs}`, JSON.stringify(unitsOptions)); } From c2d013e9a4deea629d312bf325a635113e15c048 Mon Sep 17 00:00:00 2001 From: Abdi Abdulle Date: Mon, 15 Dec 2025 11:46:17 +0100 Subject: [PATCH 7/9] e2e tests fix --- cypress/e2e/datasets/datasets-general.cy.js | 50 +++++++++------------ 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/cypress/e2e/datasets/datasets-general.cy.js b/cypress/e2e/datasets/datasets-general.cy.js index 49dd5e485b..cd66d38aee 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(); }); }); From f90769c6ad441c656bf051600b2737f399d8fce1 Mon Sep 17 00:00:00 2001 From: Abdi Abdulle Date: Mon, 15 Dec 2025 14:36:35 +0100 Subject: [PATCH 8/9] preserving dataset conditions when clearing sample conditions and vice versa. Also created a test case for it --- cypress/e2e/datasets/datasets-general.cy.js | 63 +++++++++++++++++++ cypress/fixtures/testData.js | 15 +++++ cypress/support/commands.js | 22 +++++++ .../sample-dashboard.component.html | 2 + .../sample-dashboard.component.ts | 12 +--- .../shared-filter/shared-filter.component.ts | 17 +++++ 6 files changed, 120 insertions(+), 11 deletions(-) diff --git a/cypress/e2e/datasets/datasets-general.cy.js b/cypress/e2e/datasets/datasets-general.cy.js index cd66d38aee..9525eea5e8 100644 --- a/cypress/e2e/datasets/datasets-general.cy.js +++ b/cypress/e2e/datasets/datasets-general.cy.js @@ -660,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/samples/sample-dashboard/sample-dashboard.component.html b/src/app/samples/sample-dashboard/sample-dashboard.component.html index 9b8328f40a..4c170ae07b 100644 --- a/src/app/samples/sample-dashboard/sample-dashboard.component.html +++ b/src/app/samples/sample-dashboard/sample-dashboard.component.html @@ -40,6 +40,7 @@ mat-raised-button color="secondary" class="samples-filters-clear-button" + data-cy="samples-filters-clear-button" (click)="reset()" > undo @@ -49,6 +50,7 @@ mat-raised-button color="primary" class="samples-filters-search-button" + data-cy="samples-filters-search-button" (click)="applyFilters()" > search diff --git a/src/app/samples/sample-dashboard/sample-dashboard.component.ts b/src/app/samples/sample-dashboard/sample-dashboard.component.ts index 14a85e7588..92a5951be9 100644 --- a/src/app/samples/sample-dashboard/sample-dashboard.component.ts +++ b/src/app/samples/sample-dashboard/sample-dashboard.component.ts @@ -262,23 +262,13 @@ export class SampleDashboardComponent implements OnInit, OnDestroy { reset() { this.store.dispatch(setTextFilterAction({ text: "" })); - this.store.dispatch( - updateConditionsConfigs({ - conditionConfigs: [], - }), - ); - this.store.dispatch( - updateUserSettingsAction({ - property: { conditions: [] }, - }), - ); - // Clear all characteristics 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()); } 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 82352097da..1cd8b76a3c 100644 --- a/src/app/shared/modules/shared-filter/shared-filter.component.ts +++ b/src/app/shared/modules/shared-filter/shared-filter.component.ts @@ -725,6 +725,23 @@ export class SharedFilterComponent implements OnChanges, OnInit, OnDestroy { }); } + 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()); } From fc655e094c26af7f1490f15734a1058e456845ca Mon Sep 17 00:00:00 2001 From: Abdi Abdulle Date: Mon, 15 Dec 2025 16:42:18 +0100 Subject: [PATCH 9/9] increasing timeout on getConfig --- cypress/e2e/datasets/datasets-general.cy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/e2e/datasets/datasets-general.cy.js b/cypress/e2e/datasets/datasets-general.cy.js index 9525eea5e8..9ba4674077 100644 --- a/cypress/e2e/datasets/datasets-general.cy.js +++ b/cypress/e2e/datasets/datasets-general.cy.js @@ -641,7 +641,7 @@ describe("Datasets general", () => { cy.visit("/datasets"); - cy.wait("@getConfig", { timeout: 10000 }); + cy.wait("@getConfig", { timeout: 20000 }); cy.finishedLoading(); });