From 267cab9fd22073a3c6334c6210b62ae601ef6cbc Mon Sep 17 00:00:00 2001 From: Igor Khokhriakov Date: Wed, 21 Aug 2024 13:11:46 +0200 Subject: [PATCH] Configurable facets (#1465) * Full width search bar (#1426) * First steps * Beautify * Use shared search bar component * Remove search bar from facets * Some clean ups * Some clean ups * Some clean ups * Code formatting * Separating full text search from shared search bar * Add search and clear buttons; some clean ups * Fix lint issue * Fix lint issue * Fix lint issue * Explicit action button search facets (#1457) * Change label * Add button * Add button * Full width search bar (#1426) * First steps * Beautify * Use shared search bar component * Remove search bar from facets * Some clean ups * Some clean ups * Some clean ups * Code formatting * Fix layout * Add functionality to the button * Fix lint issue, finally?! * Fix lint issue, finally! * Extract PID filter, WIP * Fix css; some cleanups * Further progress * Remove search icon * Add edit mode * Add/remove filter * Extract ClearableInputComponent * Add location-filter.component.ts * Add group-filter.component.ts * Add type-filter.component.ts * Add keyword-filter.component.ts * Add date-range-filter.component.ts * Add text-filter.component.ts * Show addable entities by default * Show default filters * Remove edit mode; some cleanups * Cleanup * Fix tests * build(deps-dev): bump the eslint group with 2 updates Bumps the eslint group with 2 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) and [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser). Updates `@typescript-eslint/eslint-plugin` from 7.7.0 to 7.7.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.7.1/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 7.7.0 to 7.7.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.7.1/packages/parser) --- updated-dependencies: - dependency-name: "@typescript-eslint/eslint-plugin" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: eslint - dependency-name: "@typescript-eslint/parser" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: eslint ... Signed-off-by: dependabot[bot] * build(deps-dev): bump cypress from 13.7.3 to 13.8.0 Bumps [cypress](https://github.com/cypress-io/cypress) from 13.7.3 to 13.8.0. - [Release notes](https://github.com/cypress-io/cypress/releases) - [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md) - [Commits](https://github.com/cypress-io/cypress/compare/v13.7.3...v13.8.0) --- updated-dependencies: - dependency-name: cypress dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * feat: Updated logo configuration (#1459) * added configuration for site logos and updated default images * fixed linting, added documentation and named images correctly * Fix tests * Introduce dialog * Click Search button after typing search query * Refactor full text search * Apply prettier * Convert onChange to reactive stream * Post merge * Fix elastic search test * Implementing Filter Settings dialog concept * Implementing Filter Settings dialog concept * Resolve post merge issues * Prettify * Prettify * Fix undefined input * Fix tests compilation * Resolve TODO: improve readability * Fix tests * Clean up unused imports * Prettify scientific conditions * Prettify scientific conditions * Prettify scientific conditions * Resolve #1466 * Remove redundant behavior * fix tests * fix tests * Fix tests * Add tests * Progress: move filters into a dedicated module; store filters state in User state * Progress: move filters into a dedicated module; store filters state in User state * Fix eslint * fix tests * make sonar happy(ier) * Replace Filter by... with Filters and Conditions * Extract filter html templates and css into separate files * Fix tests * Fix tests * Do not call fixture.destroy manually * Fix tests * Remove inheritance as it makes Angular testing unhappy * Revert "Do not call fixture.destroy manually" This reverts commit fb201ff9b6a4e85e890dca0063b9b6322d7f5964. * Add fixture.destroy * Replace static label with external function call * Remove trivial tests to avoid code duplications in case inheritance has to be removed * Fix eslint * Fix tests * Avoid @ViewChild * Try to fix test as per [SO](https://stackoverflow.com/a/66695890) * Try to fix test as per [SO](https://stackoverflow.com/a/66695890) * Revert "Remove trivial tests to avoid code duplications in case inheritance has to be removed" This reverts commit 04666e42dba4df0750778698c56079ad71f998a5. * Try to fix test as per [SO](https://stackoverflow.com/a/66695890) * Revert "Remove inheritance as it makes Angular testing unhappy" This reverts commit d75db609 * Revert "Remove inheritance as it makes Angular testing unhappy" This reverts commit d75db609 * Change Labels * Separate Filters and Conditions sections in the filter settings dialog * Remove text filter * Auto enable new condition * Allow condition editing in the filter settings dialog * Fix clear function * Fix tests --------- Signed-off-by: dependabot[bot] Co-authored-by: Max Novelli Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 12 +- .../full-text-search-bar.component.html | 10 +- .../full-text-search-bar.component.ts | 2 +- .../datasets-filter.component.html | 215 +------- .../datasets-filter.component.scss | 21 + .../datasets-filter.component.spec.ts | 474 +++--------------- .../datasets-filter.component.ts | 383 +++----------- .../datasets-filter-settings.component.html | 74 +++ .../datasets-filter-settings.component.scss | 58 +++ ...datasets-filter-settings.component.spec.ts | 183 +++++++ .../datasets-filter-settings.component.ts | 155 ++++++ src/app/datasets/datasets.module.ts | 12 + src/app/shared/MockStubs.ts | 2 +- .../filters/clearable-input.component.ts | 14 + .../filters/condition-filter.component.html | 4 + .../filters/condition-filter.component.scss | 3 + .../filters/condition-filter.component.ts | 40 ++ .../filters/date-range-filter.component.html | 19 + .../filters/date-range-filter.component.scss | 3 + .../date-range-filter.component.spec.ts | 174 +++++++ .../filters/date-range-filter.component.ts | 62 +++ .../shared/modules/filters/filters.module.ts | 80 +++ .../filters/group-filter.component.html | 29 ++ .../filters/group-filter.component.scss | 3 + .../filters/group-filter.component.spec.ts | 135 +++++ .../modules/filters/group-filter.component.ts | 60 +++ .../filters/keyword-filter.component.html | 29 ++ .../filters/keyword-filter.component.scss | 3 + .../filters/keyword-filter.component.spec.ts | 137 +++++ .../filters/keyword-filter.component.ts | 84 ++++ .../filters/location-filter.component.html | 31 ++ .../filters/location-filter.component.scss | 3 + .../filters/location-filter.component.spec.ts | 137 +++++ .../filters/location-filter.component.ts | 62 +++ .../pid-filter-contains.component.html | 9 + .../pid-filter-contains.component.scss | 3 + .../pid-filter-contains.component.spec.ts | 106 ++++ .../filters/pid-filter-contains.component.ts | 13 + .../pid-filter-startsWith.component.html | 9 + .../pid-filter-startsWith.component.scss | 3 + .../pid-filter-startsWith.component.spec.ts | 109 ++++ .../pid-filter-startsWith.component.ts | 15 + .../modules/filters/pid-filter.component.html | 9 + .../modules/filters/pid-filter.component.scss | 3 + .../filters/pid-filter.component.spec.ts | 130 +++++ .../modules/filters/pid-filter.component.ts | 65 +++ .../filters/text-filter.component.html | 10 + .../filters/text-filter.component.scss | 3 + .../filters/text-filter.component.spec.ts | 112 +++++ .../modules/filters/text-filter.component.ts | 48 ++ .../filters/type-filter.component.html | 30 ++ .../filters/type-filter.component.scss | 3 + .../filters/type-filter.component.spec.ts | 137 +++++ .../modules/filters/type-filter.component.ts | 61 +++ src/app/shared/modules/filters/utils.spec.ts | 39 ++ src/app/shared/modules/filters/utils.ts | 48 ++ .../search-parameters-dialog.component.html | 2 +- ...search-parameters-dialog.component.spec.ts | 17 +- .../search-parameters-dialog.component.ts | 24 +- src/app/shared/shared.module.ts | 2 + .../actions/datasets.actions.spec.ts | 11 +- .../actions/datasets.actions.ts | 2 +- .../state-management/actions/user.actions.ts | 14 + .../effects/datasets.effects.spec.ts | 8 +- .../reducers/datasets.reducer.spec.ts | 4 +- .../reducers/datasets.reducer.ts | 3 +- .../state-management/reducers/user.reducer.ts | 14 + .../selectors/user.selectors.spec.ts | 23 + .../selectors/user.selectors.ts | 10 + src/app/state-management/state/user.store.ts | 30 ++ 70 files changed, 2898 insertions(+), 944 deletions(-) create mode 100644 src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.html create mode 100644 src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.scss create mode 100644 src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.spec.ts create mode 100644 src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.ts create mode 100644 src/app/shared/modules/filters/clearable-input.component.ts create mode 100644 src/app/shared/modules/filters/condition-filter.component.html create mode 100644 src/app/shared/modules/filters/condition-filter.component.scss create mode 100644 src/app/shared/modules/filters/condition-filter.component.ts create mode 100644 src/app/shared/modules/filters/date-range-filter.component.html create mode 100644 src/app/shared/modules/filters/date-range-filter.component.scss create mode 100644 src/app/shared/modules/filters/date-range-filter.component.spec.ts create mode 100644 src/app/shared/modules/filters/date-range-filter.component.ts create mode 100644 src/app/shared/modules/filters/filters.module.ts create mode 100644 src/app/shared/modules/filters/group-filter.component.html create mode 100644 src/app/shared/modules/filters/group-filter.component.scss create mode 100644 src/app/shared/modules/filters/group-filter.component.spec.ts create mode 100644 src/app/shared/modules/filters/group-filter.component.ts create mode 100644 src/app/shared/modules/filters/keyword-filter.component.html create mode 100644 src/app/shared/modules/filters/keyword-filter.component.scss create mode 100644 src/app/shared/modules/filters/keyword-filter.component.spec.ts create mode 100644 src/app/shared/modules/filters/keyword-filter.component.ts create mode 100644 src/app/shared/modules/filters/location-filter.component.html create mode 100644 src/app/shared/modules/filters/location-filter.component.scss create mode 100644 src/app/shared/modules/filters/location-filter.component.spec.ts create mode 100644 src/app/shared/modules/filters/location-filter.component.ts create mode 100644 src/app/shared/modules/filters/pid-filter-contains.component.html create mode 100644 src/app/shared/modules/filters/pid-filter-contains.component.scss create mode 100644 src/app/shared/modules/filters/pid-filter-contains.component.spec.ts create mode 100644 src/app/shared/modules/filters/pid-filter-contains.component.ts create mode 100644 src/app/shared/modules/filters/pid-filter-startsWith.component.html create mode 100644 src/app/shared/modules/filters/pid-filter-startsWith.component.scss create mode 100644 src/app/shared/modules/filters/pid-filter-startsWith.component.spec.ts create mode 100644 src/app/shared/modules/filters/pid-filter-startsWith.component.ts create mode 100644 src/app/shared/modules/filters/pid-filter.component.html create mode 100644 src/app/shared/modules/filters/pid-filter.component.scss create mode 100644 src/app/shared/modules/filters/pid-filter.component.spec.ts create mode 100644 src/app/shared/modules/filters/pid-filter.component.ts create mode 100644 src/app/shared/modules/filters/text-filter.component.html create mode 100644 src/app/shared/modules/filters/text-filter.component.scss create mode 100644 src/app/shared/modules/filters/text-filter.component.spec.ts create mode 100644 src/app/shared/modules/filters/text-filter.component.ts create mode 100644 src/app/shared/modules/filters/type-filter.component.html create mode 100644 src/app/shared/modules/filters/type-filter.component.scss create mode 100644 src/app/shared/modules/filters/type-filter.component.spec.ts create mode 100644 src/app/shared/modules/filters/type-filter.component.ts create mode 100644 src/app/shared/modules/filters/utils.spec.ts create mode 100644 src/app/shared/modules/filters/utils.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 31ea6b31b..223e5bca8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -117,9 +117,9 @@ jobs: - name: Run docker-compose run: | cp CI/ESS/e2e/docker-compose.e2e.yaml docker-compose.yaml - docker-compose pull - docker-compose build --no-cache - docker-compose up -d + docker compose pull + docker compose build --no-cache + docker compose up -d - name: Wait for Backend run: | @@ -136,13 +136,13 @@ jobs: - name: docker logs if: ${{ failure() }} run: | - docker-compose logs es01 - docker-compose logs backend + docker compose logs es01 + docker compose logs backend - name: Stop docker-compose if: ${{ !cancelled() }} run: | - docker-compose down -v + docker compose down -v - uses: actions/upload-artifact@v4 if: ${{ failure() }} diff --git a/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.html b/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.html index 71be7f287..f9895119c 100644 --- a/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.html +++ b/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.html @@ -21,15 +21,15 @@ search Search - diff --git a/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.ts b/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.ts index 4fe17d6d8..f30011865 100644 --- a/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.ts +++ b/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.ts @@ -87,7 +87,7 @@ export class FullTextSearchBarComponent implements OnInit, OnDestroy { onClear(): void { this.searchTerm = ""; this.searchTermSubject.next(undefined); - //this.searchClickSubject.next(); + this.searchClickSubject.next(); } ngOnDestroy(): void { diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.html b/src/app/datasets/datasets-filter/datasets-filter.component.html index 119eb9814..350228347 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.html +++ b/src/app/datasets/datasets-filter/datasets-filter.component.html @@ -1,200 +1,35 @@ - Filter by... + Filters and Conditions - - + + + + + + + - - Location - - {{ location || "No Location" }} - cancel - - - - - - - {{ getFacetId(fc, "No Location") }} | - {{ getFacetCount(fc) }} - - - - - - Group - - {{ group }}cancel - - - - - - - {{ getFacetId(fc, "No Group") }} | - {{ getFacetCount(fc) }} - - - - - - Type - - {{ type }}cancel - - - - - - - {{ getFacetId(fc, "No Type") }} | - {{ getFacetCount(fc) }} - - - - - - Keywords - - {{ keyword }}cancel - - - - - - {{ getFacetId(fc, "No Keywords") }} - : {{ getFacetCount(fc) }} - - - - - - Start Date - End Date - - - - - - -
- - - - - - {{ condition.lhs }} - - -  =  - - -  =  - - -  <  - - -  >  - - - {{ - condition.relation === "EQUAL_TO_STRING" - ? '"' + condition.rhs + '"' - : condition.rhs - }} - {{ condition.unit | prettyUnit }} - - cancel - - + + +
+ +
+ +
diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.scss b/src/app/datasets/datasets-filter/datasets-filter.component.scss index d3c8fb819..92dbd784e 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.scss +++ b/src/app/datasets/datasets-filter/datasets-filter.component.scss @@ -5,6 +5,15 @@ mat-card { width: 100%; } + .filter-container { + display: flex; + align-items: center; + + :first-child { + flex-grow: 1; + } + } + .section-container { font-size: 1.25rem; font-weight: 425; @@ -20,6 +29,18 @@ mat-card { margin-left: auto; } + .full-width-button { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0; + + mat-icon { + margin-right: 8px; + } + } + .scientific-chips { ::ng-deep .mat-mdc-chip-list-wrapper { margin: 0; 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 5c4d78247..cfc4f6053 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.spec.ts +++ b/src/app/datasets/datasets-filter/datasets-filter.component.spec.ts @@ -11,30 +11,13 @@ import { MockStore } from "shared/MockStubs"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; -import { FacetCount } from "state-management/state/datasets.store"; import { - setSearchTermsAction, - addLocationFilterAction, - removeLocationFilterAction, - addGroupFilterAction, - removeGroupFilterAction, - addKeywordFilterAction, - removeKeywordFilterAction, - addTypeFilterAction, - removeTypeFilterAction, clearFacetsAction, - removeScientificConditionAction, - setDateRangeFilterAction, - addScientificConditionAction, - setPidTermsAction, + fetchDatasetsAction, + fetchFacetCountsAction, } from "state-management/actions/datasets.actions"; import { of } from "rxjs"; -import { - selectColumnAction, - deselectColumnAction, - deselectAllCustomColumnsAction, -} from "state-management/actions/user.actions"; -import { ScientificCondition } from "state-management/models"; +import { deselectAllCustomColumnsAction } from "state-management/actions/user.actions"; import { SharedScicatFrontendModule } from "shared/shared.module"; import { MatAutocompleteModule } from "@angular/material/autocomplete"; import { MatDialogModule, MatDialog } from "@angular/material/dialog"; @@ -43,25 +26,51 @@ import { MatInputModule } from "@angular/material/input"; import { MatSelectModule } from "@angular/material/select"; import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; import { AsyncPipe } from "@angular/common"; -import { DateTime } from "luxon"; -import { - MatDatepickerInputEvent, - MatDatepickerModule, -} from "@angular/material/datepicker"; +import { MatDatepickerModule } from "@angular/material/datepicker"; import { MatChipsModule } from "@angular/material/chips"; import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; import { MatCardModule } from "@angular/material/card"; import { MatButtonModule } from "@angular/material/button"; import { MatIconModule } from "@angular/material/icon"; import { AppConfigService } from "app-config.service"; +import { DatasetsFilterSettingsComponent } from "./settings/datasets-filter-settings.component"; +import { LocationFilterComponent } from "../../shared/modules/filters/location-filter.component"; +import { PidFilterComponent } from "../../shared/modules/filters/pid-filter.component"; +import { PidFilterContainsComponent } from "../../shared/modules/filters/pid-filter-contains.component"; +import { PidFilterStartsWithComponent } from "../../shared/modules/filters/pid-filter-startsWith.component"; +import { GroupFilterComponent } from "../../shared/modules/filters/group-filter.component"; +import { TypeFilterComponent } from "../../shared/modules/filters/type-filter.component"; +import { KeywordFilterComponent } from "../../shared/modules/filters/keyword-filter.component"; +import { DateRangeFilterComponent } from "../../shared/modules/filters/date-range-filter.component"; +import { TextFilterComponent } from "../../shared/modules/filters/text-filter.component"; +import { FilterConfig } from "../../shared/modules/filters/filters.module"; +import { selectFilters } from "../../state-management/selectors/user.selectors"; + +const filterConfigs: FilterConfig[] = [ + { type: "LocationFilterComponent", visible: true }, + { type: "PidFilterComponent", visible: true }, + { type: "PidFilterContainsComponent", visible: false }, + { type: "PidFilterStartsWithComponent", visible: false }, + { type: "GroupFilterComponent", visible: true }, + { type: "TypeFilterComponent", visible: true }, + { type: "KeywordFilterComponent", visible: true }, + { type: "DateRangeFilterComponent", visible: true }, + { type: "TextFilterComponent", visible: true }, +]; + +export class MockStoreWithFilters extends MockStore { + public select(selector: any) { + if (selector === selectFilters) { + return of(filterConfigs); + } + return of(null); + } +} export class MockMatDialog { open() { return { - afterClosed: () => - of({ - data: { lhs: "", rhs: "", relation: "EQUAL_TO_STRING", unit: "" }, - }), + afterClosed: () => of(filterConfigs), }; } } @@ -74,7 +83,7 @@ describe("DatasetsFilterComponent", () => { let component: DatasetsFilterComponent; let fixture: ComponentFixture; - let store: MockStore; + let store: MockStoreWithFilters; let dispatchSpy; beforeEach(waitForAsync(() => { @@ -100,7 +109,10 @@ describe("DatasetsFilterComponent", () => { StoreModule.forRoot({}), ], declarations: [DatasetsFilterComponent, SearchParametersDialogComponent], - providers: [AsyncPipe], + providers: [ + AsyncPipe, + { provide: Store, useClass: MockStoreWithFilters }, + ], }); TestBed.overrideComponent(DatasetsFilterComponent, { set: { @@ -124,7 +136,7 @@ describe("DatasetsFilterComponent", () => { fixture.detectChanges(); }); - beforeEach(inject([Store], (mockStore: MockStore) => { + beforeEach(inject([Store], (mockStore: MockStoreWithFilters) => { store = mockStore; })); @@ -163,418 +175,50 @@ describe("DatasetsFilterComponent", () => { it("should contain a clear all button", () => { const compiled = fixture.debugElement.nativeElement; const btn = compiled.querySelector(".datasets-filters-clear-all-button"); - expect(btn.textContent).toContain("Clear All Filters"); + expect(btn.textContent).toContain("undo Reset"); }); it("should contain a search button", () => { const compiled = fixture.debugElement.nativeElement; const btn = compiled.querySelector(".datasets-filters-search-button"); - expect(btn.textContent).toContain("Search"); - }); - - describe("#getFacetId()", () => { - it("should return the FacetCount id if present", () => { - const facetCount: FacetCount = { - _id: "test1", - count: 0, - }; - const fallback = "test2"; - - const id = component.getFacetId(facetCount, fallback); - - expect(id).toEqual("test1"); - }); - - it("should return the FacetCount id if present", () => { - const facetCount: FacetCount = { - count: 0, - }; - const fallback = "test"; - - const id = component.getFacetId(facetCount, fallback); - - expect(id).toEqual(fallback); - }); - }); - - describe("#getFacetCount()", () => { - it("should return the FacetCount", () => { - const facetCount: FacetCount = { - count: 0, - }; - - const count = component.getFacetCount(facetCount); - - expect(count).toEqual(facetCount.count); - }); - }); - - describe("#textSearchChanged()", () => { - it("should dispatch a SetSearchTermsAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const terms = "test"; - component.textSearchChanged(terms); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith(setSearchTermsAction({ terms })); - }); - }); - - describe("#onLocationInput()", () => { - it("should call next on locationInput$", () => { - const nextSpy = spyOn(component.locationInput$, "next"); - - const event = { - target: { - value: "location", - }, - }; - - component.onLocationInput(event); - - expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); - }); - }); - - describe("#onGroupInput()", () => { - it("should call next on groupInput$", () => { - const nextSpy = spyOn(component.groupInput$, "next"); - - const event = { - target: { - value: "group", - }, - }; - - component.onGroupInput(event); - - expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); - }); - }); - - describe("#onKeywordInput()", () => { - it("should call next on keywordsInput$", () => { - const nextSpy = spyOn(component.keywordsInput$, "next"); - - const event = { - target: { - value: "keyword", - }, - }; - - component.onKeywordInput(event); - - expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); - }); - }); - - describe("#onTypeInput()", () => { - it("should call next on typeInput$", () => { - const nextSpy = spyOn(component.typeInput$, "next"); - - const event = { - target: { - value: "type", - }, - }; - - component.onTypeInput(event); - - expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); - }); - }); - - describe("#locationSelected()", () => { - it("should dispatch an AddLocationFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const location = "test"; - component.locationSelected(location); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - addLocationFilterAction({ location }), - ); - }); + expect(btn.textContent).toContain("search Apply"); }); - describe("#locationRemoved()", () => { - it("should dispatch a RemoveLocationFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const location = "test"; - component.locationRemoved(location); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - removeLocationFilterAction({ location }), - ); - }); - }); - - describe("#groupSelected()", () => { - it("should dispatch an AddGroupFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const group = "test"; - component.groupSelected(group); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith(addGroupFilterAction({ group })); - }); - }); - - describe("#groupRemoved()", () => { - it("should dispatch a RemoveGroupFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const group = "test"; - component.groupRemoved(group); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - removeGroupFilterAction({ group }), - ); - }); - }); - - describe("#keywordSelected()", () => { - it("should dispatch an AddKeywordFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const keyword = "test"; - component.keywordSelected(keyword); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - addKeywordFilterAction({ keyword }), - ); - }); - }); - - describe("#keywordRemoved()", () => { - it("should dispatch a RemoveKeywordFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const keyword = "test"; - component.keywordRemoved(keyword); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - removeKeywordFilterAction({ keyword }), - ); - }); - }); - - describe("#typeSelected()", () => { - it("should dispatch an AddTypeFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const datasetType = "string"; - component.typeSelected(datasetType); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - addTypeFilterAction({ datasetType }), - ); - }); - }); - - describe("#typeRemoved()", () => { - it("should dispatch a RemoveTypeFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const datasetType = "string"; - component.typeRemoved(datasetType); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - removeTypeFilterAction({ datasetType }), - ); - }); - }); - - describe("#dateChanged()", () => { - it("should dispatch setDateRangeFilterAction with empty string values if event.value is null", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const event = { - targetElement: { - getAttribute: (name: string) => "begin", - }, - value: null, - } as MatDatepickerInputEvent; - - component.dateChanged(event); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - setDateRangeFilterAction({ begin: "", end: "" }), - ); - }); - - it("should set dateRange.begin if event has value and event.targetElement name is begin", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const beginDate = DateTime.fromJSDate(new Date("2021-01-01")); - const event = { - targetElement: { - getAttribute: (name: string) => "begin", - }, - value: beginDate, - } as MatDatepickerInputEvent; - - component.dateChanged(event); - - const expected = beginDate.toUTC().toISO(); - expect(component.dateRange.begin).toEqual(expected); - expect(dispatchSpy).not.toHaveBeenCalled(); - }); - - it("should set dateRange.end if event has value and event.targetElement name is end", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const endDate = DateTime.fromJSDate(new Date("2021-07-08")); - const event = { - targetElement: { - getAttribute: (name: string) => "end", - }, - value: endDate, - } as MatDatepickerInputEvent; - - component.dateChanged(event); - - const expected = endDate.toUTC().plus({ days: 1 }).toISO(); - expect(component.dateRange.end).toEqual(expected); - expect(dispatchSpy).not.toHaveBeenCalled(); - }); - - it("should dispatch a setDateRangeFilterAction if dateRange.begin and dateRange.end have values", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const beginDate = DateTime.fromJSDate(new Date("2021-01-01")); - const endDate = DateTime.fromJSDate(new Date("2021-07-08")); - component.dateRange.begin = beginDate.toUTC().toISO(); - const event = { - targetElement: { - getAttribute: (name: string) => "end", - }, - value: endDate, - } as MatDatepickerInputEvent; - - component.dateChanged(event); - - const expected = { - begin: beginDate.toUTC().toISO(), - end: endDate.toUTC().plus({ days: 1 }).toISO(), - }; - expect(dispatchSpy).toHaveBeenCalledOnceWith( - setDateRangeFilterAction(expected), - ); - }); - }); - - describe("#clearFacets()", () => { + describe("#reset()", () => { it("should dispatch a ClearFacetsAction and a deselectAllCustomColumnsAction", () => { dispatchSpy = spyOn(store, "dispatch"); - component.clearFacets(); + component.reset(); - expect(dispatchSpy).toHaveBeenCalledTimes(3); + expect(dispatchSpy).toHaveBeenCalledTimes(4); expect(dispatchSpy).toHaveBeenCalledWith(clearFacetsAction()); - expect(dispatchSpy).toHaveBeenCalledWith(setPidTermsAction({ pid: "" })); expect(dispatchSpy).toHaveBeenCalledWith( deselectAllCustomColumnsAction(), ); + expect(dispatchSpy).toHaveBeenCalledWith(fetchDatasetsAction()); + expect(dispatchSpy).toHaveBeenCalledWith(fetchFacetCountsAction()); }); }); - describe("#showAddConditionDialog()", () => { - it("should open SearchParametersDialog, dispatch addScientificConditionAction and selectColumnAction if dialog returns data", () => { + describe("#showDatasetsFilterSettingsDialog()", () => { + it("should open DatasetsFilterSettingsComponent", () => { spyOn(component.dialog, "open").and.callThrough(); dispatchSpy = spyOn(store, "dispatch"); - component.metadataKeys$ = of(["test", "keys"]); - component.showAddConditionDialog(); + // component.metadataKeys$ = of(["test", "keys"]); + component.showDatasetsFilterSettingsDialog(); expect(component.dialog.open).toHaveBeenCalledTimes(1); expect(component.dialog.open).toHaveBeenCalledWith( - SearchParametersDialogComponent, + DatasetsFilterSettingsComponent, { + width: "60%", data: { - parameterKeys: component["asyncPipe"].transform( - component.metadataKeys$, - ), + filterConfigs: filterConfigs, + conditionConfigs: null, }, }, ); - expect(dispatchSpy).toHaveBeenCalledTimes(2); - expect(dispatchSpy).toHaveBeenCalledWith( - addScientificConditionAction({ - condition: { - lhs: "", - rhs: "", - relation: "EQUAL_TO_STRING", - unit: "", - }, - }), - ); - expect(dispatchSpy).toHaveBeenCalledWith( - selectColumnAction({ name: "", columnType: "custom" }), - ); - }); - }); - - describe("#removeCondition()", () => { - it("should dispatch a removeScientificConditionAction and a deselectColumnAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const condition: ScientificCondition = { - lhs: "test", - relation: "EQUAL_TO_NUMERIC", - rhs: 5, - unit: "s", - }; - const index = 0; - component.removeCondition(condition, index); - - expect(dispatchSpy).toHaveBeenCalledTimes(2); - expect(dispatchSpy).toHaveBeenCalledWith( - removeScientificConditionAction({ index }), - ); - expect(dispatchSpy).toHaveBeenCalledWith( - deselectColumnAction({ name: condition.lhs, columnType: "custom" }), - ); - }); - }); - - describe("#pidSearchChanged()", () => { - it("should dispatch a SetSearchTermsAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const pid = "1"; - component.pidSearchChanged(pid); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith(setPidTermsAction({ pid })); - }); - }); - - describe("#buildPidTermsCondition()", () => { - const tests = [ - ["", "", ""], - ["1", "startsWith", { $regex: "^1" }], - ["1", "contains", { $regex: "1" }], - ["1", "equals", "1"], - ["1", "", "1"], - ]; - tests.forEach((t, i) => { - it(`should return buildPidTermsCondition ${i}`, () => { - component.appConfig.pidSearchMethod = t[1] as string; - const condition = component["buildPidTermsCondition"](t[0] as string); - expect(condition).toEqual(t[2]); - }); }); }); }); diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.ts b/src/app/datasets/datasets-filter/datasets-filter.component.ts index 3dcb54baa..4d1f8aee7 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.ts +++ b/src/app/datasets/datasets-filter/datasets-filter.component.ts @@ -1,358 +1,137 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ChangeDetectorRef, Component, OnDestroy } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; import { Store } from "@ngrx/store"; -import { - debounceTime, - distinctUntilChanged, - skipWhile, - map, -} from "rxjs/operators"; -import { FacetCount } from "state-management/state/datasets.store"; import { - selectCreationTimeFilter, - selectGroupFacetCounts, - selectGroupFilter, selectHasAppliedFilters, - selectKeywordFacetCounts, - selectKeywordsFilter, - selectLocationFacetCounts, - selectLocationFilter, selectScientificConditions, - selectSearchTerms, - selectTypeFacetCounts, - selectTypeFilter, - selectKeywordsTerms, - selectMetadataKeys, - selectPidTerms, } from "state-management/selectors/datasets.selectors"; import { - setTextFilterAction, - addKeywordFilterAction, - setSearchTermsAction, - addLocationFilterAction, - removeLocationFilterAction, - addGroupFilterAction, - removeGroupFilterAction, - removeKeywordFilterAction, - addTypeFilterAction, - removeTypeFilterAction, - setDateRangeFilterAction, clearFacetsAction, - addScientificConditionAction, - removeScientificConditionAction, - setPidTermsAction, - setPidTermsFilterAction, fetchDatasetsAction, fetchFacetCountsAction, } from "state-management/actions/datasets.actions"; -import { combineLatest, BehaviorSubject, Observable, Subscription } from "rxjs"; +import { Subscription } from "rxjs"; import { - selectColumnAction, - deselectColumnAction, deselectAllCustomColumnsAction, + updateConditionsConfigs, + updateFilterConfigs, } from "state-management/actions/user.actions"; -import { ScientificCondition } from "state-management/models"; -import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; -import { AsyncPipe } from "@angular/common"; -import { MatDatepickerInputEvent } from "@angular/material/datepicker"; -import { DateTime } from "luxon"; import { AppConfigService } from "app-config.service"; - -interface DateRange { - begin: string; - end: string; -} -enum PidTermsSearchCondition { - startsWith = "startsWith", - contains = "contains", - equals = "equals", -} +import { DatasetsFilterSettingsComponent } from "./settings/datasets-filter-settings.component"; +import { + selectConditions, + selectFilters, +} from "state-management/selectors/user.selectors"; +import { AsyncPipe } from "@angular/common"; +import { ConditionFilterComponent } from "../../shared/modules/filters/condition-filter.component"; +import { PidFilterComponent } from "../../shared/modules/filters/pid-filter.component"; +import { PidFilterContainsComponent } from "../../shared/modules/filters/pid-filter-contains.component"; +import { PidFilterStartsWithComponent } from "../../shared/modules/filters/pid-filter-startsWith.component"; +import { LocationFilterComponent } from "../../shared/modules/filters/location-filter.component"; +import { GroupFilterComponent } from "../../shared/modules/filters/group-filter.component"; +import { TypeFilterComponent } from "../../shared/modules/filters/type-filter.component"; +import { KeywordFilterComponent } from "../../shared/modules/filters/keyword-filter.component"; +import { DateRangeFilterComponent } from "../../shared/modules/filters/date-range-filter.component"; +import { TextFilterComponent } from "../../shared/modules/filters/text-filter.component"; + +const COMPONENT_MAP: { [key: string]: any } = { + PidFilterComponent: PidFilterComponent, + PidFilterContainsComponent: PidFilterContainsComponent, + PidFilterStartsWithComponent: PidFilterStartsWithComponent, + LocationFilterComponent: LocationFilterComponent, + GroupFilterComponent: GroupFilterComponent, + TypeFilterComponent: TypeFilterComponent, + KeywordFilterComponent: KeywordFilterComponent, + DateRangeFilterComponent: DateRangeFilterComponent, + TextFilterComponent: TextFilterComponent, + ConditionFilterComponent: ConditionFilterComponent, +}; @Component({ selector: "datasets-filter", templateUrl: "datasets-filter.component.html", styleUrls: ["datasets-filter.component.scss"], }) -export class DatasetsFilterComponent implements OnInit, OnDestroy { +export class DatasetsFilterComponent implements OnDestroy { private subscriptions: Subscription[] = []; - locationFacetCounts$ = this.store.select(selectLocationFacetCounts); - groupFacetCounts$ = this.store.select(selectGroupFacetCounts); - typeFacetCounts$ = this.store.select(selectTypeFacetCounts); - keywordFacetCounts$ = this.store.select(selectKeywordFacetCounts); + protected readonly ConditionFilterComponent = ConditionFilterComponent; - searchTerms$ = this.store.select(selectSearchTerms); - pidTerms$ = this.store.select(selectPidTerms); - keywordsTerms$ = this.store.select(selectKeywordsTerms); - locationFilter$ = this.store.select(selectLocationFilter); - groupFilter$ = this.store.select(selectGroupFilter); - typeFilter$ = this.store.select(selectTypeFilter); - keywordsFilter$ = this.store.select(selectKeywordsFilter); - creationTimeFilter$ = this.store.select(selectCreationTimeFilter); - scientificConditions$ = this.store.select(selectScientificConditions); - metadataKeys$ = this.store.select(selectMetadataKeys); + filterConfigs$ = this.store.select(selectFilters); - locationInput$ = new BehaviorSubject(""); - groupInput$ = new BehaviorSubject(""); - typeInput$ = new BehaviorSubject(""); - keywordsInput$ = new BehaviorSubject(""); + conditionConfigs$ = this.store.select(selectConditions); + + scientificConditions$ = this.store.select(selectScientificConditions); appConfig = this.appConfigService.getConfig(); clearSearchBar = false; - groupSuggestions$ = this.createSuggestionObserver( - this.groupFacetCounts$, - this.groupInput$, - this.groupFilter$, - ); - - locationSuggestions$ = this.createSuggestionObserver( - this.locationFacetCounts$, - this.locationInput$, - this.locationFilter$, - ); - - typeSuggestions$ = this.createSuggestionObserver( - this.typeFacetCounts$, - this.typeInput$, - this.typeFilter$, - ); - - keywordsSuggestions$ = this.createSuggestionObserver( - this.keywordFacetCounts$, - this.keywordsInput$, - this.keywordsFilter$, - ); hasAppliedFilters$ = this.store.select(selectHasAppliedFilters); - dateRange: DateRange = { - begin: "", - end: "", - }; + isInEditMode = false; constructor( public appConfigService: AppConfigService, - private asyncPipe: AsyncPipe, public dialog: MatDialog, private store: Store, + private asyncPipe: AsyncPipe, + private cdr: ChangeDetectorRef, ) {} - private buildPidTermsCondition(terms: string) { - if (!terms) return ""; - switch (this.appConfig.pidSearchMethod) { - case PidTermsSearchCondition.startsWith: { - return { $regex: `^${terms}` }; - } - case PidTermsSearchCondition.contains: { - return { $regex: terms }; - } - default: { - return terms; - } - } - } - - createSuggestionObserver( - facetCounts$: Observable, - input$: BehaviorSubject, - currentFilters$: Observable, - ): Observable { - return combineLatest([facetCounts$, input$, currentFilters$]).pipe( - map(([counts, filterString, currentFilters]) => { - if (!counts) { - return []; - } - return counts.filter( - (count) => - typeof count._id === "string" && - count._id.toLowerCase().includes(filterString.toLowerCase()) && - currentFilters.indexOf(count._id) < 0, - ); - }), - ); - } - - getFacetId(facetCount: FacetCount, fallback = ""): string { - const id = facetCount._id; - return id ? String(id) : fallback; - } - - getFacetCount(facetCount: FacetCount): number { - return facetCount.count; - } - - textSearchChanged(terms: string) { - if ("string" != typeof terms) return; - this.clearSearchBar = false; - this.store.dispatch(setSearchTermsAction({ terms })); - } - - pidSearchChanged(pid: string) { - if ("string" != typeof pid) return; - this.clearSearchBar = false; - this.store.dispatch(setPidTermsAction({ pid })); - } - - onLocationInput(event: any) { - const value = (event.target).value; - this.locationInput$.next(value); - } - - onGroupInput(event: any) { - const value = (event.target).value; - this.groupInput$.next(value); - } - - onKeywordInput(event: any) { - const value = (event.target).value; - this.keywordsInput$.next(value); - } - - onTypeInput(event: any) { - const value = (event.target).value; - this.typeInput$.next(value); - } - - locationSelected(location: string | null) { - const loc = location || ""; - this.store.dispatch(addLocationFilterAction({ location: loc })); - this.locationInput$.next(""); - } - - locationRemoved(location: string) { - this.store.dispatch(removeLocationFilterAction({ location })); - } - - groupSelected(group: string) { - this.store.dispatch(addGroupFilterAction({ group })); - this.groupInput$.next(""); - } - - groupRemoved(group: string) { - this.store.dispatch(removeGroupFilterAction({ group })); - } - - keywordSelected(keyword: string) { - this.store.dispatch(addKeywordFilterAction({ keyword })); - this.keywordsInput$.next(""); - } - - keywordRemoved(keyword: string) { - this.store.dispatch(removeKeywordFilterAction({ keyword })); - } - - typeSelected(type: string) { - this.store.dispatch(addTypeFilterAction({ datasetType: type })); - this.typeInput$.next(""); - } - - typeRemoved(type: string) { - this.store.dispatch(removeTypeFilterAction({ datasetType: type })); - } - - dateChanged(event: MatDatepickerInputEvent) { - if (event.value) { - const name = event.targetElement.getAttribute("name"); - if (name === "begin") { - this.dateRange.begin = event.value.toUTC().toISO(); - this.dateRange.end = ""; - } - if (name === "end") { - this.dateRange.end = event.value.toUTC().plus({ days: 1 }).toISO(); - } - if (this.dateRange.begin.length > 0 && this.dateRange.end.length > 0) { - this.store.dispatch(setDateRangeFilterAction(this.dateRange)); - } - } else { - this.store.dispatch(setDateRangeFilterAction({ begin: "", end: "" })); - } - } - - clearFacets() { + reset() { this.clearSearchBar = true; - this.dateRange = { - begin: "", - end: "", - }; + this.store.dispatch(clearFacetsAction()); - this.store.dispatch(setPidTermsAction({ pid: "" })); this.store.dispatch(deselectAllCustomColumnsAction()); - } + this.applyFilters(); + // we need to treat JS event loop here, otherwise this.clearSearchBar is false for the components + setTimeout(() => { + this.clearSearchBar = false; // reset value so it will be triggered again + }, 0); + } + + showDatasetsFilterSettingsDialog() { + const dialogRef = this.dialog.open(DatasetsFilterSettingsComponent, { + width: "60%", + data: { + filterConfigs: this.asyncPipe.transform(this.filterConfigs$), + conditionConfigs: this.asyncPipe.transform(this.conditionConfigs$), + }, + }); + + dialogRef.afterClosed().subscribe((result) => { + console.log("The dialog was closed"); + if (result) { + // Handle the selected filter + console.log(`Selected filter: ${result}`); + this.store.dispatch( + updateFilterConfigs({ filterConfigs: result.filterConfigs }), + ); + this.store.dispatch( + updateConditionsConfigs({ + conditionConfigs: result.conditionConfigs, + }), + ); - showAddConditionDialog() { - this.dialog - .open(SearchParametersDialogComponent, { - data: { parameterKeys: this.asyncPipe.transform(this.metadataKeys$) }, - }) - .afterClosed() - .subscribe((res) => { - if (res) { - const { data } = res; - this.store.dispatch( - addScientificConditionAction({ condition: data }), - ); - this.store.dispatch( - selectColumnAction({ name: data.lhs, columnType: "custom" }), - ); - } - }); + // this.cdr.detectChanges(); + } + }); } applyFilters() { + this.isInEditMode = false; this.store.dispatch(fetchDatasetsAction()); this.store.dispatch(fetchFacetCountsAction()); } - removeCondition(condition: ScientificCondition, index: number) { - this.store.dispatch(removeScientificConditionAction({ index })); - this.store.dispatch( - deselectColumnAction({ name: condition.lhs, columnType: "custom" }), - ); - } - - ngOnInit() { - this.subscriptions.push( - this.searchTerms$ - .pipe( - skipWhile((terms) => terms === ""), - debounceTime(500), - distinctUntilChanged(), - ) - .subscribe((terms) => { - this.store.dispatch(setTextFilterAction({ text: terms })); - }), - ); - - this.subscriptions.push( - this.keywordsTerms$ - .pipe( - skipWhile((terms) => terms === ""), - debounceTime(500), - distinctUntilChanged(), - ) - .subscribe((terms) => { - this.store.dispatch(addKeywordFilterAction({ keyword: terms })); - }), - ); - - this.subscriptions.push( - this.pidTerms$ - .pipe( - skipWhile((terms) => terms.length < 5), - debounceTime(500), - distinctUntilChanged(), - ) - .subscribe((terms) => { - const condition = this.buildPidTermsCondition(terms); - this.store.dispatch(setPidTermsFilterAction({ pid: condition })); - }), - ); - } - ngOnDestroy() { this.subscriptions.forEach((subscription) => subscription.unsubscribe()); } + + resolveComponentType(typeAsString: string): any { + return COMPONENT_MAP[typeAsString]; + } } diff --git a/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.html b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.html new file mode 100644 index 000000000..535a9f052 --- /dev/null +++ b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.html @@ -0,0 +1,74 @@ +

Configure Filters

+ +
Filters
+ + + + sort + + + + {{ getFilterLabel(filter.type) }} + + + +
Conditions
+ + + + + + {{ condition.condition.lhs }} + + +  =  + + +  =  + + +  <  + + +  >  + + + {{ + condition.condition.relation === "EQUAL_TO_STRING" + ? '"' + condition.condition.rhs + '"' + : condition.condition.rhs + }} + {{ condition.condition.unit | prettyUnit }} + + + edit + delete + + +
+ +
+ +
+
+ + + + diff --git a/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.scss b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.scss new file mode 100644 index 000000000..49e9d5afe --- /dev/null +++ b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.scss @@ -0,0 +1,58 @@ +mat-dialog-title { + background-color: #1976d2; /* Example: Material Indigo 500 */ + color: white; + padding: 12px 24px; + width: 60%; +} + +.filter-dialog-content { + max-height: 400px; + overflow: auto; + padding: 16px; + background-color: #f0f0f0; /* Light grey background for contrast */ +} + +mat-nav-list { + width: 100%; + max-height: 400px; + overflow: auto; +} + +mat-list-item { + display: flex; + align-items: center; /* Centers items vertically */ + justify-content: start; /* Aligns items to the start */ + padding: 10px; /* Provides padding within each list item */ + border-bottom: 1px solid #ccc; /* Adds a subtle line between items for better visual separation */ +} + + +.filter-item { + display: flex; + align-items: center; /* Align items vertically in the center */ + justify-content: space-between; /* Spread out the items to fill the horizontal space */ + padding: 8px 16px; /* Add some padding around the items */ + border-bottom: 1px solid #e0e0e0; /* Optional: adds a separator line between items */ +} + +.filter-toggle { + flex-shrink: 0; /* Prevents the toggle from shrinking */ +} + +.filter-name { + margin-left: 16px; /* Space between the toggle and the name */ + flex-grow: 1; /* Allows the name to take up any available space */ + white-space: nowrap; /* Prevents the text from wrapping */ + overflow: hidden; /* Keeps the text within the container */ + text-overflow: ellipsis; /* Adds an ellipsis if the text is too long */ +} + +.spacer { + flex-grow: 2; /* Forces any extra space to be added here, pushing the drag handle to the right */ +} + +.drag-handle { + cursor: grab; /* Changes the cursor to indicate draggable */ + margin-left: auto; + margin-right: 20px; +} diff --git a/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.spec.ts b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.spec.ts new file mode 100644 index 000000000..3171707b3 --- /dev/null +++ b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.spec.ts @@ -0,0 +1,183 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockMatDialogRef, MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { removeScientificConditionAction } from "state-management/actions/datasets.actions"; +import { of } from "rxjs"; +import { + deselectColumnAction, + deselectAllCustomColumnsAction, +} from "state-management/actions/user.actions"; +import { ScientificCondition } from "state-management/models"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { + MatDialogModule, + MatDialog, + MAT_DIALOG_DATA, + MatDialogRef, +} from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { AppConfigService } from "app-config.service"; +import { DatasetsFilterSettingsComponent } from "./datasets-filter-settings.component"; +import { ConditionConfig } from "../../../shared/modules/filters/filters.module"; + +export class MockMatDialog { + open() { + return { + afterClosed: () => of([]), + }; + } +} + +const getConfig = () => ({ + scienceSearchEnabled: false, +}); + +const condition: ScientificCondition = { + lhs: "test", + relation: "EQUAL_TO_NUMERIC", + rhs: 5, + unit: "s", +}; + +describe("DatasetsFilterSettingsComponent", () => { + let component: DatasetsFilterSettingsComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot({}), + ], + declarations: [ + DatasetsFilterSettingsComponent, + SearchParametersDialogComponent, + ], + providers: [AsyncPipe], + }); + TestBed.overrideComponent(DatasetsFilterSettingsComponent, { + set: { + providers: [ + { provide: AppConfigService, useValue: { getConfig } }, + { provide: MatDialog, useClass: MockMatDialog }, + { provide: MatDialogRef, useClass: MockMatDialogRef }, + { + provide: MAT_DIALOG_DATA, + useValue: { + conditionConfigs: [ + { + condition, + enabled: true, + }, + ], + }, + }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DatasetsFilterSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + it("should be created", () => { + expect(component).toBeTruthy(); + }); + + describe("#showDatasetsFilterSettingsDialog()", () => { + it("should open DatasetsFilterSettingsComponent", () => { + spyOn(component.dialog, "open").and.callThrough(); + dispatchSpy = spyOn(store, "dispatch"); + + // Spy or stub other side effects in addCondition as needed + spyOn(component, "toggleCondition").and.callFake( + (ignored: ConditionConfig) => ignored, + ); + + component.metadataKeys$ = of(["test", "keys"]); + component.addCondition(); + + expect(component.dialog.open).toHaveBeenCalledTimes(1); + expect(component.dialog.open).toHaveBeenCalledWith( + SearchParametersDialogComponent, + { + data: { + parameterKeys: ["test", "keys"], + }, + }, + ); + }); + }); + + describe("#removeCondition()", () => { + it("should dispatch a removeScientificConditionAction and a deselectColumnAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const conditionConfig: ConditionConfig = { + condition, + enabled: true, + }; + + component.removeCondition(conditionConfig, 0); + + expect(dispatchSpy).toHaveBeenCalledTimes(2); + expect(dispatchSpy).toHaveBeenCalledWith( + removeScientificConditionAction({ condition }), + ); + expect(dispatchSpy).toHaveBeenCalledWith( + deselectColumnAction({ name: condition.lhs, columnType: "custom" }), + ); + }); + }); +}); diff --git a/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.ts b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.ts new file mode 100644 index 000000000..f55af1307 --- /dev/null +++ b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.ts @@ -0,0 +1,155 @@ +import { ChangeDetectorRef, Component, Inject } from "@angular/core"; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogRef, +} from "@angular/material/dialog"; +import { SearchParametersDialogComponent } from "../../../shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AppConfigService } from "app-config.service"; +import { AsyncPipe } from "@angular/common"; +import { + addScientificConditionAction, + removeScientificConditionAction, +} from "../../../state-management/actions/datasets.actions"; +import { + deselectColumnAction, + selectColumnAction, +} from "../../../state-management/actions/user.actions"; +import { Store } from "@ngrx/store"; +import { selectMetadataKeys } from "../../../state-management/selectors/datasets.selectors"; +import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop"; +import { + ConditionConfig, + FilterConfig, +} from "../../../shared/modules/filters/filters.module"; +import { getFilterLabel } from "../../../shared/modules/filters/utils"; +import { ScientificCondition } from "../../../state-management/models"; + +@Component({ + selector: "app-type-datasets-filter-settings", + templateUrl: `./datasets-filter-settings.component.html`, + styleUrls: [`./datasets-filter-settings.component.scss`], +}) +export class DatasetsFilterSettingsComponent { + protected readonly getFilterLabel = getFilterLabel; + + metadataKeys$ = this.store.select(selectMetadataKeys); + + appConfig = this.appConfigService.getConfig(); + + constructor( + public dialogRef: MatDialogRef, + public dialog: MatDialog, + private store: Store, + private asyncPipe: AsyncPipe, + private appConfigService: AppConfigService, + @Inject(MAT_DIALOG_DATA) public data: any, + ) {} + + addCondition() { + this.dialog + .open(SearchParametersDialogComponent, { + data: { + parameterKeys: this.asyncPipe.transform(this.metadataKeys$), + }, + }) + .afterClosed() + .subscribe((res) => { + if (res) { + const { data } = res; + const condition = this.toggleCondition({ + condition: data, + enabled: false, + }); + this.data.conditionConfigs.push(condition); + } + }); + } + + editCondition(condition: ConditionConfig, i: number) { + this.store.dispatch( + removeScientificConditionAction({ condition: condition.condition }), + ); + this.store.dispatch( + deselectColumnAction({ + name: condition.condition.lhs, + columnType: "custom", + }), + ); + this.dialog + .open(SearchParametersDialogComponent, { + data: { + parameterKeys: this.asyncPipe.transform(this.metadataKeys$), + condition: condition.condition, + }, + }) + .afterClosed() + .subscribe((res) => { + if (res) { + const { data } = res; + this.data.conditionConfigs[i] = { + ...condition, + condition: data, + }; + this.store.dispatch( + addScientificConditionAction({ condition: data }), + ); + this.store.dispatch( + selectColumnAction({ name: data.lhs, columnType: "custom" }), + ); + } + }); + } + + removeCondition(condition: ConditionConfig, index: number) { + this.data.conditionConfigs.splice(index, 1); + if (condition.enabled) { + this.store.dispatch( + removeScientificConditionAction({ condition: condition.condition }), + ); + this.store.dispatch( + deselectColumnAction({ + name: condition.condition.lhs, + columnType: "custom", + }), + ); + } + } + + toggleCondition(condition: ConditionConfig) { + condition.enabled = !condition.enabled; + const data = condition.condition; + if (condition.enabled) { + this.store.dispatch(addScientificConditionAction({ condition: data })); + this.store.dispatch( + selectColumnAction({ name: data.lhs, columnType: "custom" }), + ); + } else { + this.store.dispatch(removeScientificConditionAction({ condition: data })); + this.store.dispatch( + deselectColumnAction({ name: data.lhs, columnType: "custom" }), + ); + } + return condition; + } + + toggleVisibility(filter: FilterConfig) { + filter.visible = !filter.visible; + } + + drop(event: CdkDragDrop): void { + moveItemInArray( + this.data.filterConfigs, + event.previousIndex, + event.currentIndex, + ); + } + + onApply() { + this.dialogRef.close(this.data); + } + + onCancel() { + this.dialogRef.close(); + } +} diff --git a/src/app/datasets/datasets.module.ts b/src/app/datasets/datasets.module.ts index 72c17a0bf..d20615983 100644 --- a/src/app/datasets/datasets.module.ts +++ b/src/app/datasets/datasets.module.ts @@ -84,6 +84,11 @@ import { RelatedDatasetsComponent } from "./related-datasets/related-datasets.co import { FullTextSearchBarComponent } from "./dashboard/full-text-search/full-text-search-bar.component"; import { DatafilesActionsComponent } from "./datafiles-actions/datafiles-actions.component"; import { DatafilesActionComponent } from "./datafiles-actions/datafiles-action.component"; +import { MatMenuModule } from "@angular/material/menu"; +import { DatasetsFilterSettingsComponent } from "./datasets-filter/settings/datasets-filter-settings.component"; +import { CdkDrag, CdkDragHandle, CdkDropList } from "@angular/cdk/drag-drop"; +import { FiltersModule } from "shared/modules/filters/filters.module"; +import { userReducer } from "state-management/reducers/user.reducer"; @NgModule({ imports: [ @@ -138,8 +143,14 @@ import { DatafilesActionComponent } from "./datafiles-actions/datafiles-action.c StoreModule.forFeature("samples", samplesReducer), StoreModule.forFeature("publishedData", publishedDataReducer), StoreModule.forFeature("logbooks", logbooksReducer), + StoreModule.forFeature("users", userReducer), LogbooksModule, FullTextSearchBarComponent, + MatMenuModule, + CdkDropList, + CdkDrag, + CdkDragHandle, + FiltersModule, ], declarations: [ BatchViewComponent, @@ -165,6 +176,7 @@ import { DatafilesActionComponent } from "./datafiles-actions/datafiles-action.c RelatedDatasetsComponent, DatafilesActionsComponent, DatafilesActionComponent, + DatasetsFilterSettingsComponent, ], providers: [ ArchivingService, diff --git a/src/app/shared/MockStubs.ts b/src/app/shared/MockStubs.ts index 3e1dc2776..cfd964f0a 100644 --- a/src/app/shared/MockStubs.ts +++ b/src/app/shared/MockStubs.ts @@ -125,7 +125,7 @@ export class MockAppConfigService { export class MockStore { public dispatch() {} - public select() { + public select(selector) { return of([]); } diff --git a/src/app/shared/modules/filters/clearable-input.component.ts b/src/app/shared/modules/filters/clearable-input.component.ts new file mode 100644 index 000000000..f600e9ed1 --- /dev/null +++ b/src/app/shared/modules/filters/clearable-input.component.ts @@ -0,0 +1,14 @@ +import { Component, ElementRef, Input, ViewChild } from "@angular/core"; + +//TODO move to common +@Component({ template: "" }) +export class ClearableInputComponent { + @ViewChild("input", { static: true }) input!: ElementRef; + + @Input() + set clear(value: boolean) { + if (value) { + this.input.nativeElement.value = ""; + } + } +} diff --git a/src/app/shared/modules/filters/condition-filter.component.html b/src/app/shared/modules/filters/condition-filter.component.html new file mode 100644 index 000000000..4b82a5f6a --- /dev/null +++ b/src/app/shared/modules/filters/condition-filter.component.html @@ -0,0 +1,4 @@ + + Condition + + diff --git a/src/app/shared/modules/filters/condition-filter.component.scss b/src/app/shared/modules/filters/condition-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/condition-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/condition-filter.component.ts b/src/app/shared/modules/filters/condition-filter.component.ts new file mode 100644 index 000000000..c8f15a0c8 --- /dev/null +++ b/src/app/shared/modules/filters/condition-filter.component.ts @@ -0,0 +1,40 @@ +import { Component, Input } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { ScientificCondition } from "state-management/models"; + +@Component({ + selector: "app-condition-filter", + templateUrl: "condition-filter.component.html", + styleUrls: ["condition-filter.component.scss"], +}) +export class ConditionFilterComponent { + @Input() condition: ScientificCondition; + + constructor(private store: Store) {} + + formatCondition() { + const condition = this.condition; + let relationSymbol = ""; + switch (condition.relation) { + case "EQUAL_TO_NUMERIC": + case "EQUAL_TO_STRING": + relationSymbol = "="; + break; + case "LESS_THAN": + relationSymbol = "<"; + break; + case "GREATER_THAN": + relationSymbol = ">"; + break; + default: + relationSymbol = ""; + } + + const rhsValue = + condition.relation === "EQUAL_TO_STRING" + ? `"${condition.rhs}"` + : condition.rhs; + + return `${condition.lhs} ${relationSymbol} ${rhsValue} ${condition.unit}`; + } +} diff --git a/src/app/shared/modules/filters/date-range-filter.component.html b/src/app/shared/modules/filters/date-range-filter.component.html new file mode 100644 index 000000000..61a2b1679 --- /dev/null +++ b/src/app/shared/modules/filters/date-range-filter.component.html @@ -0,0 +1,19 @@ + + {{ label }} + + + + + + + diff --git a/src/app/shared/modules/filters/date-range-filter.component.scss b/src/app/shared/modules/filters/date-range-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/date-range-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/date-range-filter.component.spec.ts b/src/app/shared/modules/filters/date-range-filter.component.spec.ts new file mode 100644 index 000000000..6534d2308 --- /dev/null +++ b/src/app/shared/modules/filters/date-range-filter.component.spec.ts @@ -0,0 +1,174 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { setDateRangeFilterAction } from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { DateTime } from "luxon"; +import { + MatDatepickerInputEvent, + MatDatepickerModule, +} from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { DateRangeFilterComponent } from "./date-range-filter.component"; + +describe("DateRangeFilterComponent", () => { + let component: DateRangeFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [DateRangeFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DateRangeFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#dateChanged()", () => { + it("should dispatch setDateRangeFilterAction with empty string values if event.value is null", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const event = { + targetElement: { + getAttribute: (name: string) => "begin", + }, + value: null, + } as MatDatepickerInputEvent; + + component.dateChanged(event); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + setDateRangeFilterAction({ begin: "", end: "" }), + ); + }); + + it("should set dateRange.begin if event has value and event.targetElement name is begin", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const beginDate = DateTime.fromJSDate(new Date("2021-01-01")); + const event = { + targetElement: { + getAttribute: (name: string) => "begin", + }, + value: beginDate, + } as MatDatepickerInputEvent; + + component.dateChanged(event); + + const expected = beginDate.toUTC().toISO(); + expect(component.dateRange.begin).toEqual(expected); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it("should set dateRange.end if event has value and event.targetElement name is end", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const endDate = DateTime.fromJSDate(new Date("2021-07-08")); + const event = { + targetElement: { + getAttribute: (name: string) => "end", + }, + value: endDate, + } as MatDatepickerInputEvent; + + component.dateChanged(event); + + const expected = endDate.toUTC().plus({ days: 1 }).toISO(); + expect(component.dateRange.end).toEqual(expected); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it("should dispatch a setDateRangeFilterAction if dateRange.begin and dateRange.end have values", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const beginDate = DateTime.fromJSDate(new Date("2021-01-01")); + const endDate = DateTime.fromJSDate(new Date("2021-07-08")); + component.dateRange.begin = beginDate.toUTC().toISO(); + const event = { + targetElement: { + getAttribute: (name: string) => "end", + }, + value: endDate, + } as MatDatepickerInputEvent; + + component.dateChanged(event); + + const expected = { + begin: beginDate.toUTC().toISO(), + end: endDate.toUTC().plus({ days: 1 }).toISO(), + }; + expect(dispatchSpy).toHaveBeenCalledOnceWith( + setDateRangeFilterAction(expected), + ); + }); + }); +}); diff --git a/src/app/shared/modules/filters/date-range-filter.component.ts b/src/app/shared/modules/filters/date-range-filter.component.ts new file mode 100644 index 000000000..dddd4ae46 --- /dev/null +++ b/src/app/shared/modules/filters/date-range-filter.component.ts @@ -0,0 +1,62 @@ +import { Component, Input } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { MatDatepickerInputEvent } from "@angular/material/datepicker"; +import { DateTime } from "luxon"; +import { setDateRangeFilterAction } from "state-management/actions/datasets.actions"; +import { selectCreationTimeFilter } from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { getFilterLabel } from "./utils"; + +interface DateRange { + begin: string; + end: string; +} + +@Component({ + selector: "app-date-range-filter", + templateUrl: "date-range-filter.component.html", + styleUrls: ["date-range-filter.component.scss"], +}) +export class DateRangeFilterComponent extends ClearableInputComponent { + creationTimeFilter$ = this.store.select(selectCreationTimeFilter); + + dateRange: DateRange = { + begin: "", + end: "", + }; + + constructor(private store: Store) { + super(); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + dateChanged(event: MatDatepickerInputEvent) { + if (event.value) { + const name = event.targetElement.getAttribute("name"); + if (name === "begin") { + this.dateRange.begin = event.value.toUTC().toISO(); + this.dateRange.end = ""; + } + if (name === "end") { + this.dateRange.end = event.value.toUTC().plus({ days: 1 }).toISO(); + } + if (this.dateRange.begin.length > 0 && this.dateRange.end.length > 0) { + this.store.dispatch(setDateRangeFilterAction(this.dateRange)); + } + } else { + this.store.dispatch(setDateRangeFilterAction({ begin: "", end: "" })); + } + } + + @Input() + set clear(value: boolean) { + if (value) + this.dateRange = { + begin: "", + end: "", + }; + } +} diff --git a/src/app/shared/modules/filters/filters.module.ts b/src/app/shared/modules/filters/filters.module.ts new file mode 100644 index 000000000..783e0a507 --- /dev/null +++ b/src/app/shared/modules/filters/filters.module.ts @@ -0,0 +1,80 @@ +import { NgModule } from "@angular/core"; +import { PidFilterContainsComponent } from "./pid-filter-contains.component"; +import { PidFilterComponent } from "./pid-filter.component"; +import { PidFilterStartsWithComponent } from "./pid-filter-startsWith.component"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { LocationFilterComponent } from "./location-filter.component"; +import { GroupFilterComponent } from "./group-filter.component"; +import { ConditionFilterComponent } from "./condition-filter.component"; +import { TypeFilterComponent } from "./type-filter.component"; +import { TextFilterComponent } from "./text-filter.component"; +import { KeywordFilterComponent } from "./keyword-filter.component"; +import { DateRangeFilterComponent } from "./date-range-filter.component"; +import { ScientificCondition } from "state-management/models"; +import { MatInputModule } from "@angular/material/input"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { AsyncPipe, NgForOf } from "@angular/common"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatIconModule } from "@angular/material/icon"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; + +@NgModule({ + declarations: [ + ClearableInputComponent, + PidFilterComponent, + PidFilterContainsComponent, + PidFilterStartsWithComponent, + LocationFilterComponent, + GroupFilterComponent, + TypeFilterComponent, + KeywordFilterComponent, + DateRangeFilterComponent, + TextFilterComponent, + ConditionFilterComponent, + ], + imports: [ + MatInputModule, + MatDatepickerModule, + AsyncPipe, + MatChipsModule, + MatIconModule, + MatAutocompleteModule, + NgForOf, + ], + exports: [ + ClearableInputComponent, + PidFilterComponent, + PidFilterContainsComponent, + PidFilterStartsWithComponent, + LocationFilterComponent, + GroupFilterComponent, + TypeFilterComponent, + KeywordFilterComponent, + DateRangeFilterComponent, + TextFilterComponent, + ConditionFilterComponent, + ], +}) +export class FiltersModule {} + +type Filter = + | "PidFilterComponent" + | "PidFilterContainsComponent" + | "PidFilterStartsWithComponent" + | "LocationFilterComponent" + | "GroupFilterComponent" + | "TypeFilterComponent" + | "KeywordFilterComponent" + | "DateRangeFilterComponent" + | "TextFilterComponent" + | "ConditionFilterComponent"; + +export interface FilterConfig { + type: Filter; + visible: boolean; +} + +export interface ConditionConfig { + condition: ScientificCondition; + enabled: boolean; +} diff --git a/src/app/shared/modules/filters/group-filter.component.html b/src/app/shared/modules/filters/group-filter.component.html new file mode 100644 index 000000000..0f202875b --- /dev/null +++ b/src/app/shared/modules/filters/group-filter.component.html @@ -0,0 +1,29 @@ + + {{ label }} + + {{ group }}cancel + + + + + + {{ getFacetId(fc, "No Group") }} | + {{ getFacetCount(fc) }} + + + diff --git a/src/app/shared/modules/filters/group-filter.component.scss b/src/app/shared/modules/filters/group-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/group-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/group-filter.component.spec.ts b/src/app/shared/modules/filters/group-filter.component.spec.ts new file mode 100644 index 000000000..7e9604d8c --- /dev/null +++ b/src/app/shared/modules/filters/group-filter.component.spec.ts @@ -0,0 +1,135 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { + addGroupFilterAction, + removeGroupFilterAction, +} from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { GroupFilterComponent } from "./group-filter.component"; + +describe("GroupFilterComponent", () => { + let component: GroupFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [GroupFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GroupFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#onGroupInput()", () => { + it("should call next on groupInput$", () => { + const nextSpy = spyOn(component.groupInput$, "next"); + + const event = { + target: { + value: "group", + }, + }; + + component.onGroupInput(event); + + expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); + }); + }); + + describe("#groupSelected()", () => { + it("should dispatch an AddGroupFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const group = "test"; + component.groupSelected(group); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith(addGroupFilterAction({ group })); + }); + }); + + describe("#groupRemoved()", () => { + it("should dispatch a RemoveGroupFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const group = "test"; + component.groupRemoved(group); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + removeGroupFilterAction({ group }), + ); + }); + }); +}); diff --git a/src/app/shared/modules/filters/group-filter.component.ts b/src/app/shared/modules/filters/group-filter.component.ts new file mode 100644 index 000000000..101e968f5 --- /dev/null +++ b/src/app/shared/modules/filters/group-filter.component.ts @@ -0,0 +1,60 @@ +import { Component } from "@angular/core"; +import { + selectGroupFacetCounts, + selectGroupFilter, +} from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { + addGroupFilterAction, + removeGroupFilterAction, +} from "state-management/actions/datasets.actions"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { BehaviorSubject } from "rxjs"; +import { ClearableInputComponent } from "./clearable-input.component"; + +@Component({ + selector: "app-group-filter", + templateUrl: "group-filter.component.html", + styleUrls: ["group-filter.component.scss"], +}) +export class GroupFilterComponent extends ClearableInputComponent { + protected readonly getFacetId = getFacetId; + protected readonly getFacetCount = getFacetCount; + + groupFilter$ = this.store.select(selectGroupFilter); + + groupFacetCounts$ = this.store.select(selectGroupFacetCounts); + groupInput$ = new BehaviorSubject(""); + + groupSuggestions$ = createSuggestionObserver( + this.groupFacetCounts$, + this.groupInput$, + this.groupFilter$, + ); + + constructor(private store: Store) { + super(); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + onGroupInput(event: any) { + const value = (event.target).value; + this.groupInput$.next(value); + } + groupSelected(group: string) { + this.store.dispatch(addGroupFilterAction({ group })); + this.groupInput$.next(""); + } + + groupRemoved(group: string) { + this.store.dispatch(removeGroupFilterAction({ group })); + } +} diff --git a/src/app/shared/modules/filters/keyword-filter.component.html b/src/app/shared/modules/filters/keyword-filter.component.html new file mode 100644 index 000000000..0b8ab5580 --- /dev/null +++ b/src/app/shared/modules/filters/keyword-filter.component.html @@ -0,0 +1,29 @@ + + {{ label }} + + {{ keyword }}cancel + + + + + + {{ getFacetId(fc, "No Keywords") }} + : {{ getFacetCount(fc) }} + + + diff --git a/src/app/shared/modules/filters/keyword-filter.component.scss b/src/app/shared/modules/filters/keyword-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/keyword-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/keyword-filter.component.spec.ts b/src/app/shared/modules/filters/keyword-filter.component.spec.ts new file mode 100644 index 000000000..7dbd13b6a --- /dev/null +++ b/src/app/shared/modules/filters/keyword-filter.component.spec.ts @@ -0,0 +1,137 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { + addKeywordFilterAction, + removeKeywordFilterAction, +} from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { KeywordFilterComponent } from "./keyword-filter.component"; + +describe("KeywordFilterComponent", () => { + let component: KeywordFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [KeywordFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KeywordFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#onKeywordInput()", () => { + it("should call next on keywordsInput$", () => { + const nextSpy = spyOn(component.keywordsInput$, "next"); + + const event = { + target: { + value: "keyword", + }, + }; + + component.onKeywordInput(event); + + expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); + }); + }); + + describe("#keywordSelected()", () => { + it("should dispatch an AddKeywordFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const keyword = "test"; + component.keywordSelected(keyword); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + addKeywordFilterAction({ keyword }), + ); + }); + }); + + describe("#keywordRemoved()", () => { + it("should dispatch a RemoveKeywordFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const keyword = "test"; + component.keywordRemoved(keyword); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + removeKeywordFilterAction({ keyword }), + ); + }); + }); +}); diff --git a/src/app/shared/modules/filters/keyword-filter.component.ts b/src/app/shared/modules/filters/keyword-filter.component.ts new file mode 100644 index 000000000..b6faf5802 --- /dev/null +++ b/src/app/shared/modules/filters/keyword-filter.component.ts @@ -0,0 +1,84 @@ +import { Component, OnDestroy } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { + selectKeywordFacetCounts, + selectKeywordsFilter, + selectKeywordsTerms, +} from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { BehaviorSubject } from "rxjs"; +import { + addKeywordFilterAction, + removeKeywordFilterAction, +} from "state-management/actions/datasets.actions"; +import { debounceTime, distinctUntilChanged, skipWhile } from "rxjs/operators"; + +@Component({ + selector: "app-keyword-filter", + templateUrl: "keyword-filter.component.html", + styleUrls: ["keyword-filter.component.scss"], +}) +export class KeywordFilterComponent + extends ClearableInputComponent + implements OnDestroy +{ + protected readonly getFacetCount = getFacetCount; + protected readonly getFacetId = getFacetId; + + keywordsTerms$ = this.store.select(selectKeywordsTerms); + + keywordsFilter$ = this.store.select(selectKeywordsFilter); + + keywordsInput$ = new BehaviorSubject(""); + keywordFacetCounts$ = this.store.select(selectKeywordFacetCounts); + + subscription = undefined; + + keywordsSuggestions$ = createSuggestionObserver( + this.keywordFacetCounts$, + this.keywordsInput$, + this.keywordsFilter$, + ); + + constructor(private store: Store) { + super(); + + this.subscription = this.keywordsTerms$ + .pipe( + skipWhile((terms) => terms === ""), + debounceTime(500), + distinctUntilChanged(), + ) + .subscribe((terms) => { + this.store.dispatch(addKeywordFilterAction({ keyword: terms })); + }); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + onKeywordInput(event: any) { + const value = (event.target).value; + this.keywordsInput$.next(value); + } + + keywordSelected(keyword: string) { + this.store.dispatch(addKeywordFilterAction({ keyword })); + this.keywordsInput$.next(""); + } + + keywordRemoved(keyword: string) { + this.store.dispatch(removeKeywordFilterAction({ keyword })); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/src/app/shared/modules/filters/location-filter.component.html b/src/app/shared/modules/filters/location-filter.component.html new file mode 100644 index 000000000..2e9b02434 --- /dev/null +++ b/src/app/shared/modules/filters/location-filter.component.html @@ -0,0 +1,31 @@ + + {{ label }} + + {{ location || "No Location" }} + cancel + + + + + + + {{ getFacetId(fc, "No Location") }} | + {{ getFacetCount(fc) }} + + + diff --git a/src/app/shared/modules/filters/location-filter.component.scss b/src/app/shared/modules/filters/location-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/location-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/location-filter.component.spec.ts b/src/app/shared/modules/filters/location-filter.component.spec.ts new file mode 100644 index 000000000..b5a0e5a52 --- /dev/null +++ b/src/app/shared/modules/filters/location-filter.component.spec.ts @@ -0,0 +1,137 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { + addLocationFilterAction, + removeLocationFilterAction, +} from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { LocationFilterComponent } from "./location-filter.component"; + +describe("LocationFilterComponent", () => { + let component: LocationFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [LocationFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LocationFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#onLocationInput()", () => { + it("should call next on locationInput$", () => { + const nextSpy = spyOn(component.locationInput$, "next"); + + const event = { + target: { + value: "location", + }, + }; + + component.onLocationInput(event); + + expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); + }); + }); + + describe("#locationSelected()", () => { + it("should dispatch an AddLocationFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const location = "test"; + component.locationSelected(location); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + addLocationFilterAction({ location }), + ); + }); + }); + + describe("#locationRemoved()", () => { + it("should dispatch a RemoveLocationFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const location = "test"; + component.locationRemoved(location); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + removeLocationFilterAction({ location }), + ); + }); + }); +}); diff --git a/src/app/shared/modules/filters/location-filter.component.ts b/src/app/shared/modules/filters/location-filter.component.ts new file mode 100644 index 000000000..0d6b44b8e --- /dev/null +++ b/src/app/shared/modules/filters/location-filter.component.ts @@ -0,0 +1,62 @@ +import { Component } from "@angular/core"; +import { + selectLocationFacetCounts, + selectLocationFilter, +} from "state-management/selectors/datasets.selectors"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { BehaviorSubject } from "rxjs"; +import { + addLocationFilterAction, + removeLocationFilterAction, +} from "state-management/actions/datasets.actions"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { Store } from "@ngrx/store"; + +@Component({ + selector: "app-location-filter", + templateUrl: "location-filter.component.html", + styleUrls: ["location-filter.component.scss"], +}) +export class LocationFilterComponent extends ClearableInputComponent { + protected readonly getFacetId = getFacetId; + protected readonly getFacetCount = getFacetCount; + + locationFacetCounts$ = this.store.select(selectLocationFacetCounts); + locationFilter$ = this.store.select(selectLocationFilter); + + locationInput$ = new BehaviorSubject(""); + + locationSuggestions$ = createSuggestionObserver( + this.locationFacetCounts$, + this.locationInput$, + this.locationFilter$, + ); + + constructor(private store: Store) { + super(); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + locationSelected(location: string | null) { + const loc = location || ""; + this.store.dispatch(addLocationFilterAction({ location: loc })); + this.locationInput$.next(""); + } + + locationRemoved(location: string) { + this.store.dispatch(removeLocationFilterAction({ location })); + } + + onLocationInput(event: any) { + const value = (event.target).value; + this.locationInput$.next(value); + } +} diff --git a/src/app/shared/modules/filters/pid-filter-contains.component.html b/src/app/shared/modules/filters/pid-filter-contains.component.html new file mode 100644 index 000000000..3cd8cdf13 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-contains.component.html @@ -0,0 +1,9 @@ + + {{ label }} + + diff --git a/src/app/shared/modules/filters/pid-filter-contains.component.scss b/src/app/shared/modules/filters/pid-filter-contains.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-contains.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/pid-filter-contains.component.spec.ts b/src/app/shared/modules/filters/pid-filter-contains.component.spec.ts new file mode 100644 index 000000000..7e478c22c --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-contains.component.spec.ts @@ -0,0 +1,106 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { AppConfigService } from "app-config.service"; +import { PidFilterContainsComponent } from "./pid-filter-contains.component"; +import { PidFilterComponent } from "./pid-filter.component"; + +const getConfig = () => ({ + scienceSearchEnabled: false, +}); + +describe("PidFilterContainsComponent", () => { + let component: PidFilterContainsComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot({}), + ], + declarations: [ + PidFilterContainsComponent, + PidFilterComponent, + SearchParametersDialogComponent, + ], + providers: [AsyncPipe], + }); + TestBed.overrideComponent(PidFilterContainsComponent, { + set: { + providers: [ + { + provide: AppConfigService, + useValue: { + getConfig, + }, + }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PidFilterContainsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#buildPidTermsCondition()", () => { + const tests = [ + { input: "1", method: "contains", expected: { $regex: "1" } }, + ]; + + tests.forEach((test, index) => { + it(`should return correct condition for test case #${index + 1}`, () => { + component.appConfig.pidSearchMethod = test.method; + const condition = component.buildPidTermsCondition(test.input); + expect(condition).toEqual(test.expected); + }); + }); + }); +}); diff --git a/src/app/shared/modules/filters/pid-filter-contains.component.ts b/src/app/shared/modules/filters/pid-filter-contains.component.ts new file mode 100644 index 000000000..bdd4e1ef8 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-contains.component.ts @@ -0,0 +1,13 @@ +import { PidFilterComponent } from "./pid-filter.component"; +import { Component } from "@angular/core"; + +@Component({ + selector: "app-pid-contains-filter", + templateUrl: "./pid-filter-contains.component.html", + styleUrls: ["./pid-filter-contains.component.scss"], +}) +export class PidFilterContainsComponent extends PidFilterComponent { + buildPidTermsCondition(terms: string): { $regex: string } { + return { $regex: terms }; + } +} diff --git a/src/app/shared/modules/filters/pid-filter-startsWith.component.html b/src/app/shared/modules/filters/pid-filter-startsWith.component.html new file mode 100644 index 000000000..3cd8cdf13 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-startsWith.component.html @@ -0,0 +1,9 @@ + + {{ label }} + + diff --git a/src/app/shared/modules/filters/pid-filter-startsWith.component.scss b/src/app/shared/modules/filters/pid-filter-startsWith.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-startsWith.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/pid-filter-startsWith.component.spec.ts b/src/app/shared/modules/filters/pid-filter-startsWith.component.spec.ts new file mode 100644 index 000000000..6c80cab7f --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-startsWith.component.spec.ts @@ -0,0 +1,109 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, + fakeAsync, + tick, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { setPidTermsFilterAction } from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { AppConfigService } from "app-config.service"; +import { PidFilterComponent } from "./pid-filter.component"; +import { PidFilterStartsWithComponent } from "./pid-filter-startsWith.component"; + +const getConfig = () => ({ + scienceSearchEnabled: false, +}); + +describe("PidFilterStartsWithComponent", () => { + let component: PidFilterStartsWithComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot({}), + ], + declarations: [ + PidFilterStartsWithComponent, + PidFilterComponent, + SearchParametersDialogComponent, + ], + providers: [AsyncPipe], + }); + TestBed.overrideComponent(PidFilterStartsWithComponent, { + set: { + providers: [ + { + provide: AppConfigService, + useValue: { + getConfig, + }, + }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PidFilterStartsWithComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#buildPidTermsCondition()", () => { + const tests = [ + { input: "1", method: "startsWith", expected: { $regex: "^1" } }, + ]; + + tests.forEach((test, index) => { + it(`should return correct condition for test case #${index + 1}`, () => { + component.appConfig.pidSearchMethod = test.method; + const condition = component["buildPidTermsCondition"](test.input); + expect(condition).toEqual(test.expected); + }); + }); + }); +}); diff --git a/src/app/shared/modules/filters/pid-filter-startsWith.component.ts b/src/app/shared/modules/filters/pid-filter-startsWith.component.ts new file mode 100644 index 000000000..5fbfe7321 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-startsWith.component.ts @@ -0,0 +1,15 @@ +import { PidFilterComponent } from "./pid-filter.component"; +import { Component } from "@angular/core"; + +@Component({ + selector: "app-pid-startsWith-filter", + templateUrl: "./pid-filter-startsWith.component.html", + styleUrls: ["./pid-filter-startsWith.component.scss"], +}) +export class PidFilterStartsWithComponent extends PidFilterComponent { + static kLabel = "PID filter (Starts With)"; + + buildPidTermsCondition(terms: string): { $regex: string } { + return { $regex: `^${terms}` }; + } +} diff --git a/src/app/shared/modules/filters/pid-filter.component.html b/src/app/shared/modules/filters/pid-filter.component.html new file mode 100644 index 000000000..3cd8cdf13 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter.component.html @@ -0,0 +1,9 @@ + + {{ label }} + + diff --git a/src/app/shared/modules/filters/pid-filter.component.scss b/src/app/shared/modules/filters/pid-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/pid-filter.component.spec.ts b/src/app/shared/modules/filters/pid-filter.component.spec.ts new file mode 100644 index 000000000..9a847223d --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter.component.spec.ts @@ -0,0 +1,130 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, + fakeAsync, + tick, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { setPidTermsFilterAction } from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { AppConfigService } from "app-config.service"; +import { PidFilterComponent } from "./pid-filter.component"; + +const getConfig = () => ({ + scienceSearchEnabled: false, +}); + +describe("PidFilterComponent", () => { + let component: PidFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot({}), + ], + declarations: [PidFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.overrideComponent(PidFilterComponent, { + set: { + providers: [ + { + provide: AppConfigService, + useValue: { + getConfig, + }, + }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PidFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#onPidInput()", () => { + it("should dispatch a SetSearchTermsAction", fakeAsync(() => { + dispatchSpy = spyOn(store, "dispatch"); + + const pid = "xxxxxx"; + const event = { target: { value: pid } }; + component.onPidInput(event); + + tick(500); //wait for it + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + setPidTermsFilterAction({ pid }), + ); + })); + }); + + describe("#buildPidTermsCondition()", () => { + const tests = [ + { input: "", method: "", expected: "" }, + { input: "1", method: "equals", expected: "1" }, + { input: "1", method: "", expected: "1" }, + ]; + + tests.forEach((test, index) => { + it(`should return correct condition for test case #${index + 1}`, () => { + component.appConfig.pidSearchMethod = test.method; + const condition = component.buildPidTermsCondition(test.input); + expect(condition).toEqual(test.expected); + }); + }); + }); +}); diff --git a/src/app/shared/modules/filters/pid-filter.component.ts b/src/app/shared/modules/filters/pid-filter.component.ts new file mode 100644 index 000000000..7e28d3a8a --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter.component.ts @@ -0,0 +1,65 @@ +import { Component, Input, OnDestroy } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { Subject, Subscription } from "rxjs"; +import { + setPidTermsAction, + setPidTermsFilterAction, +} from "state-management/actions/datasets.actions"; +import { debounceTime, distinctUntilChanged, skipWhile } from "rxjs/operators"; +import { AppConfigService } from "app-config.service"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { getFilterLabel } from "./utils"; + +@Component({ + selector: "app-pid-filter", + templateUrl: `./pid-filter.component.html`, + styleUrls: [`./pid-filter.component.scss`], +}) +export class PidFilterComponent + extends ClearableInputComponent + implements OnDestroy +{ + private pidSubject = new Subject(); + private subscription: Subscription; + + appConfig = this.appConfigService.getConfig(); + + constructor( + public appConfigService: AppConfigService, + private store: Store, + ) { + super(); + this.subscription = this.pidSubject + .pipe(debounceTime(500), distinctUntilChanged()) + .subscribe((pid) => { + const condition = !pid ? "" : this.buildPidTermsCondition(pid); + this.store.dispatch(setPidTermsFilterAction({ pid: condition })); + }); + } + + get label() { + return getFilterLabel((this.constructor as typeof PidFilterComponent).name); + } + + buildPidTermsCondition(terms: string): string | { $regex: string } { + return terms; + } + + ngOnDestroy() { + // Unsubscribe to avoid memory leaks + this.subscription.unsubscribe(); + this.pidSubject.complete(); + } + + onPidInput(event: any) { + const pid = (event.target as HTMLInputElement).value; + this.pidSubject.next(pid); + } + + @Input() + set clear(value: boolean) { + super.clear = value; + + if (value) this.store.dispatch(setPidTermsAction({ pid: "" })); + } +} diff --git a/src/app/shared/modules/filters/text-filter.component.html b/src/app/shared/modules/filters/text-filter.component.html new file mode 100644 index 000000000..db628b284 --- /dev/null +++ b/src/app/shared/modules/filters/text-filter.component.html @@ -0,0 +1,10 @@ + + {{ label }} + + diff --git a/src/app/shared/modules/filters/text-filter.component.scss b/src/app/shared/modules/filters/text-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/text-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/text-filter.component.spec.ts b/src/app/shared/modules/filters/text-filter.component.spec.ts new file mode 100644 index 000000000..12416345b --- /dev/null +++ b/src/app/shared/modules/filters/text-filter.component.spec.ts @@ -0,0 +1,112 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, + fakeAsync, + tick, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { + setSearchTermsAction, + setTextFilterAction, +} from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { TextFilterComponent } from "./text-filter.component"; + +describe("TextFilterComponent", () => { + let component: TextFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [TextFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TextFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#textSearchChanged()", () => { + it("should dispatch a SetSearchTermsAction", fakeAsync(() => { + dispatchSpy = spyOn(store, "dispatch"); + + const terms = "test"; + const event = { target: { value: terms } }; + component.textSearchChanged(event); + + tick(500); //wait for it + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + setTextFilterAction({ text: terms }), + ); + })); + }); +}); diff --git a/src/app/shared/modules/filters/text-filter.component.ts b/src/app/shared/modules/filters/text-filter.component.ts new file mode 100644 index 000000000..fd77eb764 --- /dev/null +++ b/src/app/shared/modules/filters/text-filter.component.ts @@ -0,0 +1,48 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { Store } from "@ngrx/store"; +import { setTextFilterAction } from "state-management/actions/datasets.actions"; +import { debounceTime, distinctUntilChanged, skipWhile } from "rxjs/operators"; +import { Subject, Subscription } from "rxjs"; +import { getFilterLabel } from "./utils"; + +@Component({ + selector: "app-text-filter", + templateUrl: "text-filter.component.html", + styleUrls: ["text-filter.component.scss"], +}) +export class TextFilterComponent + extends ClearableInputComponent + implements OnDestroy +{ + private textSubject = new Subject(); + + subscription: Subscription; + + constructor(private store: Store) { + super(); + this.subscription = this.textSubject + .pipe( + skipWhile((terms) => terms === ""), + debounceTime(500), + distinctUntilChanged(), + ) + .subscribe((terms) => { + this.store.dispatch(setTextFilterAction({ text: terms })); + }); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + textSearchChanged(event: any) { + const pid = (event.target as HTMLInputElement).value; + this.textSubject.next(pid); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + this.textSubject.complete(); + } +} diff --git a/src/app/shared/modules/filters/type-filter.component.html b/src/app/shared/modules/filters/type-filter.component.html new file mode 100644 index 000000000..c9b6a1640 --- /dev/null +++ b/src/app/shared/modules/filters/type-filter.component.html @@ -0,0 +1,30 @@ + + {{ label }} + + {{ type }}cancel + + + + + + + {{ getFacetId(fc, "No Type") }} | + {{ getFacetCount(fc) }} + + + diff --git a/src/app/shared/modules/filters/type-filter.component.scss b/src/app/shared/modules/filters/type-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/type-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/type-filter.component.spec.ts b/src/app/shared/modules/filters/type-filter.component.spec.ts new file mode 100644 index 000000000..a157ef39c --- /dev/null +++ b/src/app/shared/modules/filters/type-filter.component.spec.ts @@ -0,0 +1,137 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { + addTypeFilterAction, + removeTypeFilterAction, +} from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { TypeFilterComponent } from "./type-filter.component"; + +describe("TypeFilterComponent", () => { + let component: TypeFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [TypeFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TypeFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#onTypeInput()", () => { + it("should call next on typeInput$", () => { + const nextSpy = spyOn(component.typeInput$, "next"); + + const event = { + target: { + value: "type", + }, + }; + + component.onTypeInput(event); + + expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); + }); + }); + + describe("#typeSelected()", () => { + it("should dispatch an AddTypeFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const datasetType = "string"; + component.typeSelected(datasetType); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + addTypeFilterAction({ datasetType }), + ); + }); + }); + + describe("#typeRemoved()", () => { + it("should dispatch a RemoveTypeFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const datasetType = "string"; + component.typeRemoved(datasetType); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + removeTypeFilterAction({ datasetType }), + ); + }); + }); +}); diff --git a/src/app/shared/modules/filters/type-filter.component.ts b/src/app/shared/modules/filters/type-filter.component.ts new file mode 100644 index 000000000..cd9ee2199 --- /dev/null +++ b/src/app/shared/modules/filters/type-filter.component.ts @@ -0,0 +1,61 @@ +import { Component } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { + selectTypeFacetCounts, + selectTypeFilter, +} from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { BehaviorSubject } from "rxjs"; +import { + addTypeFilterAction, + removeTypeFilterAction, +} from "state-management/actions/datasets.actions"; + +@Component({ + selector: "app-type-filter", + templateUrl: "type-filter.component.html", + styleUrls: ["type-filter.component.scss"], +}) +export class TypeFilterComponent extends ClearableInputComponent { + protected readonly getFacetCount = getFacetCount; + protected readonly getFacetId = getFacetId; + + typeFacetCounts$ = this.store.select(selectTypeFacetCounts); + + typeFilter$ = this.store.select(selectTypeFilter); + typeInput$ = new BehaviorSubject(""); + + typeSuggestions$ = createSuggestionObserver( + this.typeFacetCounts$, + this.typeInput$, + this.typeFilter$, + ); + + constructor(private store: Store) { + super(); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + onTypeInput(event: any) { + const value = (event.target).value; + this.typeInput$.next(value); + } + + typeSelected(type: string) { + this.store.dispatch(addTypeFilterAction({ datasetType: type })); + this.typeInput$.next(""); + } + + typeRemoved(type: string) { + this.store.dispatch(removeTypeFilterAction({ datasetType: type })); + } +} diff --git a/src/app/shared/modules/filters/utils.spec.ts b/src/app/shared/modules/filters/utils.spec.ts new file mode 100644 index 000000000..a241259b8 --- /dev/null +++ b/src/app/shared/modules/filters/utils.spec.ts @@ -0,0 +1,39 @@ +import { FacetCount } from "../../../state-management/state/datasets.store"; +import { getFacetCount, getFacetId } from "./utils"; + +describe("#getFacetId()", () => { + it("should return the FacetCount id if present", () => { + const facetCount: FacetCount = { + _id: "test1", + count: 0, + }; + const fallback = "test2"; + + const id = getFacetId(facetCount, fallback); + + expect(id).toEqual("test1"); + }); + + it("should return the FacetCount id if present", () => { + const facetCount: FacetCount = { + count: 0, + }; + const fallback = "test"; + + const id = getFacetId(facetCount, fallback); + + expect(id).toEqual(fallback); + }); +}); + +describe("#getFacetCount()", () => { + it("should return the FacetCount", () => { + const facetCount: FacetCount = { + count: 0, + }; + + const count = getFacetCount(facetCount); + + expect(count).toEqual(facetCount.count); + }); +}); diff --git a/src/app/shared/modules/filters/utils.ts b/src/app/shared/modules/filters/utils.ts new file mode 100644 index 000000000..bfbab0782 --- /dev/null +++ b/src/app/shared/modules/filters/utils.ts @@ -0,0 +1,48 @@ +import { BehaviorSubject, combineLatest, Observable } from "rxjs"; +import { FacetCount } from "../../../state-management/state/datasets.store"; +import { map } from "rxjs/operators"; + +export function createSuggestionObserver( + facetCounts$: Observable, + input$: BehaviorSubject, + currentFilters$: Observable, +): Observable { + return combineLatest([facetCounts$, input$, currentFilters$]).pipe( + map(([counts, filterString, currentFilters]) => { + if (!counts) { + return []; + } + return counts.filter( + (count) => + typeof count._id === "string" && + count._id.toLowerCase().includes(filterString.toLowerCase()) && + currentFilters.indexOf(count._id) < 0, + ); + }), + ); +} + +export function getFacetId(facetCount: FacetCount, fallback = ""): string { + const id = facetCount._id; + return id ? String(id) : fallback; +} + +export function getFacetCount(facetCount: FacetCount): number { + return facetCount.count; +} + +const labelMap: Map = new Map([ + ["DateRangeFilterComponent", "Start Date - End Date"], + ["GroupFilterComponent", "Group"], + ["KeywordFilterComponent", "Keyword"], + ["LocationFilterComponent", "Location"], + ["PidFilterStartsWithComponent", "PID filter (Starts With)"], + ["PidFilterComponent", "PID filter (Equals)"], + ["PidFilterContainsComponent", "PID filter (Contains)"], + ["TextFilterComponent", "Text filter"], + ["TypeFilterComponent", "Type filter"], +]); + +export function getFilterLabel(type: string): string { + return labelMap.get(type) || "Default Label"; +} diff --git a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.html b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.html index 9be96519c..61c2149a3 100644 --- a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.html +++ b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.html @@ -87,7 +87,7 @@

Add Characteristic

color="primary" [disabled]="isInvalid()" > - Add + Apply diff --git a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.spec.ts b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.spec.ts index 5865322f8..321d1102e 100644 --- a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.spec.ts +++ b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.spec.ts @@ -17,6 +17,7 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { AppConfigService } from "app-config.service"; import { SearchParametersDialogComponent } from "./search-parameters-dialog.component"; +import { ScientificCondition } from "../../../state-management/models"; const getConfig = () => ({ scienceSearchUnitsEnabled: true, @@ -75,7 +76,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "gram", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); component.add(); @@ -114,7 +115,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); component.toggleUnitField(); @@ -127,7 +128,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); component.toggleUnitField(); @@ -140,7 +141,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); component.toggleUnitField(); @@ -156,7 +157,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); const isInvalid = component.isInvalid(); @@ -169,7 +170,7 @@ describe("SearchParametersDialogComponent", () => { rhs: "test", unit: "gram", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); const isInvalid = component.isInvalid(); @@ -182,7 +183,7 @@ describe("SearchParametersDialogComponent", () => { rhs: "", unit: "gram", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); const isInvalid = component.isInvalid(); @@ -195,7 +196,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "gram", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); const isInvalid = component.isInvalid(); 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 e5af6d850..d2680dc1a 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 @@ -1,9 +1,10 @@ -import { Component, Inject } from "@angular/core"; +import { ChangeDetectorRef, Component, Inject } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog"; import { AppConfigService } from "app-config.service"; import { map, startWith } from "rxjs/operators"; import { UnitsService } from "shared/services/units.service"; +import { ScientificCondition } from "../../../state-management/models"; @Component({ selector: "search-parameters-dialog", @@ -17,12 +18,15 @@ export class SearchParametersDialogComponent { units: string[] = []; parametersForm = new FormGroup({ - lhs: new FormControl("", [Validators.required, Validators.minLength(2)]), - relation: new FormControl("GREATER_THAN", [ + lhs: new FormControl(this.data.condition?.lhs || "", [ + Validators.required, + Validators.minLength(2), + ]), + relation: new FormControl(this.data.condition?.relation || "GREATER_THAN", [ Validators.required, Validators.minLength(9), ]), - rhs: new FormControl("", [ + rhs: new FormControl(this.data.condition?.rhs || "", [ Validators.required, Validators.minLength(1), ]), @@ -49,10 +53,18 @@ export class SearchParametersDialogComponent { constructor( public appConfigService: AppConfigService, - @Inject(MAT_DIALOG_DATA) public data: { parameterKeys: string[] }, + @Inject(MAT_DIALOG_DATA) + public data: { + parameterKeys: string[]; + condition?: ScientificCondition; + }, public dialogRef: MatDialogRef, private unitsService: UnitsService, - ) {} + ) { + if (this.data.condition?.lhs) { + this.getUnits(this.data.condition.lhs); + } + } add = (): void => { const { lhs, relation, unit } = this.parametersForm.value; diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 55d1ac1af..6ed28f27f 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -16,6 +16,7 @@ import { CommonModule } from "@angular/common"; import { SharedTableModule } from "./modules/shared-table/shared-table.module"; import { ScicatDataService } from "./services/scicat-data-service"; import { ScientificMetadataTreeModule } from "./modules/scientific-metadata-tree/scientific-metadata-tree.modules"; +import { FiltersModule } from "./modules/filters/filters.module"; @NgModule({ imports: [ BreadcrumbModule, @@ -48,6 +49,7 @@ import { ScientificMetadataTreeModule } from "./modules/scientific-metadata-tree FormsModule, SharedTableModule, ScientificMetadataTreeModule, + FiltersModule, ], }) export class SharedScicatFrontendModule {} diff --git a/src/app/state-management/actions/datasets.actions.spec.ts b/src/app/state-management/actions/datasets.actions.spec.ts index c09c38afa..9cb46268d 100644 --- a/src/app/state-management/actions/datasets.actions.spec.ts +++ b/src/app/state-management/actions/datasets.actions.spec.ts @@ -731,11 +731,16 @@ describe("Dataset Actions", () => { describe("removeScientificConditionAction", () => { it("should create an action", () => { - const index = 0; - const action = fromActions.removeScientificConditionAction({ index }); + const condition: ScientificCondition = { + lhs: "lhsTest", + relation: "LESS_THAN", + rhs: 5, + unit: "s", + }; + const action = fromActions.removeScientificConditionAction({ condition }); expect({ ...action }).toEqual({ type: "[Dataset] Remove Scientific Condition", - index, + condition, }); }); }); diff --git a/src/app/state-management/actions/datasets.actions.ts b/src/app/state-management/actions/datasets.actions.ts index 8d31223ee..5c6ce0d93 100644 --- a/src/app/state-management/actions/datasets.actions.ts +++ b/src/app/state-management/actions/datasets.actions.ts @@ -324,7 +324,7 @@ export const addScientificConditionAction = createAction( ); export const removeScientificConditionAction = createAction( "[Dataset] Remove Scientific Condition", - props<{ index: number }>(), + props<{ condition: ScientificCondition }>(), ); export const clearDatasetsStateAction = createAction("[Dataset] Clear State"); diff --git a/src/app/state-management/actions/user.actions.ts b/src/app/state-management/actions/user.actions.ts index c38083ddf..f04c262a0 100644 --- a/src/app/state-management/actions/user.actions.ts +++ b/src/app/state-management/actions/user.actions.ts @@ -2,6 +2,10 @@ import { HttpErrorResponse } from "@angular/common/http"; import { createAction, props } from "@ngrx/store"; import { User, AccessToken, UserIdentity, UserSetting } from "shared/sdk"; import { Message, Settings, TableColumn } from "state-management/models"; +import { + ConditionConfig, + FilterConfig, +} from "../../shared/modules/filters/filters.module"; export const setDatasetTableColumnsAction = createAction( "[User] Set Dataset Table Columns", @@ -160,3 +164,13 @@ export const saveSettingsAction = createAction( export const loadingAction = createAction("[User] Loading"); export const loadingCompleteAction = createAction("[User] Loading Complete"); + +export const updateFilterConfigs = createAction( + "[User] Update Filter Configs", + props<{ filterConfigs: FilterConfig[] }>(), +); + +export const updateConditionsConfigs = createAction( + "[User] Update Conditions Configs", + props<{ conditionConfigs: ConditionConfig[] }>(), +); diff --git a/src/app/state-management/effects/datasets.effects.spec.ts b/src/app/state-management/effects/datasets.effects.spec.ts index b2cef6a19..bf9563fbc 100644 --- a/src/app/state-management/effects/datasets.effects.spec.ts +++ b/src/app/state-management/effects/datasets.effects.spec.ts @@ -247,8 +247,14 @@ describe("DatasetEffects", () => { describe("ofType removeScientificConditionAction", () => { it("should result in a fetchMetadataKeysAction", () => { + const condition: ScientificCondition = { + lhs: "test", + relation: "EQUAL_TO_NUMERIC", + rhs: 1000, + unit: "s", + }; const action = fromActions.removeScientificConditionAction({ - index: 0, + condition, }); const outcome = fromActions.fetchMetadataKeysAction(); diff --git a/src/app/state-management/reducers/datasets.reducer.spec.ts b/src/app/state-management/reducers/datasets.reducer.spec.ts index 5b7fbe4ca..2185bf67d 100644 --- a/src/app/state-management/reducers/datasets.reducer.spec.ts +++ b/src/app/state-management/reducers/datasets.reducer.spec.ts @@ -526,9 +526,7 @@ describe("DatasetsReducer", () => { expect(sta.filters.scientific).toContain(condition); - const index = 0; - - const action = fromActions.removeScientificConditionAction({ index }); + const action = fromActions.removeScientificConditionAction({ condition }); const state = fromDatasets.datasetsReducer(initialDatasetState, action); expect(state.filters.scientific).not.toContain(condition); diff --git a/src/app/state-management/reducers/datasets.reducer.ts b/src/app/state-management/reducers/datasets.reducer.ts index cd09424ab..7c6783d88 100644 --- a/src/app/state-management/reducers/datasets.reducer.ts +++ b/src/app/state-management/reducers/datasets.reducer.ts @@ -462,9 +462,10 @@ const reducer = createReducer( ), on( fromActions.removeScientificConditionAction, - (state, { index }): DatasetState => { + (state, { condition }): DatasetState => { const currentFilters = state.filters; const scientific = [...currentFilters.scientific]; + const index = scientific.indexOf(condition); scientific.splice(index, 1); const filters = { ...currentFilters, scientific }; return { ...state, filters }; diff --git a/src/app/state-management/reducers/user.reducer.ts b/src/app/state-management/reducers/user.reducer.ts index 0f36625a8..13c16002a 100644 --- a/src/app/state-management/reducers/user.reducer.ts +++ b/src/app/state-management/reducers/user.reducer.ts @@ -222,6 +222,20 @@ const reducer = createReducer( isLoading: false, }), ), + on( + fromActions.updateFilterConfigs, + (state, { filterConfigs }): UserState => ({ + ...state, + filters: filterConfigs, + }), + ), + on( + fromActions.updateConditionsConfigs, + (state, { conditionConfigs }): UserState => ({ + ...state, + conditions: conditionConfigs, + }), + ), ); export const userReducer = (state: UserState | undefined, action: Action) => { diff --git a/src/app/state-management/selectors/user.selectors.spec.ts b/src/app/state-management/selectors/user.selectors.spec.ts index 254028e2b..d7f5cd503 100644 --- a/src/app/state-management/selectors/user.selectors.spec.ts +++ b/src/app/state-management/selectors/user.selectors.spec.ts @@ -3,6 +3,15 @@ import * as fromSelectors from "./user.selectors"; import { UserState } from "../state/user.store"; import { User, UserIdentity, Settings } from "../models"; import { AccessToken } from "shared/sdk"; +import { LocationFilterComponent } from "../../shared/modules/filters/location-filter.component"; +import { PidFilterComponent } from "../../shared/modules/filters/pid-filter.component"; +import { PidFilterContainsComponent } from "../../shared/modules/filters/pid-filter-contains.component"; +import { PidFilterStartsWithComponent } from "../../shared/modules/filters/pid-filter-startsWith.component"; +import { GroupFilterComponent } from "../../shared/modules/filters/group-filter.component"; +import { TypeFilterComponent } from "../../shared/modules/filters/type-filter.component"; +import { KeywordFilterComponent } from "../../shared/modules/filters/keyword-filter.component"; +import { DateRangeFilterComponent } from "../../shared/modules/filters/date-range-filter.component"; +import { TextFilterComponent } from "../../shared/modules/filters/text-filter.component"; const user = new User({ id: "testId", @@ -68,6 +77,20 @@ const initialUserState: UserState = { isLoading: false, columns: [{ name: "datasetName", order: 1, type: "standard", enabled: true }], + + filters: [ + { type: "LocationFilterComponent", visible: true }, + { type: "PidFilterComponent", visible: true }, + { type: "PidFilterContainsComponent", visible: false }, + { type: "PidFilterStartsWithComponent", visible: false }, + { type: "GroupFilterComponent", visible: true }, + { type: "TypeFilterComponent", visible: true }, + { type: "KeywordFilterComponent", visible: true }, + { type: "DateRangeFilterComponent", visible: true }, + { type: "TextFilterComponent", visible: true }, + ], + + conditions: [], }; describe("User Selectors", () => { diff --git a/src/app/state-management/selectors/user.selectors.ts b/src/app/state-management/selectors/user.selectors.ts index 643057e67..f03a901c1 100644 --- a/src/app/state-management/selectors/user.selectors.ts +++ b/src/app/state-management/selectors/user.selectors.ts @@ -94,6 +94,16 @@ export const selectColumns = createSelector( (state) => state.columns, ); +export const selectFilters = createSelector( + selectUserState, + (state) => state.filters, +); + +export const selectConditions = createSelector( + selectUserState, + (state) => state.conditions, +); + export const selectSampleDialogPageViewModel = createSelector( selectCurrentUser, selectProfile, diff --git a/src/app/state-management/state/user.store.ts b/src/app/state-management/state/user.store.ts index 2de8988ab..747a7b367 100644 --- a/src/app/state-management/state/user.store.ts +++ b/src/app/state-management/state/user.store.ts @@ -1,5 +1,18 @@ import { Settings, Message, User, TableColumn } from "../models"; import { AccessToken } from "shared/sdk"; +import { + ConditionConfig, + FilterConfig, +} from "../../shared/modules/filters/filters.module"; +import { LocationFilterComponent } from "../../shared/modules/filters/location-filter.component"; +import { PidFilterComponent } from "../../shared/modules/filters/pid-filter.component"; +import { PidFilterContainsComponent } from "../../shared/modules/filters/pid-filter-contains.component"; +import { PidFilterStartsWithComponent } from "../../shared/modules/filters/pid-filter-startsWith.component"; +import { GroupFilterComponent } from "../../shared/modules/filters/group-filter.component"; +import { TypeFilterComponent } from "../../shared/modules/filters/type-filter.component"; +import { KeywordFilterComponent } from "../../shared/modules/filters/keyword-filter.component"; +import { DateRangeFilterComponent } from "../../shared/modules/filters/date-range-filter.component"; +import { TextFilterComponent } from "../../shared/modules/filters/text-filter.component"; // NOTE It IS ok to make up a state of other sub states export interface UserState { @@ -19,6 +32,10 @@ export interface UserState { isLoading: boolean; columns: TableColumn[]; + + filters: FilterConfig[]; + + conditions: ConditionConfig[]; } export const initialUserState: UserState = { @@ -49,4 +66,17 @@ export const initialUserState: UserState = { isLoading: false, columns: [], + + filters: [ + { type: "LocationFilterComponent", visible: true }, + { type: "PidFilterComponent", visible: true }, + { type: "PidFilterContainsComponent", visible: false }, + { type: "PidFilterStartsWithComponent", visible: false }, + { type: "GroupFilterComponent", visible: true }, + { type: "TypeFilterComponent", visible: true }, + { type: "KeywordFilterComponent", visible: true }, + { type: "DateRangeFilterComponent", visible: true }, + ], + + conditions: [], };