mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(redeem): add redeem code batch update
This commit is contained in:
@@ -39,6 +39,15 @@
|
||||
<button @click="handleExportCodes" class="btn btn-secondary">
|
||||
{{ t('admin.redeem.exportCsv') }}
|
||||
</button>
|
||||
<button
|
||||
data-test="batch-update-open"
|
||||
@click="openBatchUpdateDialog"
|
||||
:disabled="selectedCount === 0 || batchUpdating"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<Icon name="edit" size="md" class="mr-2" />
|
||||
{{ t('admin.redeem.batchUpdate') }}
|
||||
</button>
|
||||
<button @click="showGenerateDialog = true" class="btn btn-primary">
|
||||
{{ t('admin.redeem.generateCodes') }}
|
||||
</button>
|
||||
@@ -56,6 +65,28 @@
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #header-select>
|
||||
<input
|
||||
data-test="select-all-codes"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
:checked="allVisibleSelected"
|
||||
@click.stop
|
||||
@change="toggleSelectAllVisible($event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-select="{ row }">
|
||||
<input
|
||||
data-test="select-code"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
:checked="selectedCodeIds.has(row.id)"
|
||||
@click.stop
|
||||
@change="toggleSelectRow(row.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-code="{ value }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
|
||||
@@ -174,6 +205,31 @@
|
||||
</template>
|
||||
|
||||
<template #pagination>
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
class="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg bg-primary-50 p-3 dark:bg-primary-900/20"
|
||||
>
|
||||
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.redeem.selectedCount', { count: selectedCount }) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
@click="clearSelectedCodes"
|
||||
>
|
||||
{{ t('admin.redeem.clearSelection') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="openBatchUpdateDialog"
|
||||
>
|
||||
{{ t('admin.redeem.batchUpdate') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
@@ -353,6 +409,117 @@
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Batch Update Dialog -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showBatchUpdateDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="fixed inset-0 bg-black/50" @click="closeBatchUpdateDialog"></div>
|
||||
<div
|
||||
class="relative z-10 w-full max-w-lg rounded-xl bg-white p-6 shadow-xl dark:bg-dark-800"
|
||||
>
|
||||
<h2 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.redeem.batchUpdateTitle') }}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.redeem.selectedCount', { count: selectedCount }) }}
|
||||
</p>
|
||||
|
||||
<form data-test="batch-update-form" class="space-y-4" @submit.prevent="handleBatchUpdate">
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
data-test="batch-field-status"
|
||||
v-model="batchUpdateForm.update_status"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
{{ t('admin.redeem.batchFields.status') }}
|
||||
</label>
|
||||
<Select
|
||||
v-if="batchUpdateForm.update_status"
|
||||
v-model="batchUpdateForm.status"
|
||||
data-test="batch-status-select"
|
||||
:options="batchStatusOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
v-model="batchUpdateForm.update_expires_at"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
{{ t('admin.redeem.batchFields.expiresAt') }}
|
||||
</label>
|
||||
<template v-if="batchUpdateForm.update_expires_at">
|
||||
<Select v-model="batchUpdateForm.expires_mode" :options="batchExpiryModeOptions" />
|
||||
<input
|
||||
v-if="batchUpdateForm.expires_mode === 'custom'"
|
||||
v-model="batchUpdateForm.expires_at_local"
|
||||
type="datetime-local"
|
||||
class="input"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
data-test="batch-field-notes"
|
||||
v-model="batchUpdateForm.update_notes"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
{{ t('admin.redeem.batchFields.notes') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-if="batchUpdateForm.update_notes"
|
||||
data-test="batch-notes-input"
|
||||
v-model="batchUpdateForm.notes"
|
||||
rows="3"
|
||||
class="input"
|
||||
:placeholder="t('admin.redeem.batchNotesPlaceholder')"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
v-model="batchUpdateForm.update_group_id"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
{{ t('admin.redeem.batchFields.group') }}
|
||||
</label>
|
||||
<Select
|
||||
v-if="batchUpdateForm.update_group_id"
|
||||
v-model="batchUpdateForm.group_id"
|
||||
:options="batchGroupOptions"
|
||||
:placeholder="t('admin.redeem.selectGroupPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" @click="closeBatchUpdateDialog" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
data-test="batch-update-submit"
|
||||
type="submit"
|
||||
:disabled="batchUpdating"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ batchUpdating ? t('common.submitting') : t('admin.redeem.batchUpdate') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Generated Codes Result Dialog -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showResultDialog" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
@@ -445,10 +612,18 @@ import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useTableSelection } from '@/composables/useTableSelection'
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { RedeemCode, RedeemCodeType, Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type {
|
||||
RedeemCode,
|
||||
RedeemCodeType,
|
||||
Group,
|
||||
GroupPlatform,
|
||||
SubscriptionType,
|
||||
BatchUpdateRedeemCodeFields
|
||||
} from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
@@ -492,6 +667,11 @@ const subscriptionGroupOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const batchGroupOptions = computed(() => [
|
||||
{ value: null, label: t('admin.redeem.clearGroup') },
|
||||
...subscriptionGroupOptions.value
|
||||
])
|
||||
|
||||
const generatedCodesText = computed(() => {
|
||||
return generatedCodes.value.map((code) => code.code).join('\n')
|
||||
})
|
||||
@@ -540,6 +720,7 @@ const downloadGeneratedCodes = () => {
|
||||
}
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'select', label: '' },
|
||||
{ key: 'code', label: t('admin.redeem.columns.code') },
|
||||
{ key: 'type', label: t('admin.redeem.columns.type'), sortable: true },
|
||||
{ key: 'value', label: t('admin.redeem.columns.value'), sortable: true },
|
||||
@@ -569,12 +750,24 @@ const filterStatusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.redeem.allStatus') },
|
||||
{ value: 'unused', label: t('admin.redeem.unused') },
|
||||
{ value: 'used', label: t('admin.redeem.used') },
|
||||
{ value: 'expired', label: t('admin.redeem.status.expired') }
|
||||
{ value: 'expired', label: t('admin.redeem.status.expired') },
|
||||
{ value: 'disabled', label: t('admin.redeem.status.disabled') }
|
||||
])
|
||||
|
||||
const batchStatusOptions = computed(() => [
|
||||
{ value: 'unused', label: t('admin.redeem.status.unused') },
|
||||
{ value: 'disabled', label: t('admin.redeem.status.disabled') }
|
||||
])
|
||||
|
||||
const batchExpiryModeOptions = computed(() => [
|
||||
{ value: 'clear', label: t('admin.redeem.neverExpires') },
|
||||
{ value: 'custom', label: t('admin.redeem.customExpiry') }
|
||||
])
|
||||
|
||||
const codes = ref<RedeemCode[]>([])
|
||||
const loading = ref(false)
|
||||
const generating = ref(false)
|
||||
const batchUpdating = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
type: '',
|
||||
@@ -595,9 +788,35 @@ let abortController: AbortController | null = null
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const showDeleteUnusedDialog = ref(false)
|
||||
const showBatchUpdateDialog = ref(false)
|
||||
const deletingCode = ref<RedeemCode | null>(null)
|
||||
const copiedCode = ref<string | null>(null)
|
||||
|
||||
const {
|
||||
selectedSet: selectedCodeIds,
|
||||
selectedCount,
|
||||
allVisibleSelected,
|
||||
select,
|
||||
deselect,
|
||||
clear: clearSelectedCodes,
|
||||
toggleVisible
|
||||
} = useTableSelection<RedeemCode>({
|
||||
rows: codes,
|
||||
getId: (code) => code.id
|
||||
})
|
||||
|
||||
const batchUpdateForm = reactive({
|
||||
update_status: false,
|
||||
status: 'disabled' as 'unused' | 'disabled',
|
||||
update_expires_at: false,
|
||||
expires_mode: 'clear' as 'clear' | 'custom',
|
||||
expires_at_local: '',
|
||||
update_notes: false,
|
||||
notes: '',
|
||||
update_group_id: false,
|
||||
group_id: null as number | null
|
||||
})
|
||||
|
||||
type RedeemCodeExpiryOption = 'never' | '1' | '3' | '7' | 'custom'
|
||||
|
||||
const redeemCodeExpiryOptions = computed<{ value: RedeemCodeExpiryOption; label: string }[]>(() => [
|
||||
@@ -632,7 +851,7 @@ watch(
|
||||
|
||||
const buildRedeemQueryFilters = () => ({
|
||||
type: (filters.type || undefined) as RedeemCodeType | undefined,
|
||||
status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | undefined,
|
||||
status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | 'disabled' | undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
@@ -705,6 +924,20 @@ const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const toggleSelectRow = (id: number, event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.checked) {
|
||||
select(id)
|
||||
return
|
||||
}
|
||||
deselect(id)
|
||||
}
|
||||
|
||||
const toggleSelectAllVisible = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
toggleVisible(target.checked)
|
||||
}
|
||||
|
||||
const getRedeemCodeExpiresInDays = () => {
|
||||
if (generateForm.expiry_option === 'never') {
|
||||
return undefined
|
||||
@@ -721,6 +954,69 @@ const getRedeemCodeExpiresInDays = () => {
|
||||
return Number(generateForm.expiry_option)
|
||||
}
|
||||
|
||||
const toDatetimeLocalInputValue = (date: Date) => {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
||||
date.getHours()
|
||||
)}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
const resetBatchUpdateForm = () => {
|
||||
batchUpdateForm.update_status = false
|
||||
batchUpdateForm.status = 'disabled'
|
||||
batchUpdateForm.update_expires_at = false
|
||||
batchUpdateForm.expires_mode = 'clear'
|
||||
batchUpdateForm.expires_at_local = toDatetimeLocalInputValue(
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
)
|
||||
batchUpdateForm.update_notes = false
|
||||
batchUpdateForm.notes = ''
|
||||
batchUpdateForm.update_group_id = false
|
||||
batchUpdateForm.group_id = null
|
||||
}
|
||||
|
||||
const openBatchUpdateDialog = () => {
|
||||
if (selectedCount.value === 0) {
|
||||
appStore.showInfo(t('admin.redeem.selectCodesFirst'))
|
||||
return
|
||||
}
|
||||
resetBatchUpdateForm()
|
||||
showBatchUpdateDialog.value = true
|
||||
}
|
||||
|
||||
const closeBatchUpdateDialog = () => {
|
||||
showBatchUpdateDialog.value = false
|
||||
}
|
||||
|
||||
const buildBatchUpdateFields = (): BatchUpdateRedeemCodeFields | null => {
|
||||
const fields: BatchUpdateRedeemCodeFields = {}
|
||||
|
||||
if (batchUpdateForm.update_status) {
|
||||
fields.status = batchUpdateForm.status
|
||||
}
|
||||
if (batchUpdateForm.update_expires_at) {
|
||||
if (batchUpdateForm.expires_mode === 'clear') {
|
||||
fields.expires_at = null
|
||||
} else {
|
||||
const expiresAt = new Date(batchUpdateForm.expires_at_local)
|
||||
if (!batchUpdateForm.expires_at_local || Number.isNaN(expiresAt.getTime())) {
|
||||
appStore.showError(t('admin.redeem.expiryDaysRequired'))
|
||||
return null
|
||||
}
|
||||
fields.expires_at = expiresAt.toISOString()
|
||||
}
|
||||
}
|
||||
if (batchUpdateForm.update_notes) {
|
||||
fields.notes = batchUpdateForm.notes
|
||||
}
|
||||
if (batchUpdateForm.update_group_id) {
|
||||
fields.group_id =
|
||||
batchUpdateForm.group_id == null ? null : Number(batchUpdateForm.group_id)
|
||||
}
|
||||
|
||||
return Object.keys(fields).length > 0 ? fields : null
|
||||
}
|
||||
|
||||
const handleGenerateCodes = async () => {
|
||||
// 订阅类型必须选择分组
|
||||
if (generateForm.type === 'subscription' && !generateForm.group_id) {
|
||||
@@ -834,6 +1130,43 @@ const confirmDeleteUnused = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchUpdate = async () => {
|
||||
const ids = Array.from(selectedCodeIds.value)
|
||||
if (ids.length === 0) {
|
||||
appStore.showInfo(t('admin.redeem.selectCodesFirst'))
|
||||
return
|
||||
}
|
||||
|
||||
const hasSelectedFields =
|
||||
batchUpdateForm.update_status ||
|
||||
batchUpdateForm.update_expires_at ||
|
||||
batchUpdateForm.update_notes ||
|
||||
batchUpdateForm.update_group_id
|
||||
if (!hasSelectedFields) {
|
||||
appStore.showError(t('admin.redeem.noBatchFieldsSelected'))
|
||||
return
|
||||
}
|
||||
|
||||
const fields = buildBatchUpdateFields()
|
||||
if (!fields) {
|
||||
return
|
||||
}
|
||||
|
||||
batchUpdating.value = true
|
||||
try {
|
||||
const result = await adminAPI.redeem.batchUpdate(ids, fields)
|
||||
appStore.showSuccess(t('admin.redeem.batchUpdateSuccess', { count: result.updated }))
|
||||
showBatchUpdateDialog.value = false
|
||||
clearSelectedCodes()
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToBatchUpdate'))
|
||||
console.error('Error batch updating codes:', error)
|
||||
} finally {
|
||||
batchUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载订阅类型分组
|
||||
const loadSubscriptionGroups = async () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import RedeemView from '../RedeemView.vue'
|
||||
|
||||
const { listRedeemCodes, batchUpdateRedeemCodes, getAllGroups, showSuccess, showError, showInfo } =
|
||||
vi.hoisted(() => ({
|
||||
listRedeemCodes: vi.fn(),
|
||||
batchUpdateRedeemCodes: vi.fn(),
|
||||
getAllGroups: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showInfo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
redeem: {
|
||||
list: listRedeemCodes,
|
||||
generate: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
batchDelete: vi.fn(),
|
||||
batchUpdate: batchUpdateRedeemCodes,
|
||||
exportCodes: vi.fn()
|
||||
},
|
||||
groups: {
|
||||
getAll: getAllGroups
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess,
|
||||
showError,
|
||||
showInfo
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copyToClipboard: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const DataTableStub = {
|
||||
props: ['columns', 'data'],
|
||||
template: `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column.key">
|
||||
<slot :name="'header-' + column.key" :column="column">{{ column.label }}</slot>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in data" :key="row.id">
|
||||
<td v-for="column in columns" :key="column.key">
|
||||
<slot :name="'cell-' + column.key" :row="row" :value="row[column.key]">
|
||||
{{ row[column.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
const SelectStub = {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup(props: { options: Array<{ value: unknown; label: string }> }, { emit }: { emit: (event: string, ...args: unknown[]) => void }) {
|
||||
const onChange = (event: Event) => {
|
||||
const raw = (event.target as HTMLSelectElement).value
|
||||
const option = props.options.find((item) => String(item.value ?? '') === raw)
|
||||
const value = option ? option.value : raw
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value, option ?? null)
|
||||
}
|
||||
return { onChange }
|
||||
},
|
||||
template: `
|
||||
<select v-bind="$attrs" :value="modelValue ?? ''" @change="onChange">
|
||||
<option v-for="option in options" :key="String(option.value ?? '')" :value="option.value ?? ''">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
|
||||
describe('admin RedeemView batch update', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
listRedeemCodes.mockReset()
|
||||
batchUpdateRedeemCodes.mockReset()
|
||||
getAllGroups.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showError.mockReset()
|
||||
showInfo.mockReset()
|
||||
|
||||
listRedeemCodes.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
code: 'CODE-1',
|
||||
type: 'balance',
|
||||
value: 10,
|
||||
status: 'unused',
|
||||
used_by: null,
|
||||
used_at: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
expires_at: null
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: 'CODE-2',
|
||||
type: 'balance',
|
||||
value: 20,
|
||||
status: 'unused',
|
||||
used_by: null,
|
||||
used_at: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
expires_at: null
|
||||
}
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1
|
||||
})
|
||||
batchUpdateRedeemCodes.mockResolvedValue({ updated: 1, message: 'ok' })
|
||||
getAllGroups.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('submits only checked fields for selected redeem codes', async () => {
|
||||
const wrapper = mount(RedeemView, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
TablePageLayout: {
|
||||
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
|
||||
},
|
||||
DataTable: DataTableStub,
|
||||
Pagination: true,
|
||||
ConfirmDialog: true,
|
||||
Select: SelectStub,
|
||||
GroupBadge: true,
|
||||
GroupOptionItem: true,
|
||||
Icon: true,
|
||||
Teleport: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.findAll('[data-test="select-code"]')[0].setValue(true)
|
||||
await wrapper.get('[data-test="batch-update-open"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-test="batch-field-status"]').setValue(true)
|
||||
await wrapper.get('[data-test="batch-status-select"]').setValue('disabled')
|
||||
await wrapper.get('[data-test="batch-field-notes"]').setValue(true)
|
||||
await wrapper.get('[data-test="batch-notes-input"]').setValue('maintenance')
|
||||
await wrapper.get('[data-test="batch-update-form"]').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(batchUpdateRedeemCodes).toHaveBeenCalledWith([1], {
|
||||
status: 'disabled',
|
||||
notes: 'maintenance'
|
||||
})
|
||||
expect(showSuccess).toHaveBeenCalledWith('admin.redeem.batchUpdateSuccess')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user