diff --git a/frontend/src/stores/companies.test.ts b/frontend/src/stores/companies.test.ts index 2a49e89..1ceb335 100644 --- a/frontend/src/stores/companies.test.ts +++ b/frontend/src/stores/companies.test.ts @@ -5,10 +5,12 @@ import { useCompaniesStore } from '@/stores/companies' import type { Company, CompanyDirectoryFilters, + CompanyDirectoryPage, CompanyRequest, + CompanyRequestPage, CompanySummary, } from '@/types/companies' -import { nuiCall } from '@/utils/nui' +import { nuiCall, type NuiResponse } from '@/utils/nui' vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) @@ -84,6 +86,17 @@ const filters: CompanyDirectoryFilters = { sort: 'relevance', } +function deferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + describe('companies store', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -132,6 +145,208 @@ describe('companies store', () => { }) }) + it('ignores an older directory response after the filters change', async () => { + const firstResponse = deferred>() + const secondResponse = deferred>() + mockNuiCall + .mockReturnValueOnce(firstResponse.promise) + .mockReturnValueOnce(secondResponse.promise) + const store = useCompaniesStore() + const searchedFilters = { ...filters, search: 'medical' } + const medical = { + ...summary, + id: 'medical', + name: 'Los Santos Medical', + } + + const firstLoad = store.loadCompanies(filters) + const secondLoad = store.loadCompanies(searchedFilters) + secondResponse.resolve({ + data: { categories: [], companies: [medical], nextCursor: null }, + success: true, + }) + + await expect(secondLoad).resolves.toBe(true) + firstResponse.resolve({ + data: { categories: [], companies: [summary], nextCursor: null }, + success: true, + }) + + await expect(firstLoad).resolves.toBe(false) + expect(store.directory).toEqual([medical]) + expect(store.directoryFilters).toEqual(searchedFilters) + expect(store.directoryLoading).toBe(false) + }) + + it('ignores an older customer request response after the list changes', async () => { + const firstResponse = deferred>() + const secondResponse = deferred>() + mockNuiCall + .mockReturnValueOnce(firstResponse.promise) + .mockReturnValueOnce(secondResponse.promise) + const store = useCompaniesStore() + const closedRequest = { ...request, id: 'request-closed' } + + const firstLoad = store.loadMyRequests('open') + const secondLoad = store.loadMyRequests('closed') + secondResponse.resolve({ + data: { nextCursor: null, requests: [closedRequest], unreadCount: 1 }, + success: true, + }) + + await expect(secondLoad).resolves.toBe(true) + firstResponse.resolve({ + data: { nextCursor: null, requests: [request], unreadCount: 7 }, + success: true, + }) + + await expect(firstLoad).resolves.toBe(false) + expect(store.myRequests).toEqual([closedRequest]) + expect(store.myRequestsList).toBe('closed') + expect(store.customerUnreadCount).toBe(1) + expect(store.myRequestsLoading).toBe(false) + }) + + it('keeps directory data and its initial error when an append fails', async () => { + mockNuiCall + .mockResolvedValueOnce({ + error: 'temporarily_unavailable', + success: false, + }) + .mockResolvedValueOnce({ + data: { + categories: [], + companies: [ + { ...summary, id: 'medical', name: 'Los Santos Medical' }, + ], + nextCursor: null, + }, + success: true, + }) + const store = useCompaniesStore() + store.directory = [summary] + store.directoryError = 'initial_error' + store.directoryFilters = { ...filters } + store.directoryNextCursor = 'page-2' + + expect(await store.loadCompanies(filters, true)).toBe(false) + expect(store.directory).toEqual([summary]) + expect(store.directoryNextCursor).toBe('page-2') + expect(store.directoryError).toBe('initial_error') + expect(store.directoryAppendError).toBe('temporarily_unavailable') + + expect(await store.loadCompanies(filters, true)).toBe(true) + expect(store.directory.map((item) => item.id)).toEqual([ + 'police', + 'medical', + ]) + expect(store.directoryError).toBe('initial_error') + expect(store.directoryAppendError).toBe('') + }) + + it('keeps customer requests and their initial error when an append fails', async () => { + mockNuiCall + .mockResolvedValueOnce({ + error: 'temporarily_unavailable', + success: false, + }) + .mockResolvedValueOnce({ + data: { + nextCursor: null, + requests: [{ ...request, id: 'request-2' }], + unreadCount: 2, + }, + success: true, + }) + const store = useCompaniesStore() + store.customerUnreadCount = 1 + store.myRequests = [request] + store.myRequestsError = 'initial_error' + store.myRequestsList = 'open' + store.myRequestsNextCursor = 'page-2' + + expect(await store.loadMyRequests('open', true)).toBe(false) + expect(store.myRequests).toEqual([request]) + expect(store.myRequestsNextCursor).toBe('page-2') + expect(store.customerUnreadCount).toBe(1) + expect(store.myRequestsError).toBe('initial_error') + expect(store.myRequestsAppendError).toBe('temporarily_unavailable') + + expect(await store.loadMyRequests('open', true)).toBe(true) + expect(store.myRequests.map((item) => item.id)).toEqual([ + 'request-1', + 'request-2', + ]) + expect(store.customerUnreadCount).toBe(2) + expect(store.myRequestsError).toBe('initial_error') + expect(store.myRequestsAppendError).toBe('') + }) + + it('invalidates an in-flight customer page when the device scope is cleared', async () => { + const response = deferred>() + mockNuiCall.mockReturnValueOnce(response.promise) + const store = useCompaniesStore() + store.bindDeviceScope('device-a', 'sim-a') + store.myRequests = [request] + store.myRequestsList = 'open' + store.myRequestsNextCursor = 'page-2' + + const load = store.loadMyRequests('open', true) + store.resetDeviceScope() + response.resolve({ + data: { + nextCursor: null, + requests: [{ ...request, id: 'request-stale' }], + unreadCount: 8, + }, + success: true, + }) + + await expect(load).resolves.toBe(false) + expect(store.myRequests).toEqual([]) + expect(store.customerUnreadCount).toBe(0) + expect(store.myRequestsLoaded).toBe(false) + expect(store.myRequestsLoadingMore).toBe(false) + }) + + it('guards directory appends by loading state, cursor and exact filters', async () => { + const store = useCompaniesStore() + store.directoryFilters = { ...filters } + store.directoryNextCursor = 'page-2' + store.directoryLoading = true + + expect(await store.loadCompanies(filters, true)).toBe(false) + store.directoryLoading = false + expect( + await store.loadCompanies({ ...filters, categoryId: 'public' }, true), + ).toBe(false) + store.directoryNextCursor = null + expect(await store.loadCompanies(filters, true)).toBe(false) + store.directoryNextCursor = 'page-2' + store.directoryLoadingMore = true + expect(await store.loadCompanies(filters, true)).toBe(false) + + expect(mockNuiCall).not.toHaveBeenCalled() + }) + + it('guards customer appends by loading state, cursor and exact list', async () => { + const store = useCompaniesStore() + store.myRequestsList = 'open' + store.myRequestsNextCursor = 'page-2' + store.myRequestsLoading = true + + expect(await store.loadMyRequests('open', true)).toBe(false) + store.myRequestsLoading = false + expect(await store.loadMyRequests('closed', true)).toBe(false) + store.myRequestsNextCursor = null + expect(await store.loadMyRequests('open', true)).toBe(false) + store.myRequestsNextCursor = 'page-2' + store.myRequestsLoadingMore = true + expect(await store.loadMyRequests('open', true)).toBe(false) + + expect(mockNuiCall).not.toHaveBeenCalled() + }) + it('uses server-provided unread counts for the app badge', async () => { mockNuiCall.mockResolvedValueOnce({ data: { diff --git a/frontend/src/stores/companies.ts b/frontend/src/stores/companies.ts index 4e46162..e2297da 100644 --- a/frontend/src/stores/companies.ts +++ b/frontend/src/stores/companies.ts @@ -35,6 +35,20 @@ function mergeById(current: T[], incoming: T[]): T[] { return [...merged.values()] } +function sameDirectoryFilters( + current: CompanyDirectoryFilters, + requested: CompanyDirectoryFilters, +): boolean { + return ( + current.acceptsRequests === requested.acceptsRequests && + current.availability === requested.availability && + current.categoryId === requested.categoryId && + current.hasLocation === requested.hasLocation && + current.search === requested.search && + current.sort === requested.sort + ) +} + export const useCompaniesStore = defineStore('companies', { state: () => ({ categories: [] as CompanyDirectoryPage['categories'], @@ -49,11 +63,13 @@ export const useCompaniesStore = defineStore('companies', { search: '', sort: 'relevance', } as CompanyDirectoryFilters, + directoryAppendError: '', directoryError: '', directoryLoaded: false, directoryLoading: false, directoryLoadingMore: false, directoryNextCursor: null as string | null, + directoryRequestGeneration: 0, deviceScopeKey: '', deviceScopeVersion: 0, members: [] as CompanyMember[], @@ -61,12 +77,14 @@ export const useCompaniesStore = defineStore('companies', { mutationError: '', mutating: false, myRequests: [] as CompanyRequestSummary[], + myRequestsAppendError: '', myRequestsList: 'open' as CompanyRequestList, myRequestsError: '', myRequestsLoaded: false, myRequestsLoading: false, myRequestsLoadingMore: false, myRequestsNextCursor: null as string | null, + myRequestsRequestGeneration: 0, request: null as CompanyRequest | null, requestError: '', requestLoading: false, @@ -151,38 +169,67 @@ export const useCompaniesStore = defineStore('companies', { filters: CompanyDirectoryFilters, append = false, ): Promise { - if (append && (!this.directoryNextCursor || this.directoryLoadingMore)) { + const requestFilters = { ...filters } + if ( + append && + (this.directoryLoading || + this.directoryLoadingMore || + !this.directoryNextCursor || + !sameDirectoryFilters(this.directoryFilters, requestFilters)) + ) { return false } - if (append) this.directoryLoadingMore = true - else this.directoryLoading = true + if (append) { + this.directoryLoadingMore = true + } else { + this.directoryRequestGeneration += 1 + this.directoryLoading = true + this.directoryLoadingMore = false + this.directoryAppendError = '' + this.directoryError = '' + this.directoryFilters = requestFilters + } this.directoryLoaded = true - this.directoryFilters = { ...filters } + const requestGeneration = this.directoryRequestGeneration + const requestCursor = append ? this.directoryNextCursor : null const response = await nuiCall('companies:list', { - acceptsRequests: filters.acceptsRequests, - availability: filters.availability, - categoryId: filters.categoryId, - cursor: append ? this.directoryNextCursor : null, - hasLocation: filters.hasLocation, - search: filters.search, - sort: filters.sort, + acceptsRequests: requestFilters.acceptsRequests, + availability: requestFilters.availability, + categoryId: requestFilters.categoryId, + cursor: requestCursor, + hasLocation: requestFilters.hasLocation, + search: requestFilters.search, + sort: requestFilters.sort, }) - this.directoryLoading = false - this.directoryLoadingMore = false - if (!response.success || !response.data) { - this.directoryError = response.error ?? 'request_failed' - if (!append) { - this.directory = [] - this.directoryNextCursor = null + const isCurrentRequest = + requestGeneration === this.directoryRequestGeneration && + sameDirectoryFilters(this.directoryFilters, requestFilters) + if (!isCurrentRequest) { + if (requestGeneration === this.directoryRequestGeneration) { + if (append) this.directoryLoadingMore = false + else this.directoryLoading = false } return false } + if (append) this.directoryLoadingMore = false + else this.directoryLoading = false + if (!response.success || !response.data) { + if (append) { + this.directoryAppendError = response.error ?? 'request_failed' + return false + } + this.directoryError = response.error ?? 'request_failed' + this.directory = [] + this.directoryNextCursor = null + return false + } this.categories = response.data.categories ?? this.categories this.directory = append ? mergeById(this.directory, response.data.companies) : response.data.companies this.directoryNextCursor = response.data.nextCursor - this.directoryError = '' + if (append) this.directoryAppendError = '' + else this.directoryError = '' return true }, async loadCompany(companyId: string): Promise { @@ -206,37 +253,64 @@ export const useCompaniesStore = defineStore('companies', { ): Promise { if ( append && - (!this.myRequestsNextCursor || this.myRequestsLoadingMore) + (this.myRequestsLoading || + this.myRequestsLoadingMore || + !this.myRequestsNextCursor || + list !== this.myRequestsList) ) { return false } - if (append) this.myRequestsLoadingMore = true - else this.myRequestsLoading = true + if (append) { + this.myRequestsLoadingMore = true + } else { + this.myRequestsRequestGeneration += 1 + this.myRequestsLoading = true + this.myRequestsLoadingMore = false + this.myRequestsAppendError = '' + this.myRequestsError = '' + this.myRequestsList = list + } this.myRequestsLoaded = true - this.myRequestsList = list const deviceScopeVersion = this.deviceScopeVersion + const requestGeneration = this.myRequestsRequestGeneration + const requestCursor = append ? this.myRequestsNextCursor : null const response = await nuiCall( 'companies:my-requests', { - cursor: append ? this.myRequestsNextCursor : null, + cursor: requestCursor, list, }, ) - this.myRequestsLoading = false - this.myRequestsLoadingMore = false - if (deviceScopeVersion !== this.deviceScopeVersion) return false + const isCurrentRequest = + deviceScopeVersion === this.deviceScopeVersion && + requestGeneration === this.myRequestsRequestGeneration && + list === this.myRequestsList + if (!isCurrentRequest) { + if ( + deviceScopeVersion === this.deviceScopeVersion && + requestGeneration === this.myRequestsRequestGeneration + ) { + if (append) this.myRequestsLoadingMore = false + else this.myRequestsLoading = false + } + return false + } + if (append) this.myRequestsLoadingMore = false + else this.myRequestsLoading = false if (!response.success || !response.data) { + if (append) { + this.myRequestsAppendError = response.error ?? 'request_failed' + return false + } this.myRequestsError = response.error ?? 'request_failed' - if (!append) { - this.myRequests = [] - this.myRequestsNextCursor = null - if ( - response.error === 'anonymous_sim' || - response.error === 'device_not_found' || - response.error === 'no_sim' - ) { - this.customerUnreadCount = 0 - } + this.myRequests = [] + this.myRequestsNextCursor = null + if ( + response.error === 'anonymous_sim' || + response.error === 'device_not_found' || + response.error === 'no_sim' + ) { + this.customerUnreadCount = 0 } return false } @@ -245,7 +319,8 @@ export const useCompaniesStore = defineStore('companies', { : response.data.requests this.myRequestsNextCursor = response.data.nextCursor this.customerUnreadCount = Math.max(0, response.data.unreadCount) - this.myRequestsError = '' + if (append) this.myRequestsAppendError = '' + else this.myRequestsError = '' return true }, async loadRequest(requestId: string): Promise { @@ -524,8 +599,10 @@ export const useCompaniesStore = defineStore('companies', { }, resetDeviceScope(): void { this.deviceScopeVersion += 1 + this.myRequestsRequestGeneration += 1 this.customerUnreadCount = 0 this.myRequests = [] + this.myRequestsAppendError = '' this.myRequestsError = '' this.myRequestsLoaded = false this.myRequestsLoading = false diff --git a/frontend/src/views/apps/CompaniesApp.vue b/frontend/src/views/apps/CompaniesApp.vue index efe4e4e..ab8f39d 100644 --- a/frontend/src/views/apps/CompaniesApp.vue +++ b/frontend/src/views/apps/CompaniesApp.vue @@ -1,42 +1,47 @@ + + - - {{ phone.t('Apps.companies.states.directoryError') }} -

{{ errorText(companies.directoryError) }}

- - - {{ phone.t('Apps.companies.tryAgain') }} - - - - - - {{ - phone.t( - filtersActive - ? 'Apps.companies.states.noResults' - : 'Apps.companies.states.noCompanies', - ) - }} - -

- {{ - phone.t( - filtersActive - ? 'Apps.companies.states.noResultsBody' - : 'Apps.companies.states.noCompaniesBody', - ) - }} -

- - {{ phone.t('Apps.companies.directory.resetFilters') }} - -
- +
+ - - {{ phone.t('Apps.companies.states.requestsError') }} -

{{ errorText(companies.myRequestsError) }}

- - {{ phone.t('Apps.companies.tryAgain') }} - - - + +
+ - - {{ + :title=" phone.t( `Apps.companies.states.no${requestList === 'open' ? 'Open' : 'Closed'}Requests`, ) - }} -

{{ phone.t('Apps.companies.states.noRequestsBody') }}

- - {{ phone.t('Apps.companies.requests.findCompany') }} - - - - + + +
+ + { - - - - {{ phone.t('Apps.companies.loadMore') }} - - + + + + -
- - - {{ phone.t('Apps.companies.loading.work') }} - - + + - - {{ phone.t('Apps.companies.states.workError') }} -

{{ errorText(companies.workContextError) }}

- - {{ phone.t('Apps.companies.tryAgain') }} - -
- + + + - - {{ phone.t('Apps.companies.work.notAuthorized') }} -

{{ phone.t('Apps.companies.work.notAuthorizedBody') }}

-
+ + -
+ -
- - - {{ phone.t('Apps.companies.loading.profile') }} - - + + - - {{ phone.t('Apps.companies.states.profileError') }} -

{{ errorText(companies.directoryError) }}

- {{ - phone.t('Apps.companies.back') - }} -
+ + + -
+ -
- - - {{ phone.t('Apps.companies.loading.request') }} - - + + - - {{ phone.t('Apps.companies.states.requestError') }} -

{{ errorText(companies.requestError) }}

- {{ - phone.t('Apps.companies.back') - }} -
+ + + -
+ -
- + {{ workCompany.name }} @@ -1799,13 +1759,13 @@ onBeforeUnmount(() => { }) }} - + - {{ + {{ phone.t('Apps.companies.manager.availability') - }} - - + + { @click="setCompanyAvailability(availability)" > {{ phone.t(`Apps.companies.availability.${availability}`) }} - - + + -
+ - - - - - - - - - - - - - - - - + + + + + + + + + + + + +
- - - + + + + + {{ + phone.t('Apps.companies.composer.chooseService') + }} + + + + + + + + + + + + + + {{ phone.t('Apps.companies.composer.contact') }} + + {{ + phone.t('Apps.companies.composer.registeredSim', { + number: maskedPhoneNumber(phone.device.sim.number), + }) + }} + + {{ + phone.t('Apps.companies.composer.registeredSimRequired') + }} + + + + + {{ + phone.t('Apps.companies.composer.addPhotos', { + count: String(requestMedia.length), + }) + }} + +
+ + + + + + +
+ + + {{ phone.t('Apps.companies.composer.send') }} + + +
- - - + {{ phone.t('Apps.companies.workActions.claim') }} - - + {{ phone.t('Apps.companies.workActions.assign') }} - - + {{ phone.t('Apps.companies.workActions.callCustomer') }} - - + { status: phone.t(`Apps.companies.requestStatuses.${status}`), }) }} - - - - + + + + {{ phone.t('Apps.companies.close') }} - - - + + +
- - - + {{ phone.t('Apps.companies.assignment.confirm') }} + + +
- - + - - + - {{ toastText }} - - + +