ENH - migrate Companies to Sky UI

This commit is contained in:
DerEchteAlec
2026-08-13 03:10:26 +02:00
parent 2cf15c70be
commit 32e49928ef
3 changed files with 1230 additions and 1057 deletions
+216 -1
View File
@@ -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<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<NuiResponse<CompanyDirectoryPage>>()
const secondResponse = deferred<NuiResponse<CompanyDirectoryPage>>()
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<NuiResponse<CompanyRequestPage>>()
const secondResponse = deferred<NuiResponse<CompanyRequestPage>>()
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<NuiResponse<CompanyRequestPage>>()
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: {
+115 -38
View File
@@ -35,6 +35,20 @@ function mergeById<T extends { id: string }>(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<boolean> {
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<CompanyDirectoryPage>('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<boolean> {
@@ -206,37 +253,64 @@ export const useCompaniesStore = defineStore('companies', {
): Promise<boolean> {
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<CompanyRequestPage>(
'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<boolean> {
@@ -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
File diff suppressed because it is too large Load Diff