From fa992223016ac3dc958e532543282d1c7a5c9372 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Mon, 1 Dec 2025 15:52:28 -0700 Subject: [PATCH 01/19] WIP: Licensee search update - Renamed unusable list / paging components as Legacy - Added new blank components that will hold new core list / paging functionality --- .../Licensee/LicenseeList/LicenseeList.less | 66 +-- .../LicenseeList/LicenseeList.spec.ts | 128 +----- .../Licensee/LicenseeList/LicenseeList.ts | 368 +---------------- .../Licensee/LicenseeList/LicenseeList.vue | 73 +--- .../LicenseeListLegacy.less | 70 ++++ .../LicenseeListLegacy.spec.ts | 141 +++++++ .../LicenseeListLegacy/LicenseeListLegacy.ts | 382 ++++++++++++++++++ .../LicenseeListLegacy/LicenseeListLegacy.vue | 82 ++++ .../LicenseeSearch/LicenseeSearch.less | 69 +--- .../LicenseeSearch/LicenseeSearch.spec.ts | 2 +- .../Licensee/LicenseeSearch/LicenseeSearch.ts | 200 +-------- .../LicenseeSearch/LicenseeSearch.vue | 47 +-- .../LicenseeSearchLegacy.less | 73 ++++ .../LicenseeSearchLegacy.spec.ts | 19 + .../LicenseeSearchLegacy.ts | 212 ++++++++++ .../LicenseeSearchLegacy.vue | 56 +++ .../Lists/ListContainer/ListContainer.spec.ts | 12 +- .../Lists/ListContainer/ListContainer.ts | 5 +- .../Lists/ListContainer/ListContainer.vue | 12 +- .../Lists/Pagination/Pagination.less | 72 +--- .../Lists/Pagination/Pagination.spec.ts | 102 +---- .../components/Lists/Pagination/Pagination.ts | 235 +---------- .../Lists/Pagination/Pagination.vue | 39 +- .../PaginationLegacy/PaginationLegacy.less | 74 ++++ .../PaginationLegacy/PaginationLegacy.spec.ts | 113 ++++++ .../PaginationLegacy/PaginationLegacy.ts | 239 +++++++++++ .../PaginationLegacy/PaginationLegacy.vue | 46 +++ .../src/pages/LicensingList/LicensingList.ts | 2 +- .../PublicLicensingList.ts | 2 +- .../src/store/license/license.mutations.ts | 4 +- webroot/src/store/license/license.state.ts | 4 +- 31 files changed, 1559 insertions(+), 1390 deletions(-) create mode 100644 webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.less create mode 100644 webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.spec.ts create mode 100644 webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.ts create mode 100644 webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue create mode 100644 webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.less create mode 100644 webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.spec.ts create mode 100644 webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.ts create mode 100644 webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue create mode 100644 webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.less create mode 100644 webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.spec.ts create mode 100644 webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.ts create mode 100644 webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.vue diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.less b/webroot/src/components/Licensee/LicenseeList/LicenseeList.less index 5ddce8005..62c35ea92 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.less +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.less @@ -2,69 +2,5 @@ // LicenseeList.less // CompactConnect // -// Created by InspiringApps on 7/1/2024. +// Created by InspiringApps on 12/1/2025. // - -.licensee-list-container { - background-color: transparent; - - .search-toggle-container { - display: flex; - flex-direction: column; - flex-wrap: wrap; - align-items: flex-end; - margin-top: 1.2rem; - - @media @desktopWidth { - flex-direction: row; - align-items: center; - justify-content: flex-end; - } - - .search-tag { - display: flex; - align-content: center; - align-items: center; - justify-content: center; - width: 100%; - margin-top: 1.2rem; - padding: 0.4rem 1rem; - border-radius: @borderRadiusPillShape; - color: @white; - background-color: @darkBlue; - - @media @desktopWidth { - order: 1; - width: auto; - margin-top: 0; - margin-right: 2.4rem; - } - - .title { - padding: 0 0.4rem; - } - - .search-terms { - font-weight: @fontWeightBold; - } - - .search-terms-reset { - width: 2rem; - margin-left: auto; - padding: 0.4rem 0.2rem; - cursor: pointer; - stroke: @white; - } - } - - .search-toggle { - width: 100%; - min-height: 4.8rem; - - @media @desktopWidth { - order: 2; - width: auto; - } - } - } -} diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts index c3a468ff2..c7a67eff7 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts @@ -2,25 +2,12 @@ // LicenseeList.spec.ts // CompactConnect // -// Created by InspiringApps on 7/1/2024. +// Created by InspiringApps on 12/1/2025. // -import chaiMatchPattern from 'chai-match-pattern'; -import chai from 'chai'; -import { mountShallow, mountFull } from '@tests/helpers/setup'; +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; import LicenseeList from '@components/Licensee/LicenseeList/LicenseeList.vue'; -import { Compact, CompactType } from '@models/Compact/Compact.model'; -import sinon from 'sinon'; - -chai.use(chaiMatchPattern); - -const { expect } = chai; -const lastKey = 'lastKey'; -const prevLastKey = 'prevLastKey'; -const populateComponentStorePagingKeys = (component) => { - component.$store.dispatch('license/setStoreLicenseeLastKey', lastKey); - component.$store.dispatch('license/setStoreLicenseePrevLastKey', prevLastKey); -}; describe('LicenseeList component', async () => { it('should mount the component', async () => { @@ -29,113 +16,4 @@ describe('LicenseeList component', async () => { expect(wrapper.exists()).to.equal(true); expect(wrapper.findComponent(LicenseeList).exists()).to.equal(true); }); - it('should successfully re-fetch data with previous paging key if going back a page', async () => { - const wrapper = await mountShallow(LicenseeList); - const component = wrapper.vm; - const fetchListData = sinon.spy(); - - component.fetchListData = fetchListData; - component.isInitialFetchCompleted = true; - populateComponentStorePagingKeys(component); - - await component.paginationChange({ firstIndex: 0, prevNext: -1 }); - - expect(component.prevKey).to.equal(prevLastKey); - expect(component.nextKey).to.equal(''); - expect(fetchListData.calledOnce).to.equal(true); - }); - it('should successfully re-fetch data with next paging key if going forward a page', async () => { - const wrapper = await mountShallow(LicenseeList); - const component = wrapper.vm; - const fetchListData = sinon.spy(); - - component.fetchListData = fetchListData; - component.isInitialFetchCompleted = true; - populateComponentStorePagingKeys(component); - - await component.paginationChange({ firstIndex: 0, prevNext: 1 }); - - expect(component.prevKey).to.equal(''); - expect(component.nextKey).to.equal(lastKey); - expect(fetchListData.calledOnce).to.equal(true); - }); - it('should successfully re-fetch data when returning to first page', async () => { - const wrapper = await mountShallow(LicenseeList); - const component = wrapper.vm; - const fetchListData = sinon.spy(); - - component.fetchListData = fetchListData; - component.isInitialFetchCompleted = true; - populateComponentStorePagingKeys(component); - - await component.paginationChange({ firstIndex: 0, prevNext: undefined }); - - expect(component.prevKey).to.equal(''); - expect(component.nextKey).to.equal(''); - expect(fetchListData.calledOnce).to.equal(true); - }); - it('should successfully not re-fetch data if page change before initial fetch completes', async () => { - const wrapper = await mountShallow(LicenseeList); - const component = wrapper.vm; - const fetchListData = sinon.spy(); - - component.fetchListData = fetchListData; - component.isInitialFetchCompleted = false; - populateComponentStorePagingKeys(component); - - await component.paginationChange({ firstIndex: 0, prevNext: 1 }); - - expect(component.prevKey).to.equal(''); - expect(component.nextKey).to.equal(lastKey); - expect(fetchListData.notCalled).to.equal(true); - }); - it('should successfully not re-fetch data if page change from search results', async () => { - const wrapper = await mountShallow(LicenseeList); - const component = wrapper.vm; - const fetchListData = sinon.spy(); - - component.fetchListData = fetchListData; - component.isInitialFetchCompleted = true; - populateComponentStorePagingKeys(component); - - await component.paginationChange({ firstIndex: 0, prevNext: 0 }); - - expect(component.prevKey).to.equal(''); - expect(component.nextKey).to.equal(''); - expect(fetchListData.notCalled).to.equal(true); - }); - it('should successfully fetch data with expected search params (no params)', async () => { - const wrapper = await mountFull(LicenseeList); - const component = wrapper.vm; - const requestConfig = await component.fetchListData(); - - expect(requestConfig).to.matchPattern({ - jurisdiction: undefined, - licenseeFirstName: undefined, - licenseeLastName: undefined, - '...': '', - }); - }); - it('should successfully fetch data with expected search params (all params)', async () => { - const wrapper = await mountShallow(LicenseeList); - const component = wrapper.vm; - const testParams = { - firstName: 'firstName', - lastName: 'lastName', - state: 'state', - }; - - await component.$store.dispatch('user/setCurrentCompact', new Compact({ type: CompactType.ASLP })); - await component.$store.dispatch('license/setStoreSearch', testParams); - - const requestConfig = await component.fetchListData(); - - expect(requestConfig).to.matchPattern({ - compact: CompactType.ASLP, - jurisdiction: testParams.state, - licenseeFirstName: testParams.firstName, - licenseeLastName: testParams.lastName, - '...': '', - }); - }); }); diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts index 835bca7e4..cc1efbdf2 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts @@ -2,379 +2,15 @@ // LicenseeList.ts // CompactConnect // -// Created by InspiringApps on 7/1/2024. +// Created by InspiringApps on 12/1/2025. // -import { - Component, - Vue, - Prop, - toNative -} from 'vue-facing-decorator'; -import ListContainer from '@components/Lists/ListContainer/ListContainer.vue'; -import LicenseeSearch, { LicenseSearch } from '@components/Licensee/LicenseeSearch/LicenseeSearch.vue'; -import LicenseeRow from '@components/Licensee/LicenseeRow/LicenseeRow.vue'; -import CloseX from '@components/Icons/CloseX/CloseX.vue'; -import { SortDirection } from '@store/sorting/sorting.state'; -import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, PageChangeConfig } from '@store/pagination/pagination.state'; -import { PageExhaustError } from '@store/pagination'; -import { RequestParamsInterfaceLocal } from '@network/licenseApi/data.api'; -import { State } from '@models/State/State.model'; +import { Component, Vue, toNative } from 'vue-facing-decorator'; @Component({ name: 'LicenseeList', - components: { - ListContainer, - LicenseeSearch, - LicenseeRow, - CloseX, - }, }) class LicenseeList extends Vue { - @Prop({ required: true }) protected listId!: string; - @Prop({ default: false }) protected isPublicSearch?: boolean; - - // - // Data - // - hasSearched = false; - shouldShowSearchModal = false; - isInitialFetchCompleted = false; - prevKey = ''; - nextKey = ''; - - // - // Lifecycle - // - async created() { - if (this.licenseStoreRecordCount) { - this.hasSearched = true; - } - } - - async mounted() { - if (!this.licenseStoreRecordCount) { - // License store is empty - apply defaults - await this.setDefaultSort(); - await this.setDefaultPaging(); - } else if (this.licenseStoreRecordCount === 1 && !this.searchDisplayAll) { - // Edge case: Returning from a detail page that was refreshed / cache-cleared - this.shouldShowSearchModal = true; - } else { - // License store already has records - this.isInitialFetchCompleted = true; - } - } - - // - // Computed - // - get sortingStore() { - return this.$store.state.sorting; - } - - get paginationStore() { - return this.$store.state.pagination; - } - - get userStore(): any { - return this.$store.state.user; - } - - get licenseStore(): any { - return this.$store.state.license; - } - - get licenseStoreRecordCount(): number { - return this.licenseStore.model?.length || 0; - } - - get searchParams(): LicenseSearch { - return this.licenseStore.search; - } - - get searchDisplayCompact(): string { - return (this.isPublicSearch) ? this.userStore.currentCompact?.abbrev() || '' : ''; - } - - get searchDisplayFirstName(): string { - const delimiter = (this.searchDisplayCompact) ? ', ' : ''; - let displayFirstName = ''; - - if (this.searchParams.firstName) { - displayFirstName = `${delimiter}${this.searchParams.firstName}` || ''; - } - - return displayFirstName; - } - - get searchDisplayLastName(): string { - const delimiter = (this.searchDisplayCompact && !this.searchDisplayFirstName) ? ', ' : ''; - const subDelimiter = (this.searchDisplayFirstName) ? ' ' : ''; - let displayLastName = ''; - - if (this.searchParams.lastName) { - displayLastName = `${delimiter}${subDelimiter}${this.searchParams.lastName}` || ''; - } - - return displayLastName; - } - - get searchDisplayState(): string { - const { state } = this.searchParams; - const { searchDisplayCompact, searchDisplayFirstName, searchDisplayLastName } = this; - const delimiter = (searchDisplayCompact || searchDisplayFirstName || searchDisplayLastName) ? ', ' : ''; - let displayState = ''; - - if (state) { - const stateModel = new State({ abbrev: state }); - - displayState = `${delimiter}${stateModel.name()}`; - } - - return displayState; - } - - get searchDisplayAll(): string { - const { - searchDisplayCompact, - searchDisplayFirstName, - searchDisplayLastName, - searchDisplayState - } = this; - - return [ - searchDisplayCompact, - searchDisplayFirstName, - searchDisplayLastName, - searchDisplayState - ].join('').trim(); - } - - get sortOptions(): Array { - const options = [ - // Temp for limited server sorting support - // { value: 'firstName', name: this.$t('common.firstName') }, - { value: 'lastName', name: this.$t('common.lastName'), isDefault: true }, - // { value: 'licenseStates', name: this.$t('licensing.homeState') }, - // { value: 'privilegeStates', name: this.$t('licensing.privileges') }, - // { value: 'status', name: this.$t('licensing.status') }, - ]; - - return options; - } - - get headerRecord() { - const record = { - firstName: this.$t('common.firstName'), - lastName: this.$t('common.lastName'), - ssnMaskedPartial: () => this.$t('licensing.ssn'), - homeJurisdictionDisplay: () => this.$t('licensing.homeState'), - privilegeStatesDisplay: () => this.$t('licensing.privileges'), - statusDisplay: () => this.$t('licensing.status'), - }; - - return record; - } - - // - // Methods - // - toggleSearch(): void { - this.shouldShowSearchModal = !this.shouldShowSearchModal; - } - - handleSearch(params: LicenseSearch): void { - this.$store.dispatch('license/setStoreSearch', params); - this.$store.dispatch('pagination/updatePaginationPage', { - paginationId: this.listId, - newPage: 1, - }); - this.fetchListData(); - - if (!this.hasSearched) { - this.hasSearched = true; - } else { - this.toggleSearch(); - } - } - - async resetSearch(): Promise { - this.$store.dispatch('license/resetStoreSearch'); - - if (this.isPublicSearch) { - await this.$store.dispatch('user/setCurrentCompact', null); - } - - this.toggleSearch(); - } - - async setDefaultSort() { - const { listId } = this; - const defaultSortOption = this.sortOptions.find((option) => option.isDefault) || this.sortOptions[0]; - const { option, direction } = this.sortingStore.sortingMap[listId] || {}; - - if (!option) { - await this.$store.dispatch('sorting/updateSortOption', { - sortingId: listId, - newOption: defaultSortOption.value, - }); - } - - if (!direction) { - await this.$store.dispatch('sorting/updateSortDirection', { - sortingId: listId, - newDirection: SortDirection.asc, - }); - } - } - - async setDefaultPaging(shouldForce = false) { - const { listId } = this; - const { page, size } = this.paginationStore.paginationMap[this.listId] || {}; - const { prevLastKey } = this.licenseStore; - - if (!page || shouldForce) { - await this.$store.dispatch('pagination/updatePaginationPage', { - paginationId: listId, - newPage: DEFAULT_PAGE, - }); - } - - if (!size) { - await this.$store.dispatch('pagination/updatePaginationSize', { - paginationId: listId, - newSize: DEFAULT_PAGE_SIZE, - }); - } - - if (prevLastKey) { - this.prevKey = prevLastKey; - } - } - - async fetchListData() { - const { searchParams } = this; - const sorting = this.sortingStore.sortingMap[this.listId]; - const { option, direction } = sorting || {}; - const pagination = this.paginationStore.paginationMap[this.listId]; - const { page, size } = pagination || {}; - const requestConfig: RequestParamsInterfaceLocal = {}; - - // Sorting params - if (option) { - const serverSortByMap = { - firstName: 'givenName', - lastName: 'familyName', - lastUpdate: 'dateOfUpdate', - }; - - requestConfig.sortBy = serverSortByMap[option]; - } - - if (direction) { - const serverSortDirectionMap = { - asc: 'ascending', - desc: 'descending', - }; - - requestConfig.sortDirection = serverSortDirectionMap[direction]; - } - - // Paging params - if (page && !this.licenseStore.error) { - if (this.nextKey && page !== 1) { - requestConfig.getNextPage = true; - } else if (this.prevKey) { - requestConfig.getPrevPage = true; - } - } - - // Search params - requestConfig.isPublic = this.isPublicSearch; - - if (searchParams?.compact) { - requestConfig.compact = searchParams?.compact; - } else { - requestConfig.compact = this.userStore.currentCompact?.type; - } - - if (searchParams?.firstName) { - requestConfig.licenseeFirstName = searchParams.firstName; - } - if (searchParams?.lastName) { - requestConfig.licenseeLastName = searchParams.lastName; - } - if (searchParams?.state) { - requestConfig.jurisdiction = searchParams.state.toLowerCase(); - } - - // Make fetch request - await this.$store.dispatch('license/getLicenseesRequest', { - params: { - ...requestConfig, - pageNum: page, - pageSize: size, - } - }); - - this.isInitialFetchCompleted = true; - - // If we've reached the end of paging - if (this.licenseStore.error instanceof PageExhaustError && page > 1) { - // Support for limited server paging support: - // The server does not respond with how many total records there are, only keys to fetch - // the current or next page. So the frontend can't know it's the end of paging until we get back 0 records. - // At that point, we no longer have usable prevLastKey & lastKey values from the server, and need to re-fetch - // the last page to get stable. - - // Update pagination store page - this.$store.dispatch('pagination/updatePaginationPage', { - paginationId: this.listId, - newPage: page - 1, - }); - // Re-fetch with prevLastKey - await this.$store.dispatch('license/getLicenseesRequest', { - params: { - ...requestConfig, - getPrevPage: true, - getNextPage: false, - pageNum: page, - pageSize: size, - } - }); - // After fetch, delete lastKey from the store (to disable "next" button) - this.$store.dispatch('license/setStoreLicenseeLastKey', null); - } - - return requestConfig; - } - - async sortingChange() { - if (this.isInitialFetchCompleted) { - await this.fetchListData(); - } - } - - // Match pageChange() @Prop signature from /components/Lists/Pagination/Pagination.ts - async paginationChange({ firstIndex, prevNext }: PageChangeConfig) { - const isInitialInProgress = firstIndex === 0 && prevNext === 0; - - if (prevNext === -1) { - this.prevKey = this.licenseStore.prevLastKey; - this.nextKey = ''; - } else if (prevNext === 1) { - this.prevKey = ''; - this.nextKey = this.licenseStore.lastKey; - } else { - this.prevKey = ''; - this.nextKey = ''; - } - - if (!isInitialInProgress && this.isInitialFetchCompleted) { - await this.fetchListData(); - } - } } export default toNative(LicenseeList); diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue b/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue index 0eeaffece..5d200d2f2 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue @@ -2,80 +2,11 @@ LicenseeList.vue CompactConnect - Created by InspiringApps on 7/1/2024. + Created by InspiringApps on 12/1/2025. --> diff --git a/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.less b/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.less new file mode 100644 index 000000000..fb701a496 --- /dev/null +++ b/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.less @@ -0,0 +1,70 @@ +// +// LicenseeListLegacy.less +// CompactConnect +// +// Created by InspiringApps on 7/1/2024. +// + +.licensee-list-container { + background-color: transparent; + + .search-toggle-container { + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-items: flex-end; + margin-top: 1.2rem; + + @media @desktopWidth { + flex-direction: row; + align-items: center; + justify-content: flex-end; + } + + .search-tag { + display: flex; + align-content: center; + align-items: center; + justify-content: center; + width: 100%; + margin-top: 1.2rem; + padding: 0.4rem 1rem; + border-radius: @borderRadiusPillShape; + color: @white; + background-color: @darkBlue; + + @media @desktopWidth { + order: 1; + width: auto; + margin-top: 0; + margin-right: 2.4rem; + } + + .title { + padding: 0 0.4rem; + } + + .search-terms { + font-weight: @fontWeightBold; + } + + .search-terms-reset { + width: 2rem; + margin-left: auto; + padding: 0.4rem 0.2rem; + cursor: pointer; + stroke: @white; + } + } + + .search-toggle { + width: 100%; + min-height: 4.8rem; + + @media @desktopWidth { + order: 2; + width: auto; + } + } + } +} diff --git a/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.spec.ts b/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.spec.ts new file mode 100644 index 000000000..9fe4a3490 --- /dev/null +++ b/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.spec.ts @@ -0,0 +1,141 @@ +// +// LicenseeListLegacy.spec.ts +// CompactConnect +// +// Created by InspiringApps on 7/1/2024. +// + +import chaiMatchPattern from 'chai-match-pattern'; +import chai from 'chai'; +import { mountShallow, mountFull } from '@tests/helpers/setup'; +import LicenseeList from '@components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue'; +import { Compact, CompactType } from '@models/Compact/Compact.model'; +import sinon from 'sinon'; + +chai.use(chaiMatchPattern); + +const { expect } = chai; +const lastKey = 'lastKey'; +const prevLastKey = 'prevLastKey'; +const populateComponentStorePagingKeys = (component) => { + component.$store.dispatch('license/setStoreLicenseeLastKey', lastKey); + component.$store.dispatch('license/setStoreLicenseePrevLastKey', prevLastKey); +}; + +describe('LicenseeList component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(LicenseeList); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(LicenseeList).exists()).to.equal(true); + }); + it('should successfully re-fetch data with previous paging key if going back a page', async () => { + const wrapper = await mountShallow(LicenseeList); + const component = wrapper.vm; + const fetchListData = sinon.spy(); + + component.fetchListData = fetchListData; + component.isInitialFetchCompleted = true; + populateComponentStorePagingKeys(component); + + await component.paginationChange({ firstIndex: 0, prevNext: -1 }); + + expect(component.prevKey).to.equal(prevLastKey); + expect(component.nextKey).to.equal(''); + expect(fetchListData.calledOnce).to.equal(true); + }); + it('should successfully re-fetch data with next paging key if going forward a page', async () => { + const wrapper = await mountShallow(LicenseeList); + const component = wrapper.vm; + const fetchListData = sinon.spy(); + + component.fetchListData = fetchListData; + component.isInitialFetchCompleted = true; + populateComponentStorePagingKeys(component); + + await component.paginationChange({ firstIndex: 0, prevNext: 1 }); + + expect(component.prevKey).to.equal(''); + expect(component.nextKey).to.equal(lastKey); + expect(fetchListData.calledOnce).to.equal(true); + }); + it('should successfully re-fetch data when returning to first page', async () => { + const wrapper = await mountShallow(LicenseeList); + const component = wrapper.vm; + const fetchListData = sinon.spy(); + + component.fetchListData = fetchListData; + component.isInitialFetchCompleted = true; + populateComponentStorePagingKeys(component); + + await component.paginationChange({ firstIndex: 0, prevNext: undefined }); + + expect(component.prevKey).to.equal(''); + expect(component.nextKey).to.equal(''); + expect(fetchListData.calledOnce).to.equal(true); + }); + it('should successfully not re-fetch data if page change before initial fetch completes', async () => { + const wrapper = await mountShallow(LicenseeList); + const component = wrapper.vm; + const fetchListData = sinon.spy(); + + component.fetchListData = fetchListData; + component.isInitialFetchCompleted = false; + populateComponentStorePagingKeys(component); + + await component.paginationChange({ firstIndex: 0, prevNext: 1 }); + + expect(component.prevKey).to.equal(''); + expect(component.nextKey).to.equal(lastKey); + expect(fetchListData.notCalled).to.equal(true); + }); + it('should successfully not re-fetch data if page change from search results', async () => { + const wrapper = await mountShallow(LicenseeList); + const component = wrapper.vm; + const fetchListData = sinon.spy(); + + component.fetchListData = fetchListData; + component.isInitialFetchCompleted = true; + populateComponentStorePagingKeys(component); + + await component.paginationChange({ firstIndex: 0, prevNext: 0 }); + + expect(component.prevKey).to.equal(''); + expect(component.nextKey).to.equal(''); + expect(fetchListData.notCalled).to.equal(true); + }); + it('should successfully fetch data with expected search params (no params)', async () => { + const wrapper = await mountFull(LicenseeList); + const component = wrapper.vm; + const requestConfig = await component.fetchListData(); + + expect(requestConfig).to.matchPattern({ + jurisdiction: undefined, + licenseeFirstName: undefined, + licenseeLastName: undefined, + '...': '', + }); + }); + it('should successfully fetch data with expected search params (all params)', async () => { + const wrapper = await mountShallow(LicenseeList); + const component = wrapper.vm; + const testParams = { + firstName: 'firstName', + lastName: 'lastName', + state: 'state', + }; + + await component.$store.dispatch('user/setCurrentCompact', new Compact({ type: CompactType.ASLP })); + await component.$store.dispatch('license/setStoreSearch', testParams); + + const requestConfig = await component.fetchListData(); + + expect(requestConfig).to.matchPattern({ + compact: CompactType.ASLP, + jurisdiction: testParams.state, + licenseeFirstName: testParams.firstName, + licenseeLastName: testParams.lastName, + '...': '', + }); + }); +}); diff --git a/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.ts b/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.ts new file mode 100644 index 000000000..d4529caf8 --- /dev/null +++ b/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.ts @@ -0,0 +1,382 @@ +// +// LicenseeListLegacy.ts +// CompactConnect +// +// Created by InspiringApps on 7/1/2024. +// + +import { + Component, + Vue, + Prop, + toNative +} from 'vue-facing-decorator'; +import ListContainer from '@components/Lists/ListContainer/ListContainer.vue'; +import LicenseeSearch, { LicenseSearchLegacy } from '@components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue'; +import LicenseeRow from '@components/Licensee/LicenseeRow/LicenseeRow.vue'; +import CloseX from '@components/Icons/CloseX/CloseX.vue'; +import { SortDirection } from '@store/sorting/sorting.state'; +import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, PageChangeConfig } from '@store/pagination/pagination.state'; +import { PageExhaustError } from '@store/pagination'; +import { RequestParamsInterfaceLocal } from '@network/licenseApi/data.api'; +import { State } from '@models/State/State.model'; + +@Component({ + name: 'LicenseeListLegacy', + components: { + ListContainer, + LicenseeSearch, + LicenseeRow, + CloseX, + }, +}) +class LicenseeList extends Vue { + @Prop({ required: true }) protected listId!: string; + @Prop({ default: false }) protected isPublicSearch?: boolean; + + // + // Data + // + hasSearched = false; + shouldShowSearchModal = false; + isInitialFetchCompleted = false; + prevKey = ''; + nextKey = ''; + + // + // Lifecycle + // + async created() { + if (this.licenseStoreRecordCount) { + this.hasSearched = true; + } + } + + async mounted() { + if (!this.licenseStoreRecordCount) { + // License store is empty - apply defaults + await this.setDefaultSort(); + await this.setDefaultPaging(); + } else if (this.licenseStoreRecordCount === 1 && !this.searchDisplayAll) { + // Edge case: Returning from a detail page that was refreshed / cache-cleared + this.shouldShowSearchModal = true; + } else { + // License store already has records + this.isInitialFetchCompleted = true; + } + } + + // + // Computed + // + get sortingStore() { + return this.$store.state.sorting; + } + + get paginationStore() { + return this.$store.state.pagination; + } + + get userStore(): any { + return this.$store.state.user; + } + + get licenseStore(): any { + return this.$store.state.license; + } + + get licenseStoreRecordCount(): number { + return this.licenseStore.model?.length || 0; + } + + get searchParams(): LicenseSearchLegacy { + return this.licenseStore.search; + } + + get searchDisplayCompact(): string { + return (this.isPublicSearch) ? this.userStore.currentCompact?.abbrev() || '' : ''; + } + + get searchDisplayFirstName(): string { + const delimiter = (this.searchDisplayCompact) ? ', ' : ''; + let displayFirstName = ''; + + if (this.searchParams.firstName) { + displayFirstName = `${delimiter}${this.searchParams.firstName}` || ''; + } + + return displayFirstName; + } + + get searchDisplayLastName(): string { + const delimiter = (this.searchDisplayCompact && !this.searchDisplayFirstName) ? ', ' : ''; + const subDelimiter = (this.searchDisplayFirstName) ? ' ' : ''; + let displayLastName = ''; + + if (this.searchParams.lastName) { + displayLastName = `${delimiter}${subDelimiter}${this.searchParams.lastName}` || ''; + } + + return displayLastName; + } + + get searchDisplayState(): string { + const { state } = this.searchParams; + const { searchDisplayCompact, searchDisplayFirstName, searchDisplayLastName } = this; + const delimiter = (searchDisplayCompact || searchDisplayFirstName || searchDisplayLastName) ? ', ' : ''; + let displayState = ''; + + if (state) { + const stateModel = new State({ abbrev: state }); + + displayState = `${delimiter}${stateModel.name()}`; + } + + return displayState; + } + + get searchDisplayAll(): string { + const { + searchDisplayCompact, + searchDisplayFirstName, + searchDisplayLastName, + searchDisplayState + } = this; + + return [ + searchDisplayCompact, + searchDisplayFirstName, + searchDisplayLastName, + searchDisplayState + ].join('').trim(); + } + + get sortOptions(): Array { + const options = [ + // Temp for limited server sorting support + // { value: 'firstName', name: this.$t('common.firstName') }, + { value: 'lastName', name: this.$t('common.lastName'), isDefault: true }, + // { value: 'licenseStates', name: this.$t('licensing.homeState') }, + // { value: 'privilegeStates', name: this.$t('licensing.privileges') }, + // { value: 'status', name: this.$t('licensing.status') }, + ]; + + return options; + } + + get headerRecord() { + const record = { + firstName: this.$t('common.firstName'), + lastName: this.$t('common.lastName'), + ssnMaskedPartial: () => this.$t('licensing.ssn'), + homeJurisdictionDisplay: () => this.$t('licensing.homeState'), + privilegeStatesDisplay: () => this.$t('licensing.privileges'), + statusDisplay: () => this.$t('licensing.status'), + }; + + return record; + } + + // + // Methods + // + toggleSearch(): void { + this.shouldShowSearchModal = !this.shouldShowSearchModal; + } + + handleSearch(params: LicenseSearchLegacy): void { + this.$store.dispatch('license/setStoreSearch', params); + this.$store.dispatch('pagination/updatePaginationPage', { + paginationId: this.listId, + newPage: 1, + }); + this.fetchListData(); + + if (!this.hasSearched) { + this.hasSearched = true; + } else { + this.toggleSearch(); + } + } + + async resetSearch(): Promise { + this.$store.dispatch('license/resetStoreSearch'); + + if (this.isPublicSearch) { + await this.$store.dispatch('user/setCurrentCompact', null); + } + + this.toggleSearch(); + } + + async setDefaultSort() { + const { listId } = this; + const defaultSortOption = this.sortOptions.find((option) => option.isDefault) || this.sortOptions[0]; + const { option, direction } = this.sortingStore.sortingMap[listId] || {}; + + if (!option) { + await this.$store.dispatch('sorting/updateSortOption', { + sortingId: listId, + newOption: defaultSortOption.value, + }); + } + + if (!direction) { + await this.$store.dispatch('sorting/updateSortDirection', { + sortingId: listId, + newDirection: SortDirection.asc, + }); + } + } + + async setDefaultPaging(shouldForce = false) { + const { listId } = this; + const { page, size } = this.paginationStore.paginationMap[this.listId] || {}; + const { prevLastKey } = this.licenseStore; + + if (!page || shouldForce) { + await this.$store.dispatch('pagination/updatePaginationPage', { + paginationId: listId, + newPage: DEFAULT_PAGE, + }); + } + + if (!size) { + await this.$store.dispatch('pagination/updatePaginationSize', { + paginationId: listId, + newSize: DEFAULT_PAGE_SIZE, + }); + } + + if (prevLastKey) { + this.prevKey = prevLastKey; + } + } + + async fetchListData() { + const { searchParams } = this; + const sorting = this.sortingStore.sortingMap[this.listId]; + const { option, direction } = sorting || {}; + const pagination = this.paginationStore.paginationMap[this.listId]; + const { page, size } = pagination || {}; + const requestConfig: RequestParamsInterfaceLocal = {}; + + // Sorting params + if (option) { + const serverSortByMap = { + firstName: 'givenName', + lastName: 'familyName', + lastUpdate: 'dateOfUpdate', + }; + + requestConfig.sortBy = serverSortByMap[option]; + } + + if (direction) { + const serverSortDirectionMap = { + asc: 'ascending', + desc: 'descending', + }; + + requestConfig.sortDirection = serverSortDirectionMap[direction]; + } + + // Paging params + if (page && !this.licenseStore.error) { + if (this.nextKey && page !== 1) { + requestConfig.getNextPage = true; + } else if (this.prevKey) { + requestConfig.getPrevPage = true; + } + } + + // Search params + requestConfig.isPublic = this.isPublicSearch; + + if (searchParams?.compact) { + requestConfig.compact = searchParams?.compact; + } else { + requestConfig.compact = this.userStore.currentCompact?.type; + } + + if (searchParams?.firstName) { + requestConfig.licenseeFirstName = searchParams.firstName; + } + if (searchParams?.lastName) { + requestConfig.licenseeLastName = searchParams.lastName; + } + if (searchParams?.state) { + requestConfig.jurisdiction = searchParams.state.toLowerCase(); + } + + // Make fetch request + await this.$store.dispatch('license/getLicenseesRequest', { + params: { + ...requestConfig, + pageNum: page, + pageSize: size, + } + }); + + this.isInitialFetchCompleted = true; + + // If we've reached the end of paging + if (this.licenseStore.error instanceof PageExhaustError && page > 1) { + // Support for limited server paging support: + // The server does not respond with how many total records there are, only keys to fetch + // the current or next page. So the frontend can't know it's the end of paging until we get back 0 records. + // At that point, we no longer have usable prevLastKey & lastKey values from the server, and need to re-fetch + // the last page to get stable. + + // Update pagination store page + this.$store.dispatch('pagination/updatePaginationPage', { + paginationId: this.listId, + newPage: page - 1, + }); + // Re-fetch with prevLastKey + await this.$store.dispatch('license/getLicenseesRequest', { + params: { + ...requestConfig, + getPrevPage: true, + getNextPage: false, + pageNum: page, + pageSize: size, + } + }); + // After fetch, delete lastKey from the store (to disable "next" button) + this.$store.dispatch('license/setStoreLicenseeLastKey', null); + } + + return requestConfig; + } + + async sortingChange() { + if (this.isInitialFetchCompleted) { + await this.fetchListData(); + } + } + + // Match pageChange() @Prop signature from /components/Lists/Pagination/Pagination.ts + async paginationChange({ firstIndex, prevNext }: PageChangeConfig) { + const isInitialInProgress = firstIndex === 0 && prevNext === 0; + + if (prevNext === -1) { + this.prevKey = this.licenseStore.prevLastKey; + this.nextKey = ''; + } else if (prevNext === 1) { + this.prevKey = ''; + this.nextKey = this.licenseStore.lastKey; + } else { + this.prevKey = ''; + this.nextKey = ''; + } + + if (!isInitialInProgress && this.isInitialFetchCompleted) { + await this.fetchListData(); + } + } +} + +export default toNative(LicenseeList); + +// export default LicenseeList; diff --git a/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue b/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue new file mode 100644 index 000000000..e99294337 --- /dev/null +++ b/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue @@ -0,0 +1,82 @@ + + + + + + diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less index af5036efc..2ec75ec57 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less @@ -2,72 +2,5 @@ // LicenseeSearch.less // CompactConnect // -// Created by InspiringApps on 9/12/2024. +// Created by InspiringApps on 12/1/2025. // - -.licensee-search-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - min-height: 60vh; - border-radius: 8px; - background-color: @white; - - .search-icon { - width: 4.8rem; - margin: 1.6rem 0; - fill: @fontColor; - } - - .search-title { - font-weight: @fontWeightBold; - font-size: 2.6rem; - } - - .search-subtext { - text-align: center; - } - - .search-form { - display: flex; - flex-direction: column; - flex-wrap: wrap; - width: 100%; - max-width: 64rem; - padding: 2.4rem; - } - - .search-form-row { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - width: 100%; - - @media @tabletWidth { - flex-direction: row; - } - } - - .search-input { - width: 100%; - - @media @tabletWidth { - width: 48%; - } - - &.search-submit { - align-items: center; - - &:deep(.input-submit) { - width: 100%; - } - } - } - - .search-submit { - margin-top: 2.4rem; - } -} diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.spec.ts b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.spec.ts index abfd505a7..be614bd50 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.spec.ts +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.spec.ts @@ -2,7 +2,7 @@ // LicenseeSearch.spec.ts // CompactConnect // -// Created by InspiringApps on 9/12/2024. +// Created by InspiringApps on 12/1/2025. // import { expect } from 'chai'; diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts index f05446ede..edc24e1f5 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts @@ -2,209 +2,15 @@ // LicenseeSearch.ts // CompactConnect // -// Created by InspiringApps on 9/12/2024. +// Created by InspiringApps on 12/1/2025. // -import { - Component, - mixins, - Prop, - Watch, - toNative -} from 'vue-facing-decorator'; -import { reactive, computed } from 'vue'; -import MixinForm from '@components/Forms/_mixins/form.mixin'; -import InputText from '@components/Forms/InputText/InputText.vue'; -import InputSelect from '@components/Forms/InputSelect/InputSelect.vue'; -import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; -import SearchIcon from '@components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue'; -import { CompactType, CompactSerializer } from '@models/Compact/Compact.model'; -import { State } from '@models/State/State.model'; -import { FormInput } from '@models/FormInput/FormInput.model'; -import Joi from 'joi'; - -export interface LicenseSearch { - compact?: string; - firstName?: string; - lastName?: string; - state?: string; -} +import { Component, Vue, toNative } from 'vue-facing-decorator'; @Component({ name: 'LicenseeSearch', - components: { - InputText, - InputSelect, - InputSubmit, - SearchIcon, - }, - emits: [ 'searchParams' ], }) -class LicenseeSearch extends mixins(MixinForm) { - @Prop({ default: {}}) searchParams!: LicenseSearch; - @Prop({ default: false }) isPublicSearch!: boolean; - - // - // Lifecycle - // - created() { - this.initFormInputs(); - } - - // - // Computed - // - get userStore() { - return this.$store.state.user; - } - - get compactType(): CompactType | null { - return this.userStore.currentCompact?.type; - } - - get compactOptions(): Array { - const options = this.$tm('compacts').map((compact) => ({ - value: compact.key, - name: compact.name, - })); - - options.unshift({ - value: '', - name: computed(() => this.$t('common.selectOption')), - }); - - return options; - } - - get enableCompactSelect(): boolean { - return this.isPublicSearch; - } - - get compactStates(): Array { - return this.userStore.currentCompact?.memberStates || []; - } - - get stateOptions(): Array { - const compactMemberStates = this.compactStates.map((state) => ({ - value: state.abbrev, name: state.name() - })); - const defaultSelectOption: any = { value: '' }; - - if (!compactMemberStates.length) { - defaultSelectOption.name = ''; - } else { - defaultSelectOption.name = computed(() => this.$t('common.selectOption')); - } - - compactMemberStates.unshift(defaultSelectOption); - - return compactMemberStates; - } - - // - // Methods - // - initFormInputs(): void { - this.formData = reactive({ - firstName: new FormInput({ - id: 'first-name', - name: 'first-name', - label: computed(() => this.$t('common.firstName')), - placeholder: computed(() => this.$t('licensing.searchPlaceholderName')), - validation: Joi.string().min(0).max(100).messages(this.joiMessages.string), - value: this.searchParams.firstName || '', - enforceMax: true, - }), - lastName: new FormInput({ - id: 'last-name', - name: 'last-name', - label: computed(() => this.$t('common.lastName')), - placeholder: computed(() => this.$t('licensing.searchPlaceholderName')), - validation: Joi.string().min(0).max(100).messages(this.joiMessages.string), - value: this.searchParams.lastName || '', - enforceMax: true, - }), - state: new FormInput({ - id: 'state', - name: 'state', - label: computed(() => this.$t('common.stateJurisdiction')), - valueOptions: this.stateOptions, - value: this.searchParams.state || '', - isDisabled: computed(() => this.enableCompactSelect && !this.compactType), - }), - submit: new FormInput({ - isSubmitInput: true, - id: 'submit', - }), - }); - - if (this.enableCompactSelect) { - this.formData.compact = new FormInput({ - id: 'search-compact', - name: 'search-compact', - label: computed(() => this.$t('licensing.licenseTypeSearch')), - validation: Joi.string().required().messages(this.joiMessages.string), - valueOptions: this.compactOptions, - value: this.searchParams.compact || this.compactType || '', - }); - } - - this.watchFormInputs(); // Important if you want automated form validation - } - - async updateCurrentCompact(): Promise { - const { compact: selectedCompactType, state } = this.formData; - - if (this.enableCompactSelect) { - await this.$store.dispatch('user/setCurrentCompact', CompactSerializer.fromServer({ type: selectedCompactType.value })); - state.value = ''; - } - } - - async handleSubmit(): Promise { - this.validateAll({ asTouched: true }); - this.customValidateLastName(); - - if (this.isFormValid) { - this.startFormLoading(); - - const allowedSearchProps = [ - 'compact', - 'firstName', - 'lastName', - 'state' - ]; - const searchProps: LicenseSearch = {}; - - allowedSearchProps.forEach((searchProp) => { searchProps[searchProp] = this.formValues[searchProp]; }); - this.$emit('searchParams', searchProps); - - this.endFormLoading(); - } - } - - // Last name is currently optional overall, but required if first name is included; therefore needs this non-trivial validation logic - customValidateLastName(asTouched = true): void { - const { firstName, lastName } = this.formData; - const shouldSkip = (asTouched) ? false : !lastName.isTouched; - - if (!shouldSkip && firstName.value && !lastName.value) { - lastName.isValid = false; - lastName.errorMessage = this.$t('inputErrors.lastNameRequired'); - } else if (!lastName.isValid) { - lastName.isValid = true; - lastName.errorMessage = ''; - } - - this.checkValidForAll(); - } - - // - // Watch - // - @Watch('compactStates') updateStateInput() { - this.formData.state.valueOptions = this.stateOptions; - } +class LicenseeSearch extends Vue { } export default toNative(LicenseeSearch); diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue index ba1e15a36..2bb9119b5 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue @@ -2,54 +2,11 @@ LicenseeSearch.vue CompactConnect - Created by InspiringApps on 9/12/2024. + Created by InspiringApps on 12/1/2025. --> diff --git a/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.less b/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.less new file mode 100644 index 000000000..2926b4bef --- /dev/null +++ b/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.less @@ -0,0 +1,73 @@ +// +// LicenseeSearchLegacy.less +// CompactConnect +// +// Created by InspiringApps on 9/12/2024. +// + +.licensee-search-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + min-height: 60vh; + border-radius: 8px; + background-color: @white; + + .search-icon { + width: 4.8rem; + margin: 1.6rem 0; + fill: @fontColor; + } + + .search-title { + font-weight: @fontWeightBold; + font-size: 2.6rem; + } + + .search-subtext { + text-align: center; + } + + .search-form { + display: flex; + flex-direction: column; + flex-wrap: wrap; + width: 100%; + max-width: 64rem; + padding: 2.4rem; + } + + .search-form-row { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + width: 100%; + + @media @tabletWidth { + flex-direction: row; + } + } + + .search-input { + width: 100%; + + @media @tabletWidth { + width: 48%; + } + + &.search-submit { + align-items: center; + + &:deep(.input-submit) { + width: 100%; + } + } + } + + .search-submit { + margin-top: 2.4rem; + } +} diff --git a/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.spec.ts b/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.spec.ts new file mode 100644 index 000000000..58ba50b05 --- /dev/null +++ b/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.spec.ts @@ -0,0 +1,19 @@ +// +// LicenseeSearchLegacy.spec.ts +// CompactConnect +// +// Created by InspiringApps on 9/12/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import LicenseeSearch from '@components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue'; + +describe('LicenseeSearch component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(LicenseeSearch); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(LicenseeSearch).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.ts b/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.ts new file mode 100644 index 000000000..449f9d3ff --- /dev/null +++ b/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.ts @@ -0,0 +1,212 @@ +// +// LicenseeSearchLegacy.ts +// CompactConnect +// +// Created by InspiringApps on 9/12/2024. +// + +import { + Component, + mixins, + Prop, + Watch, + toNative +} from 'vue-facing-decorator'; +import { reactive, computed } from 'vue'; +import MixinForm from '@components/Forms/_mixins/form.mixin'; +import InputText from '@components/Forms/InputText/InputText.vue'; +import InputSelect from '@components/Forms/InputSelect/InputSelect.vue'; +import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; +import SearchIcon from '@components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue'; +import { CompactType, CompactSerializer } from '@models/Compact/Compact.model'; +import { State } from '@models/State/State.model'; +import { FormInput } from '@models/FormInput/FormInput.model'; +import Joi from 'joi'; + +export interface LicenseSearchLegacy { + compact?: string; + firstName?: string; + lastName?: string; + state?: string; +} + +@Component({ + name: 'LicenseeSearchLegacy', + components: { + InputText, + InputSelect, + InputSubmit, + SearchIcon, + }, + emits: [ 'searchParams' ], +}) +class LicenseeSearch extends mixins(MixinForm) { + @Prop({ default: {}}) searchParams!: LicenseSearchLegacy; + @Prop({ default: false }) isPublicSearch!: boolean; + + // + // Lifecycle + // + created() { + this.initFormInputs(); + } + + // + // Computed + // + get userStore() { + return this.$store.state.user; + } + + get compactType(): CompactType | null { + return this.userStore.currentCompact?.type; + } + + get compactOptions(): Array { + const options = this.$tm('compacts').map((compact) => ({ + value: compact.key, + name: compact.name, + })); + + options.unshift({ + value: '', + name: computed(() => this.$t('common.selectOption')), + }); + + return options; + } + + get enableCompactSelect(): boolean { + return this.isPublicSearch; + } + + get compactStates(): Array { + return this.userStore.currentCompact?.memberStates || []; + } + + get stateOptions(): Array { + const compactMemberStates = this.compactStates.map((state) => ({ + value: state.abbrev, name: state.name() + })); + const defaultSelectOption: any = { value: '' }; + + if (!compactMemberStates.length) { + defaultSelectOption.name = ''; + } else { + defaultSelectOption.name = computed(() => this.$t('common.selectOption')); + } + + compactMemberStates.unshift(defaultSelectOption); + + return compactMemberStates; + } + + // + // Methods + // + initFormInputs(): void { + this.formData = reactive({ + firstName: new FormInput({ + id: 'first-name', + name: 'first-name', + label: computed(() => this.$t('common.firstName')), + placeholder: computed(() => this.$t('licensing.searchPlaceholderName')), + validation: Joi.string().min(0).max(100).messages(this.joiMessages.string), + value: this.searchParams.firstName || '', + enforceMax: true, + }), + lastName: new FormInput({ + id: 'last-name', + name: 'last-name', + label: computed(() => this.$t('common.lastName')), + placeholder: computed(() => this.$t('licensing.searchPlaceholderName')), + validation: Joi.string().min(0).max(100).messages(this.joiMessages.string), + value: this.searchParams.lastName || '', + enforceMax: true, + }), + state: new FormInput({ + id: 'state', + name: 'state', + label: computed(() => this.$t('common.stateJurisdiction')), + valueOptions: this.stateOptions, + value: this.searchParams.state || '', + isDisabled: computed(() => this.enableCompactSelect && !this.compactType), + }), + submit: new FormInput({ + isSubmitInput: true, + id: 'submit', + }), + }); + + if (this.enableCompactSelect) { + this.formData.compact = new FormInput({ + id: 'search-compact', + name: 'search-compact', + label: computed(() => this.$t('licensing.licenseTypeSearch')), + validation: Joi.string().required().messages(this.joiMessages.string), + valueOptions: this.compactOptions, + value: this.searchParams.compact || this.compactType || '', + }); + } + + this.watchFormInputs(); // Important if you want automated form validation + } + + async updateCurrentCompact(): Promise { + const { compact: selectedCompactType, state } = this.formData; + + if (this.enableCompactSelect) { + await this.$store.dispatch('user/setCurrentCompact', CompactSerializer.fromServer({ type: selectedCompactType.value })); + state.value = ''; + } + } + + async handleSubmit(): Promise { + this.validateAll({ asTouched: true }); + this.customValidateLastName(); + + if (this.isFormValid) { + this.startFormLoading(); + + const allowedSearchProps = [ + 'compact', + 'firstName', + 'lastName', + 'state' + ]; + const searchProps: LicenseSearchLegacy = {}; + + allowedSearchProps.forEach((searchProp) => { searchProps[searchProp] = this.formValues[searchProp]; }); + this.$emit('searchParams', searchProps); + + this.endFormLoading(); + } + } + + // Last name is currently optional overall, but required if first name is included; therefore needs this non-trivial validation logic + customValidateLastName(asTouched = true): void { + const { firstName, lastName } = this.formData; + const shouldSkip = (asTouched) ? false : !lastName.isTouched; + + if (!shouldSkip && firstName.value && !lastName.value) { + lastName.isValid = false; + lastName.errorMessage = this.$t('inputErrors.lastNameRequired'); + } else if (!lastName.isValid) { + lastName.isValid = true; + lastName.errorMessage = ''; + } + + this.checkValidForAll(); + } + + // + // Watch + // + @Watch('compactStates') updateStateInput() { + this.formData.state.valueOptions = this.stateOptions; + } +} + +export default toNative(LicenseeSearch); + +// export default LicenseeSearch; diff --git a/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue b/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue new file mode 100644 index 000000000..c6a82dc8c --- /dev/null +++ b/webroot/src/components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue @@ -0,0 +1,56 @@ + + + + + + diff --git a/webroot/src/components/Lists/ListContainer/ListContainer.spec.ts b/webroot/src/components/Lists/ListContainer/ListContainer.spec.ts index c5ea3b2c0..bb73d0a2f 100644 --- a/webroot/src/components/Lists/ListContainer/ListContainer.spec.ts +++ b/webroot/src/components/Lists/ListContainer/ListContainer.spec.ts @@ -9,7 +9,7 @@ import { expect } from 'chai'; import { mountShallow } from '@tests/helpers/setup'; import ListContainer from '@components/Lists/ListContainer/ListContainer.vue'; import Sorting from '@components/Lists/Sorting/Sorting.vue'; -import Pagination from '@components/Lists/Pagination/Pagination.vue'; +import PaginationLegacy from '@components/Lists/PaginationLegacy/PaginationLegacy.vue'; describe('ListContainer component', async () => { it('should mount the component', async () => { @@ -41,7 +41,7 @@ describe('ListContainer component', async () => { expect(wrapper.find('.no-records').exists(), 'no records').to.equal(true); expect(wrapper.findAllComponents(Sorting).length, 'sorting elements').to.equal(0); - expect(wrapper.findAllComponents(Pagination).length, 'pagination elements').to.equal(0); + expect(wrapper.findAllComponents(PaginationLegacy).length, 'pagination elements').to.equal(0); }); it('should have expected default UI with records', async () => { const wrapper = await mountShallow(ListContainer, { @@ -57,7 +57,7 @@ describe('ListContainer component', async () => { expect(wrapper.find('.no-records').exists(), 'no records').to.equal(false); expect(wrapper.findAllComponents(Sorting).length, 'sorting elements').to.equal(0); - expect(wrapper.findAllComponents(Pagination).length, 'pagination elements').to.equal(2); + expect(wrapper.findAllComponents(PaginationLegacy).length, 'pagination elements').to.equal(2); }); it('should exclude top pagination', async () => { const wrapper = await mountShallow(ListContainer, { @@ -72,7 +72,7 @@ describe('ListContainer component', async () => { }, }); - expect(wrapper.findAllComponents(Pagination).length).to.equal(1); + expect(wrapper.findAllComponents(PaginationLegacy).length).to.equal(1); }); it('should exclude bottom pagination', async () => { const wrapper = await mountShallow(ListContainer, { @@ -87,7 +87,7 @@ describe('ListContainer component', async () => { }, }); - expect(wrapper.findAllComponents(Pagination).length).to.equal(1); + expect(wrapper.findAllComponents(PaginationLegacy).length).to.equal(1); }); it('should exclude all pagination', async () => { const wrapper = await mountShallow(ListContainer, { @@ -103,7 +103,7 @@ describe('ListContainer component', async () => { }, }); - expect(wrapper.findAllComponents(Pagination).length).to.equal(0); + expect(wrapper.findAllComponents(PaginationLegacy).length).to.equal(0); }); it('should exclude sorting', async () => { const wrapper = await mountShallow(ListContainer, { diff --git a/webroot/src/components/Lists/ListContainer/ListContainer.ts b/webroot/src/components/Lists/ListContainer/ListContainer.ts index b337dab78..1c62542fc 100644 --- a/webroot/src/components/Lists/ListContainer/ListContainer.ts +++ b/webroot/src/components/Lists/ListContainer/ListContainer.ts @@ -12,20 +12,21 @@ import { toNative } from 'vue-facing-decorator'; import MixinListManipulation from '@/components/Lists/_mixins/ListManipulation.mixin'; -import Pagination from '@components/Lists/Pagination/Pagination.vue'; +import PaginationLegacy from '@components/Lists/PaginationLegacy/PaginationLegacy.vue'; import Sorting from '@components/Lists/Sorting/Sorting.vue'; import CompactToggle from '@components/Lists/CompactToggle/CompactToggle.vue'; @Component({ name: 'ListContainer', components: { - Pagination, + PaginationLegacy, Sorting, CompactToggle, }, }) class ListContainer extends mixins(MixinListManipulation) { @Prop({ required: true }) protected listData!: Array; // Extending class should more specifically type + @Prop({ default: true }) private isLegacyPaging?: boolean; @Prop({ required: true }) private pageChange!: (firstIndex: number, lastIndexExclusive: number) => any; @Prop({ required: true }) private sortChange!: (newSortOption: string, ascending: boolean) => any; @Prop({ default: 0 }) private listSize?: number; diff --git a/webroot/src/components/Lists/ListContainer/ListContainer.vue b/webroot/src/components/Lists/ListContainer/ListContainer.vue index 449eaf8c6..3fd14b64a 100644 --- a/webroot/src/components/Lists/ListContainer/ListContainer.vue +++ b/webroot/src/components/Lists/ListContainer/ListContainer.vue @@ -21,16 +21,16 @@ :sortChange="sortChange" :sortingId="listId" /> - + >
@@ -52,17 +52,17 @@
{{ emptyMessage }}
- + >
diff --git a/webroot/src/components/Lists/Pagination/Pagination.less b/webroot/src/components/Lists/Pagination/Pagination.less index 3eb7db718..fd18587f6 100644 --- a/webroot/src/components/Lists/Pagination/Pagination.less +++ b/webroot/src/components/Lists/Pagination/Pagination.less @@ -1,74 +1,6 @@ // // Pagination.less -// InspiringApps modules +// CompactConnect // -// Created by InspiringApps on 5/21/2020. +// Created by InspiringApps on 12/1/2025. // - -.pagination-container { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - margin-left: auto; - - &.bottom-pagination { - margin-top: 1.6rem; - } - - .pagination-list { - display: flex; - margin-bottom: 1.0rem; - - .pagination-item { - display: flex; - align-items: center; - justify-content: center; - width: 3.2rem; - height: 3.2rem; - border-radius: 4px; - color: @black; - - &:not(:last-child) { - margin-right: 0.4rem; - } - } - - .selected { - border-color: @primaryColor; - color: @white; - background-color: @primaryColor; - } - - .clickable { - cursor: pointer; - - &:not(.caret) { - border: 1px solid @lightGrey; - border-radius: 4px; - } - } - - .caret { - &.previous { - width: 3.2rem; - } - - &.next { - width: 3.2rem; - } - - svg { - width: 1.2rem; - height: 1.2rem; - fill: @primaryColor; - } - } - } - - .page-size { - display: none; - margin-bottom: 3.2rem; - margin-left: 1.6rem; - } -} diff --git a/webroot/src/components/Lists/Pagination/Pagination.spec.ts b/webroot/src/components/Lists/Pagination/Pagination.spec.ts index f23924611..44168731a 100644 --- a/webroot/src/components/Lists/Pagination/Pagination.spec.ts +++ b/webroot/src/components/Lists/Pagination/Pagination.spec.ts @@ -1,113 +1,19 @@ // // Pagination.spec.ts -// InspiringApps modules +// CompactConnect // -// Created by InspiringApps on 5/21/2020. +// Created by InspiringApps on 12/1/2025. // -import sinon from 'sinon'; import { expect } from 'chai'; -import { mountShallow, mountFull } from '@tests/helpers/setup'; +import { mountShallow } from '@tests/helpers/setup'; import Pagination from '@components/Lists/Pagination/Pagination.vue'; -import LeftCaretIcon from '@components/Icons/LeftCaretIcon/LeftCaretIcon.vue'; -import RightCaretIcon from '@components/Icons/RightCaretIcon/RightCaretIcon.vue'; describe('Pagination component', async () => { it('should mount the component', async () => { - const wrapper = await mountShallow(Pagination, { - props: { - paginationId: 'test', - pageChange: () => null, - pagingPrevKey: '', - pagingNextKey: '', - }, - }); + const wrapper = await mountShallow(Pagination); expect(wrapper.exists()).to.equal(true); expect(wrapper.findComponent(Pagination).exists()).to.equal(true); - expect(wrapper.findComponent(LeftCaretIcon).exists()).to.equal(false); - expect(wrapper.findComponent(RightCaretIcon).exists()).to.equal(false); - }); - it('should load with expected default behavior', async () => { - const spy = sinon.spy(); - const wrapper = await mountShallow(Pagination, { - props: { - paginationId: 'test', - pageChange: spy, - listSize: 0, - pagingPrevKey: '', - pagingNextKey: '', - } - }); - const instance = wrapper.vm; - - expect(instance.currentPage).to.equal(1); - expect(spy.calledOnce).to.be.true; - expect(spy.withArgs({ firstIndex: 0, lastIndexExclusive: 25, prevNext: 0 }).calledOnce).to.be.true; - expect(instance.pageCount).to.equal(0); - }); - it('should load with next paging', async () => { - const spy = sinon.spy(); - const wrapper = await mountFull(Pagination, { - props: { - paginationId: 'test', - pageChange: spy, - pagingPrevKey: '', - pagingNextKey: 'test-next', - }, - }); - const instance = wrapper.vm; - const pages = instance.pages.map((page) => page.displayValue); - - expect(pages).to.be.an('array'); - expect(pages).to.have.length(1); - expect(pages[0]).to.equal(1); - expect(wrapper.findComponent(LeftCaretIcon).exists(), 'previous arrow').to.equal(false); - expect(wrapper.findComponent(RightCaretIcon).exists(), 'next arrow').to.equal(true); - }); - it('should advance with next page (2)', async () => { - const spy = sinon.spy(); - const wrapper = await mountFull(Pagination, { - props: { - paginationId: 'test', - pageChange: spy, - pagingPrevKey: 'test-prev', - pagingNextKey: 'test-next', - }, - }); - const instance = wrapper.vm; - - await wrapper.get('.pagination-item.next').trigger('click'); - - const pages = instance.pages.map((page) => page.displayValue); - - expect(pages).to.be.an('array'); - expect(pages).to.have.length(2); - expect(pages[0]).to.equal(1); - expect(pages[1]).to.equal(2); - expect(wrapper.findComponent(RightCaretIcon).exists(), 'next arrow').to.equal(true); - }); - it('should advance with next page (3)', async () => { - const spy = sinon.spy(); - const wrapper = await mountFull(Pagination, { - props: { - paginationId: 'test', - pageChange: spy, - pagingPrevKey: 'test-prev', - pagingNextKey: 'test-next', - }, - }); - const instance = wrapper.vm; - - await wrapper.get('.pagination-item.next').trigger('click'); - - const pages = instance.pages.map((page) => page.displayValue); - - expect(pages).to.be.an('array'); - expect(pages).to.have.length(3); - expect(pages[0]).to.equal(1); - expect(pages[1]).to.equal('...'); - expect(pages[2]).to.equal(3); - expect(wrapper.findComponent(RightCaretIcon).exists(), 'next arrow').to.equal(true); }); }); diff --git a/webroot/src/components/Lists/Pagination/Pagination.ts b/webroot/src/components/Lists/Pagination/Pagination.ts index b3d352179..941e094c8 100644 --- a/webroot/src/components/Lists/Pagination/Pagination.ts +++ b/webroot/src/components/Lists/Pagination/Pagination.ts @@ -1,239 +1,18 @@ // // Pagination.ts -// InspiringApps modules +// CompactConnect // -// Created by InspiringApps on 5/21/2020. +// Created by InspiringApps on 12/1/2025. // -import { - Component, - mixins, - Prop, - Watch -} from 'vue-facing-decorator'; -import { reactive, computed, nextTick } from 'vue'; -import MixinForm from '@components/Forms/_mixins/form.mixin'; -import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, PageChangeConfig } from '@store/pagination/pagination.state'; -import InputSelect from '@components/Forms/InputSelect/InputSelect.vue'; -import LeftCaretIcon from '@components/Icons/LeftCaretIcon/LeftCaretIcon.vue'; -import RightCaretIcon from '@components/Icons/RightCaretIcon/RightCaretIcon.vue'; -import { FormInput } from '@/models/FormInput/FormInput.model'; - -// const MAX_PAGES_VISIBLE = 7; - -const createPaginationItem = (pageNum, currentPage) => ({ - id: pageNum, - clickable: pageNum > 0 && pageNum !== currentPage, - displayValue: (pageNum > 0) ? pageNum : '...', - selected: pageNum > 0 && pageNum === currentPage -}); +import { Component, Vue, toNative } from 'vue-facing-decorator'; @Component({ name: 'Pagination', - components: { - InputSelect, - LeftCaretIcon, - RightCaretIcon - }, }) -export default class Pagination extends mixins(MixinForm) { - @Prop({ required: true }) private paginationId!: string; // The pagination store id of the list instance - @Prop({ required: true }) private listSize!: number; // The total number of list items (not all server APIs provide this) - @Prop({ required: true }) pagingPrevKey!: string | null; // The server API paging key for the previous page results - @Prop({ required: true }) pagingNextKey!: string | null; // The server API paging key for the next page results - @Prop() private pageSizeConfig?: Array<{ value: number; name: string; isDefault?: boolean }>; // Optional custom config of the page size selector (options for how many items are shown per page) - @Prop() private ariaLabel?: string; // Optional aria label for the pagination container element - @Prop({ required: true }) private pageChange!: (config: PageChangeConfig) => any; // A callback method that will be called on page-change - - // - // Data - // - paginationStore: any = {}; - ellipsis = (key) => createPaginationItem(key, -1); - defaultPageSizeOptions = [ - { value: 25, name: '25', isDefault: true }, - ]; - - defaultPageSize = DEFAULT_PAGE_SIZE; - - // - // Lifecycle - // - created() { - this.paginationStore = this.$store.state.pagination; - this.initFormInputs(); - - const { - paginationId, - pageSizeConfig, - paginationStore, - $store - } = this; - const pagination = paginationStore.paginationMap[paginationId]; - - if (pageSizeConfig) { - let defaultPageSizeOption = pageSizeConfig.find(({ isDefault }) => (isDefault)); - - if (!defaultPageSizeOption) { - defaultPageSizeOption = this.defaultPageSizeOptions.find(({ isDefault }) => (isDefault)); - } - - this.defaultPageSize = (defaultPageSizeOption) - ? defaultPageSizeOption.value - : pageSizeConfig[0].value; - - $store.dispatch('pagination/updatePaginationSize', { paginationId, newSize: this.defaultPageSize }); - } - - if (!pagination) { - $store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: 1 }); - } - } - - mounted() { - const { currentPage, pageSize, pageChange } = this; - const firstIndex = (currentPage - 1) * pageSize; - const lastIndex = currentPage * pageSize; - - pageChange({ firstIndex, lastIndexExclusive: lastIndex, prevNext: 0 }); - } - - // - // Computed - // - get pageSizeOptions(): Array { - const { pageSizeConfig, defaultPageSizeOptions } = this; - let options = pageSizeConfig; - - if (!options || !options.length) { - options = defaultPageSizeOptions; - } - - return options; - } - - get currentPage(): number { - const pagination = this.paginationStore.paginationMap[this.paginationId]; - - return (pagination) ? pagination.page : DEFAULT_PAGE; - } - - get pageSize(): number { - const pagination = this.paginationStore.paginationMap[this.paginationId]; - - return (pagination) ? pagination.size : this.defaultPageSize; - } - - get pageCount(): number { - return Math.ceil(this.listSize / this.pageSize); - } - - get isFirstPage(): boolean { - return this.currentPage === 1; - } - - get isLastPage(): boolean { - return this.currentPage === this.pageCount; - } - - get pages(): Array { - const { currentPage, ellipsis } = this; - const pageItems: Array = []; - - if (currentPage === 1) { - pageItems.push(createPaginationItem(1, currentPage)); - } else if (currentPage === 2) { - pageItems.push(createPaginationItem(1, currentPage)); - pageItems.push(createPaginationItem(2, currentPage)); - } else if (currentPage > 2) { - pageItems.push(createPaginationItem(1, currentPage)); - pageItems.push(ellipsis(0)); - pageItems.push(createPaginationItem(currentPage, currentPage)); - } - - return pageItems; - } - - // - // Watchers - // - @Watch('$props.paginationId') handleUpdatePagingId() { - this.resetPaging(); - } - - @Watch('$props.pageSizeConfig', { deep: true }) handleUpdatePageSizeConfig() { - this.resetPaging(); - } - - // - // Methods - // - initFormInputs(): void { - this.formData = reactive({ - pageSizeOptions: new FormInput({ - id: 'page-size', - name: 'page-size', - label: computed(() => this.$t('paging.pageSize')), - value: this.pageSize, - valueOptions: this.pageSizeOptions.map((option) => ({ - value: option.value, - name: option.name, - })), - }), - }); - } - - setPage(newPage: number, increment?: number) { - const { pageSize } = this; - const zeroBasedIndex = (newPage - 1) * pageSize; - - if (this.currentPage !== newPage) { - this.$store.dispatch('pagination/updatePaginationPage', { paginationId: this.paginationId, newPage }); - this.pageChange({ - firstIndex: zeroBasedIndex, - lastIndexExclusive: zeroBasedIndex + pageSize, - prevNext: increment, - }); - } - } - - setSize(formInput: FormInput) { - const newSize = Number(formInput.value); - const { - listSize, - currentPage, - paginationId, - pageChange, - $store - } = this; - let newFirstIndex = (currentPage - 1) * newSize; - - if (newFirstIndex >= listSize) { - const newPageCount = Math.ceil(listSize / newSize); - - newFirstIndex = (newPageCount - 1) * newSize; - $store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: newPageCount }); - } - - $store.dispatch('pagination/updatePaginationSize', { paginationId, newSize }); - pageChange({ - firstIndex: newFirstIndex, - lastIndexExclusive: newFirstIndex + newSize, - prevNext: 0, - }); - } +class Pagination extends Vue { +} - resetPaging(): void { - // If any variables that affect paging have changed (page size, etc.) then we need to reset to the first page with the new variables. - nextTick(() => { - const { pageSize, pageChange, paginationId } = this; +export default toNative(Pagination); - this.$store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: 1 }); - pageChange({ - firstIndex: 0, - lastIndexExclusive: pageSize, - prevNext: 0, - }); - }); - } -} +// export default Pagination; diff --git a/webroot/src/components/Lists/Pagination/Pagination.vue b/webroot/src/components/Lists/Pagination/Pagination.vue index 85e0b93c0..241f522b9 100644 --- a/webroot/src/components/Lists/Pagination/Pagination.vue +++ b/webroot/src/components/Lists/Pagination/Pagination.vue @@ -1,45 +1,12 @@ diff --git a/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.less b/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.less new file mode 100644 index 000000000..71b940fe1 --- /dev/null +++ b/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.less @@ -0,0 +1,74 @@ +// +// PaginationLegacy.less +// InspiringApps modules +// +// Created by InspiringApps on 5/21/2020. +// + +.pagination-container { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + margin-left: auto; + + &.bottom-pagination { + margin-top: 1.6rem; + } + + .pagination-list { + display: flex; + margin-bottom: 1.0rem; + + .pagination-item { + display: flex; + align-items: center; + justify-content: center; + width: 3.2rem; + height: 3.2rem; + border-radius: 4px; + color: @black; + + &:not(:last-child) { + margin-right: 0.4rem; + } + } + + .selected { + border-color: @primaryColor; + color: @white; + background-color: @primaryColor; + } + + .clickable { + cursor: pointer; + + &:not(.caret) { + border: 1px solid @lightGrey; + border-radius: 4px; + } + } + + .caret { + &.previous { + width: 3.2rem; + } + + &.next { + width: 3.2rem; + } + + svg { + width: 1.2rem; + height: 1.2rem; + fill: @primaryColor; + } + } + } + + .page-size { + display: none; + margin-bottom: 3.2rem; + margin-left: 1.6rem; + } +} diff --git a/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.spec.ts b/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.spec.ts new file mode 100644 index 000000000..7e1d5e630 --- /dev/null +++ b/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.spec.ts @@ -0,0 +1,113 @@ +// +// PaginationLegacy.spec.ts +// InspiringApps modules +// +// Created by InspiringApps on 5/21/2020. +// + +import sinon from 'sinon'; +import { expect } from 'chai'; +import { mountShallow, mountFull } from '@tests/helpers/setup'; +import Pagination from '@components/Lists/PaginationLegacy/PaginationLegacy.vue'; +import LeftCaretIcon from '@components/Icons/LeftCaretIcon/LeftCaretIcon.vue'; +import RightCaretIcon from '@components/Icons/RightCaretIcon/RightCaretIcon.vue'; + +describe('Pagination component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Pagination, { + props: { + paginationId: 'test', + pageChange: () => null, + pagingPrevKey: '', + pagingNextKey: '', + }, + }); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Pagination).exists()).to.equal(true); + expect(wrapper.findComponent(LeftCaretIcon).exists()).to.equal(false); + expect(wrapper.findComponent(RightCaretIcon).exists()).to.equal(false); + }); + it('should load with expected default behavior', async () => { + const spy = sinon.spy(); + const wrapper = await mountShallow(Pagination, { + props: { + paginationId: 'test', + pageChange: spy, + listSize: 0, + pagingPrevKey: '', + pagingNextKey: '', + } + }); + const instance = wrapper.vm; + + expect(instance.currentPage).to.equal(1); + expect(spy.calledOnce).to.be.true; + expect(spy.withArgs({ firstIndex: 0, lastIndexExclusive: 25, prevNext: 0 }).calledOnce).to.be.true; + expect(instance.pageCount).to.equal(0); + }); + it('should load with next paging', async () => { + const spy = sinon.spy(); + const wrapper = await mountFull(Pagination, { + props: { + paginationId: 'test', + pageChange: spy, + pagingPrevKey: '', + pagingNextKey: 'test-next', + }, + }); + const instance = wrapper.vm; + const pages = instance.pages.map((page) => page.displayValue); + + expect(pages).to.be.an('array'); + expect(pages).to.have.length(1); + expect(pages[0]).to.equal(1); + expect(wrapper.findComponent(LeftCaretIcon).exists(), 'previous arrow').to.equal(false); + expect(wrapper.findComponent(RightCaretIcon).exists(), 'next arrow').to.equal(true); + }); + it('should advance with next page (2)', async () => { + const spy = sinon.spy(); + const wrapper = await mountFull(Pagination, { + props: { + paginationId: 'test', + pageChange: spy, + pagingPrevKey: 'test-prev', + pagingNextKey: 'test-next', + }, + }); + const instance = wrapper.vm; + + await wrapper.get('.pagination-item.next').trigger('click'); + + const pages = instance.pages.map((page) => page.displayValue); + + expect(pages).to.be.an('array'); + expect(pages).to.have.length(2); + expect(pages[0]).to.equal(1); + expect(pages[1]).to.equal(2); + expect(wrapper.findComponent(RightCaretIcon).exists(), 'next arrow').to.equal(true); + }); + it('should advance with next page (3)', async () => { + const spy = sinon.spy(); + const wrapper = await mountFull(Pagination, { + props: { + paginationId: 'test', + pageChange: spy, + pagingPrevKey: 'test-prev', + pagingNextKey: 'test-next', + }, + }); + const instance = wrapper.vm; + + await wrapper.get('.pagination-item.next').trigger('click'); + + const pages = instance.pages.map((page) => page.displayValue); + + expect(pages).to.be.an('array'); + expect(pages).to.have.length(3); + expect(pages[0]).to.equal(1); + expect(pages[1]).to.equal('...'); + expect(pages[2]).to.equal(3); + expect(wrapper.findComponent(RightCaretIcon).exists(), 'next arrow').to.equal(true); + }); +}); diff --git a/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.ts b/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.ts new file mode 100644 index 000000000..36af1e2a0 --- /dev/null +++ b/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.ts @@ -0,0 +1,239 @@ +// +// PaginationLegacy.ts +// InspiringApps modules +// +// Created by InspiringApps on 5/21/2020. +// + +import { + Component, + mixins, + Prop, + Watch +} from 'vue-facing-decorator'; +import { reactive, computed, nextTick } from 'vue'; +import MixinForm from '@components/Forms/_mixins/form.mixin'; +import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, PageChangeConfig } from '@store/pagination/pagination.state'; +import InputSelect from '@components/Forms/InputSelect/InputSelect.vue'; +import LeftCaretIcon from '@components/Icons/LeftCaretIcon/LeftCaretIcon.vue'; +import RightCaretIcon from '@components/Icons/RightCaretIcon/RightCaretIcon.vue'; +import { FormInput } from '@/models/FormInput/FormInput.model'; + +// const MAX_PAGES_VISIBLE = 7; + +const createPaginationItem = (pageNum, currentPage) => ({ + id: pageNum, + clickable: pageNum > 0 && pageNum !== currentPage, + displayValue: (pageNum > 0) ? pageNum : '...', + selected: pageNum > 0 && pageNum === currentPage +}); + +@Component({ + name: 'PaginationLegacy', + components: { + InputSelect, + LeftCaretIcon, + RightCaretIcon + }, +}) +export default class Pagination extends mixins(MixinForm) { + @Prop({ required: true }) private paginationId!: string; // The pagination store id of the list instance + @Prop({ required: true }) private listSize!: number; // The total number of list items (not all server APIs provide this) + @Prop({ required: true }) pagingPrevKey!: string | null; // The server API paging key for the previous page results + @Prop({ required: true }) pagingNextKey!: string | null; // The server API paging key for the next page results + @Prop() private pageSizeConfig?: Array<{ value: number; name: string; isDefault?: boolean }>; // Optional custom config of the page size selector (options for how many items are shown per page) + @Prop() private ariaLabel?: string; // Optional aria label for the pagination container element + @Prop({ required: true }) private pageChange!: (config: PageChangeConfig) => any; // A callback method that will be called on page-change + + // + // Data + // + paginationStore: any = {}; + ellipsis = (key) => createPaginationItem(key, -1); + defaultPageSizeOptions = [ + { value: 25, name: '25', isDefault: true }, + ]; + + defaultPageSize = DEFAULT_PAGE_SIZE; + + // + // Lifecycle + // + created() { + this.paginationStore = this.$store.state.pagination; + this.initFormInputs(); + + const { + paginationId, + pageSizeConfig, + paginationStore, + $store + } = this; + const pagination = paginationStore.paginationMap[paginationId]; + + if (pageSizeConfig) { + let defaultPageSizeOption = pageSizeConfig.find(({ isDefault }) => (isDefault)); + + if (!defaultPageSizeOption) { + defaultPageSizeOption = this.defaultPageSizeOptions.find(({ isDefault }) => (isDefault)); + } + + this.defaultPageSize = (defaultPageSizeOption) + ? defaultPageSizeOption.value + : pageSizeConfig[0].value; + + $store.dispatch('pagination/updatePaginationSize', { paginationId, newSize: this.defaultPageSize }); + } + + if (!pagination) { + $store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: 1 }); + } + } + + mounted() { + const { currentPage, pageSize, pageChange } = this; + const firstIndex = (currentPage - 1) * pageSize; + const lastIndex = currentPage * pageSize; + + pageChange({ firstIndex, lastIndexExclusive: lastIndex, prevNext: 0 }); + } + + // + // Computed + // + get pageSizeOptions(): Array { + const { pageSizeConfig, defaultPageSizeOptions } = this; + let options = pageSizeConfig; + + if (!options || !options.length) { + options = defaultPageSizeOptions; + } + + return options; + } + + get currentPage(): number { + const pagination = this.paginationStore.paginationMap[this.paginationId]; + + return (pagination) ? pagination.page : DEFAULT_PAGE; + } + + get pageSize(): number { + const pagination = this.paginationStore.paginationMap[this.paginationId]; + + return (pagination) ? pagination.size : this.defaultPageSize; + } + + get pageCount(): number { + return Math.ceil(this.listSize / this.pageSize); + } + + get isFirstPage(): boolean { + return this.currentPage === 1; + } + + get isLastPage(): boolean { + return this.currentPage === this.pageCount; + } + + get pages(): Array { + const { currentPage, ellipsis } = this; + const pageItems: Array = []; + + if (currentPage === 1) { + pageItems.push(createPaginationItem(1, currentPage)); + } else if (currentPage === 2) { + pageItems.push(createPaginationItem(1, currentPage)); + pageItems.push(createPaginationItem(2, currentPage)); + } else if (currentPage > 2) { + pageItems.push(createPaginationItem(1, currentPage)); + pageItems.push(ellipsis(0)); + pageItems.push(createPaginationItem(currentPage, currentPage)); + } + + return pageItems; + } + + // + // Watchers + // + @Watch('$props.paginationId') handleUpdatePagingId() { + this.resetPaging(); + } + + @Watch('$props.pageSizeConfig', { deep: true }) handleUpdatePageSizeConfig() { + this.resetPaging(); + } + + // + // Methods + // + initFormInputs(): void { + this.formData = reactive({ + pageSizeOptions: new FormInput({ + id: 'page-size', + name: 'page-size', + label: computed(() => this.$t('paging.pageSize')), + value: this.pageSize, + valueOptions: this.pageSizeOptions.map((option) => ({ + value: option.value, + name: option.name, + })), + }), + }); + } + + setPage(newPage: number, increment?: number) { + const { pageSize } = this; + const zeroBasedIndex = (newPage - 1) * pageSize; + + if (this.currentPage !== newPage) { + this.$store.dispatch('pagination/updatePaginationPage', { paginationId: this.paginationId, newPage }); + this.pageChange({ + firstIndex: zeroBasedIndex, + lastIndexExclusive: zeroBasedIndex + pageSize, + prevNext: increment, + }); + } + } + + setSize(formInput: FormInput) { + const newSize = Number(formInput.value); + const { + listSize, + currentPage, + paginationId, + pageChange, + $store + } = this; + let newFirstIndex = (currentPage - 1) * newSize; + + if (newFirstIndex >= listSize) { + const newPageCount = Math.ceil(listSize / newSize); + + newFirstIndex = (newPageCount - 1) * newSize; + $store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: newPageCount }); + } + + $store.dispatch('pagination/updatePaginationSize', { paginationId, newSize }); + pageChange({ + firstIndex: newFirstIndex, + lastIndexExclusive: newFirstIndex + newSize, + prevNext: 0, + }); + } + + resetPaging(): void { + // If any variables that affect paging have changed (page size, etc.) then we need to reset to the first page with the new variables. + nextTick(() => { + const { pageSize, pageChange, paginationId } = this; + + this.$store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: 1 }); + pageChange({ + firstIndex: 0, + lastIndexExclusive: pageSize, + prevNext: 0, + }); + }); + } +} diff --git a/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.vue b/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.vue new file mode 100644 index 000000000..3f90a7953 --- /dev/null +++ b/webroot/src/components/Lists/PaginationLegacy/PaginationLegacy.vue @@ -0,0 +1,46 @@ + + + + + + diff --git a/webroot/src/pages/LicensingList/LicensingList.ts b/webroot/src/pages/LicensingList/LicensingList.ts index a71a1ce1e..3b27883b9 100644 --- a/webroot/src/pages/LicensingList/LicensingList.ts +++ b/webroot/src/pages/LicensingList/LicensingList.ts @@ -7,7 +7,7 @@ import { Component, Vue } from 'vue-facing-decorator'; import Section from '@components/Section/Section.vue'; -import LicenseeList from '@components/Licensee/LicenseeList/LicenseeList.vue'; +import LicenseeList from '@components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue'; @Component({ name: 'LicensingList', diff --git a/webroot/src/pages/PublicLicensingList/PublicLicensingList.ts b/webroot/src/pages/PublicLicensingList/PublicLicensingList.ts index 28f531beb..a5fe860b6 100644 --- a/webroot/src/pages/PublicLicensingList/PublicLicensingList.ts +++ b/webroot/src/pages/PublicLicensingList/PublicLicensingList.ts @@ -7,7 +7,7 @@ import { Component, Vue } from 'vue-facing-decorator'; import Section from '@components/Section/Section.vue'; -import LicenseeList from '@components/Licensee/LicenseeList/LicenseeList.vue'; +import LicenseeList from '@components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue'; @Component({ name: 'LicensingListPublic', diff --git a/webroot/src/store/license/license.mutations.ts b/webroot/src/store/license/license.mutations.ts index afdcd5f42..aed0a770a 100644 --- a/webroot/src/store/license/license.mutations.ts +++ b/webroot/src/store/license/license.mutations.ts @@ -4,7 +4,7 @@ // // Created by InspiringApps on 7/2/24. // -import { LicenseSearch } from '@components/Licensee/LicenseeSearch/LicenseeSearch.vue'; +import { LicenseSearchLegacy } from '@components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue'; export enum MutationTypes { GET_LICENSEES_REQUEST = '[License] Get Licensees Request', @@ -97,7 +97,7 @@ export default { console.warn('Cannot remove Licensee with null ID from the store:'); } }, - [MutationTypes.STORE_UPDATE_SEARCH]: (state: any, search: LicenseSearch) => { + [MutationTypes.STORE_UPDATE_SEARCH]: (state: any, search: LicenseSearchLegacy) => { state.search = search; }, [MutationTypes.STORE_RESET_SEARCH]: (state: any) => { diff --git a/webroot/src/store/license/license.state.ts b/webroot/src/store/license/license.state.ts index 5464adc56..9f90676be 100644 --- a/webroot/src/store/license/license.state.ts +++ b/webroot/src/store/license/license.state.ts @@ -4,7 +4,7 @@ // // Created by InspiringApps on 7/2/24. // -import { LicenseSearch } from '@components/Licensee/LicenseeSearch/LicenseeSearch.vue'; +import { LicenseSearchLegacy } from '@components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue'; export interface State { model: Array | null; @@ -13,7 +13,7 @@ export interface State { lastKey: string | null; isLoading: boolean; error: any | null; - search: LicenseSearch; + search: LicenseSearchLegacy; } export const state: State = { From aafd8eddade93ce687b7ea2ee9f04935cfa3eaf0 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Tue, 2 Dec 2025 15:47:37 -0700 Subject: [PATCH 02/19] WIP: Licensee search update - Implement roughed out version of standard paging for staff licensee search --- .../Licensee/LicenseeList/LicenseeList.less | 64 ++++ .../Licensee/LicenseeList/LicenseeList.ts | 308 +++++++++++++++++- .../Licensee/LicenseeList/LicenseeList.vue | 70 +++- .../LicenseeSearch/LicenseeSearch.less | 67 ++++ .../Licensee/LicenseeSearch/LicenseeSearch.ts | 181 +++++++++- .../LicenseeSearch/LicenseeSearch.vue | 42 ++- .../Lists/ListContainer/ListContainer.ts | 2 + .../Lists/ListContainer/ListContainer.vue | 17 + .../Lists/Pagination/Pagination.less | 50 +++ .../Lists/Pagination/Pagination.spec.ts | 6 +- .../components/Lists/Pagination/Pagination.ts | 244 +++++++++++++- .../Lists/Pagination/Pagination.vue | 41 ++- webroot/src/network/data.api.ts | 9 + webroot/src/network/licenseApi/data.api.ts | 19 ++ webroot/src/network/mocks/mock.data.api.ts | 12 + .../src/pages/LicensingList/LicensingList.ts | 4 +- .../src/pages/LicensingList/LicensingList.vue | 1 + webroot/src/store/license/license.actions.ts | 12 + 18 files changed, 1136 insertions(+), 13 deletions(-) diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.less b/webroot/src/components/Licensee/LicenseeList/LicenseeList.less index 62c35ea92..c10093f15 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.less +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.less @@ -4,3 +4,67 @@ // // Created by InspiringApps on 12/1/2025. // + +.licensee-list-container { + background-color: transparent; + + .search-toggle-container { + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-items: flex-end; + margin-top: 1.2rem; + + @media @desktopWidth { + flex-direction: row; + align-items: center; + justify-content: flex-end; + } + + .search-tag { + display: flex; + align-content: center; + align-items: center; + justify-content: center; + width: 100%; + margin-top: 1.2rem; + padding: 0.4rem 1rem; + border-radius: @borderRadiusPillShape; + color: @white; + background-color: @darkBlue; + + @media @desktopWidth { + order: 1; + width: auto; + margin-top: 0; + margin-right: 2.4rem; + } + + .title { + padding: 0 0.4rem; + } + + .search-terms { + font-weight: @fontWeightBold; + } + + .search-terms-reset { + width: 2rem; + margin-left: auto; + padding: 0.4rem 0.2rem; + cursor: pointer; + stroke: @white; + } + } + + .search-toggle { + width: 100%; + min-height: 4.8rem; + + @media @desktopWidth { + order: 2; + width: auto; + } + } + } +} diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts index cc1efbdf2..891cb9c81 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts @@ -5,12 +5,318 @@ // Created by InspiringApps on 12/1/2025. // -import { Component, Vue, toNative } from 'vue-facing-decorator'; +import { + Component, + Vue, + Prop, + toNative +} from 'vue-facing-decorator'; +import ListContainer from '@components/Lists/ListContainer/ListContainer.vue'; +import LicenseeSearch, { LicenseSearch } from '@components/Licensee/LicenseeSearch/LicenseeSearch.vue'; +import LicenseeRow from '@components/Licensee/LicenseeRow/LicenseeRow.vue'; +import CloseX from '@components/Icons/CloseX/CloseX.vue'; +import { SortDirection } from '@store/sorting/sorting.state'; +import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '@store/pagination/pagination.state'; +import { RequestParamsInterfaceLocal } from '@network/licenseApi/data.api'; +import { State } from '@models/State/State.model'; @Component({ name: 'LicenseeList', + components: { + ListContainer, + LicenseeSearch, + LicenseeRow, + CloseX, + }, }) class LicenseeList extends Vue { + @Prop({ required: true }) protected listId!: string; + @Prop({ default: false }) protected isPublicSearch?: boolean; + + // + // Data + // + hasSearched = false; + shouldShowSearchModal = false; + isInitialFetchCompleted = false; + + // + // Lifecycle + // + async created() { + if (this.licenseStoreRecordCount) { + this.hasSearched = true; + } + } + + async mounted() { + if (!this.licenseStoreRecordCount) { + // License store is empty - apply defaults + await this.setDefaultSort(); + await this.setDefaultPaging(); + } else if (this.licenseStoreRecordCount === 1 && !this.searchDisplayAll) { + // Edge case: Returning from a detail page that was refreshed / cache-cleared + this.shouldShowSearchModal = true; + } else { + // License store already has records + this.isInitialFetchCompleted = true; + } + } + + // + // Computed + // + get sortingStore() { + return this.$store.state.sorting; + } + + get paginationStore() { + return this.$store.state.pagination; + } + + get userStore(): any { + return this.$store.state.user; + } + + get licenseStore(): any { + return this.$store.state.license; + } + + get licenseStoreRecordCount(): number { + return this.licenseStore.model?.length || 0; + } + + get searchParams(): LicenseSearch { + return this.licenseStore.search; + } + + get searchDisplayCompact(): string { + return (this.isPublicSearch) ? this.userStore.currentCompact?.abbrev() || '' : ''; + } + + get searchDisplayFirstName(): string { + const delimiter = (this.searchDisplayCompact) ? ', ' : ''; + let displayFirstName = ''; + + if (this.searchParams.firstName) { + displayFirstName = `${delimiter}${this.searchParams.firstName}` || ''; + } + + return displayFirstName; + } + + get searchDisplayLastName(): string { + const delimiter = (this.searchDisplayCompact && !this.searchDisplayFirstName) ? ', ' : ''; + const subDelimiter = (this.searchDisplayFirstName) ? ' ' : ''; + let displayLastName = ''; + + if (this.searchParams.lastName) { + displayLastName = `${delimiter}${subDelimiter}${this.searchParams.lastName}` || ''; + } + + return displayLastName; + } + + get searchDisplayState(): string { + const { state } = this.searchParams; + const { searchDisplayCompact, searchDisplayFirstName, searchDisplayLastName } = this; + const delimiter = (searchDisplayCompact || searchDisplayFirstName || searchDisplayLastName) ? ', ' : ''; + let displayState = ''; + + if (state) { + const stateModel = new State({ abbrev: state }); + + displayState = `${delimiter}${stateModel.name()}`; + } + + return displayState; + } + + get searchDisplayAll(): string { + const { + searchDisplayCompact, + searchDisplayFirstName, + searchDisplayLastName, + searchDisplayState + } = this; + + return [ + searchDisplayCompact, + searchDisplayFirstName, + searchDisplayLastName, + searchDisplayState + ].join('').trim(); + } + + get sortOptions(): Array { + const options = [ + // Temp for limited server sorting support + // { value: 'firstName', name: this.$t('common.firstName') }, + { value: 'lastName', name: this.$t('common.lastName'), isDefault: true }, + // { value: 'licenseStates', name: this.$t('licensing.homeState') }, + // { value: 'privilegeStates', name: this.$t('licensing.privileges') }, + // { value: 'status', name: this.$t('licensing.status') }, + ]; + + return options; + } + + get headerRecord() { + const record = { + firstName: this.$t('common.firstName'), + lastName: this.$t('common.lastName'), + ssnMaskedPartial: () => this.$t('licensing.ssn'), + homeJurisdictionDisplay: () => this.$t('licensing.homeState'), + privilegeStatesDisplay: () => this.$t('licensing.privileges'), + statusDisplay: () => this.$t('licensing.status'), + }; + + return record; + } + + // + // Methods + // + toggleSearch(): void { + this.shouldShowSearchModal = !this.shouldShowSearchModal; + } + + handleSearch(params: LicenseSearch): void { + this.$store.dispatch('license/setStoreSearch', params); + this.$store.dispatch('pagination/updatePaginationPage', { + paginationId: this.listId, + newPage: 1, + }); + this.fetchListData(); + + if (!this.hasSearched) { + this.hasSearched = true; + } else { + this.toggleSearch(); + } + } + + async resetSearch(): Promise { + this.$store.dispatch('license/resetStoreSearch'); + + if (this.isPublicSearch) { + await this.$store.dispatch('user/setCurrentCompact', null); + } + + this.toggleSearch(); + } + + async setDefaultSort() { + const { listId } = this; + const defaultSortOption = this.sortOptions.find((option) => option.isDefault) || this.sortOptions[0]; + const { option, direction } = this.sortingStore.sortingMap[listId] || {}; + + if (!option) { + await this.$store.dispatch('sorting/updateSortOption', { + sortingId: listId, + newOption: defaultSortOption.value, + }); + } + + if (!direction) { + await this.$store.dispatch('sorting/updateSortDirection', { + sortingId: listId, + newDirection: SortDirection.asc, + }); + } + } + + async setDefaultPaging(shouldForce = false) { + const { listId } = this; + const { page, size } = this.paginationStore.paginationMap[this.listId] || {}; + + if (!page || shouldForce) { + await this.$store.dispatch('pagination/updatePaginationPage', { + paginationId: listId, + newPage: DEFAULT_PAGE, + }); + } + + if (!size) { + await this.$store.dispatch('pagination/updatePaginationSize', { + paginationId: listId, + newSize: DEFAULT_PAGE_SIZE, + }); + } + } + + async fetchListData() { + const { searchParams } = this; + const sorting = this.sortingStore.sortingMap[this.listId]; + const { option, direction } = sorting || {}; + const pagination = this.paginationStore.paginationMap[this.listId]; + const { page, size } = pagination || {}; + const requestConfig: RequestParamsInterfaceLocal = {}; + + // Sorting params + if (option) { + const serverSortByMap = { + firstName: 'givenName', + lastName: 'familyName', + lastUpdate: 'dateOfUpdate', + }; + + requestConfig.sortBy = serverSortByMap[option]; + } + + if (direction) { + const serverSortDirectionMap = { + asc: 'ascending', + desc: 'descending', + }; + + requestConfig.sortDirection = serverSortDirectionMap[direction]; + } + + // Search params + requestConfig.isPublic = this.isPublicSearch; + + if (searchParams?.compact) { + requestConfig.compact = searchParams?.compact; + } else { + requestConfig.compact = this.userStore.currentCompact?.type; + } + + if (searchParams?.firstName) { + requestConfig.licenseeFirstName = searchParams.firstName; + } + if (searchParams?.lastName) { + requestConfig.licenseeLastName = searchParams.lastName; + } + if (searchParams?.state) { + requestConfig.jurisdiction = searchParams.state.toLowerCase(); + } + + // Make fetch request + await this.$store.dispatch('license/getLicenseesSearchRequest', { + params: { + ...requestConfig, + pageNum: page, + pageSize: size, + } + }); + + this.isInitialFetchCompleted = true; + + return requestConfig; + } + + async sortingChange() { + if (this.isInitialFetchCompleted) { + await this.fetchListData(); + } + } + + async paginationChange() { + if (this.isInitialFetchCompleted) { + await this.fetchListData(); + } + } } export default toNative(LicenseeList); diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue b/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue index 5d200d2f2..31bd28834 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue @@ -6,7 +6,75 @@ --> diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less index 2ec75ec57..41fe001f0 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less @@ -4,3 +4,70 @@ // // Created by InspiringApps on 12/1/2025. // + +.licensee-search-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + min-height: 60vh; + border-radius: 8px; + background-color: @white; + + .search-icon { + width: 4.8rem; + margin: 1.6rem 0; + fill: @fontColor; + } + + .search-title { + font-weight: @fontWeightBold; + font-size: 2.6rem; + } + + .search-subtext { + text-align: center; + } + + .search-form { + display: flex; + flex-direction: column; + flex-wrap: wrap; + width: 100%; + max-width: 64rem; + padding: 2.4rem; + } + + .search-form-row { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + width: 100%; + + @media @tabletWidth { + flex-direction: row; + } + } + + .search-input { + width: 100%; + + @media @tabletWidth { + width: 48%; + } + + &.search-submit { + align-items: center; + + &:deep(.input-submit) { + width: 100%; + } + } + } + + .search-submit { + margin-top: 2.4rem; + } +} diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts index edc24e1f5..dd6f06295 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts @@ -5,12 +5,189 @@ // Created by InspiringApps on 12/1/2025. // -import { Component, Vue, toNative } from 'vue-facing-decorator'; +import { + Component, + mixins, + Prop, + Watch, + toNative +} from 'vue-facing-decorator'; +import { reactive, computed } from 'vue'; +import MixinForm from '@components/Forms/_mixins/form.mixin'; +import InputText from '@components/Forms/InputText/InputText.vue'; +import InputSelect from '@components/Forms/InputSelect/InputSelect.vue'; +import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; +import SearchIcon from '@components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue'; +import { CompactType, CompactSerializer } from '@models/Compact/Compact.model'; +import { State } from '@models/State/State.model'; +import { FormInput } from '@models/FormInput/FormInput.model'; +import Joi from 'joi'; + +export interface LicenseSearch { + compact?: string; + firstName?: string; + lastName?: string; + state?: string; +} @Component({ name: 'LicenseeSearch', + components: { + InputText, + InputSelect, + InputSubmit, + SearchIcon, + }, + emits: [ 'searchParams' ], }) -class LicenseeSearch extends Vue { +class LicenseeSearch extends mixins(MixinForm) { + @Prop({ default: {}}) searchParams!: LicenseSearch; + @Prop({ default: false }) isPublicSearch!: boolean; + + // + // Lifecycle + // + created() { + this.initFormInputs(); + } + + // + // Computed + // + get userStore() { + return this.$store.state.user; + } + + get compactType(): CompactType | null { + return this.userStore.currentCompact?.type; + } + + get compactOptions(): Array { + const options = this.$tm('compacts').map((compact) => ({ + value: compact.key, + name: compact.name, + })); + + options.unshift({ + value: '', + name: computed(() => this.$t('common.selectOption')), + }); + + return options; + } + + get enableCompactSelect(): boolean { + return this.isPublicSearch; + } + + get compactStates(): Array { + return this.userStore.currentCompact?.memberStates || []; + } + + get stateOptions(): Array { + const compactMemberStates = this.compactStates.map((state) => ({ + value: state.abbrev, name: state.name() + })); + const defaultSelectOption: any = { value: '' }; + + if (!compactMemberStates.length) { + defaultSelectOption.name = ''; + } else { + defaultSelectOption.name = computed(() => this.$t('common.selectOption')); + } + + compactMemberStates.unshift(defaultSelectOption); + + return compactMemberStates; + } + + // + // Methods + // + initFormInputs(): void { + this.formData = reactive({ + firstName: new FormInput({ + id: 'first-name', + name: 'first-name', + label: computed(() => this.$t('common.firstName')), + placeholder: computed(() => this.$t('licensing.searchPlaceholderName')), + validation: Joi.string().min(0).max(100).messages(this.joiMessages.string), + value: this.searchParams.firstName || '', + enforceMax: true, + }), + lastName: new FormInput({ + id: 'last-name', + name: 'last-name', + label: computed(() => this.$t('common.lastName')), + placeholder: computed(() => this.$t('licensing.searchPlaceholderName')), + validation: Joi.string().min(0).max(100).messages(this.joiMessages.string), + value: this.searchParams.lastName || '', + enforceMax: true, + }), + state: new FormInput({ + id: 'state', + name: 'state', + label: computed(() => this.$t('common.stateJurisdiction')), + valueOptions: this.stateOptions, + value: this.searchParams.state || '', + isDisabled: computed(() => this.enableCompactSelect && !this.compactType), + }), + submit: new FormInput({ + isSubmitInput: true, + id: 'submit', + }), + }); + + if (this.enableCompactSelect) { + this.formData.compact = new FormInput({ + id: 'search-compact', + name: 'search-compact', + label: computed(() => this.$t('licensing.licenseTypeSearch')), + validation: Joi.string().required().messages(this.joiMessages.string), + valueOptions: this.compactOptions, + value: this.searchParams.compact || this.compactType || '', + }); + } + + this.watchFormInputs(); // Important if you want automated form validation + } + + async updateCurrentCompact(): Promise { + const { compact: selectedCompactType, state } = this.formData; + + if (this.enableCompactSelect) { + await this.$store.dispatch('user/setCurrentCompact', CompactSerializer.fromServer({ type: selectedCompactType.value })); + state.value = ''; + } + } + + async handleSubmit(): Promise { + this.validateAll({ asTouched: true }); + + if (this.isFormValid) { + this.startFormLoading(); + + const allowedSearchProps = [ + 'compact', + 'firstName', + 'lastName', + 'state' + ]; + const searchProps: LicenseSearch = {}; + + allowedSearchProps.forEach((searchProp) => { searchProps[searchProp] = this.formValues[searchProp]; }); + this.$emit('searchParams', searchProps); + + this.endFormLoading(); + } + } + + // + // Watch + // + @Watch('compactStates') updateStateInput() { + this.formData.state.valueOptions = this.stateOptions; + } } export default toNative(LicenseeSearch); diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue index 2bb9119b5..1174d818b 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue @@ -6,7 +6,47 @@ --> diff --git a/webroot/src/components/Lists/ListContainer/ListContainer.ts b/webroot/src/components/Lists/ListContainer/ListContainer.ts index 1c62542fc..366729e92 100644 --- a/webroot/src/components/Lists/ListContainer/ListContainer.ts +++ b/webroot/src/components/Lists/ListContainer/ListContainer.ts @@ -13,6 +13,7 @@ import { } from 'vue-facing-decorator'; import MixinListManipulation from '@/components/Lists/_mixins/ListManipulation.mixin'; import PaginationLegacy from '@components/Lists/PaginationLegacy/PaginationLegacy.vue'; +import Pagination from '@components/Lists/Pagination/Pagination.vue'; import Sorting from '@components/Lists/Sorting/Sorting.vue'; import CompactToggle from '@components/Lists/CompactToggle/CompactToggle.vue'; @@ -20,6 +21,7 @@ import CompactToggle from '@components/Lists/CompactToggle/CompactToggle.vue'; name: 'ListContainer', components: { PaginationLegacy, + Pagination, Sorting, CompactToggle, }, diff --git a/webroot/src/components/Lists/ListContainer/ListContainer.vue b/webroot/src/components/Lists/ListContainer/ListContainer.vue index 3fd14b64a..9c6298e8f 100644 --- a/webroot/src/components/Lists/ListContainer/ListContainer.vue +++ b/webroot/src/components/Lists/ListContainer/ListContainer.vue @@ -31,6 +31,14 @@ :pageChange="pageChange" :pageSizeConfig="pageSizeConfig" > +
@@ -63,6 +71,15 @@ :pageChange="pageChange" :pageSizeConfig="pageSizeConfig" > +
diff --git a/webroot/src/components/Lists/Pagination/Pagination.less b/webroot/src/components/Lists/Pagination/Pagination.less index fd18587f6..0466eddbd 100644 --- a/webroot/src/components/Lists/Pagination/Pagination.less +++ b/webroot/src/components/Lists/Pagination/Pagination.less @@ -4,3 +4,53 @@ // // Created by InspiringApps on 12/1/2025. // + +.pagination-container { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + margin-left: auto; + + &.bottom-pagination { + margin-top: 1.6rem; + } + + .pagination-list { + display: flex; + margin-bottom: 1.0rem; + + .pagination-item { + display: flex; + align-items: center; + justify-content: center; + width: 3.2rem; + height: 3.2rem; + color: @midGrey; + } + + .selected { + color: @primaryColor; + font-weight: @fontWeightBold; + } + + .clickable { + cursor: pointer; + } + + .caret { + border: 0.1rem solid @lightGrey; + border-radius: @borderRadiusSmall; + + svg { + width: 1.2rem; + height: 1.2rem; + } + } + } + + .page-size { + margin-bottom: 3.2rem; + margin-left: 1.6rem; + } +} diff --git a/webroot/src/components/Lists/Pagination/Pagination.spec.ts b/webroot/src/components/Lists/Pagination/Pagination.spec.ts index 44168731a..deeffc5d0 100644 --- a/webroot/src/components/Lists/Pagination/Pagination.spec.ts +++ b/webroot/src/components/Lists/Pagination/Pagination.spec.ts @@ -11,7 +11,11 @@ import Pagination from '@components/Lists/Pagination/Pagination.vue'; describe('Pagination component', async () => { it('should mount the component', async () => { - const wrapper = await mountShallow(Pagination); + const wrapper = await mountShallow(Pagination, { + props: { + pageChange: () => null, + }, + }); expect(wrapper.exists()).to.equal(true); expect(wrapper.findComponent(Pagination).exists()).to.equal(true); diff --git a/webroot/src/components/Lists/Pagination/Pagination.ts b/webroot/src/components/Lists/Pagination/Pagination.ts index 941e094c8..675b11aaa 100644 --- a/webroot/src/components/Lists/Pagination/Pagination.ts +++ b/webroot/src/components/Lists/Pagination/Pagination.ts @@ -5,14 +5,248 @@ // Created by InspiringApps on 12/1/2025. // -import { Component, Vue, toNative } from 'vue-facing-decorator'; +import { + Component, + mixins, + Prop, + Watch +} from 'vue-facing-decorator'; +import { reactive, computed, nextTick } from 'vue'; +import MixinForm from '@components/Forms/_mixins/form.mixin'; +import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '@store/pagination/pagination.state'; +import InputSelect from '@components/Forms/InputSelect/InputSelect.vue'; +import LeftCaretIcon from '@components/Icons/LeftCaretIcon/LeftCaretIcon.vue'; +import RightCaretIcon from '@components/Icons/RightCaretIcon/RightCaretIcon.vue'; +import { FormInput } from '@/models/FormInput/FormInput.model'; + +const MAX_PAGES_VISIBLE = 7; + +const createPaginationItem = (pageNum, currentPage) => ({ + id: pageNum, + clickable: pageNum > 0, + displayValue: (pageNum > 0) ? pageNum : '...', + selected: pageNum === currentPage +}); @Component({ name: 'Pagination', + components: { + InputSelect, + LeftCaretIcon, + RightCaretIcon + } }) -class Pagination extends Vue { -} +export default class Pagination extends mixins(MixinForm) { + @Prop({ required: true }) private pageChange!: (firstIndex: number, lastIndexExclusive: number) => any; + @Prop({ required: true }) private listSize!: number; + @Prop({ required: true }) private paginationId!: string; + @Prop() private pageSizeConfig?: Array<{ value: number; name: string; isDefault?: boolean }>; + @Prop() private ariaLabel?: string; + + // + // Data + // + paginationStore: any = {}; + ellipsis = (key) => createPaginationItem(key, -1); + defaultPageSizeOptions = [ + // { value: 2, name: '2', isDefault: true }, + // { value: 10, name: '10', isDefault: false }, + { value: 25, name: '25', isDefault: true }, + ]; + + defaultPageSize = DEFAULT_PAGE_SIZE; + + // + // Lifecycle + // + created() { + this.paginationStore = this.$store.state.pagination; + this.initFormInputs(); + + const { + paginationId, + pageSizeConfig, + paginationStore, + $store + } = this; + const pagination = paginationStore.paginationMap[paginationId]; + + if (pageSizeConfig) { + let defaultPageSizeOption = pageSizeConfig.find(({ isDefault }) => (isDefault)); + + if (!defaultPageSizeOption) { + defaultPageSizeOption = this.defaultPageSizeOptions.find(({ isDefault }) => (isDefault)); + } + + this.defaultPageSize = (defaultPageSizeOption) + ? defaultPageSizeOption.value + : pageSizeConfig[0].value; + + $store.dispatch('pagination/updatePaginationSize', { paginationId, newSize: this.defaultPageSize }); + } + + if (!pagination) { + $store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: 1 }); + } + } + + mounted() { + const { currentPage, pageSize, pageChange } = this; + const firstIndex = (currentPage - 1) * pageSize; + const lastIndex = currentPage * pageSize; + + pageChange(firstIndex, lastIndex); + } + + // + // Computed + // + get pageSizeOptions(): Array { + const { pageSizeConfig, defaultPageSizeOptions } = this; + let options = pageSizeConfig; + + if (!options || !options.length) { + options = defaultPageSizeOptions; + } + + return options; + } + + get currentPage(): number { + const pagination = this.paginationStore.paginationMap[this.paginationId]; + + return (pagination) ? pagination.page : DEFAULT_PAGE; + } + + get pageSize() { + const pagination = this.paginationStore.paginationMap[this.paginationId]; -export default toNative(Pagination); + return (pagination) ? pagination.size : this.defaultPageSize; + } -// export default Pagination; + get pageCount() { + return Math.ceil(this.listSize / this.pageSize); + } + + get isFirstPage() { + return this.currentPage === 1; + } + + get isLastPage() { + return this.currentPage === this.pageCount; + } + + get pages() { + const { currentPage, pageCount, ellipsis } = this; + + const visiblePagesCount = Math.min(MAX_PAGES_VISIBLE, pageCount) || 1; + const visiblePagesThreshold = (visiblePagesCount - 1) / 2; + const tempArray = Array(visiblePagesCount - 1); + const paginationDisplaysArray = [...tempArray.keys()].map((i) => i + 1); + const firstPage = () => createPaginationItem(1, currentPage); + const lastPage = () => createPaginationItem(pageCount, currentPage); + let pageItems; + + if (pageCount <= MAX_PAGES_VISIBLE) { + pageItems = paginationDisplaysArray.map((index) => { + const item = createPaginationItem(index, currentPage); + + return item; + }); + pageItems.push(lastPage()); + } else if (currentPage <= visiblePagesThreshold) { + pageItems = paginationDisplaysArray.map((index) => { + const item = createPaginationItem(index, currentPage); + + return item; + }); + pageItems[pageItems.length - 1] = ellipsis(0); + pageItems.push(lastPage()); + } else if (currentPage > pageCount - visiblePagesThreshold) { + pageItems = paginationDisplaysArray.map((paginationDisplay, index) => { + const item = createPaginationItem(pageCount - index, currentPage); + + return item; + }); + pageItems.reverse(); + pageItems[0] = ellipsis(0); + pageItems.unshift(firstPage()); + } else { + pageItems = []; + pageItems.push(firstPage()); + pageItems.push(ellipsis(0)); + pageItems.push(createPaginationItem(currentPage - 1, currentPage)); + pageItems.push(createPaginationItem(currentPage, currentPage)); + pageItems.push(createPaginationItem(currentPage + 1, currentPage)); + pageItems.push(ellipsis(-1)); + pageItems.push(lastPage()); + } + + return pageItems; + } + + // + // Watchers + // + @Watch('$props', { deep: true }) calculateNewIndices() { + nextTick(() => { + const { + pageSize, pageChange, $store, paginationId + } = this; + const newFirstIndex = 1 - 1; + + $store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: 1 }); + pageChange(newFirstIndex, newFirstIndex + pageSize); + }); + } + + // + // Methods + // + initFormInputs(): void { + this.formData = reactive({ + pageSizeOptions: new FormInput({ + id: 'page-size', + name: 'page-size', + label: computed(() => this.$t('paging.pageSize')), + value: this.pageSize, + valueOptions: this.pageSizeOptions.map((option) => ({ + value: option.value, + name: option.name, + })), + }), + }); + } + + setPage(newPage) { + const { pageSize } = this; + const zeroBasedIndex = (newPage - 1) * pageSize; + + if (this.currentPage !== newPage) { + this.$store.dispatch('pagination/updatePaginationPage', { paginationId: this.paginationId, newPage }); + this.pageChange(zeroBasedIndex, zeroBasedIndex + pageSize); + } + } + + setSize(formInput: FormInput) { + const newSize = Number(formInput.value); + const { + listSize, + currentPage, + paginationId, + pageChange, + $store + } = this; + let newFirstIndex = (currentPage - 1) * newSize; + + if (newFirstIndex >= listSize) { + const newPageCount = Math.ceil(listSize / newSize); + + newFirstIndex = (newPageCount - 1) * newSize; + $store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: newPageCount }); + } + + $store.dispatch('pagination/updatePaginationSize', { paginationId, newSize }); + pageChange(newFirstIndex, newFirstIndex + newSize); + } +} diff --git a/webroot/src/components/Lists/Pagination/Pagination.vue b/webroot/src/components/Lists/Pagination/Pagination.vue index 241f522b9..b69eae26e 100644 --- a/webroot/src/components/Lists/Pagination/Pagination.vue +++ b/webroot/src/components/Lists/Pagination/Pagination.vue @@ -6,7 +6,46 @@ --> diff --git a/webroot/src/network/data.api.ts b/webroot/src/network/data.api.ts index 0b1d640eb..3f13995d3 100644 --- a/webroot/src/network/data.api.ts +++ b/webroot/src/network/data.api.ts @@ -127,6 +127,15 @@ export class DataApi { return licenseDataApi.getLicenseesPublic(params); } + /** + * GET Licensees (Search - Staff). + * @param {object} [params] The request query parameters config. + * @return {Promise} An array of users server response. + */ + public getLicenseesSearchStaff(params) { + return licenseDataApi.getLicenseesSearchStaff(params); + } + /** * GET Licensee by ID. * @param {string} compact A compact type. diff --git a/webroot/src/network/licenseApi/data.api.ts b/webroot/src/network/licenseApi/data.api.ts index 2f52256a3..3b5c7a608 100644 --- a/webroot/src/network/licenseApi/data.api.ts +++ b/webroot/src/network/licenseApi/data.api.ts @@ -217,6 +217,25 @@ export class LicenseDataApi implements DataApiInterface { return response; } + /** + * GET Licensees (Search - Staff). + * @param {RequestParamsInterfaceLocal} [params={}] The request query parameters config. + * @return {Promise} Response metadata + an array of licensees. + */ + public async getLicenseesSearchStaff(params: RequestParamsInterfaceLocal = {}) { + // + // @TODO: Replace with new OpenSearch endpoint once available + // + const requestParams: RequestParamsInterfaceRemote = this.prepRequestPostParams(params); + const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/providers/query`, requestParams); + const { providers } = serverReponse; + const response = { + licensees: providers.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), + }; + + return response; + } + /** * GET Licensee by ID. * @param {string} licenseeId A licensee ID. diff --git a/webroot/src/network/mocks/mock.data.api.ts b/webroot/src/network/mocks/mock.data.api.ts index 5956658ec..c36739a15 100644 --- a/webroot/src/network/mocks/mock.data.api.ts +++ b/webroot/src/network/mocks/mock.data.api.ts @@ -182,6 +182,18 @@ export class DataApi { })); } + // Get Licensees (Search - Staff) + public getLicenseesSearchStaff(params: any = {}) { + const records = licensees.providers + .concat(licensees.providers) + .concat(licensees.providers); + + return wait(500).then(() => ({ + licensees: records.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), + params, + })); + } + // Get Licensee by ID public getLicensee(compact, licenseeId) { const serverResponse = licensees.providers.find((item) => item.providerId === licenseeId); diff --git a/webroot/src/pages/LicensingList/LicensingList.ts b/webroot/src/pages/LicensingList/LicensingList.ts index 3b27883b9..4cc674bcb 100644 --- a/webroot/src/pages/LicensingList/LicensingList.ts +++ b/webroot/src/pages/LicensingList/LicensingList.ts @@ -7,12 +7,14 @@ import { Component, Vue } from 'vue-facing-decorator'; import Section from '@components/Section/Section.vue'; -import LicenseeList from '@components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue'; +import LicenseeListLegacy from '@components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue'; +import LicenseeList from '@components/Licensee/LicenseeList/LicenseeList.vue'; @Component({ name: 'LicensingList', components: { Section, + LicenseeListLegacy, LicenseeList, }, }) diff --git a/webroot/src/pages/LicensingList/LicensingList.vue b/webroot/src/pages/LicensingList/LicensingList.vue index fc9e84e85..6489b8a30 100644 --- a/webroot/src/pages/LicensingList/LicensingList.vue +++ b/webroot/src/pages/LicensingList/LicensingList.vue @@ -7,6 +7,7 @@ diff --git a/webroot/src/store/license/license.actions.ts b/webroot/src/store/license/license.actions.ts index a1521c49a..35f161323 100644 --- a/webroot/src/store/license/license.actions.ts +++ b/webroot/src/store/license/license.actions.ts @@ -36,6 +36,18 @@ export default { dispatch('getLicenseesFailure', error); }); }, + getLicenseesSearchRequest: async ({ commit, dispatch }, { params }: any) => { + commit(MutationTypes.GET_LICENSEES_REQUEST); + + const apiRequest = dataApi.getLicenseesSearchStaff; + + await apiRequest(params).then(async ({ licensees }) => { + await dispatch('setStoreLicensees', licensees); + dispatch('getLicenseesSuccess', licensees); + }).catch((error) => { + dispatch('getLicenseesFailure', error); + }); + }, getLicenseesSuccess: ({ commit }) => { commit(MutationTypes.GET_LICENSEES_SUCCESS); }, From 4435f78b1cff8d162ff3d5fe41918cee10f48773 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Tue, 9 Dec 2025 16:12:39 -0700 Subject: [PATCH 03/19] WIP: Licensee search update - Implement roughed out version of the admin search form - Implement roughed out version of server payload prep for the opensearch provider query --- .../Licensee/LicenseeList/LicenseeList.ts | 52 ++-- .../LicenseeSearch/LicenseeSearch.less | 40 ++- .../Licensee/LicenseeSearch/LicenseeSearch.ts | 165 ++++++++++-- .../LicenseeSearch/LicenseeSearch.vue | 105 +++++++- webroot/src/locales/en.json | 31 ++- webroot/src/locales/es.json | 29 ++ webroot/src/network/licenseApi/data.api.ts | 253 +++++++++++++++++- webroot/src/network/mocks/mock.data.api.ts | 4 + 8 files changed, 619 insertions(+), 60 deletions(-) diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts index 891cb9c81..396cb5b63 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts @@ -17,7 +17,7 @@ import LicenseeRow from '@components/Licensee/LicenseeRow/LicenseeRow.vue'; import CloseX from '@components/Icons/CloseX/CloseX.vue'; import { SortDirection } from '@store/sorting/sorting.state'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '@store/pagination/pagination.state'; -import { RequestParamsInterfaceLocal } from '@network/licenseApi/data.api'; +import { SearchParamsInterfaceLocal } from '@network/licenseApi/data.api'; import { State } from '@models/State/State.model'; @Component({ @@ -117,14 +117,14 @@ class LicenseeList extends Vue { return displayLastName; } - get searchDisplayState(): string { - const { state } = this.searchParams; + get searchDisplayHomeState(): string { + const { homeState } = this.searchParams; const { searchDisplayCompact, searchDisplayFirstName, searchDisplayLastName } = this; const delimiter = (searchDisplayCompact || searchDisplayFirstName || searchDisplayLastName) ? ', ' : ''; let displayState = ''; - if (state) { - const stateModel = new State({ abbrev: state }); + if (homeState) { + const stateModel = new State({ abbrev: homeState }); displayState = `${delimiter}${stateModel.name()}`; } @@ -137,14 +137,14 @@ class LicenseeList extends Vue { searchDisplayCompact, searchDisplayFirstName, searchDisplayLastName, - searchDisplayState + searchDisplayHomeState } = this; return [ searchDisplayCompact, searchDisplayFirstName, searchDisplayLastName, - searchDisplayState + searchDisplayHomeState ].join('').trim(); } @@ -165,7 +165,6 @@ class LicenseeList extends Vue { const record = { firstName: this.$t('common.firstName'), lastName: this.$t('common.lastName'), - ssnMaskedPartial: () => this.$t('licensing.ssn'), homeJurisdictionDisplay: () => this.$t('licensing.homeState'), privilegeStatesDisplay: () => this.$t('licensing.privileges'), statusDisplay: () => this.$t('licensing.status'), @@ -251,7 +250,7 @@ class LicenseeList extends Vue { const { option, direction } = sorting || {}; const pagination = this.paginationStore.paginationMap[this.listId]; const { page, size } = pagination || {}; - const requestConfig: RequestParamsInterfaceLocal = {}; + const requestConfig: SearchParamsInterfaceLocal = {}; // Sorting params if (option) { @@ -265,12 +264,7 @@ class LicenseeList extends Vue { } if (direction) { - const serverSortDirectionMap = { - asc: 'ascending', - desc: 'descending', - }; - - requestConfig.sortDirection = serverSortDirectionMap[direction]; + requestConfig.sortDirection = direction; } // Search params @@ -288,8 +282,32 @@ class LicenseeList extends Vue { if (searchParams?.lastName) { requestConfig.licenseeLastName = searchParams.lastName; } - if (searchParams?.state) { - requestConfig.jurisdiction = searchParams.state.toLowerCase(); + if (searchParams?.homeState) { + requestConfig.homeState = searchParams.homeState.toLowerCase(); + } + if (searchParams?.privilegeState) { + requestConfig.privilegeState = searchParams.privilegeState.toLowerCase(); + } + if (searchParams?.privilegePurchaseStartDate) { + requestConfig.privilegePurchaseStartDate = searchParams.privilegePurchaseStartDate; + } + if (searchParams?.privilegePurchaseEndDate) { + requestConfig.privilegePurchaseEndDate = searchParams.privilegePurchaseEndDate; + } + if (searchParams?.militaryStatus) { + requestConfig.militaryStatus = searchParams.militaryStatus; + } + if (searchParams?.investigationStatus) { + requestConfig.investigationStatus = searchParams.investigationStatus; + } + if (searchParams?.encumberStartDate) { + requestConfig.encumberStartDate = searchParams.encumberStartDate; + } + if (searchParams?.encumberEndDate) { + requestConfig.encumberEndDate = searchParams.encumberEndDate; + } + if (searchParams?.npi) { + requestConfig.npi = searchParams.npi; } // Make fetch request diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less index 41fe001f0..6bf7124c1 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less @@ -6,6 +6,9 @@ // .licensee-search-container { + @inputWidth: 56%; + @inputSeparatorWidth: 4%; + display: flex; flex-direction: column; align-items: center; @@ -49,13 +52,38 @@ @media @tabletWidth { flex-direction: row; } + + &.date-range { + flex-direction: column; + align-items: center; + + @media @desktopWidth { + flex-direction: row; + } + + .date-range-separator { + display: none; + width: @inputSeparatorWidth; + text-align: center; + + @media @desktopWidth { + display: block; + } + } + } } .search-input { width: 100%; @media @tabletWidth { - width: 48%; + width: @inputWidth; + } + + @media @desktopWidth { + &.date-range-input { + width: ~"calc((@{inputWidth} / 2) - (@{inputSeparatorWidth}) / 2)"; + } } &.search-submit { @@ -67,6 +95,16 @@ } } + .date-section-label { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-bottom: 0.6rem; + color: @fontColor; + font-weight: @fontWeightBold; + font-size: 1.6rem; + } + .search-submit { margin-top: 2.4rem; } diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts index dd6f06295..19e8487bf 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts @@ -12,22 +12,38 @@ import { Watch, toNative } from 'vue-facing-decorator'; -import { reactive, computed } from 'vue'; +import { + reactive, + computed, + ComputedRef, + nextTick +} from 'vue'; import MixinForm from '@components/Forms/_mixins/form.mixin'; import InputText from '@components/Forms/InputText/InputText.vue'; import InputSelect from '@components/Forms/InputSelect/InputSelect.vue'; +import InputDate from '@components/Forms/InputDate/InputDate.vue'; import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; import SearchIcon from '@components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue'; +import MockPopulate from '@components/Forms/MockPopulate/MockPopulate.vue'; import { CompactType, CompactSerializer } from '@models/Compact/Compact.model'; import { State } from '@models/State/State.model'; import { FormInput } from '@models/FormInput/FormInput.model'; import Joi from 'joi'; +import moment from 'moment'; export interface LicenseSearch { compact?: string; firstName?: string; lastName?: string; - state?: string; + homeState?: string; + privilegeState?: string; + privilegePurchaseStartDate?: string; + privilegePurchaseEndDate?: string; + militaryStatus?: string; + investigationStatus?: string; + encumberStartDate?: string; + encumberEndDate?: string; + npi?: string; } @Component({ @@ -35,8 +51,10 @@ export interface LicenseSearch { components: { InputText, InputSelect, + InputDate, InputSubmit, SearchIcon, + MockPopulate, }, emits: [ 'searchParams' ], }) @@ -62,7 +80,7 @@ class LicenseeSearch extends mixins(MixinForm) { return this.userStore.currentCompact?.type; } - get compactOptions(): Array { + get compactOptions(): Array<{ value: string, name: string | ComputedRef }> { const options = this.$tm('compacts').map((compact) => ({ value: compact.key, name: compact.name, @@ -84,21 +102,46 @@ class LicenseeSearch extends mixins(MixinForm) { return this.userStore.currentCompact?.memberStates || []; } - get stateOptions(): Array { - const compactMemberStates = this.compactStates.map((state) => ({ - value: state.abbrev, name: state.name() + get stateOptions(): Array<{ value: string | undefined, name: string | ComputedRef }> { + const compactMemberStates: Array<{ value: string | undefined, name: string | ComputedRef }> = this.compactStates + .map((state) => ({ + value: state.abbrev, name: state.name() + })); + + compactMemberStates.unshift({ + value: '', + name: (compactMemberStates.length) ? computed(() => this.$t('common.selectOption')) : '', + }); + + return compactMemberStates; + } + + get militaryStatusOptions(): Array<{ value: string, name: string | ComputedRef }> { + const options = this.$tm('military.militaryStatusOptions').map((option) => ({ + value: option.key, + name: option.name, })); - const defaultSelectOption: any = { value: '' }; - if (!compactMemberStates.length) { - defaultSelectOption.name = ''; - } else { - defaultSelectOption.name = computed(() => this.$t('common.selectOption')); - } + options.unshift({ + value: '', + name: computed(() => this.$t('common.selectOption')), + }); - compactMemberStates.unshift(defaultSelectOption); + return options; + } - return compactMemberStates; + get investigationStatusOptions(): Array<{ value: string, name: string | ComputedRef }> { + const options = [ + { value: '', name: computed(() => this.$t('common.selectOption')) }, + { value: 'under-investigation', name: computed(() => this.$t('licensing.underInvestigationSearch')) }, + { value: 'not-under-investigation', name: computed(() => this.$t('licensing.notUnderInvestigationSearch')) }, + ]; + + return options; + } + + get isMockPopulateEnabled(): boolean { + return Boolean(this.$envConfig.isDevelopment); } // @@ -124,14 +167,68 @@ class LicenseeSearch extends mixins(MixinForm) { value: this.searchParams.lastName || '', enforceMax: true, }), - state: new FormInput({ - id: 'state', - name: 'state', - label: computed(() => this.$t('common.stateJurisdiction')), + homeState: new FormInput({ + id: 'home-state', + name: 'home-state', + label: computed(() => this.$t('licensing.homeState')), valueOptions: this.stateOptions, - value: this.searchParams.state || '', + value: this.searchParams.homeState || '', isDisabled: computed(() => this.enableCompactSelect && !this.compactType), }), + privilegeState: new FormInput({ + id: 'privilege-state', + name: 'privilege-state', + label: computed(() => this.$t('licensing.privilegeState')), + valueOptions: this.stateOptions, + value: this.searchParams.privilegeState || '', + }), + privilegePurchaseStartDate: new FormInput({ + id: 'privilege-purchase-start-date', + name: 'privilege-purchase-start-date', + label: computed(() => this.$t('common.startDate')), + value: this.searchParams.privilegePurchaseStartDate || '', + }), + privilegePurchaseEndDate: new FormInput({ + id: 'privilege-purchase-end-date', + name: 'privilege-purchase-end-date', + label: computed(() => this.$t('common.endDate')), + value: this.searchParams.privilegePurchaseEndDate || '', + }), + militaryStatus: new FormInput({ + id: 'military-status', + name: 'military-status', + label: computed(() => this.$t('military.militaryStatusTitle')), + valueOptions: this.militaryStatusOptions, + value: this.searchParams.militaryStatus || '', + }), + investigationStatus: new FormInput({ + id: 'investigation-status', + name: 'investigation-status', + label: computed(() => this.$t('licensing.underInvestigationStatusSearch')), + valueOptions: this.investigationStatusOptions, + value: this.searchParams.investigationStatus || '', + }), + encumberStartDate: new FormInput({ + id: 'encumber-start-date', + name: 'encumber-start-date', + label: computed(() => this.$t('common.startDate')), + value: this.searchParams.encumberStartDate || '', + }), + encumberEndDate: new FormInput({ + id: 'encumber-end-date', + name: 'encumber-end-date', + label: computed(() => this.$t('common.endDate')), + value: this.searchParams.encumberEndDate || '', + }), + npi: new FormInput({ + id: 'npi', + name: 'npi', + label: computed(() => this.$t('licensing.npi')), + placeholder: computed(() => this.$t('licensing.searchPlaceholderNpi')), + validation: Joi.string().min(0).max(100).messages(this.joiMessages.string), + value: this.searchParams.npi || '', + enforceMax: true, + }), submit: new FormInput({ isSubmitInput: true, id: 'submit', @@ -171,7 +268,15 @@ class LicenseeSearch extends mixins(MixinForm) { 'compact', 'firstName', 'lastName', - 'state' + 'homeState', + 'privilegeState', + 'privilegePurchaseStartDate', + 'privilegePurchaseEndDate', + 'militaryStatus', + 'investigationStatus', + 'encumberStartDate', + 'encumberEndDate', + 'npi', ]; const searchProps: LicenseSearch = {}; @@ -182,6 +287,26 @@ class LicenseeSearch extends mixins(MixinForm) { } } + async mockPopulate(): Promise { + this.formData.firstName.value = 'Test'; + this.formData.lastName.value = 'User'; + this.formData.homeState.value = 'co'; + this.formData.privilegeState.value = 'co'; + this.formData.privilegePurchaseStartDate.value = moment().startOf('month').format('YYYY-MM-DD'); + this.formData.privilegePurchaseEndDate.value = moment().endOf('month').format('YYYY-MM-DD'); + this.formData.militaryStatus.value = 'approved'; + this.formData.investigationStatus.value = 'under-investigation'; + this.formData.encumberStartDate.value = moment().startOf('month').format('YYYY-MM-DD'); + this.formData.encumberEndDate.value = moment().endOf('month').format('YYYY-MM-DD'); + this.formData.npi.value = 'ABC123'; + + this.validateAll({ asTouched: true }); + await nextTick(); + const submitButton = document.getElementById('submit'); + + submitButton?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + // // Watch // diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue index 1174d818b..ec66b1c06 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue @@ -12,6 +12,13 @@
{{ $t('licensing.searchTitle') }}
{{ $t('licensing.searchSubtext') }}
+
+ +
-
- -
+
+ +
+
+ +
+
+ +
+
+ + - + +
+
+ +
+
+ +
+
+ +
+
+ + - + +
+
+ +
; + query?: any; + // query?: { + // match_all?: object, + // bool?: { + // must: Array<{ + // [key: string]: any, + // }>, + // }, + // }; +} + export interface DataApiInterface { api: AxiosInstance; } @@ -166,6 +206,181 @@ export class LicenseDataApi implements DataApiInterface { return requestParams; } + /** + * Prep a query request for Search requests. + * @param {SearchParamsInterfaceLocal} params The request query parameters config. + * @return {SearchParamsInterfaceRemote} The request query body. + */ + public prepRequestSearchParams(params: SearchParamsInterfaceLocal = {}): SearchParamsInterfaceRemote { + const { + licenseeFirstName, + licenseeLastName, + homeState, + privilegeState, + privilegePurchaseStartDate, + privilegePurchaseEndDate, + militaryStatus, + investigationStatus, + encumberStartDate, + encumberEndDate, + npi, + pageSize, + pageNumber, + sortBy, + sortDirection, + } = params; + const hasSearchTerms = Boolean( + licenseeFirstName + || licenseeLastName + || homeState + || privilegeState + || privilegePurchaseStartDate + || privilegePurchaseEndDate + || militaryStatus + || investigationStatus + || encumberStartDate + || encumberEndDate + || npi + ); + const requestParams: SearchParamsInterfaceRemote = {}; + + // QUERY + // https://docs.opensearch.org/latest/query-dsl/ + if (hasSearchTerms) { + requestParams.query = { + bool: { + must: [], + }, + }; + const conditions = requestParams.query.bool.must; + + if (licenseeFirstName) { + conditions.push({ match_phrase_prefix: { givenName: licenseeFirstName }}); + } + if (licenseeLastName) { + conditions.push({ match_phrase_prefix: { familyName: licenseeLastName }}); + } + if (homeState) { + conditions.push({ term: { licenseJurisdiction: homeState }}); + } + if (privilegeState) { + conditions.push({ + nested: { + path: 'privileges', + query: { + term: { privilegeJurisdiction: privilegeState }, + }, + inner_hits: {} + }, + }); + } + if (privilegePurchaseStartDate || privilegePurchaseEndDate) { + const condition = { + nested: { + path: 'privileges', + query: { + range: { + dateOfIssuance: {}, + }, + }, + inner_hits: {} + }, + }; + const conditionRule: { gte?: string, lte?: string } = condition.nested.query.range.dateOfIssuance; + + if (privilegePurchaseStartDate) { + conditionRule.gte = privilegePurchaseStartDate; + } + if (privilegePurchaseEndDate) { + conditionRule.lte = privilegePurchaseEndDate; + } + + conditions.push(condition); + } + if (militaryStatus) { + conditions.push({ term: { militaryStatus }}); + } + if (investigationStatus) { + // conditions.push({ term: { investigationStatus }}); + if (investigationStatus === 'under-investigation') { + conditions.push({ + nested: { + path: 'investigations', + query: { + term: { type: 'investigation' }, + }, + inner_hits: {} + }, + }); + } else { + conditions.push({ + nested: { + path: 'investigations', + query: { + must_not: [{ + term: { type: 'investigation' }, + }], + }, + inner_hits: {} + }, + }); + } + } + if (encumberStartDate || encumberEndDate) { + const condition = { + nested: { + path: 'adverseActions', + query: { + range: { + effectiveStartDate: {}, + }, + }, + inner_hits: {} + }, + }; + const conditionRule: { gte?: string, lte?: string } = condition.nested.query.range.effectiveStartDate; + + if (encumberStartDate) { + conditionRule.gte = encumberStartDate; + } + if (encumberEndDate) { + conditionRule.lte = encumberEndDate; + } + + conditions.push(condition); + } + if (npi) { + conditions.push({ match: { npi }}); + } + } else { + requestParams.query = { + match_all: {}, + }; + } + + // PAGING + // https://docs.opensearch.org/latest/search-plugins/searching-data/paginate/#the-from-and-size-parameters + if (pageSize) { + requestParams.size = pageSize; + + if (pageNumber) { + requestParams.from = pageSize * (pageNumber - 1); + } + } + + // SORT + // https://docs.opensearch.org/latest/search-plugins/searching-data/sort/ + if (sortBy) { + requestParams.sort = [{ + [sortBy]: { + order: sortDirection || SortDirection.asc, + }, + }]; + } + + return requestParams; + } + /** * POST Create Licensee Account * @param {string} compact A compact type. @@ -219,21 +434,31 @@ export class LicenseDataApi implements DataApiInterface { /** * GET Licensees (Search - Staff). - * @param {RequestParamsInterfaceLocal} [params={}] The request query parameters config. - * @return {Promise} Response metadata + an array of licensees. + * @param {SearchParamsInterfaceLocal} [params={}] The request query parameters config. + * @return {Promise} Response metadata + an array of licensees. */ - public async getLicenseesSearchStaff(params: RequestParamsInterfaceLocal = {}) { - // - // @TODO: Replace with new OpenSearch endpoint once available + public async getLicenseesSearchStaff(params: SearchParamsInterfaceLocal = {}) { + const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); + + console.log(`request params:`); + console.log(requestParams); + console.log(JSON.stringify(requestParams, null, 2)); + console.log(``); + + // const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/providers/search`, requestParams); + // const { total = {}, providers } = serverReponse; + // const { value: totalMatchCount } = total; + // const response = { + // totalMatchCount, + // licensees: providers.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), + // }; // - const requestParams: RequestParamsInterfaceRemote = this.prepRequestPostParams(params); - const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/providers/query`, requestParams); - const { providers } = serverReponse; - const response = { - licensees: providers.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), - }; + // return response; - return response; + return { + totalMatchCount: 0, + licensees: [], + }; } /** diff --git a/webroot/src/network/mocks/mock.data.api.ts b/webroot/src/network/mocks/mock.data.api.ts index c36739a15..5a593d5ba 100644 --- a/webroot/src/network/mocks/mock.data.api.ts +++ b/webroot/src/network/mocks/mock.data.api.ts @@ -188,6 +188,10 @@ export class DataApi { .concat(licensees.providers) .concat(licensees.providers); + console.log(`raw params:`); + console.log(params); + console.log(``); + return wait(500).then(() => ({ licensees: records.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), params, From 25c714f6a3c3ae7d2bacc6951841491700171edd Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Wed, 10 Dec 2025 16:32:21 -0700 Subject: [PATCH 04/19] WIP: Licensee search update - Add search terms display to list view - Style the new paging UI - Updates to opensearch queries --- .../Licensee/LicenseeList/LicenseeList.less | 10 ++ .../Licensee/LicenseeList/LicenseeList.ts | 126 +++++++++++++----- .../LicenseeSearch/LicenseeSearch.less | 7 + .../Licensee/LicenseeSearch/LicenseeSearch.ts | 38 +++++- .../LicenseeSearch/LicenseeSearch.vue | 8 ++ .../Lists/Pagination/Pagination.less | 37 +++-- .../Lists/Pagination/Pagination.vue | 2 +- .../Page/PageContainer/PageContainer.less | 4 + .../Page/PageHeader/PageHeader.less | 4 + webroot/src/locales/en.json | 13 +- webroot/src/locales/es.json | 13 +- webroot/src/network/licenseApi/data.api.ts | 46 ++++--- webroot/src/network/mocks/mock.data.api.ts | 8 +- webroot/src/store/license/license.actions.ts | 3 +- 14 files changed, 238 insertions(+), 81 deletions(-) diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.less b/webroot/src/components/Licensee/LicenseeList/LicenseeList.less index c10093f15..149f16d15 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.less +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.less @@ -36,10 +36,19 @@ @media @desktopWidth { order: 1; width: auto; + max-width: 75%; margin-top: 0; margin-right: 2.4rem; } + @media @largeDesktopWidth { + max-width: 85%; + } + + @media @extraLargeDesktopWidth { + max-width: 95%; + } + .title { padding: 0 0.4rem; } @@ -50,6 +59,7 @@ .search-terms-reset { width: 2rem; + min-width: 2rem; margin-left: auto; padding: 0.4rem 0.2rem; cursor: pointer; diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts index 396cb5b63..7f6438c06 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts @@ -11,6 +11,7 @@ import { Prop, toNative } from 'vue-facing-decorator'; +import { serverDateFormat, displayDateFormat } from '@/app.config'; import ListContainer from '@components/Lists/ListContainer/ListContainer.vue'; import LicenseeSearch, { LicenseSearch } from '@components/Licensee/LicenseeSearch/LicenseeSearch.vue'; import LicenseeRow from '@components/Licensee/LicenseeRow/LicenseeRow.vue'; @@ -19,6 +20,7 @@ import { SortDirection } from '@store/sorting/sorting.state'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '@store/pagination/pagination.state'; import { SearchParamsInterfaceLocal } from '@network/licenseApi/data.api'; import { State } from '@models/State/State.model'; +import moment from 'moment'; @Component({ name: 'LicenseeList', @@ -94,58 +96,112 @@ class LicenseeList extends Vue { return (this.isPublicSearch) ? this.userStore.currentCompact?.abbrev() || '' : ''; } - get searchDisplayFirstName(): string { - const delimiter = (this.searchDisplayCompact) ? ', ' : ''; - let displayFirstName = ''; + get searchDisplayFullName(): string { + const { firstName = '', lastName = '' } = this.searchParams; - if (this.searchParams.firstName) { - displayFirstName = `${delimiter}${this.searchParams.firstName}` || ''; + return `${firstName} ${lastName}`.trim(); + } + + get searchDisplayHomeState(): string { + const { homeState } = this.searchParams; + + return (homeState) ? `${this.$t('licensing.homeState')}: ${new State({ abbrev: homeState }).name()}` : ''; + } + + get searchDisplayPrivilegeState(): string { + const { privilegeState } = this.searchParams; + + return (privilegeState) ? `${this.$t('licensing.privilegeState')}: ${new State({ abbrev: privilegeState }).name()}` : ''; + } + + get searchDisplayPrivilegePurchaseDates(): string { + const { privilegePurchaseStartDate = '', privilegePurchaseEndDate = '' } = this.searchParams; + let displayDates = ''; + + if (privilegePurchaseStartDate || privilegePurchaseEndDate) { + const startDate = (privilegePurchaseStartDate) + ? moment(privilegePurchaseStartDate, serverDateFormat).format(displayDateFormat) + : '∞'; + const endDate = (privilegePurchaseEndDate) + ? moment(privilegePurchaseEndDate, serverDateFormat).format(displayDateFormat) + : '∞'; + + displayDates = `${this.$t('licensing.purchaseDate')}: ${startDate}-${endDate}`; + } + + return displayDates; + } + + get searchDisplayMilitaryStatus(): string { + const { militaryStatus } = this.searchParams; + let displayStatus = ''; + + if (militaryStatus) { + const statusOptions = this.$tm('military.militaryStatusOptions') || []; + const selectedOption = statusOptions.find((statusOption) => statusOption.key === militaryStatus); + + if (selectedOption?.name) { + displayStatus = `${this.$t('military.militaryStatusTitle')}: ${selectedOption.name}`; + } } - return displayFirstName; + return displayStatus; } - get searchDisplayLastName(): string { - const delimiter = (this.searchDisplayCompact && !this.searchDisplayFirstName) ? ', ' : ''; - const subDelimiter = (this.searchDisplayFirstName) ? ' ' : ''; - let displayLastName = ''; + get searchDisplayInvestigationStatus(): string { + const { investigationStatus } = this.searchParams; + let displayStatus = ''; + + if (investigationStatus) { + const statusOptions = this.$tm('licensing.investigationStatusOptions') || []; + const selectedOption = statusOptions.find((statusOption) => statusOption.key === investigationStatus); - if (this.searchParams.lastName) { - displayLastName = `${delimiter}${subDelimiter}${this.searchParams.lastName}` || ''; + if (selectedOption?.name) { + displayStatus = `${selectedOption.name}`; + } } - return displayLastName; + return displayStatus; } - get searchDisplayHomeState(): string { - const { homeState } = this.searchParams; - const { searchDisplayCompact, searchDisplayFirstName, searchDisplayLastName } = this; - const delimiter = (searchDisplayCompact || searchDisplayFirstName || searchDisplayLastName) ? ', ' : ''; - let displayState = ''; + get searchDisplayEncumberDates(): string { + const { encumberStartDate = '', encumberEndDate = '' } = this.searchParams; + let displayDates = ''; - if (homeState) { - const stateModel = new State({ abbrev: homeState }); + if (encumberStartDate || encumberEndDate) { + const startDate = (encumberStartDate) + ? moment(encumberStartDate, serverDateFormat).format(displayDateFormat) + : '∞'; + const endDate = (encumberEndDate) + ? moment(encumberEndDate, serverDateFormat).format(displayDateFormat) + : '∞'; - displayState = `${delimiter}${stateModel.name()}`; + displayDates = `${this.$t('licensing.encumbered')}: ${startDate}-${endDate}`; } - return displayState; + return displayDates; + } + + get searchDisplayNpi(): string { + const { npi = '' } = this.searchParams; + + return (npi) ? `${this.$t('licensing.npi')}: ${npi}`.trim() : ''; } get searchDisplayAll(): string { - const { - searchDisplayCompact, - searchDisplayFirstName, - searchDisplayLastName, - searchDisplayHomeState - } = this; - - return [ - searchDisplayCompact, - searchDisplayFirstName, - searchDisplayLastName, - searchDisplayHomeState - ].join('').trim(); + const joined = [ + this.searchDisplayCompact, + this.searchDisplayFullName, + this.searchDisplayHomeState, + this.searchDisplayPrivilegeState, + this.searchDisplayPrivilegePurchaseDates, + this.searchDisplayMilitaryStatus, + this.searchDisplayInvestigationStatus, + this.searchDisplayEncumberDates, + this.searchDisplayNpi + ].join(', ').trim(); + + return joined.replace(/(^[,\s]+)|([,\s]+$)/g, '').replace(/(,\s)\1+/g, ', '); } get sortOptions(): Array { diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less index 6bf7124c1..b5c78cf58 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less @@ -108,4 +108,11 @@ .search-submit { margin-top: 2.4rem; } + + .clear-form { + display: inline-block; + margin-bottom: 1.2rem; + font-style: italic; + cursor: pointer; + } } diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts index 19e8487bf..b630ca793 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts @@ -131,11 +131,15 @@ class LicenseeSearch extends mixins(MixinForm) { } get investigationStatusOptions(): Array<{ value: string, name: string | ComputedRef }> { - const options = [ - { value: '', name: computed(() => this.$t('common.selectOption')) }, - { value: 'under-investigation', name: computed(() => this.$t('licensing.underInvestigationSearch')) }, - { value: 'not-under-investigation', name: computed(() => this.$t('licensing.notUnderInvestigationSearch')) }, - ]; + const options = this.$tm('licensing.investigationStatusOptions').map((option) => ({ + value: option.key, + name: option.name, + })); + + options.unshift({ + value: '', + name: computed(() => this.$t('common.selectOption')), + }); return options; } @@ -287,6 +291,25 @@ class LicenseeSearch extends mixins(MixinForm) { } } + resetForm(): void { + this.formData.firstName.value = ''; + this.formData.lastName.value = ''; + this.formData.homeState.value = ''; + this.formData.privilegeState.value = ''; + this.formData.privilegePurchaseStartDate.value = ''; + this.formData.privilegePurchaseEndDate.value = ''; + this.formData.militaryStatus.value = ''; + this.formData.investigationStatus.value = ''; + this.formData.encumberStartDate.value = ''; + this.formData.encumberEndDate.value = ''; + this.formData.npi.value = ''; + this.isFormLoading = false; + this.isFormSuccessful = false; + this.isFormError = false; + this.updateFormSubmitSuccess(''); + this.updateFormSubmitError(''); + } + async mockPopulate(): Promise { this.formData.firstName.value = 'Test'; this.formData.lastName.value = 'User'; @@ -295,7 +318,7 @@ class LicenseeSearch extends mixins(MixinForm) { this.formData.privilegePurchaseStartDate.value = moment().startOf('month').format('YYYY-MM-DD'); this.formData.privilegePurchaseEndDate.value = moment().endOf('month').format('YYYY-MM-DD'); this.formData.militaryStatus.value = 'approved'; - this.formData.investigationStatus.value = 'under-investigation'; + this.formData.investigationStatus.value = 'underInvestigation'; this.formData.encumberStartDate.value = moment().startOf('month').format('YYYY-MM-DD'); this.formData.encumberEndDate.value = moment().endOf('month').format('YYYY-MM-DD'); this.formData.npi.value = 'ABC123'; @@ -311,7 +334,8 @@ class LicenseeSearch extends mixins(MixinForm) { // Watch // @Watch('compactStates') updateStateInput() { - this.formData.state.valueOptions = this.stateOptions; + this.formData.homeState.valueOptions = this.stateOptions; + this.formData.privilegeState.valueOptions = this.stateOptions; } } diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue index ec66b1c06..447afa99c 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue @@ -19,6 +19,14 @@ class="mock-populate search-input" /> +
- + { + Object.keys(range).forEach((nestedDateKey) => { + if (privilegePurchaseStartDate) { + range[nestedDateKey].gte = privilegePurchaseStartDate; + } + if (privilegePurchaseEndDate) { + range[nestedDateKey].lte = privilegePurchaseEndDate; + } + }); + }); conditions.push(condition); } @@ -307,7 +323,7 @@ export class LicenseDataApi implements DataApiInterface { nested: { path: 'investigations', query: { - term: { type: 'investigation' }, + term: { 'investigations.type': 'investigation' }, }, inner_hits: {} }, @@ -318,7 +334,7 @@ export class LicenseDataApi implements DataApiInterface { path: 'investigations', query: { must_not: [{ - term: { type: 'investigation' }, + term: { 'investigations.type': 'investigation' }, }], }, inner_hits: {} @@ -332,13 +348,13 @@ export class LicenseDataApi implements DataApiInterface { path: 'adverseActions', query: { range: { - effectiveStartDate: {}, + 'adverseActions.effectiveStartDate': {}, }, }, inner_hits: {} }, }; - const conditionRule: { gte?: string, lte?: string } = condition.nested.query.range.effectiveStartDate; + const conditionRule: { gte?: string, lte?: string } = condition.nested.query.range['adverseActions.effectiveStartDate']; if (encumberStartDate) { conditionRule.gte = encumberStartDate; diff --git a/webroot/src/network/mocks/mock.data.api.ts b/webroot/src/network/mocks/mock.data.api.ts index 5a593d5ba..d603d4dd4 100644 --- a/webroot/src/network/mocks/mock.data.api.ts +++ b/webroot/src/network/mocks/mock.data.api.ts @@ -184,15 +184,15 @@ export class DataApi { // Get Licensees (Search - Staff) public getLicenseesSearchStaff(params: any = {}) { + const pages = 10; const records = licensees.providers + .concat(licensees.providers) + .concat(licensees.providers) .concat(licensees.providers) .concat(licensees.providers); - console.log(`raw params:`); - console.log(params); - console.log(``); - return wait(500).then(() => ({ + totalMatchCount: records.length * pages, licensees: records.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), params, })); diff --git a/webroot/src/store/license/license.actions.ts b/webroot/src/store/license/license.actions.ts index 35f161323..1a15e89f7 100644 --- a/webroot/src/store/license/license.actions.ts +++ b/webroot/src/store/license/license.actions.ts @@ -41,7 +41,8 @@ export default { const apiRequest = dataApi.getLicenseesSearchStaff; - await apiRequest(params).then(async ({ licensees }) => { + await apiRequest(params).then(async ({ totalMatchCount, licensees }) => { + await dispatch('setStoreLicenseeCount', totalMatchCount); await dispatch('setStoreLicensees', licensees); dispatch('getLicenseesSuccess', licensees); }).catch((error) => { From 478b4407b5931637652516d66dda5f99a45da72c Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Thu, 11 Dec 2025 13:46:45 -0700 Subject: [PATCH 05/19] WIP: Licensee search update - Added the provider vs. privilege search UI variants - Added an export-only UI flow to the privilege search --- .../Forms/InputSubmit/InputSubmit.ts | 2 + .../Forms/InputSubmit/InputSubmit.vue | 2 + .../Licensee/LicenseeList/LicenseeList.ts | 83 ++++++++++------- .../LicenseeSearch/LicenseeSearch.less | 14 ++- .../Licensee/LicenseeSearch/LicenseeSearch.ts | 67 +++++++++++++- .../LicenseeSearch/LicenseeSearch.vue | 17 ++++ webroot/src/locales/en.json | 5 ++ webroot/src/locales/es.json | 5 ++ webroot/src/network/data.api.ts | 9 ++ webroot/src/network/licenseApi/data.api.ts | 48 +++++++--- webroot/src/network/mocks/mock.data.api.ts | 11 +++ webroot/src/store/license/license.actions.ts | 25 +++++- .../src/store/license/license.mutations.ts | 22 +++++ webroot/src/store/license/license.spec.ts | 90 ++++++++++++++++++- webroot/src/store/license/license.state.ts | 5 +- webroot/src/styles.common/mixins/buttons.less | 2 +- 16 files changed, 358 insertions(+), 49 deletions(-) diff --git a/webroot/src/components/Forms/InputSubmit/InputSubmit.ts b/webroot/src/components/Forms/InputSubmit/InputSubmit.ts index fe88549fb..dadf4a8e1 100644 --- a/webroot/src/components/Forms/InputSubmit/InputSubmit.ts +++ b/webroot/src/components/Forms/InputSubmit/InputSubmit.ts @@ -20,6 +20,8 @@ class InputSubmit extends mixins(MixinInput) { @Prop({ default: '' }) private label?: string; @Prop({ default: true }) private isEnabled?: boolean; @Prop({ default: false }) private isWarning?: boolean; + @Prop({ default: false }) private isTransparent?: boolean; + @Prop({ default: false }) private isTextLike?: boolean; } export default toNative(InputSubmit); diff --git a/webroot/src/components/Forms/InputSubmit/InputSubmit.vue b/webroot/src/components/Forms/InputSubmit/InputSubmit.vue index e7836f822..c535fe488 100644 --- a/webroot/src/components/Forms/InputSubmit/InputSubmit.vue +++ b/webroot/src/components/Forms/InputSubmit/InputSubmit.vue @@ -36,6 +36,8 @@ class="input-submit" :class="{ 'warning': isWarning, + 'transparent': isTransparent, + 'text-like': isTextLike, }" :aria-describedby="(formInput.successMessage) ? `${formInput.id}-success` : `${formInput.id}-error`" :aria-errormessage="`${formInput.id}-error`" diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts index 7f6438c06..d8efe9841 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts @@ -13,7 +13,7 @@ import { } from 'vue-facing-decorator'; import { serverDateFormat, displayDateFormat } from '@/app.config'; import ListContainer from '@components/Lists/ListContainer/ListContainer.vue'; -import LicenseeSearch, { LicenseSearch } from '@components/Licensee/LicenseeSearch/LicenseeSearch.vue'; +import LicenseeSearch, { LicenseSearch, SearchTypes } from '@components/Licensee/LicenseeSearch/LicenseeSearch.vue'; import LicenseeRow from '@components/Licensee/LicenseeRow/LicenseeRow.vue'; import CloseX from '@components/Icons/CloseX/CloseX.vue'; import { SortDirection } from '@store/sorting/sorting.state'; @@ -244,10 +244,12 @@ class LicenseeList extends Vue { }); this.fetchListData(); - if (!this.hasSearched) { - this.hasSearched = true; - } else { - this.toggleSearch(); + if (!params.isDirectExport) { + if (!this.hasSearched) { + this.hasSearched = true; + } else { + this.toggleSearch(); + } } } @@ -300,7 +302,35 @@ class LicenseeList extends Vue { } } - async fetchListData() { + async fetchListData(): Promise { + const { searchParams } = this; + const { searchType } = searchParams; + const requestConfig = this.prepareSearchBody(); + + if (searchType === SearchTypes.PROVIDER) { + await this.$store.dispatch('license/getLicenseesSearchRequest', { + params: { ...requestConfig } + }); + } else if (searchType === SearchTypes.PRIVILEGE) { + const response = await this.$store.dispatch('license/getPrivilegesRequest', { + params: { ...requestConfig } + }); + const { downloadUrl } = response; + const tempLink = document.createElement('a'); + + tempLink.href = downloadUrl; + tempLink.target = '_blank'; // @TODO: Test with & without this against S3 + tempLink.rel = 'noopener noreferrer'; + tempLink.download = `privilege_export.csv`; + tempLink.click(); + } + + this.isInitialFetchCompleted = true; + + return requestConfig; + } + + prepareSearchBody(): SearchParamsInterfaceLocal { const { searchParams } = this; const sorting = this.sortingStore.sortingMap[this.listId]; const { option, direction } = sorting || {}; @@ -308,21 +338,6 @@ class LicenseeList extends Vue { const { page, size } = pagination || {}; const requestConfig: SearchParamsInterfaceLocal = {}; - // Sorting params - if (option) { - const serverSortByMap = { - firstName: 'givenName', - lastName: 'familyName', - lastUpdate: 'dateOfUpdate', - }; - - requestConfig.sortBy = serverSortByMap[option]; - } - - if (direction) { - requestConfig.sortDirection = direction; - } - // Search params requestConfig.isPublic = this.isPublicSearch; @@ -366,16 +381,24 @@ class LicenseeList extends Vue { requestConfig.npi = searchParams.npi; } - // Make fetch request - await this.$store.dispatch('license/getLicenseesSearchRequest', { - params: { - ...requestConfig, - pageNum: page, - pageSize: size, - } - }); + // Paging params + requestConfig.pageNumber = page; + requestConfig.pageSize = size; - this.isInitialFetchCompleted = true; + // Sorting params + if (option) { + const serverSortByMap = { + firstName: 'givenName', + lastName: 'familyName', + lastUpdate: 'dateOfUpdate', + }; + + requestConfig.sortBy = serverSortByMap[option]; + } + + if (direction) { + requestConfig.sortDirection = direction; + } return requestConfig; } diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less index b5c78cf58..bb2b096a3 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.less @@ -86,7 +86,8 @@ } } - &.search-submit { + &.search-submit, + &.export-submit { align-items: center; &:deep(.input-submit) { @@ -95,6 +96,17 @@ } } + .search-type-input { + @media @desktopWidth { + flex-direction: row; + + &:deep(.radio-button-group-container) { + flex-direction: row; + margin-left: auto; + } + } + } + .date-section-label { display: flex; flex-wrap: wrap; diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts index b630ca793..8f1394007 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts @@ -19,6 +19,7 @@ import { nextTick } from 'vue'; import MixinForm from '@components/Forms/_mixins/form.mixin'; +import InputRadioGroup from '@components/Forms/InputRadioGroup/InputRadioGroup.vue'; import InputText from '@components/Forms/InputText/InputText.vue'; import InputSelect from '@components/Forms/InputSelect/InputSelect.vue'; import InputDate from '@components/Forms/InputDate/InputDate.vue'; @@ -31,7 +32,14 @@ import { FormInput } from '@models/FormInput/FormInput.model'; import Joi from 'joi'; import moment from 'moment'; +export enum SearchTypes { + PROVIDER = 'provider', + PRIVILEGE = 'privilege', +} + export interface LicenseSearch { + searchType: SearchTypes; + isDirectExport?: boolean; compact?: string; firstName?: string; lastName?: string; @@ -49,6 +57,7 @@ export interface LicenseSearch { @Component({ name: 'LicenseeSearch', components: { + InputRadioGroup, InputText, InputSelect, InputDate, @@ -62,6 +71,11 @@ class LicenseeSearch extends mixins(MixinForm) { @Prop({ default: {}}) searchParams!: LicenseSearch; @Prop({ default: false }) isPublicSearch!: boolean; + // + // Data + // + selectedSearchType: SearchTypes | null = null; + // // Lifecycle // @@ -72,6 +86,10 @@ class LicenseeSearch extends mixins(MixinForm) { // // Computed // + get licenseStore(): any { + return this.$store.state.license; + } + get userStore() { return this.$store.state.user; } @@ -116,6 +134,27 @@ class LicenseeSearch extends mixins(MixinForm) { return compactMemberStates; } + get searchTypeOptions(): Array<{ value: string | undefined, name: string | ComputedRef }> { + return [ + { + value: SearchTypes.PROVIDER, + name: this.$t('licensing.providers'), + }, + { + value: SearchTypes.PRIVILEGE, + name: this.$t('licensing.privileges'), + }, + ]; + } + + get isSearchByProviders(): boolean { + return this.selectedSearchType === SearchTypes.PROVIDER; + } + + get isSearchByPrivileges(): boolean { + return this.selectedSearchType === SearchTypes.PRIVILEGE; + } + get militaryStatusOptions(): Array<{ value: string, name: string | ComputedRef }> { const options = this.$tm('military.militaryStatusOptions').map((option) => ({ value: option.key, @@ -144,6 +183,14 @@ class LicenseeSearch extends mixins(MixinForm) { return options; } + get isSearchButtonEnabled(): boolean { + return this.isSearchByProviders; + } + + get isExportButtonEnabled(): boolean { + return this.isSearchByPrivileges && !this.licenseStore.isExporting; + } + get isMockPopulateEnabled(): boolean { return Boolean(this.$envConfig.isDevelopment); } @@ -152,7 +199,16 @@ class LicenseeSearch extends mixins(MixinForm) { // Methods // initFormInputs(): void { + this.selectedSearchType = SearchTypes.PROVIDER; this.formData = reactive({ + searchType: new FormInput({ + id: 'search-type', + name: 'search-type', + label: computed(() => this.$t('licensing.searchTypeTitle')), + validation: Joi.string().required().messages(this.joiMessages.string), + valueOptions: this.searchTypeOptions, + value: this.selectedSearchType || '', + }), firstName: new FormInput({ id: 'first-name', name: 'first-name', @@ -262,6 +318,12 @@ class LicenseeSearch extends mixins(MixinForm) { } } + updateSearchType(): void { + const searchType = this.formData.searchType.value; + + this.selectedSearchType = searchType; + } + async handleSubmit(): Promise { this.validateAll({ asTouched: true }); @@ -282,7 +344,10 @@ class LicenseeSearch extends mixins(MixinForm) { 'encumberEndDate', 'npi', ]; - const searchProps: LicenseSearch = {}; + const searchProps: LicenseSearch = { + searchType: this.selectedSearchType || SearchTypes.PROVIDER, + isDirectExport: this.isSearchByPrivileges, + }; allowedSearchProps.forEach((searchProp) => { searchProps[searchProp] = this.formValues[searchProp]; }); this.$emit('searchParams', searchProps); diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue index 447afa99c..8a3fcb0d1 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue @@ -34,6 +34,13 @@ @input="updateCurrentCompact" />
+
+ +
+
diff --git a/webroot/src/locales/en.json b/webroot/src/locales/en.json index be97f59c9..27850e28b 100644 --- a/webroot/src/locales/en.json +++ b/webroot/src/locales/en.json @@ -24,6 +24,8 @@ "replaceFile": "Replace file", "replaceFiles": "Replace files", "downloadFile": "Download", + "exportCsv": "Export CSV", + "exportInProgress": "Exporting...", "change": "Change", "add": "Add", "remove": "Remove", @@ -579,6 +581,7 @@ "searchTitlePublic": "Verify a compact privilege", "searchSubtext": "Enter at least one field to search for licensing data.", "searchStateDisabled": "Please first select a profession type", + "searchTypeTitle": "Search for", "licenseExpiredMessage": "Your license has expired.", "searchLabel": "Edit search", "searchPlaceholderName": "Enter name", @@ -591,6 +594,8 @@ "residenceLocation": "Residence location", "generateVerification": "Generate verification doc", "generateVerificationSubtext": "Printer-friendly view", + "provider": "Provider", + "providers": "Providers", "obtainPrivileges": "Obtain Privileges", "privileges": "Privileges", "privilege": "Privilege", diff --git a/webroot/src/locales/es.json b/webroot/src/locales/es.json index 11aef1a57..2ae888f1e 100644 --- a/webroot/src/locales/es.json +++ b/webroot/src/locales/es.json @@ -24,6 +24,8 @@ "replaceFile": "Reemplazar el archivo", "replaceFiles": "Reemplazar archivos", "downloadFile": "Descargar", + "exportCsv": "Exportar CSV", + "exportInProgress": "Exportador...", "change": "Cambiar", "add": "Agregar", "remove": "Eliminar", @@ -562,6 +564,7 @@ "accountEmail": "Correo electrónico de la cuenta", "searchSubtext": "Introduzca al menos un campo para buscar datos de licencia.", "searchStateDisabled": "Por favor, primero seleccione un tipo de profesión", + "searchTypeTitle": "Buscar", "searchLabel": "Editar búsqueda", "searchPlaceholderName": "Introducir nombre", "homeState": "Estado de origen", @@ -613,6 +616,8 @@ "generateVerification": "Generar documento de verificación", "generateVerificationSubtext": "Vista para imprimir", "obtainPrivileges": "Obtener privilegios", + "provider": "Proveedor", + "providers": "Proveedores", "expired": "Caducada", "issued": "Emitida", "expires": "Caduca", diff --git a/webroot/src/network/data.api.ts b/webroot/src/network/data.api.ts index 3f13995d3..dec7081ef 100644 --- a/webroot/src/network/data.api.ts +++ b/webroot/src/network/data.api.ts @@ -136,6 +136,15 @@ export class DataApi { return licenseDataApi.getLicenseesSearchStaff(params); } + /** + * GET Privileges (Export - Staff). + * @param {object} [params] The request query parameters config. + * @return {Promise} An array of users server response. + */ + public getPrivilegesExportStaff(params) { + return licenseDataApi.getPrivilegesExportStaff(params); + } + /** * GET Licensee by ID. * @param {string} compact A compact type. diff --git a/webroot/src/network/licenseApi/data.api.ts b/webroot/src/network/licenseApi/data.api.ts index 9717b4d5a..74a821ddf 100644 --- a/webroot/src/network/licenseApi/data.api.ts +++ b/webroot/src/network/licenseApi/data.api.ts @@ -82,15 +82,14 @@ export interface SearchParamsInterfaceRemote { order?: string, } }>; - query?: any; - // query?: { - // match_all?: object, - // bool?: { - // must: Array<{ - // [key: string]: any, - // }>, - // }, - // }; + query?: { + match_all?: object, // eslint-disable-line camelcase + bool?: { + must: Array<{ + [key: string]: any, + }>, + }, + }; } export interface DataApiInterface { @@ -252,7 +251,7 @@ export class LicenseDataApi implements DataApiInterface { must: [], }, }; - const conditions = requestParams.query.bool.must; + const conditions = requestParams.query?.bool?.must || []; if (licenseeFirstName) { conditions.push({ match_phrase_prefix: { givenName: licenseeFirstName }}); @@ -317,7 +316,6 @@ export class LicenseDataApi implements DataApiInterface { conditions.push({ term: { militaryStatus }}); } if (investigationStatus) { - // conditions.push({ term: { investigationStatus }}); if (investigationStatus === 'under-investigation') { conditions.push({ nested: { @@ -456,6 +454,9 @@ export class LicenseDataApi implements DataApiInterface { public async getLicenseesSearchStaff(params: SearchParamsInterfaceLocal = {}) { const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); + // + // @TODO + // console.log(`request params:`); console.log(requestParams); console.log(JSON.stringify(requestParams, null, 2)); @@ -477,6 +478,31 @@ export class LicenseDataApi implements DataApiInterface { }; } + /** + * GET Privileges (Export - Staff). + * @param {SearchParamsInterfaceLocal} [params={}] The request query parameters config. + * @return {Promise} Response metadata + an array of licensees. + */ + public async getPrivilegesExportStaff(params: SearchParamsInterfaceLocal = {}) { + const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); + + // + // @TODO + // + console.log(`request params:`); + console.log(requestParams); + console.log(JSON.stringify(requestParams, null, 2)); + console.log(``); + + // const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/privileges/export`, requestParams); + // + // return serverReponse; + + return { + downloadUrl: 'https://cdn.prod.website-files.com/66a083c22bdfd06a6aee5193/6913a447111789a56d2f13b9_IA-Logo-Primary-FullColor.svg', + }; + } + /** * GET Licensee by ID. * @param {string} licenseeId A licensee ID. diff --git a/webroot/src/network/mocks/mock.data.api.ts b/webroot/src/network/mocks/mock.data.api.ts index d603d4dd4..66ed1f3fb 100644 --- a/webroot/src/network/mocks/mock.data.api.ts +++ b/webroot/src/network/mocks/mock.data.api.ts @@ -191,6 +191,8 @@ export class DataApi { .concat(licensees.providers) .concat(licensees.providers); + console.log(params); + return wait(500).then(() => ({ totalMatchCount: records.length * pages, licensees: records.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), @@ -198,6 +200,15 @@ export class DataApi { })); } + // Get Privileges (Export - Staff) + public getPrivilegesExportStaff(params: any = {}) { + return wait(1000).then(() => ({ + // downloadUrl: 'https://cdn.prod.website-files.com/66a083c22bdfd06a6aee5193/6913a447111789a56d2f13b9_IA-Logo-Primary-FullColor.svg', + downloadUrl: 'https://www.examplefile.com/file-download/519', + params, + })); + } + // Get Licensee by ID public getLicensee(compact, licenseeId) { const serverResponse = licensees.providers.find((item) => item.providerId === licenseeId); diff --git a/webroot/src/store/license/license.actions.ts b/webroot/src/store/license/license.actions.ts index 1a15e89f7..b5a088b68 100644 --- a/webroot/src/store/license/license.actions.ts +++ b/webroot/src/store/license/license.actions.ts @@ -96,6 +96,9 @@ export default { setStoreSearch: ({ commit }, search) => { commit(MutationTypes.STORE_UPDATE_SEARCH, search); }, + setStoreExporting: ({ commit }, isExporting) => { + commit(MutationTypes.STORE_UPDATE_EXPORTING, isExporting); + }, resetStoreSearch: ({ commit }) => { commit(MutationTypes.STORE_RESET_SEARCH); }, @@ -126,11 +129,31 @@ export default { dispatch('getPrivilegeHistoryFailure', error); }); }, - // GET PRIVILEGE HISTORY SUCCESS / FAIL HANDLERS getPrivilegeHistorySuccess: ({ commit }, history) => { commit(MutationTypes.GET_PRIVILEGE_HISTORY_SUCCESS, { history }); }, getPrivilegeHistoryFailure: ({ commit }, error: Error) => { commit(MutationTypes.GET_PRIVILEGE_HISTORY_FAILURE, error); }, + // GET PRIVILEGES + getPrivilegesRequest: async ({ commit, dispatch }, { params }: any) => { + commit(MutationTypes.GET_PRIVILEGES_REQUEST); + + return dataApi.getPrivilegesExportStaff(params).then((response) => { + dispatch('getPrivilegesSuccess'); + + // Just returning the response since, for now, the only option is being returned a file download URI. + return response; + }).catch((error) => { + dispatch('getPrivilegesFailure', error); + + return error; + }); + }, + getPrivilegesSuccess: ({ commit }) => { + commit(MutationTypes.GET_PRIVILEGES_SUCCESS); + }, + getPrivilegesFailure: ({ commit }, error: Error) => { + commit(MutationTypes.GET_PRIVILEGES_FAILURE, error); + }, }; diff --git a/webroot/src/store/license/license.mutations.ts b/webroot/src/store/license/license.mutations.ts index aed0a770a..e6ce2f2c3 100644 --- a/webroot/src/store/license/license.mutations.ts +++ b/webroot/src/store/license/license.mutations.ts @@ -25,6 +25,10 @@ export enum MutationTypes { STORE_UPDATE_SEARCH = '[License] Update search params', STORE_RESET_SEARCH = '[License] Reset search params', STORE_RESET_LICENSE = '[License] Reset license store', + GET_PRIVILEGES_REQUEST = '[License] Get Privileges Request', + GET_PRIVILEGES_FAILURE = '[License] Get Privileges Failure', + GET_PRIVILEGES_SUCCESS = '[License] Get Privileges Success', + STORE_UPDATE_EXPORTING = '[License] Update Exporting State', } export default { @@ -142,4 +146,22 @@ export default { state.isLoading = false; state.error = error; }, + [MutationTypes.GET_PRIVILEGES_REQUEST]: (state: any) => { + state.isLoading = true; + state.isExporting = true; + state.error = null; + }, + [MutationTypes.GET_PRIVILEGES_FAILURE]: (state: any, error: Error) => { + state.isLoading = false; + state.isExporting = false; + state.error = error; + }, + [MutationTypes.GET_PRIVILEGES_SUCCESS]: (state: any) => { + state.isLoading = false; + state.isExporting = false; + state.error = null; + }, + [MutationTypes.STORE_UPDATE_EXPORTING]: (state: any, isExporting: boolean) => { + state.isExporting = isExporting; + }, }; diff --git a/webroot/src/store/license/license.spec.ts b/webroot/src/store/license/license.spec.ts index c40fa7dfb..3364f7860 100644 --- a/webroot/src/store/license/license.spec.ts +++ b/webroot/src/store/license/license.spec.ts @@ -298,9 +298,45 @@ describe('License Store Mutations', () => { expect(state.error).to.equal(null); expect(state.model[1].privileges[0].history.length).to.equal(0); }); + it('should successfully get privileges request', () => { + const state = {}; + + mutations[MutationTypes.GET_PRIVILEGES_REQUEST](state); + + expect(state.isLoading).to.equal(true); + expect(state.isExporting).to.equal(true); + expect(state.error).to.equal(null); + }); + it('should successfully get privileges failure', () => { + const state = {}; + const error = new Error(); + + mutations[MutationTypes.GET_PRIVILEGES_FAILURE](state, error); + + expect(state.isLoading).to.equal(false); + expect(state.isExporting).to.equal(false); + expect(state.error).to.equal(error); + }); + it('should successfully get privileges success', () => { + const state = {}; + + mutations[MutationTypes.GET_PRIVILEGES_SUCCESS](state); + + expect(state.isLoading).to.equal(false); + expect(state.isExporting).to.equal(false); + expect(state.error).to.equal(null); + }); + it('should successfully update exporting state', () => { + const state = {}; + const isExporting = true; + + mutations[MutationTypes.STORE_UPDATE_EXPORTING](state, isExporting); + + expect(state.isExporting).to.equal(isExporting); + }); }); describe('License Store Actions', async () => { - it('should successfully start licensees request with next page', async () => { + it('should successfully start licensees request with next page (legacy search)', async () => { const commit = sinon.spy(); const dispatch = sinon.spy(); const params = { getNextPage: true }; @@ -311,7 +347,7 @@ describe('License Store Actions', async () => { expect(commit.firstCall.args).to.matchPattern([MutationTypes.GET_LICENSEES_REQUEST]); expect(dispatch.callCount).to.equal(4); }); - it('should successfully start licensees request with previous page', async () => { + it('should successfully start licensees request with previous page (legacy search)', async () => { const commit = sinon.spy(); const dispatch = sinon.spy(); const params = { getPrevPage: true }; @@ -322,7 +358,7 @@ describe('License Store Actions', async () => { expect(commit.firstCall.args).to.matchPattern([MutationTypes.GET_LICENSEES_REQUEST]); expect(dispatch.callCount).to.equal(4); }); - it('should successfully start licensees request as public request', async () => { + it('should successfully start licensees request as public request (legacy search)', async () => { const commit = sinon.spy(); const dispatch = sinon.spy(); const params = { isPublic: true }; @@ -333,6 +369,17 @@ describe('License Store Actions', async () => { expect(commit.firstCall.args).to.matchPattern([MutationTypes.GET_LICENSEES_REQUEST]); expect(dispatch.callCount).to.equal(4); }); + it('should successfully start licensees request as staff search request (opensearch)', async () => { + const commit = sinon.spy(); + const dispatch = sinon.spy(); + const params = {}; + + await actions.getLicenseesSearchRequest({ commit, getters, dispatch }, { params }); + + expect(commit.calledOnce).to.equal(true); + expect(commit.firstCall.args).to.matchPattern([MutationTypes.GET_LICENSEES_REQUEST]); + expect(dispatch.callCount).to.equal(3); + }); it('should successfully start licensees failure', () => { const commit = sinon.spy(); const error = new Error(); @@ -454,6 +501,15 @@ describe('License Store Actions', async () => { expect(commit.calledOnce).to.equal(true); expect(commit.firstCall.args).to.matchPattern([MutationTypes.STORE_RESET_SEARCH]); }); + it('should successfully update exporting state', () => { + const commit = sinon.spy(); + const isExporting = true; + + actions.setStoreExporting({ commit }, isExporting); + + expect(commit.calledOnce).to.equal(true); + expect(commit.firstCall.args).to.matchPattern([MutationTypes.STORE_UPDATE_EXPORTING, isExporting]); + }); it('should successfully reset store', () => { const commit = sinon.spy(); @@ -522,6 +578,34 @@ describe('License Store Actions', async () => { expect(commit.firstCall.args).to.matchPattern([MutationTypes.GET_PRIVILEGE_HISTORY_SUCCESS, { history }]); }); + it('should successfully start privileges request (as export)', async () => { + const commit = sinon.spy(); + const dispatch = sinon.spy(); + const params = {}; + + await actions.getPrivilegesRequest({ commit, getters, dispatch }, { params }); + + expect(commit.calledOnce).to.equal(true); + expect(commit.firstCall.args).to.matchPattern([MutationTypes.GET_PRIVILEGES_REQUEST]); + expect(dispatch.callCount).to.equal(1); + }); + it('should successfully start privileges failure', () => { + const commit = sinon.spy(); + const error = new Error(); + + actions.getPrivilegesFailure({ commit }, error); + + expect(commit.calledOnce).to.equal(true); + expect(commit.firstCall.args).to.matchPattern([MutationTypes.GET_PRIVILEGES_FAILURE, error]); + }); + it('should successfully start privileges success', () => { + const commit = sinon.spy(); + + actions.getPrivilegesSuccess({ commit }); + + expect(commit.calledOnce).to.equal(true); + expect(commit.firstCall.args).to.matchPattern([MutationTypes.GET_PRIVILEGES_SUCCESS]); + }); }); describe('License Store Getters', async () => { it('should successfully get paging previous last key', async () => { diff --git a/webroot/src/store/license/license.state.ts b/webroot/src/store/license/license.state.ts index 9f90676be..b87c1ef0d 100644 --- a/webroot/src/store/license/license.state.ts +++ b/webroot/src/store/license/license.state.ts @@ -5,6 +5,7 @@ // Created by InspiringApps on 7/2/24. // import { LicenseSearchLegacy } from '@components/Licensee/LicenseeSearchLegacy/LicenseeSearchLegacy.vue'; +import { LicenseSearch } from '@components/Licensee/LicenseeSearch/LicenseeSearch.vue'; export interface State { model: Array | null; @@ -12,8 +13,9 @@ export interface State { prevLastKey: string | null; lastKey: string | null; isLoading: boolean; + isExporting: boolean; error: any | null; - search: LicenseSearchLegacy; + search: LicenseSearchLegacy | LicenseSearch; } export const state: State = { @@ -22,6 +24,7 @@ export const state: State = { prevLastKey: null, lastKey: null, isLoading: false, + isExporting: false, error: null, search: { compact: '', diff --git a/webroot/src/styles.common/mixins/buttons.less b/webroot/src/styles.common/mixins/buttons.less index 5b781b7a0..9cfec1663 100644 --- a/webroot/src/styles.common/mixins/buttons.less +++ b/webroot/src/styles.common/mixins/buttons.less @@ -8,7 +8,7 @@ .button-primary() { @buttonEnabledBGColor: @primaryColor; @buttonEnabledTextColor: @white; - @buttonDisabledBGColor: @lightGrey; + @buttonDisabledBGColor: darken(@lightGrey, 20%); @buttonDisabledTextColor: @white; @buttonHoverBGColor: darken(@primaryColor, 8%); @buttonHoverTextColor: @white; From fc5f5079e7c29d79aef8e99e89df0d7758796449 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Mon, 15 Dec 2025 11:31:21 -0700 Subject: [PATCH 06/19] WIP: Licensee search update - Moved the search API to a new network folder - Added env param for search API URI - Added env param for specifically disabling statsig - Updated opensearch syntax to keyword for sort fields --- .github/workflows/check-webroot.yml | 2 + webroot/.env.example | 2 + webroot/README.md | 16 + .../Licensee/LicenseeList/LicenseeList.ts | 2 +- .../components/Lists/Pagination/Pagination.ts | 2 +- webroot/src/network/data.api.ts | 41 +- webroot/src/network/licenseApi/data.api.ts | 286 -------------- webroot/src/network/mocks/mock.data.api.ts | 55 +-- webroot/src/network/searchApi/data.api.ts | 357 ++++++++++++++++++ webroot/src/network/searchApi/interceptors.ts | 82 ++++ .../src/plugins/EnvConfig/envConfig.plugin.ts | 4 + webroot/src/plugins/Statsig/statsig.plugin.ts | 4 +- 12 files changed, 518 insertions(+), 335 deletions(-) create mode 100644 webroot/src/network/searchApi/data.api.ts create mode 100644 webroot/src/network/searchApi/interceptors.ts diff --git a/.github/workflows/check-webroot.yml b/.github/workflows/check-webroot.yml index a8180b8e5..452a3586d 100644 --- a/.github/workflows/check-webroot.yml +++ b/.github/workflows/check-webroot.yml @@ -33,6 +33,7 @@ jobs: VUE_APP_ROBOTS_META: noindex,nofollow VUE_APP_API_STATE_ROOT: https://api.test.jcc.iaapi.io VUE_APP_API_LICENSE_ROOT: https://api.test.jcc.iaapi.io + VUE_APP_API_SEARCH_ROOT: https://search.test.jcc.iaapi.io VUE_APP_COGNITO_REGION: us-east-1 VUE_APP_COGNITO_AUTH_DOMAIN_STAFF: https://ia-cc-staff-test.auth.us-east-1.amazoncognito.com VUE_APP_COGNITO_CLIENT_ID_STAFF: ${{ secrets.DEV_WEBROOT_COGNITO_CLIENT_ID_STAFF }} @@ -88,6 +89,7 @@ jobs: VUE_APP_ROBOTS_META: ${{ env.VUE_APP_ROBOTS_META }} VUE_APP_API_STATE_ROOT: ${{ env.VUE_APP_API_STATE_ROOT }} VUE_APP_API_LICENSE_ROOT: ${{ env.VUE_APP_API_LICENSE_ROOT }} + VUE_APP_API_SEARCH_ROOT: ${{ env.VUE_APP_API_SEARCH_ROOT }} VUE_APP_COGNITO_REGION: ${{ env.VUE_APP_COGNITO_REGION }} VUE_APP_COGNITO_AUTH_DOMAIN_STAFF: ${{ env.VUE_APP_COGNITO_AUTH_DOMAIN_STAFF }} VUE_APP_COGNITO_CLIENT_ID_STAFF: ${{ env.VUE_APP_COGNITO_CLIENT_ID_STAFF }} diff --git a/webroot/.env.example b/webroot/.env.example index 185652395..856b3de93 100644 --- a/webroot/.env.example +++ b/webroot/.env.example @@ -5,6 +5,7 @@ VUE_APP_DOMAIN=http://localhost:3018 VUE_APP_ROBOTS_META=noindex,nofollow VUE_APP_API_STATE_ROOT=https://api.test.jcc.iaapi.io VUE_APP_API_LICENSE_ROOT=https://api.test.jcc.iaapi.io +VUE_APP_API_SEARCH_ROOT=https://search.test.jcc.iaapi.io VUE_APP_API_USER_ROOT=https://api.test.jcc.iaapi.io VUE_APP_COGNITO_REGION=us-east-1 VUE_APP_COGNITO_AUTH_DOMAIN_STAFF=https://staff-auth.test.jcc.iaapi.io @@ -13,6 +14,7 @@ VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE=https://licensee-auth.test.jcc.iaapi.io VUE_APP_COGNITO_CLIENT_ID_LICENSEE=topd4vhftng5cfm3ccgkb6ejd VUE_APP_RECAPTCHA_KEY=6Le-3bgqAAAAAILDVUKkRnAF9SSzb8o9uv5lY7Ih VUE_APP_STATSIG_KEY=TODO +VUE_APP_STATSIG_DISABLED=false VUE_APP_MOCK_API=false VUE_APP_MOCK_API_PAYMENT_LOGIN_ID=TODO VUE_APP_MOCK_API_PAYMENT_CLIENT_KEY=TODO diff --git a/webroot/README.md b/webroot/README.md index 03ab04ddd..595b4ac4e 100644 --- a/webroot/README.md +++ b/webroot/README.md @@ -76,6 +76,14 @@ - Prod: `https://api.compactconnect.org` - _Local_ :arrow_heading_down: - `https://api.test.jcc.iaapi.io` + - **`VUE_APP_API_SEARCH_ROOT`** + - _Server_ :arrow_heading_up: + - IA Test: `https://search.test.jcc.iaapi.io` + - CSG Test: `https://search.test.compactconnect.org` + - Beta: `https://search.beta.compactconnect.org` + - Prod: `https://search.compactconnect.org` + - _Local_ :arrow_heading_down: + - `https://api.test.jcc.iaapi.io` - **`VUE_APP_COGNITO_REGION`** - _Server_ :arrow_heading_up: - IA Test: `us-east-1` @@ -132,6 +140,14 @@ - Prod: TODO - _Local_ :arrow_heading_down: - TODO + - **`VUE_APP_STATSIG_DISABLED`** + - _Server_ :arrow_heading_up: + - IA Test: `false` + - CSG Test: `false` + - Beta: `false` + - Prod: `false` + - _Local_ :arrow_heading_down: + - `true` or `false` as needed - **`VUE_APP_MOCK_API`** :arrow_heading_down: - Only used for local development - `true` if mock API should be used diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts index d8efe9841..81e181f13 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts @@ -18,7 +18,7 @@ import LicenseeRow from '@components/Licensee/LicenseeRow/LicenseeRow.vue'; import CloseX from '@components/Icons/CloseX/CloseX.vue'; import { SortDirection } from '@store/sorting/sorting.state'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '@store/pagination/pagination.state'; -import { SearchParamsInterfaceLocal } from '@network/licenseApi/data.api'; +import { SearchParamsInterfaceLocal } from '@network/searchApi/data.api'; import { State } from '@models/State/State.model'; import moment from 'moment'; diff --git a/webroot/src/components/Lists/Pagination/Pagination.ts b/webroot/src/components/Lists/Pagination/Pagination.ts index 675b11aaa..312f5280b 100644 --- a/webroot/src/components/Lists/Pagination/Pagination.ts +++ b/webroot/src/components/Lists/Pagination/Pagination.ts @@ -178,7 +178,7 @@ export default class Pagination extends mixins(MixinForm) { pageItems.push(createPaginationItem(currentPage - 1, currentPage)); pageItems.push(createPaginationItem(currentPage, currentPage)); pageItems.push(createPaginationItem(currentPage + 1, currentPage)); - pageItems.push(ellipsis(-1)); + pageItems.push(ellipsis(0)); pageItems.push(lastPage()); } diff --git a/webroot/src/network/data.api.ts b/webroot/src/network/data.api.ts index dec7081ef..25a4d34d4 100644 --- a/webroot/src/network/data.api.ts +++ b/webroot/src/network/data.api.ts @@ -7,6 +7,7 @@ import { stateDataApi } from '@network/stateApi/data.api'; import { licenseDataApi } from '@network/licenseApi/data.api'; +import { searchDataApi } from '@network/searchApi/data.api'; import { userDataApi } from '@network/userApi/data.api'; import { exampleDataApi } from '@network/exampleApi/data.api'; import { PaymentProcessorConfig, CompactConfig, CompactStateConfig } from '@models/Compact/Compact.model'; @@ -19,6 +20,7 @@ export class DataApi { public initInterceptors(router) { stateDataApi.initInterceptors(router); licenseDataApi.initInterceptors(router); + searchDataApi.initInterceptors(router); userDataApi.initInterceptors(router); exampleDataApi.initInterceptors(router); } @@ -127,24 +129,6 @@ export class DataApi { return licenseDataApi.getLicenseesPublic(params); } - /** - * GET Licensees (Search - Staff). - * @param {object} [params] The request query parameters config. - * @return {Promise} An array of users server response. - */ - public getLicenseesSearchStaff(params) { - return licenseDataApi.getLicenseesSearchStaff(params); - } - - /** - * GET Privileges (Export - Staff). - * @param {object} [params] The request query parameters config. - * @return {Promise} An array of users server response. - */ - public getPrivilegesExportStaff(params) { - return licenseDataApi.getPrivilegesExportStaff(params); - } - /** * GET Licensee by ID. * @param {string} compact A compact type. @@ -445,6 +429,27 @@ export class DataApi { ); } + // ======================================================================== + // SEARCH API + // ======================================================================== + /** + * GET Licensees (Search - Staff). + * @param {object} [params] The request query parameters config. + * @return {Promise} An array of users server response. + */ + public getLicenseesSearchStaff(params) { + return searchDataApi.getLicenseesSearchStaff(params); + } + + /** + * GET Privileges (Export - Staff). + * @param {object} [params] The request query parameters config. + * @return {Promise} An array of users server response. + */ + public getPrivilegesExportStaff(params) { + return searchDataApi.getPrivilegesExportStaff(params); + } + // ======================================================================== // USER API // ======================================================================== diff --git a/webroot/src/network/licenseApi/data.api.ts b/webroot/src/network/licenseApi/data.api.ts index 74a821ddf..c4f5806ef 100644 --- a/webroot/src/network/licenseApi/data.api.ts +++ b/webroot/src/network/licenseApi/data.api.ts @@ -13,7 +13,6 @@ import { responseSuccess, responseError } from '@network/licenseApi/interceptors'; -import { SortDirection } from '@store/sorting/sorting.state'; import { LicenseeSerializer } from '@models/Licensee/Licensee.model'; import { PrivilegeAttestation, PrivilegeAttestationSerializer } from '@models/PrivilegeAttestation/PrivilegeAttestation.model'; import { LicenseHistoryItemSerializer, LicenseHistoryItem } from '@/models/LicenseHistoryItem/LicenseHistoryItem.model'; @@ -54,44 +53,6 @@ export interface RequestParamsInterfaceRemote { }, } -export interface SearchParamsInterfaceLocal { - isPublic?: boolean; - compact?: string; - licenseeFirstName?: string; - licenseeLastName?: string; - homeState?: string; - privilegeState?: string; - privilegePurchaseStartDate?: string; - privilegePurchaseEndDate?: string; - militaryStatus?: string; - investigationStatus?: string; - encumberStartDate?: string; - encumberEndDate?: string; - npi?: string; - pageSize?: number; - pageNumber?: number; - sortBy?: string; - sortDirection?: string; -} - -export interface SearchParamsInterfaceRemote { - from?: number; - size?: number; - sort?: Array<{ - [key: string]: { - order?: string, - } - }>; - query?: { - match_all?: object, // eslint-disable-line camelcase - bool?: { - must: Array<{ - [key: string]: any, - }>, - }, - }; -} - export interface DataApiInterface { api: AxiosInstance; } @@ -205,196 +166,6 @@ export class LicenseDataApi implements DataApiInterface { return requestParams; } - /** - * Prep a query request for Search requests. - * @param {SearchParamsInterfaceLocal} params The request query parameters config. - * @return {SearchParamsInterfaceRemote} The request query body. - */ - public prepRequestSearchParams(params: SearchParamsInterfaceLocal = {}): SearchParamsInterfaceRemote { - const { - licenseeFirstName, - licenseeLastName, - homeState, - privilegeState, - privilegePurchaseStartDate, - privilegePurchaseEndDate, - militaryStatus, - investigationStatus, - encumberStartDate, - encumberEndDate, - npi, - pageSize, - pageNumber, - sortBy, - sortDirection, - } = params; - const hasSearchTerms = Boolean( - licenseeFirstName - || licenseeLastName - || homeState - || privilegeState - || privilegePurchaseStartDate - || privilegePurchaseEndDate - || militaryStatus - || investigationStatus - || encumberStartDate - || encumberEndDate - || npi - ); - const requestParams: SearchParamsInterfaceRemote = {}; - - // QUERY - // https://docs.opensearch.org/latest/query-dsl/ - if (hasSearchTerms) { - requestParams.query = { - bool: { - must: [], - }, - }; - const conditions = requestParams.query?.bool?.must || []; - - if (licenseeFirstName) { - conditions.push({ match_phrase_prefix: { givenName: licenseeFirstName }}); - } - if (licenseeLastName) { - conditions.push({ match_phrase_prefix: { familyName: licenseeLastName }}); - } - if (homeState) { - conditions.push({ term: { licenseJurisdiction: homeState }}); - } - if (privilegeState) { - conditions.push({ - nested: { - path: 'privileges', - query: { - term: { 'privileges.jurisdiction': privilegeState }, - }, - inner_hits: {} - }, - }); - } - if (privilegePurchaseStartDate || privilegePurchaseEndDate) { - const condition = { - nested: { - path: 'privileges', - query: { - bool: { - should: [ - { - range: { - 'privileges.dateOfIssuance': {}, - }, - }, - { - range: { - 'privileges.dateOfRenewal': {}, - }, - }, - ], - minimum_should_match: 1, - }, - }, - inner_hits: {} - }, - }; - const conditionRules = condition.nested.query.bool.should; - - conditionRules.forEach(({ range }) => { - Object.keys(range).forEach((nestedDateKey) => { - if (privilegePurchaseStartDate) { - range[nestedDateKey].gte = privilegePurchaseStartDate; - } - if (privilegePurchaseEndDate) { - range[nestedDateKey].lte = privilegePurchaseEndDate; - } - }); - }); - - conditions.push(condition); - } - if (militaryStatus) { - conditions.push({ term: { militaryStatus }}); - } - if (investigationStatus) { - if (investigationStatus === 'under-investigation') { - conditions.push({ - nested: { - path: 'investigations', - query: { - term: { 'investigations.type': 'investigation' }, - }, - inner_hits: {} - }, - }); - } else { - conditions.push({ - nested: { - path: 'investigations', - query: { - must_not: [{ - term: { 'investigations.type': 'investigation' }, - }], - }, - inner_hits: {} - }, - }); - } - } - if (encumberStartDate || encumberEndDate) { - const condition = { - nested: { - path: 'adverseActions', - query: { - range: { - 'adverseActions.effectiveStartDate': {}, - }, - }, - inner_hits: {} - }, - }; - const conditionRule: { gte?: string, lte?: string } = condition.nested.query.range['adverseActions.effectiveStartDate']; - - if (encumberStartDate) { - conditionRule.gte = encumberStartDate; - } - if (encumberEndDate) { - conditionRule.lte = encumberEndDate; - } - - conditions.push(condition); - } - if (npi) { - conditions.push({ match: { npi }}); - } - } else { - requestParams.query = { - match_all: {}, - }; - } - - // PAGING - // https://docs.opensearch.org/latest/search-plugins/searching-data/paginate/#the-from-and-size-parameters - if (pageSize) { - requestParams.size = pageSize; - - if (pageNumber) { - requestParams.from = pageSize * (pageNumber - 1); - } - } - - // SORT - // https://docs.opensearch.org/latest/search-plugins/searching-data/sort/ - if (sortBy) { - requestParams.sort = [{ - [sortBy]: { - order: sortDirection || SortDirection.asc, - }, - }]; - } - - return requestParams; - } - /** * POST Create Licensee Account * @param {string} compact A compact type. @@ -446,63 +217,6 @@ export class LicenseDataApi implements DataApiInterface { return response; } - /** - * GET Licensees (Search - Staff). - * @param {SearchParamsInterfaceLocal} [params={}] The request query parameters config. - * @return {Promise} Response metadata + an array of licensees. - */ - public async getLicenseesSearchStaff(params: SearchParamsInterfaceLocal = {}) { - const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); - - // - // @TODO - // - console.log(`request params:`); - console.log(requestParams); - console.log(JSON.stringify(requestParams, null, 2)); - console.log(``); - - // const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/providers/search`, requestParams); - // const { total = {}, providers } = serverReponse; - // const { value: totalMatchCount } = total; - // const response = { - // totalMatchCount, - // licensees: providers.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), - // }; - // - // return response; - - return { - totalMatchCount: 0, - licensees: [], - }; - } - - /** - * GET Privileges (Export - Staff). - * @param {SearchParamsInterfaceLocal} [params={}] The request query parameters config. - * @return {Promise} Response metadata + an array of licensees. - */ - public async getPrivilegesExportStaff(params: SearchParamsInterfaceLocal = {}) { - const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); - - // - // @TODO - // - console.log(`request params:`); - console.log(requestParams); - console.log(JSON.stringify(requestParams, null, 2)); - console.log(``); - - // const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/privileges/export`, requestParams); - // - // return serverReponse; - - return { - downloadUrl: 'https://cdn.prod.website-files.com/66a083c22bdfd06a6aee5193/6913a447111789a56d2f13b9_IA-Logo-Primary-FullColor.svg', - }; - } - /** * GET Licensee by ID. * @param {string} licenseeId A licensee ID. diff --git a/webroot/src/network/mocks/mock.data.api.ts b/webroot/src/network/mocks/mock.data.api.ts index 66ed1f3fb..1255a23bd 100644 --- a/webroot/src/network/mocks/mock.data.api.ts +++ b/webroot/src/network/mocks/mock.data.api.ts @@ -182,33 +182,6 @@ export class DataApi { })); } - // Get Licensees (Search - Staff) - public getLicenseesSearchStaff(params: any = {}) { - const pages = 10; - const records = licensees.providers - .concat(licensees.providers) - .concat(licensees.providers) - .concat(licensees.providers) - .concat(licensees.providers); - - console.log(params); - - return wait(500).then(() => ({ - totalMatchCount: records.length * pages, - licensees: records.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), - params, - })); - } - - // Get Privileges (Export - Staff) - public getPrivilegesExportStaff(params: any = {}) { - return wait(1000).then(() => ({ - // downloadUrl: 'https://cdn.prod.website-files.com/66a083c22bdfd06a6aee5193/6913a447111789a56d2f13b9_IA-Logo-Primary-FullColor.svg', - downloadUrl: 'https://www.examplefile.com/file-download/519', - params, - })); - } - // Get Licensee by ID public getLicensee(compact, licenseeId) { const serverResponse = licensees.providers.find((item) => item.providerId === licenseeId); @@ -572,6 +545,34 @@ export class DataApi { return response; } + // ======================================================================== + // SEARCH API + // ======================================================================== + // Get Licensees (Search - Staff) + public getLicenseesSearchStaff(params: any = {}) { + const pages = 10; + const records = licensees.providers + .concat(licensees.providers) + .concat(licensees.providers) + .concat(licensees.providers) + .concat(licensees.providers); + + return wait(500).then(() => ({ + totalMatchCount: records.length * pages, + licensees: records.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), + params, + })); + } + + // Get Privileges (Export - Staff) + public getPrivilegesExportStaff(params: any = {}) { + return wait(1000).then(() => ({ + // downloadUrl: 'https://cdn.prod.website-files.com/66a083c22bdfd06a6aee5193/6913a447111789a56d2f13b9_IA-Logo-Primary-FullColor.svg', + downloadUrl: 'https://www.examplefile.com/file-download/519', + params, + })); + } + // ======================================================================== // STAFF USER API // ======================================================================== diff --git a/webroot/src/network/searchApi/data.api.ts b/webroot/src/network/searchApi/data.api.ts new file mode 100644 index 000000000..15a8f598e --- /dev/null +++ b/webroot/src/network/searchApi/data.api.ts @@ -0,0 +1,357 @@ +// +// search.api.ts +// CompactConnect +// +// Created by InspiringApps on 12/15/25. +// + +// import { FeatureGates } from '@/app.config'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import { + requestError, + requestSuccess, + responseSuccess, + responseError +} from '@network/searchApi/interceptors'; +import { SortDirection } from '@store/sorting/sorting.state'; +import { LicenseeSerializer } from '@models/Licensee/Licensee.model'; +import axios, { AxiosInstance } from 'axios'; + +export interface SearchParamsInterfaceLocal { + isPublic?: boolean; + compact?: string; + licenseeFirstName?: string; + licenseeLastName?: string; + homeState?: string; + privilegeState?: string; + privilegePurchaseStartDate?: string; + privilegePurchaseEndDate?: string; + militaryStatus?: string; + investigationStatus?: string; + encumberStartDate?: string; + encumberEndDate?: string; + npi?: string; + pageSize?: number; + pageNumber?: number; + sortBy?: string; + sortDirection?: string; +} + +export interface SearchParamsInterfaceRemote { + from?: number; + size?: number; + sort?: Array<{ + [key: string]: { + order?: string, + } + }>; + query?: { + match_all?: object, // eslint-disable-line camelcase + bool?: { + must: Array<{ + [key: string]: any, + }>, + }, + }; +} + +export interface DataApiInterface { + api: AxiosInstance; +} + +export class SearchDataApi implements DataApiInterface { + api: AxiosInstance; + + public constructor() { + // Initial Axios config + this.api = axios.create({ + baseURL: envConfig.apiUrlSearch, + timeout: 30000, + headers: { + 'Cache-Control': 'no-cache', + Accept: 'application/json', + get: { + Accept: 'application/json', + }, + post: { + 'Content-Type': 'application/json', + }, + put: { + 'Content-Type': 'application/json', + }, + }, + }); + } + + /** + * Attach Axios interceptors with injected contexts. + * https://github.com/axios/axios#interceptors + * @param {Store} store + */ + public initInterceptors(store) { + const requestSuccessInterceptor = requestSuccess(); + const requestErrorInterceptor = requestError(); + const responseSuccessInterceptor = responseSuccess(); + const responseErrorInterceptor = responseError(store); + + // Request Interceptors + this.api.interceptors.request.use( + requestSuccessInterceptor, + requestErrorInterceptor + ); + // Response Interceptors + this.api.interceptors.response.use( + responseSuccessInterceptor, + responseErrorInterceptor + ); + } + + /** + * Prep a query request for Search requests. + * @param {SearchParamsInterfaceLocal} params The request query parameters config. + * @return {SearchParamsInterfaceRemote} The request query body. + */ + public prepRequestSearchParams(params: SearchParamsInterfaceLocal = {}): SearchParamsInterfaceRemote { + const { + licenseeFirstName, + licenseeLastName, + homeState, + privilegeState, + privilegePurchaseStartDate, + privilegePurchaseEndDate, + militaryStatus, + investigationStatus, + encumberStartDate, + encumberEndDate, + npi, + pageSize, + pageNumber, + sortBy, + sortDirection, + } = params; + const hasSearchTerms = Boolean( + licenseeFirstName + || licenseeLastName + || homeState + || privilegeState + || privilegePurchaseStartDate + || privilegePurchaseEndDate + || militaryStatus + || investigationStatus + || encumberStartDate + || encumberEndDate + || npi + ); + const requestParams: SearchParamsInterfaceRemote = {}; + + // QUERY + // https://docs.opensearch.org/latest/query-dsl/ + if (hasSearchTerms) { + requestParams.query = { + bool: { + must: [], + }, + }; + const conditions = requestParams.query?.bool?.must || []; + + if (licenseeFirstName) { + conditions.push({ match_phrase_prefix: { givenName: licenseeFirstName }}); + } + if (licenseeLastName) { + conditions.push({ match_phrase_prefix: { familyName: licenseeLastName }}); + } + if (homeState) { + conditions.push({ term: { licenseJurisdiction: homeState }}); + } + if (privilegeState) { + conditions.push({ + nested: { + path: 'privileges', + query: { + term: { 'privileges.jurisdiction': privilegeState }, + }, + inner_hits: {} + }, + }); + } + if (privilegePurchaseStartDate || privilegePurchaseEndDate) { + const condition = { + nested: { + path: 'privileges', + query: { + bool: { + should: [ + { + range: { + 'privileges.dateOfIssuance': {}, + }, + }, + { + range: { + 'privileges.dateOfRenewal': {}, + }, + }, + ], + minimum_should_match: 1, + }, + }, + inner_hits: {} + }, + }; + const conditionRules = condition.nested.query.bool.should; + + conditionRules.forEach(({ range }) => { + Object.keys(range).forEach((nestedDateKey) => { + if (privilegePurchaseStartDate) { + range[nestedDateKey].gte = privilegePurchaseStartDate; + } + if (privilegePurchaseEndDate) { + range[nestedDateKey].lte = privilegePurchaseEndDate; + } + }); + }); + + conditions.push(condition); + } + if (militaryStatus) { + conditions.push({ term: { militaryStatus }}); + } + if (investigationStatus) { + if (investigationStatus === 'under-investigation') { + conditions.push({ + nested: { + path: 'investigations', + query: { + term: { 'investigations.type': 'investigation' }, + }, + inner_hits: {} + }, + }); + } else { + conditions.push({ + nested: { + path: 'investigations', + query: { + must_not: [{ + term: { 'investigations.type': 'investigation' }, + }], + }, + inner_hits: {} + }, + }); + } + } + if (encumberStartDate || encumberEndDate) { + const condition = { + nested: { + path: 'adverseActions', + query: { + range: { + 'adverseActions.effectiveStartDate': {}, + }, + }, + inner_hits: {} + }, + }; + const conditionRule: { gte?: string, lte?: string } = condition.nested.query.range['adverseActions.effectiveStartDate']; + + if (encumberStartDate) { + conditionRule.gte = encumberStartDate; + } + if (encumberEndDate) { + conditionRule.lte = encumberEndDate; + } + + conditions.push(condition); + } + if (npi) { + conditions.push({ match: { npi }}); + } + } else { + requestParams.query = { + match_all: {}, + }; + } + + // PAGING + // https://docs.opensearch.org/latest/search-plugins/searching-data/paginate/#the-from-and-size-parameters + if (pageSize) { + requestParams.size = pageSize; + + if (pageNumber) { + requestParams.from = pageSize * (pageNumber - 1); + } + } + + // SORT + // https://docs.opensearch.org/latest/search-plugins/searching-data/sort/ + if (sortBy) { + requestParams.sort = [{ + [`${sortBy}.keyword`]: { + order: sortDirection || SortDirection.asc, + }, + }]; + } + + return requestParams; + } + + /** + * GET Licensees (Search - Staff). + * @param {SearchParamsInterfaceLocal} [params={}] The request query parameters config. + * @return {Promise} Response metadata + an array of licensees. + */ + public async getLicenseesSearchStaff(params: SearchParamsInterfaceLocal = {}) { + const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); + + // + // @TODO + // + console.log(`request params:`); + console.log(requestParams); + console.log(JSON.stringify(requestParams, null, 2)); + console.log(``); + + const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/providers/search`, requestParams); + const { total = {}, providers } = serverReponse; + const { value: totalMatchCount } = total; + const response = { + totalMatchCount, + licensees: providers.map((serverItem) => LicenseeSerializer.fromServer(serverItem)), + }; + + return response; + + // return { + // totalMatchCount: 0, + // licensees: [], + // }; + } + + /** + * GET Privileges (Export - Staff). + * @param {SearchParamsInterfaceLocal} [params={}] The request query parameters config. + * @return {Promise} Response metadata + an array of licensees. + */ + public async getPrivilegesExportStaff(params: SearchParamsInterfaceLocal = {}) { + const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); + + // + // @TODO + // + console.log(`request params:`); + console.log(requestParams); + console.log(JSON.stringify(requestParams, null, 2)); + console.log(``); + + // const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/privileges/export`, requestParams); + // + // return serverReponse; + + return { + downloadUrl: 'https://cdn.prod.website-files.com/66a083c22bdfd06a6aee5193/6913a447111789a56d2f13b9_IA-Logo-Primary-FullColor.svg', + }; + } +} + +export const searchDataApi = new SearchDataApi(); diff --git a/webroot/src/network/searchApi/interceptors.ts b/webroot/src/network/searchApi/interceptors.ts new file mode 100644 index 000000000..69652aa85 --- /dev/null +++ b/webroot/src/network/searchApi/interceptors.ts @@ -0,0 +1,82 @@ +// +// interceptors.ts +// CompactConnect +// +// Created by InspiringApps on 12/15/25. +// +import { authStorage, tokens } from '@/app.config'; + +// ============================================================================ +// = REQUEST INTERCEPTORS = +// ============================================================================ +/** + * Get Axios API request interceptor. + * @return {AxiosInterceptor} Function that amends the outgoing client API request. + */ +export const requestSuccess = () => async (requestConfig) => { + const authTokenStaff = authStorage.getItem(tokens.staff.AUTH_TOKEN); + const authTokenStaffType = authStorage.getItem(tokens.staff.AUTH_TOKEN_TYPE); + const authTokenLicensee = authStorage.getItem(tokens.licensee.ID_TOKEN); + const authTokenLicenseeType = authStorage.getItem(tokens.licensee.AUTH_TOKEN_TYPE); + const { headers } = requestConfig; + + // Add auth token + headers.Authorization = `${authTokenStaffType || authTokenLicenseeType} ${authTokenStaff || authTokenLicensee}`; + + return requestConfig; +}; + +/** + * Get Axios API request error interceptor. + * @NOTE: Not sure what triggers this; perhaps a code error in the request chain? + * @return {AxiosInterceptor} Function that handles error with the outgoing client API request. + */ +export const requestError = () => (error) => Promise.reject(error); + +// ============================================================================ +// = RESPONSE INTERCEPTORS = +// ============================================================================ +/** + * Get Axios API response success interceptor. + * @return {AxiosInterceptor} Function that extracts the incoming server API response (from within the Axios response wrapper). + */ +export const responseSuccess = () => (response) => { + const serverData = response.data; + + return serverData; +}; + +/** + * Get Axios API response error interceptor. + * @param {Router} router The vue router + * @return {AxiosInterceptor} Function that extracts the incoming server API response (from within the Axios response wrapper). + */ +export const responseError = (router) => (error) => { + const axiosResponse = error.response; + let serverResponse = (axiosResponse) ? axiosResponse.data : null; + + if (axiosResponse) { + // Get API response + serverResponse = axiosResponse.data || {}; + serverResponse.responseStatus = axiosResponse.status; + + switch (axiosResponse.status) { + case 401: + router.push({ name: 'Logout' }); + break; + default: + // Continue + } + } else { + // API unavailable + serverResponse = error; + } + + return Promise.reject(serverResponse); +}; + +export default { + requestSuccess, + responseSuccess, + responseError, +}; diff --git a/webroot/src/plugins/EnvConfig/envConfig.plugin.ts b/webroot/src/plugins/EnvConfig/envConfig.plugin.ts index 8dcc29e19..820a686bb 100644 --- a/webroot/src/plugins/EnvConfig/envConfig.plugin.ts +++ b/webroot/src/plugins/EnvConfig/envConfig.plugin.ts @@ -43,6 +43,7 @@ export interface EnvConfig { domain?: string; apiUrlState?: string; apiUrlLicense?: string; + apiUrlSearch?: string; apiUrlUser?: string; apiUrlExample?: string; apiKeyExample?: string; @@ -53,6 +54,7 @@ export interface EnvConfig { cognitoClientIdLicensee?: string; recaptchaKey?: string; statsigKey?: string; + isStatsigDisabled?: boolean; isUsingMockApi?: boolean; } @@ -74,6 +76,7 @@ export const config: EnvConfig = { domain: context.VUE_APP_DOMAIN, apiUrlState: context.VUE_APP_API_STATE_ROOT, apiUrlLicense: context.VUE_APP_API_LICENSE_ROOT, + apiUrlSearch: context.VUE_APP_API_SEARCH_ROOT, apiUrlUser: context.VUE_APP_API_USER_ROOT, apiUrlExample: '/api', apiKeyExample: 'example', @@ -84,6 +87,7 @@ export const config: EnvConfig = { cognitoClientIdLicensee: context.VUE_APP_COGNITO_CLIENT_ID_LICENSEE, recaptchaKey: context.VUE_APP_RECAPTCHA_KEY, statsigKey: context.VUE_APP_STATSIG_KEY, + isStatsigDisabled: (context.VUE_APP_STATSIG_DISABLED === 'true'), isUsingMockApi: (context.VUE_APP_MOCK_API === 'true'), }; diff --git a/webroot/src/plugins/Statsig/statsig.plugin.ts b/webroot/src/plugins/Statsig/statsig.plugin.ts index bc5ff1bbe..8b5fcebb9 100644 --- a/webroot/src/plugins/Statsig/statsig.plugin.ts +++ b/webroot/src/plugins/Statsig/statsig.plugin.ts @@ -100,8 +100,8 @@ export const getStatsigClient = async () => { export const initStatsig = async () => { const liveEnvironmentMaxWaitMs = moment.duration(2, 'seconds').asMilliseconds(); - const { isTest, isUsingMockApi } = envConfig; - const isLiveEnvironment = !(isTest || isUsingMockApi); + const { isTest, isUsingMockApi, isStatsigDisabled } = envConfig; + const isLiveEnvironment = !(isTest || isUsingMockApi || isStatsigDisabled); const mockStatsigClient = await getStatsigClientMock(isLiveEnvironment); // Don't allow Statsig remote failures to block app loading - only wait for a set amount of time before just using a mock const statsigClient = await Promise.race([ From 8e3c9995430d6ada578ab9641f99cbc661fce0a6 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Tue, 16 Dec 2025 14:57:18 -0700 Subject: [PATCH 07/19] WIP: Licensee search update - Updated queries for more complex nested search scenarios - Started testing with privilege export CSVs - Disabled the military status search until the next PR --- .../Licensee/LicenseeList/LicenseeList.ts | 77 +++--- .../Licensee/LicenseeList/LicenseeList.vue | 1 + .../Licensee/LicenseeSearch/LicenseeSearch.ts | 13 +- .../LicenseeSearch/LicenseeSearch.vue | 4 +- webroot/src/locales/en.json | 3 +- webroot/src/locales/es.json | 3 +- webroot/src/network/mocks/mock.data.api.ts | 4 +- webroot/src/network/searchApi/data.api.ts | 224 ++++++++++-------- webroot/src/store/license/license.actions.ts | 2 +- 9 files changed, 200 insertions(+), 131 deletions(-) diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts index 81e181f13..84a6e107a 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts @@ -40,6 +40,7 @@ class LicenseeList extends Vue { // hasSearched = false; shouldShowSearchModal = false; + searchErrorOverride = ''; isInitialFetchCompleted = false; // @@ -206,12 +207,7 @@ class LicenseeList extends Vue { get sortOptions(): Array { const options = [ - // Temp for limited server sorting support - // { value: 'firstName', name: this.$t('common.firstName') }, { value: 'lastName', name: this.$t('common.lastName'), isDefault: true }, - // { value: 'licenseStates', name: this.$t('licensing.homeState') }, - // { value: 'privilegeStates', name: this.$t('licensing.privileges') }, - // { value: 'status', name: this.$t('licensing.status') }, ]; return options; @@ -242,6 +238,7 @@ class LicenseeList extends Vue { paginationId: this.listId, newPage: 1, }); + this.searchErrorOverride = ''; this.fetchListData(); if (!params.isDirectExport) { @@ -308,21 +305,14 @@ class LicenseeList extends Vue { const requestConfig = this.prepareSearchBody(); if (searchType === SearchTypes.PROVIDER) { + // Provider licensee search is a standard REST JSON call await this.$store.dispatch('license/getLicenseesSearchRequest', { params: { ...requestConfig } }); } else if (searchType === SearchTypes.PRIVILEGE) { - const response = await this.$store.dispatch('license/getPrivilegesRequest', { - params: { ...requestConfig } - }); - const { downloadUrl } = response; - const tempLink = document.createElement('a'); - - tempLink.href = downloadUrl; - tempLink.target = '_blank'; // @TODO: Test with & without this against S3 - tempLink.rel = 'noopener noreferrer'; - tempLink.download = `privilege_export.csv`; - tempLink.click(); + // Privilege search is a file download call + requestConfig.isForPrivileges = true; + await this.handlePrivilegeDownload(requestConfig); } this.isInitialFetchCompleted = true; @@ -337,6 +327,7 @@ class LicenseeList extends Vue { const pagination = this.paginationStore.paginationMap[this.listId]; const { page, size } = pagination || {}; const requestConfig: SearchParamsInterfaceLocal = {}; + const { isDirectExport } = searchParams; // Search params requestConfig.isPublic = this.isPublicSearch; @@ -382,27 +373,55 @@ class LicenseeList extends Vue { } // Paging params - requestConfig.pageNumber = page; - requestConfig.pageSize = size; + if (!isDirectExport) { + requestConfig.pageNumber = page; + requestConfig.pageSize = size; + } // Sorting params - if (option) { - const serverSortByMap = { - firstName: 'givenName', - lastName: 'familyName', - lastUpdate: 'dateOfUpdate', - }; - - requestConfig.sortBy = serverSortByMap[option]; - } + if (!isDirectExport) { + if (option) { + const serverSortByMap = { + lastName: 'familyName', + }; + + requestConfig.sortBy = serverSortByMap[option]; + } - if (direction) { - requestConfig.sortDirection = direction; + if (direction) { + requestConfig.sortDirection = direction; + } } return requestConfig; } + async handlePrivilegeDownload(requestConfig: SearchParamsInterfaceLocal): Promise { + let errorMessage = ''; + const response = await this.$store.dispatch('license/getPrivilegesRequest', { + params: { ...requestConfig } + }).catch((error) => { + errorMessage = error?.message || error; + }); + + if (errorMessage) { + this.searchErrorOverride = errorMessage; + } else if (response) { + const { fileUrl } = response; + const tempLink = document.createElement('a'); + + if (!fileUrl) { + this.searchErrorOverride = this.$t('serverErrors.searchErrorGeneral'); + } else { + tempLink.href = fileUrl; + tempLink.target = '_blank'; + tempLink.rel = 'noopener noreferrer'; + tempLink.download = `privilege_export.csv`; + tempLink.click(); + } + } + } + async sortingChange() { if (this.isInitialFetchCompleted) { await this.fetchListData(); diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue b/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue index 31bd28834..ad7627972 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue @@ -13,6 +13,7 @@ diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts index 8f1394007..00ee052dd 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts @@ -70,6 +70,7 @@ export interface LicenseSearch { class LicenseeSearch extends mixins(MixinForm) { @Prop({ default: {}}) searchParams!: LicenseSearch; @Prop({ default: false }) isPublicSearch!: boolean; + @Prop({ default: '' }) errorOverride?: string; // // Data @@ -382,7 +383,7 @@ class LicenseeSearch extends mixins(MixinForm) { this.formData.privilegeState.value = 'co'; this.formData.privilegePurchaseStartDate.value = moment().startOf('month').format('YYYY-MM-DD'); this.formData.privilegePurchaseEndDate.value = moment().endOf('month').format('YYYY-MM-DD'); - this.formData.militaryStatus.value = 'approved'; + // this.formData.militaryStatus.value = 'approved'; // @TODO: Adding this in next PR with military status updates this.formData.investigationStatus.value = 'underInvestigation'; this.formData.encumberStartDate.value = moment().startOf('month').format('YYYY-MM-DD'); this.formData.encumberEndDate.value = moment().endOf('month').format('YYYY-MM-DD'); @@ -402,6 +403,16 @@ class LicenseeSearch extends mixins(MixinForm) { this.formData.homeState.valueOptions = this.stateOptions; this.formData.privilegeState.valueOptions = this.stateOptions; } + + @Watch('errorOverride') updateError() { + const { errorOverride } = this; + + if (errorOverride) { + this.updateFormSubmitError(errorOverride); + } else { + this.validateAll(); + } + } } export default toNative(LicenseeSearch); diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue index 8a3fcb0d1..7e33d2005 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue @@ -96,12 +96,12 @@ :startDate="new Date()" /> -
+
({ - // downloadUrl: 'https://cdn.prod.website-files.com/66a083c22bdfd06a6aee5193/6913a447111789a56d2f13b9_IA-Logo-Primary-FullColor.svg', - downloadUrl: 'https://www.examplefile.com/file-download/519', + // fileUrl: 'https://cdn.prod.website-files.com/66a083c22bdfd06a6aee5193/6913a447111789a56d2f13b9_IA-Logo-Primary-FullColor.svg', + fileUrl: 'https://www.examplefile.com/file-download/519', params, })); } diff --git a/webroot/src/network/searchApi/data.api.ts b/webroot/src/network/searchApi/data.api.ts index 15a8f598e..6b4fa5f43 100644 --- a/webroot/src/network/searchApi/data.api.ts +++ b/webroot/src/network/searchApi/data.api.ts @@ -35,6 +35,7 @@ export interface SearchParamsInterfaceLocal { pageNumber?: number; sortBy?: string; sortDirection?: string; + isForPrivileges?: boolean; } export interface SearchParamsInterfaceRemote { @@ -128,6 +129,7 @@ export class SearchDataApi implements DataApiInterface { pageNumber, sortBy, sortDirection, + isForPrivileges } = params; const hasSearchTerms = Boolean( licenseeFirstName @@ -154,6 +156,9 @@ export class SearchDataApi implements DataApiInterface { }; const conditions = requestParams.query?.bool?.must || []; + // + // Licensee search props + // if (licenseeFirstName) { conditions.push({ match_phrase_prefix: { givenName: licenseeFirstName }}); } @@ -163,109 +168,149 @@ export class SearchDataApi implements DataApiInterface { if (homeState) { conditions.push({ term: { licenseJurisdiction: homeState }}); } - if (privilegeState) { - conditions.push({ - nested: { - path: 'privileges', - query: { - term: { 'privileges.jurisdiction': privilegeState }, - }, - inner_hits: {} - }, - }); + if (militaryStatus) { + conditions.push({ term: { militaryStatus }}); + } + if (npi) { + conditions.push({ match: { npi }}); } - if (privilegePurchaseStartDate || privilegePurchaseEndDate) { - const condition = { + // + // Privilege search props + // + if (privilegeState || privilegePurchaseStartDate || privilegePurchaseEndDate) { + const privilegeCondition: any = { nested: { path: 'privileges', query: { bool: { - should: [ - { - range: { - 'privileges.dateOfIssuance': {}, - }, - }, - { - range: { - 'privileges.dateOfRenewal': {}, - }, - }, - ], - minimum_should_match: 1, + must: [], }, }, - inner_hits: {} }, }; - const conditionRules = condition.nested.query.bool.should; - - conditionRules.forEach(({ range }) => { - Object.keys(range).forEach((nestedDateKey) => { - if (privilegePurchaseStartDate) { - range[nestedDateKey].gte = privilegePurchaseStartDate; - } - if (privilegePurchaseEndDate) { - range[nestedDateKey].lte = privilegePurchaseEndDate; - } - }); - }); + const privilegeConditions = privilegeCondition.nested.query.bool.must || []; - conditions.push(condition); - } - if (militaryStatus) { - conditions.push({ term: { militaryStatus }}); - } - if (investigationStatus) { - if (investigationStatus === 'under-investigation') { - conditions.push({ - nested: { - path: 'investigations', - query: { - term: { 'investigations.type': 'investigation' }, + if (isForPrivileges) { + privilegeCondition.nested.inner_hits = {}; + } + + if (privilegeState) { + privilegeConditions.push({ term: { 'privileges.jurisdiction': privilegeState }}); + } + + if (privilegePurchaseStartDate || privilegePurchaseEndDate) { + const dateConditions = [ + { + range: { + 'privileges.dateOfIssuance': {}, }, - inner_hits: {} }, - }); - } else { - conditions.push({ - nested: { - path: 'investigations', - query: { - must_not: [{ - term: { 'investigations.type': 'investigation' }, - }], + { + range: { + 'privileges.dateOfRenewal': {}, }, - inner_hits: {} }, + ]; + + dateConditions.forEach((dateCondition) => { + const { range } = dateCondition; + + Object.keys(range).forEach((nestedDateKey) => { + if (privilegePurchaseStartDate) { + range[nestedDateKey].gte = privilegePurchaseStartDate; + } + if (privilegePurchaseEndDate) { + range[nestedDateKey].lte = privilegePurchaseEndDate; + } + }); + privilegeConditions.push(dateCondition); }); } + + conditions.push(privilegeCondition); } + // + // Adverse action search props + // if (encumberStartDate || encumberEndDate) { - const condition = { - nested: { - path: 'adverseActions', - query: { - range: { - 'adverseActions.effectiveStartDate': {}, + const subConditions: any = { + bool: { + should: [], + minimum_should_match: 1, + } + }; + const getSubCondition = (topPath: string) => { + const nestedPath = `${topPath}.adverseActions`; + const subCondition = { + nested: { + path: topPath, + query: { + nested: { + path: nestedPath, + query: { + range: { + [`${nestedPath}.effectiveStartDate`]: {}, + }, + }, + }, }, }, - inner_hits: {} - }, + }; + const subConditionRule: { gte?: string, lte?: string } = subCondition.nested.query.nested.query.range[`${nestedPath}.effectiveStartDate`]; + + if (encumberStartDate) { + subConditionRule.gte = encumberStartDate; + } + if (encumberEndDate) { + subConditionRule.lte = encumberEndDate; + } + + return subCondition; }; - const conditionRule: { gte?: string, lte?: string } = condition.nested.query.range['adverseActions.effectiveStartDate']; - if (encumberStartDate) { - conditionRule.gte = encumberStartDate; - } - if (encumberEndDate) { - conditionRule.lte = encumberEndDate; - } + subConditions.bool.should.push(getSubCondition('licenses')); + subConditions.bool.should.push(getSubCondition('privileges')); - conditions.push(condition); + conditions.push(subConditions); } - if (npi) { - conditions.push({ match: { npi }}); + // + // Investigation search props + // + if (investigationStatus) { + const subConditions: any = { bool: {}}; + const getSubCondition = (topPath: string) => { + const nestedPath = `${topPath}.investigations`; + const subCondition = { + nested: { + path: topPath, + query: { + nested: { + path: nestedPath, + query: { + term: { [`${nestedPath}.type`]: 'investigation' }, + }, + }, + }, + }, + }; + + return subCondition; + }; + + if (investigationStatus === 'underInvestigation') { + subConditions.bool.should = [ + getSubCondition('licenses'), + getSubCondition('privileges'), + ]; + subConditions.bool.minimum_should_match = 1; + } else { + subConditions.bool.must_not = [ + getSubCondition('licenses'), + getSubCondition('privileges'), + ]; + } + + conditions.push(subConditions); } } else { requestParams.query = { @@ -304,9 +349,7 @@ export class SearchDataApi implements DataApiInterface { public async getLicenseesSearchStaff(params: SearchParamsInterfaceLocal = {}) { const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); - // // @TODO - // console.log(`request params:`); console.log(requestParams); console.log(JSON.stringify(requestParams, null, 2)); @@ -321,11 +364,6 @@ export class SearchDataApi implements DataApiInterface { }; return response; - - // return { - // totalMatchCount: 0, - // licensees: [], - // }; } /** @@ -336,21 +374,19 @@ export class SearchDataApi implements DataApiInterface { public async getPrivilegesExportStaff(params: SearchParamsInterfaceLocal = {}) { const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); - // // @TODO - // console.log(`request params:`); console.log(requestParams); console.log(JSON.stringify(requestParams, null, 2)); console.log(``); - // const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/privileges/export`, requestParams); - // - // return serverReponse; + const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/privileges/export`, requestParams); - return { - downloadUrl: 'https://cdn.prod.website-files.com/66a083c22bdfd06a6aee5193/6913a447111789a56d2f13b9_IA-Logo-Primary-FullColor.svg', - }; + return serverReponse; + + // return { + // downloadUrl: 'https://cdn.prod.website-files.com/66a083c22bdfd06a6aee5193/6913a447111789a56d2f13b9_IA-Logo-Primary-FullColor.svg', + // }; } } diff --git a/webroot/src/store/license/license.actions.ts b/webroot/src/store/license/license.actions.ts index b5a088b68..64e3018ba 100644 --- a/webroot/src/store/license/license.actions.ts +++ b/webroot/src/store/license/license.actions.ts @@ -147,7 +147,7 @@ export default { }).catch((error) => { dispatch('getPrivilegesFailure', error); - return error; + throw error; }); }, getPrivilegesSuccess: ({ commit }) => { From 04940116d76c49a4b0ee24f74febb87d397caaa3 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Thu, 18 Dec 2025 11:17:44 -0700 Subject: [PATCH 08/19] WIP: Licensee search update - Added window function to log query output for helping with query debugging as needed - Minor updates to pagination --- .../components/Lists/Pagination/Pagination.ts | 57 ++++++++++++------- webroot/src/network/searchApi/data.api.ts | 32 +++++++---- 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/webroot/src/components/Lists/Pagination/Pagination.ts b/webroot/src/components/Lists/Pagination/Pagination.ts index 312f5280b..8a3d96916 100644 --- a/webroot/src/components/Lists/Pagination/Pagination.ts +++ b/webroot/src/components/Lists/Pagination/Pagination.ts @@ -46,11 +46,8 @@ export default class Pagination extends mixins(MixinForm) { // // Data // - paginationStore: any = {}; ellipsis = (key) => createPaginationItem(key, -1); defaultPageSizeOptions = [ - // { value: 2, name: '2', isDefault: true }, - // { value: 10, name: '10', isDefault: false }, { value: 25, name: '25', isDefault: true }, ]; @@ -60,7 +57,6 @@ export default class Pagination extends mixins(MixinForm) { // Lifecycle // created() { - this.paginationStore = this.$store.state.pagination; this.initFormInputs(); const { @@ -91,16 +87,29 @@ export default class Pagination extends mixins(MixinForm) { } mounted() { - const { currentPage, pageSize, pageChange } = this; + const { + currentPage, + pageSize, + pageChange, + paginationStore, + paginationId + } = this; const firstIndex = (currentPage - 1) * pageSize; const lastIndex = currentPage * pageSize; + const { page = 0, size = 0 } = paginationStore.paginationMap[paginationId] || {}; - pageChange(firstIndex, lastIndex); + if ((page * size) !== lastIndex) { + pageChange(firstIndex, lastIndex); + } } // // Computed // + get paginationStore() { + return this.$store.state.pagination; + } + get pageSizeOptions(): Array { const { pageSizeConfig, defaultPageSizeOptions } = this; let options = pageSizeConfig; @@ -185,21 +194,6 @@ export default class Pagination extends mixins(MixinForm) { return pageItems; } - // - // Watchers - // - @Watch('$props', { deep: true }) calculateNewIndices() { - nextTick(() => { - const { - pageSize, pageChange, $store, paginationId - } = this; - const newFirstIndex = 1 - 1; - - $store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: 1 }); - pageChange(newFirstIndex, newFirstIndex + pageSize); - }); - } - // // Methods // @@ -249,4 +243,25 @@ export default class Pagination extends mixins(MixinForm) { $store.dispatch('pagination/updatePaginationSize', { paginationId, newSize }); pageChange(newFirstIndex, newFirstIndex + newSize); } + + resetPaging(): void { + // If any variables that affect paging have changed (page size, etc.) then we need to reset to the first page with the new variables. + nextTick(() => { + const { pageSize, pageChange, paginationId } = this; + + this.$store.dispatch('pagination/updatePaginationPage', { paginationId, newPage: 1 }); + pageChange(0, pageSize); + }); + } + + // + // Watchers + // + @Watch('$props.paginationId') handleUpdatePagingId() { + this.resetPaging(); + } + + @Watch('$props.pageSizeConfig', { deep: true }) handleUpdatePageSizeConfig() { + this.resetPaging(); + } } diff --git a/webroot/src/network/searchApi/data.api.ts b/webroot/src/network/searchApi/data.api.ts index 6b4fa5f43..5854ca29f 100644 --- a/webroot/src/network/searchApi/data.api.ts +++ b/webroot/src/network/searchApi/data.api.ts @@ -56,6 +56,18 @@ export interface SearchParamsInterfaceRemote { }; } +const appWindow = window as any; + +appWindow.ccQueryToggle = (): void => { + if (appWindow.ccIsQueryLogEnabled) { + appWindow.ccIsQueryLogEnabled = false; + console.log('CompactConnect search query logging: DISABLED'); + } else { + appWindow.ccIsQueryLogEnabled = true; + console.log('CompactConnect search query logging: ENABLED'); + } +}; + export interface DataApiInterface { api: AxiosInstance; } @@ -349,11 +361,11 @@ export class SearchDataApi implements DataApiInterface { public async getLicenseesSearchStaff(params: SearchParamsInterfaceLocal = {}) { const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); - // @TODO - console.log(`request params:`); - console.log(requestParams); - console.log(JSON.stringify(requestParams, null, 2)); - console.log(``); + if (appWindow.ccIsQueryLogEnabled) { + console.log(`${new Date()}:`); + console.log(JSON.stringify(requestParams, null, 2)); + console.log(``); + } const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/providers/search`, requestParams); const { total = {}, providers } = serverReponse; @@ -374,11 +386,11 @@ export class SearchDataApi implements DataApiInterface { public async getPrivilegesExportStaff(params: SearchParamsInterfaceLocal = {}) { const requestParams: SearchParamsInterfaceRemote = this.prepRequestSearchParams(params); - // @TODO - console.log(`request params:`); - console.log(requestParams); - console.log(JSON.stringify(requestParams, null, 2)); - console.log(``); + if (appWindow.ccIsQueryLogEnabled) { + console.log(`${new Date()}:`); + console.log(JSON.stringify(requestParams, null, 2)); + console.log(``); + } const serverReponse: any = await this.api.post(`/v1/compacts/${params.compact}/privileges/export`, requestParams); From c9d7d9d935cdf751e1d39c437cc00a0f40a59b1e Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Thu, 18 Dec 2025 14:00:49 -0700 Subject: [PATCH 09/19] WIP: Licensee search update - PR review updates --- .../stacks/frontend_deployment_stack/deployment.py | 1 + .../LicenseeListLegacy/LicenseeListLegacy.vue | 2 +- .../Licensee/LicenseeSearch/LicenseeSearch.vue | 4 ++-- webroot/src/locales/es.json | 2 +- webroot/src/network/searchApi/data.api.ts | 14 ++++++++------ 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/backend/compact-connect-ui-app/stacks/frontend_deployment_stack/deployment.py b/backend/compact-connect-ui-app/stacks/frontend_deployment_stack/deployment.py index d1a70b5f9..abd425177 100644 --- a/backend/compact-connect-ui-app/stacks/frontend_deployment_stack/deployment.py +++ b/backend/compact-connect-ui-app/stacks/frontend_deployment_stack/deployment.py @@ -62,6 +62,7 @@ def __init__( 'VUE_APP_ROBOTS_META': robots_meta, 'VUE_APP_API_STATE_ROOT': f'{HTTPS_PREFIX}{persistent_stack_app_config_values.api_domain_name}', 'VUE_APP_API_LICENSE_ROOT': f'{HTTPS_PREFIX}{persistent_stack_app_config_values.api_domain_name}', + 'VUE_APP_API_LICENSE_ROOT': f'{HTTPS_PREFIX}search.{persistent_stack_app_config_values.api_domain_name}', 'VUE_APP_API_USER_ROOT': f'{HTTPS_PREFIX}{persistent_stack_app_config_values.api_domain_name}', 'VUE_APP_COGNITO_REGION': 'us-east-1', 'VUE_APP_COGNITO_AUTH_DOMAIN_STAFF': f'{HTTPS_PREFIX}{persistent_stack_app_config_values.staff_cognito_domain}', diff --git a/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue b/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue index e99294337..963142ad3 100644 --- a/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue +++ b/webroot/src/components/Licensee/LicenseeListLegacy/LicenseeListLegacy.vue @@ -16,7 +16,7 @@ @searchParams="handleSearch" />
-
+

{{ $t('licensing.licensingListTitle') }}

diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue index 7e33d2005..8b12ce3bd 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.vue @@ -69,7 +69,7 @@