Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,6 @@ jobs:

- name: Vitest
run: npm test

- name: Smoke tests
run: npm run test:smoke
1 change: 1 addition & 0 deletions vueManager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"preview": "vite preview",
"lint": "eslint . --fix",
"lint:ci": "eslint . --max-warnings 0",
"test:smoke": "node --test tests/*.test.js",
"format": "prettier --write \"src/**/*.{js,vue,scss,css}\"",
"format:check": "prettier --check \"src/**/*.{js,vue,scss,css}\"",
"lint:all": "npm run lint && npm run format:check && npm run lint:scss",
Expand Down
72 changes: 40 additions & 32 deletions vueManager/src/components/CategoryProductsGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import draggable from 'vuedraggable'

import { useCategoryProductsInlineEdit } from '../composables/useCategoryProductsInlineEdit.js'
import { useSelection } from '../composables/useSelection.js'
import { useStaleRequestGuard } from '../composables/useStaleRequestGuard.js'
import {
GridColumnEditorType,
isSelectLikeEditorType,
Expand Down Expand Up @@ -58,6 +59,7 @@ const {

const columns = ref([])
const filters = ref({})
const { runGuarded } = useStaleRequestGuard()
const loading = ref(false)
const products = ref([])
const totalRecords = ref(0)
Expand Down Expand Up @@ -128,43 +130,51 @@ function nestedMutationParams() {
* Load products list
*/
async function loadProducts() {
loading.value = true

try {
const params = {
start: first.value,
limit: rows.value,
sort: sortField.value,
dir: sortOrder.value === 1 ? 'ASC' : 'DESC',
nested: nested.value ? 1 : 0,
}
await runGuarded(loading, async (signal, isCurrent) => {
const params = {
start: first.value,
limit: rows.value,
sort: sortField.value,
dir: sortOrder.value === 1 ? 'ASC' : 'DESC',
nested: nested.value ? 1 : 0,
}

// Apply filter values. Option-type columns are JOIN-ed at runtime — backend
// reads their filters as `filter_{fieldName}` (see CategoryProductsListService).
// Builtin product/data filters keep the original direct-param contract.
Object.keys(filterValues.value).forEach(key => {
const value = filterValues.value[key]
if (value === null || value === undefined || value === '') {
return
}
const col = columns.value.find(c => c.name === key)
if (col && col.type === 'option') {
params[`filter_${key}`] = value
} else {
params[key] = value
}
})

const response = await request.get(
`/api/mgr/categories/${props.categoryId}/products`,
params,
{ signal }
)

// Apply filter values. Option-type columns are JOIN-ed at runtime — backend
// reads their filters as `filter_{fieldName}` (see CategoryProductsListService).
// Builtin product/data filters keep the original direct-param contract.
Object.keys(filterValues.value).forEach(key => {
const value = filterValues.value[key]
if (value === null || value === undefined || value === '') {
if (!isCurrent()) {
return
}
const col = columns.value.find(c => c.name === key)
if (col && col.type === 'option') {
params[`filter_${key}`] = value

if (response && response.results) {
products.value = response.results
totalRecords.value = response.total || 0
} else {
params[key] = value
console.error('[CategoryProductsGrid] Invalid response:', response)
products.value = []
totalRecords.value = 0
}
})

const response = await request.get(`/api/mgr/categories/${props.categoryId}/products`, params)

if (response && response.results) {
products.value = response.results
totalRecords.value = response.total || 0
} else {
console.error('[CategoryProductsGrid] Invalid response:', response)
products.value = []
totalRecords.value = 0
}
} catch (error) {
console.error('[CategoryProductsGrid] Error loading products:', error)
toast.add({
Expand All @@ -173,8 +183,6 @@ async function loadProducts() {
detail: error.message || _('error_loading_data'),
life: 5000,
})
} finally {
loading.value = false
}
}

Expand Down
98 changes: 51 additions & 47 deletions vueManager/src/components/OrdersGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { computed, onMounted, ref } from 'vue'

import { useGridFilterParams } from '../composables/useGridFilterParams.js'
import { useSelection } from '../composables/useSelection.js'
import { useStaleRequestGuard } from '../composables/useStaleRequestGuard.js'
import request from '../request.js'
import { formatLocalDateYmd } from '../utils/formatLocalDateYmd.js'
import ActionsColumn from './ActionsColumn.vue'
Expand Down Expand Up @@ -60,6 +61,7 @@ const {
const columns = ref([])
const filters = ref({})
const { setDirectFilterKeys, addFilterParam } = useGridFilterParams()
const { runGuarded } = useStaleRequestGuard()
const loading = ref(false)
const orders = ref([])
const totalRecords = ref(0)
Expand All @@ -86,58 +88,62 @@ const sortedFilters = computed(() => {
* Load orders list
*/
async function loadOrders() {
loading.value = true

try {
const params = {
start: first.value,
limit: rows.value,
sort: sortField.value,
dir: sortOrder.value === 1 ? 'ASC' : 'DESC',
show_drafts: showDrafts.value ? 1 : 0,
}
await runGuarded(loading, async (signal, isCurrent) => {
const params = {
start: first.value,
limit: rows.value,
sort: sortField.value,
dir: sortOrder.value === 1 ? 'ASC' : 'DESC',
show_drafts: showDrafts.value ? 1 : 0,
}

// Apply filter values
Object.keys(filterValues.value).forEach(key => {
const value = filterValues.value[key]
if (value !== null && value !== undefined && value !== '') {
const filterConfig = filters.value[key]
if (filterConfig?.type === 'daterange' && Array.isArray(value)) {
if (value[0]) {
addFilterParam(
params,
filterConfig.fields?.from || `${key}_from`,
formatLocalDateYmd(value[0])
)
// Apply filter values
Object.keys(filterValues.value).forEach(key => {
const value = filterValues.value[key]
if (value !== null && value !== undefined && value !== '') {
const filterConfig = filters.value[key]
if (filterConfig?.type === 'daterange' && Array.isArray(value)) {
if (value[0]) {
addFilterParam(
params,
filterConfig.fields?.from || `${key}_from`,
formatLocalDateYmd(value[0])
)
}
if (value[1]) {
addFilterParam(
params,
filterConfig.fields?.to || `${key}_to`,
formatLocalDateYmd(value[1])
)
}
} else if (filterConfig?.type === 'datepicker' && value) {
addFilterParam(params, key, formatLocalDateYmd(value))
} else {
addFilterParam(params, key, value)
}
if (value[1]) {
addFilterParam(
params,
filterConfig.fields?.to || `${key}_to`,
formatLocalDateYmd(value[1])
)
}
} else if (filterConfig?.type === 'datepicker' && value) {
addFilterParam(params, key, formatLocalDateYmd(value))
} else {
addFilterParam(params, key, value)
}
}
})
})

const response = await request.get('/api/mgr/orders', params)
const response = await request.get('/api/mgr/orders', params, { signal })

if (response && response.results) {
orders.value = response.results
totalRecords.value = response.total || 0
if (response.stats) {
stats.value = response.stats
if (!isCurrent()) {
return
}
} else {
console.error('[OrdersGrid] Invalid response:', response)
orders.value = []
totalRecords.value = 0
}

if (response && response.results) {
orders.value = response.results
totalRecords.value = response.total || 0
if (response.stats) {
stats.value = response.stats
}
} else {
console.error('[OrdersGrid] Invalid response:', response)
orders.value = []
totalRecords.value = 0
}
})
} catch (error) {
console.error('[OrdersGrid] Error loading orders:', error)
toast.add({
Expand All @@ -146,8 +152,6 @@ async function loadOrders() {
detail: error.message || _('error_loading_data'),
life: 5000,
})
} finally {
loading.value = false
}
}

Expand Down
61 changes: 61 additions & 0 deletions vueManager/src/composables/useStaleRequestGuard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { onScopeDispose } from 'vue'

/**
* Guards async list loads against stale responses when pagination/filters change quickly (#385).
*
* Each beginLoad() aborts the previous in-flight fetch and bumps a sequence id.
* Prefer runGuarded() in grid loaders to centralize loading/abort/stale handling.
*/
export function useStaleRequestGuard() {
let loadSeq = 0
/** @type {AbortController | null} */
let abortController = null

onScopeDispose(() => {
abortController?.abort()
abortController = null
})

/**
* @returns {{ seq: number, signal: AbortSignal, isCurrent: () => boolean }}
*/
function beginLoad() {
abortController?.abort()
abortController = new AbortController()
const seq = ++loadSeq

return {
seq,
signal: abortController.signal,
isCurrent: () => seq === loadSeq,
}
}

function isAbortError(error) {
return error?.name === 'AbortError'
}

/**
* @param {import('vue').Ref<boolean>} loadingRef
* @param {(signal: AbortSignal, isCurrent: () => boolean) => Promise<void>} task
*/
async function runGuarded(loadingRef, task) {
const load = beginLoad()
loadingRef.value = true

try {
await task(load.signal, load.isCurrent)
} catch (error) {
if (isAbortError(error) || !load.isCurrent()) {
return
}
throw error
} finally {
if (load.isCurrent()) {
loadingRef.value = false
}
}
}

return { beginLoad, isAbortError, runGuarded }
}
8 changes: 8 additions & 0 deletions vueManager/src/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ class Request {
credentials: 'same-origin',
}

if (options.signal) {
fetchOptions.signal = options.signal
}

let url

if (method === 'GET' && data) {
Expand Down Expand Up @@ -144,6 +148,10 @@ class Request {
throw error
}

if (error?.name === 'AbortError') {
throw error
}

throw new RequestError(error.message || 'Network error', 0, { originalError: error })
}
}
Expand Down
Loading