fix(web): 允许在列表页核对编辑结果
This commit is contained in:
8 files changed
+200
-27
No files matched your search
@@ -797,9 +797,7 @@ describe('AccessKeyDrawer', () => {
|
||||
|
||||
const check = element<HTMLButtonElement>('[data-test="access-key-save"]')
|
||||
expect(check.textContent).toContain('Check result')
|
||||
expect(element<HTMLButtonElement>('.app-drawer__close').disabled).toBe(true)
|
||||
requestDrawerClose('close')
|
||||
expect(wrapper.emitted('update:open') ?? []).not.toContainEqual([false])
|
||||
expect(element<HTMLButtonElement>('.app-drawer__close').disabled).toBe(false)
|
||||
|
||||
check.click()
|
||||
await flushPromises()
|
||||
@@ -828,7 +826,7 @@ describe('AccessKeyDrawer', () => {
|
||||
expect(element<HTMLButtonElement>('[data-test="access-key-save"]').textContent).toContain(
|
||||
'Check result',
|
||||
)
|
||||
expect(element<HTMLButtonElement>('.app-drawer__close').disabled).toBe(true)
|
||||
expect(element<HTMLButtonElement>('.app-drawer__close').disabled).toBe(false)
|
||||
}
|
||||
wrapper.unmount()
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
revealAccessKey,
|
||||
updateAccessKey,
|
||||
type CreateAccessKeyRequest,
|
||||
type UpdateAccessKeyRequest,
|
||||
} from '@/api/control/access-keys'
|
||||
import type { AccessKeyDto, AccessProtocol, GroupSummary } from '@/api/control/types'
|
||||
import { RequestCancelledError } from '@/api/errors'
|
||||
@@ -26,6 +25,7 @@ import SecretValue from '@/components/ui/SecretValue.vue'
|
||||
|
||||
import { accessKeyProtocolOptions, buildAccessKeyModelOptions } from './access-key-options'
|
||||
import type { PendingAccessKeyCreateOperation } from './access-key-create-operation'
|
||||
import type { PendingAccessKeyEditOperation } from './access-key-edit-operation'
|
||||
import {
|
||||
materializeAccessKeyFilters,
|
||||
validateAccessKeyScope,
|
||||
@@ -45,11 +45,6 @@ import {
|
||||
} from './access-key-patch'
|
||||
import { useEphemeralSecret } from './use-ephemeral-secret'
|
||||
|
||||
interface PendingAccessKeyEditReconciliation {
|
||||
base: AccessKeyDto
|
||||
patch: UpdateAccessKeyRequest
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean
|
||||
@@ -57,12 +52,14 @@ const props = withDefaults(
|
||||
groups: GroupSummary[]
|
||||
groupCatalogState?: GroupCatalogState
|
||||
createOperation?: PendingAccessKeyCreateOperation | null
|
||||
editOperation?: PendingAccessKeyEditOperation | null
|
||||
}>(),
|
||||
{ createOperation: null, groupCatalogState: 'ready' },
|
||||
{ createOperation: null, editOperation: null, groupCatalogState: 'ready' },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
'update:createOperation': [operation: PendingAccessKeyCreateOperation | null]
|
||||
'update:editOperation': [operation: PendingAccessKeyEditOperation | null]
|
||||
}>()
|
||||
const client = useApiClient()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -74,10 +71,11 @@ const result = ref<AccessKeyDto | null>(null)
|
||||
const operationID = ref('')
|
||||
const createPayload = ref<CreateAccessKeyRequest | null>(null)
|
||||
const createOperationRetained = ref(false)
|
||||
const editOperationRetained = ref(false)
|
||||
const pending = ref(false)
|
||||
const failed = ref(false)
|
||||
const mutationState = ref<'idle' | 'indeterminate' | 'reconciling'>('idle')
|
||||
const editReconciliation = ref<PendingAccessKeyEditReconciliation | null>(null)
|
||||
const editReconciliation = ref<PendingAccessKeyEditOperation | null>(null)
|
||||
const editNotApplied = ref(false)
|
||||
const revealPending = ref(false)
|
||||
const revealFailed = ref(false)
|
||||
@@ -97,7 +95,7 @@ const createOperationActive = computed(
|
||||
const formLocked = computed(
|
||||
() => pending.value || createOperationActive.value || editReconciliation.value !== null,
|
||||
)
|
||||
const closeBlocked = computed(() => pending.value || editReconciliation.value !== null)
|
||||
const closeBlocked = computed(() => pending.value)
|
||||
const resultSecret = computed(() =>
|
||||
result.value ? ephemeralSecret.read(`access-key:${result.value.id}`) : null,
|
||||
)
|
||||
@@ -110,7 +108,8 @@ const unsavedDirty = computed(
|
||||
() =>
|
||||
dirty.value &&
|
||||
!createCompleted.value &&
|
||||
!(createOperationActive.value && createOperationRetained.value),
|
||||
!(createOperationActive.value && createOperationRetained.value) &&
|
||||
!(editReconciliation.value && editOperationRetained.value),
|
||||
)
|
||||
const groupCatalog = computed(() => ({
|
||||
state: props.groupCatalogState,
|
||||
@@ -195,6 +194,7 @@ function clearLocalState(): void {
|
||||
operationID.value = ''
|
||||
createPayload.value = null
|
||||
createOperationRetained.value = false
|
||||
editOperationRetained.value = false
|
||||
pending.value = false
|
||||
revealPending.value = false
|
||||
revealFailed.value = false
|
||||
@@ -206,21 +206,34 @@ function clearLocalState(): void {
|
||||
}
|
||||
|
||||
async function resetForOpen(): Promise<void> {
|
||||
const carriedOperation = props.accessKey ? null : props.createOperation
|
||||
base.value = props.accessKey
|
||||
draft.value = carriedOperation
|
||||
? createAccessKeyDraftFromCreateInput(carriedOperation.payload)
|
||||
: createAccessKeyDraft(props.accessKey)
|
||||
const carriedCreateOperation = props.accessKey ? null : props.createOperation
|
||||
const carriedEditOperation =
|
||||
props.accessKey && props.editOperation?.base.id === props.accessKey.id
|
||||
? props.editOperation
|
||||
: null
|
||||
base.value = carriedEditOperation?.base ?? props.accessKey
|
||||
draft.value = carriedCreateOperation
|
||||
? createAccessKeyDraftFromCreateInput(carriedCreateOperation.payload)
|
||||
: carriedEditOperation
|
||||
? createAccessKeyDraft({
|
||||
...carriedEditOperation.base,
|
||||
...carriedEditOperation.patch,
|
||||
filters: carriedEditOperation.patch.filters ?? carriedEditOperation.base.filters,
|
||||
})
|
||||
: createAccessKeyDraft(props.accessKey)
|
||||
result.value = null
|
||||
operationID.value = props.accessKey
|
||||
? ''
|
||||
: (carriedOperation?.idempotencyKey ?? crypto.randomUUID())
|
||||
createPayload.value = carriedOperation ? cloneCreatePayload(carriedOperation.payload) : null
|
||||
createOperationRetained.value = carriedOperation !== null
|
||||
: (carriedCreateOperation?.idempotencyKey ?? crypto.randomUUID())
|
||||
createPayload.value = carriedCreateOperation
|
||||
? cloneCreatePayload(carriedCreateOperation.payload)
|
||||
: null
|
||||
createOperationRetained.value = carriedCreateOperation !== null
|
||||
editOperationRetained.value = carriedEditOperation !== null
|
||||
ephemeralSecret.clear()
|
||||
failed.value = false
|
||||
mutationState.value = carriedOperation?.state ?? 'idle'
|
||||
editReconciliation.value = null
|
||||
mutationState.value = carriedCreateOperation?.state ?? carriedEditOperation?.state ?? 'idle'
|
||||
editReconciliation.value = carriedEditOperation
|
||||
editNotApplied.value = false
|
||||
modelInput.value = ''
|
||||
await nextTick()
|
||||
@@ -371,6 +384,8 @@ async function save(): Promise<void> {
|
||||
draft.value = createAccessKeyDraft(saved)
|
||||
result.value = null
|
||||
editReconciliation.value = null
|
||||
editOperationRetained.value = false
|
||||
emit('update:editOperation', null)
|
||||
} else {
|
||||
const saved = await createAccessKey(
|
||||
client,
|
||||
@@ -441,10 +456,14 @@ async function save(): Promise<void> {
|
||||
updateBody &&
|
||||
(outcome.kind === 'indeterminate' || outcome.kind === 'reconciling')
|
||||
) {
|
||||
editReconciliation.value = {
|
||||
const operation: PendingAccessKeyEditOperation = {
|
||||
base: currentBase,
|
||||
patch: updateBody,
|
||||
state: outcome.kind,
|
||||
}
|
||||
editReconciliation.value = operation
|
||||
editOperationRetained.value = true
|
||||
emit('update:editOperation', operation)
|
||||
}
|
||||
mutationState.value =
|
||||
outcome.kind === 'reconciling'
|
||||
@@ -479,6 +498,8 @@ async function reconcileEdit(): Promise<void> {
|
||||
const latest = accessKeys.find((accessKey) => accessKey.id === attempt.base.id)
|
||||
if (!latest) {
|
||||
editReconciliation.value = null
|
||||
editOperationRetained.value = false
|
||||
emit('update:editOperation', null)
|
||||
mutationState.value = 'idle'
|
||||
failed.value = true
|
||||
return
|
||||
@@ -487,6 +508,8 @@ async function reconcileEdit(): Promise<void> {
|
||||
base.value = latest
|
||||
draft.value = createAccessKeyDraft(latest)
|
||||
editReconciliation.value = null
|
||||
editOperationRetained.value = false
|
||||
emit('update:editOperation', null)
|
||||
mutationState.value = 'idle'
|
||||
return
|
||||
}
|
||||
@@ -496,19 +519,33 @@ async function reconcileEdit(): Promise<void> {
|
||||
) {
|
||||
base.value = latest
|
||||
editReconciliation.value = null
|
||||
editOperationRetained.value = false
|
||||
emit('update:editOperation', null)
|
||||
mutationState.value = 'idle'
|
||||
failed.value = true
|
||||
editNotApplied.value = true
|
||||
return
|
||||
}
|
||||
mutationState.value = 'indeterminate'
|
||||
const operation: PendingAccessKeyEditOperation = {
|
||||
...attempt,
|
||||
state: 'indeterminate',
|
||||
}
|
||||
editReconciliation.value = operation
|
||||
emit('update:editOperation', operation)
|
||||
mutationState.value = operation.state
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
controller === activeController &&
|
||||
editReconciliation.value === attempt &&
|
||||
!(error instanceof RequestCancelledError)
|
||||
) {
|
||||
mutationState.value = 'indeterminate'
|
||||
const operation: PendingAccessKeyEditOperation = {
|
||||
...attempt,
|
||||
state: 'indeterminate',
|
||||
}
|
||||
editReconciliation.value = operation
|
||||
emit('update:editOperation', operation)
|
||||
mutationState.value = operation.state
|
||||
}
|
||||
} finally {
|
||||
if (controller === activeController) {
|
||||
|
||||
@@ -283,6 +283,77 @@ describe('AccessKeysView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps an unknown edit operation at page scope after the drawer closes', async () => {
|
||||
const applied: AccessKeyDto = { ...keys[0], name: 'renamed-client' }
|
||||
const secondary: AccessKeyDto = {
|
||||
...keys[0],
|
||||
id: 10,
|
||||
name: 'secondary-client',
|
||||
masked_key: 'sk-gl-••••••••beef',
|
||||
}
|
||||
let listCalls = 0
|
||||
const requestMock = vi.fn(async (path: string, options?: ApiRequestOptions) => {
|
||||
if (path === '/api/groups' && options?.method === 'GET') return groups
|
||||
if (path === '/api/access-keys' && options?.method === 'GET') {
|
||||
listCalls += 1
|
||||
return listCalls === 1 ? [keys[0], secondary] : [applied, secondary]
|
||||
}
|
||||
if (path === '/api/access-keys/9' && options?.method === 'PUT') throw new NetworkError()
|
||||
throw new Error(`unexpected ${path}`)
|
||||
})
|
||||
const confirm = vi.fn(() => false)
|
||||
vi.stubGlobal('confirm', confirm)
|
||||
const { wrapper } = await mountView(requestMock as ApiClient['request'])
|
||||
|
||||
await wrapper.get('[data-test="access-key-edit-9"]').trigger('click')
|
||||
await flushPromises()
|
||||
const name = document.querySelector<HTMLInputElement>('[data-test="access-key-name"]')
|
||||
if (!name) throw new Error('missing edit name')
|
||||
name.value = 'renamed-client'
|
||||
name.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
documentButton('[data-test="access-key-save"]').click()
|
||||
await flushPromises()
|
||||
|
||||
const close = documentButton('.app-drawer__close')
|
||||
expect(close.disabled).toBe(false)
|
||||
close.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
expect(wrapper.get('[data-test="access-key-edit-operation-notice"]').text()).toContain(
|
||||
'renamed-client',
|
||||
)
|
||||
expect(wrapper.get('[data-test="access-key-edit-operation-notice"]').text()).toContain(
|
||||
'unknown outcome',
|
||||
)
|
||||
|
||||
await wrapper.get('[data-test="access-key-edit-10"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector<HTMLInputElement>('[data-test="access-key-name"]')?.value).toBe(
|
||||
'renamed-client',
|
||||
)
|
||||
documentButton('.app-drawer__close').click()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-test="access-key-edit-operation-check"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
documentButton('[data-test="access-key-save"]').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
requestMock.mock.calls.filter(
|
||||
([path, options]) => path === '/api/access-keys/9' && options?.method === 'PUT',
|
||||
),
|
||||
).toHaveLength(1)
|
||||
expect(listCalls).toBe(2)
|
||||
expect(wrapper.find('[data-test="access-key-edit-operation-notice"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not focus detached content when deleted is followed by immediate unmount', async () => {
|
||||
const request = vi.fn(async (path: string, options?: ApiRequestOptions) => {
|
||||
if (path === '/api/access-keys' && options?.method === 'GET') return keys
|
||||
|
||||
@@ -19,6 +19,7 @@ import QueryFeedback from '@/components/ui/QueryFeedback.vue'
|
||||
import AccessKeyDrawer from './AccessKeyDrawer.vue'
|
||||
import AccessKeyTable from './AccessKeyTable.vue'
|
||||
import type { PendingAccessKeyCreateOperation } from './access-key-create-operation'
|
||||
import type { PendingAccessKeyEditOperation } from './access-key-edit-operation'
|
||||
|
||||
const client = useApiClient()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -26,6 +27,7 @@ const { t } = useI18n()
|
||||
const drawerOpen = ref(false)
|
||||
const selected = ref<AccessKeyDto | null>(null)
|
||||
const createOperation = ref<PendingAccessKeyCreateOperation | null>(null)
|
||||
const editOperation = ref<PendingAccessKeyEditOperation | null>(null)
|
||||
const viewRoot = ref<HTMLElement | null>(null)
|
||||
const deletionAnnouncement = ref('')
|
||||
let restoreFocus: HTMLElement | null = null
|
||||
@@ -54,6 +56,14 @@ const operationNoticeKey = computed(() =>
|
||||
? 'accessKeys.operation.reconciling'
|
||||
: 'accessKeys.operation.indeterminate',
|
||||
)
|
||||
const editOperationNoticeKey = computed(() =>
|
||||
editOperation.value?.state === 'reconciling'
|
||||
? 'accessKeys.operation.editReconciling'
|
||||
: 'accessKeys.operation.editIndeterminate',
|
||||
)
|
||||
const editOperationName = computed(
|
||||
() => editOperation.value?.patch.name ?? editOperation.value?.base.name ?? '',
|
||||
)
|
||||
|
||||
function createKey(): void {
|
||||
selected.value = null
|
||||
@@ -62,6 +72,10 @@ function createKey(): void {
|
||||
}
|
||||
|
||||
function editKey(accessKey: AccessKeyDto, trigger: HTMLElement): void {
|
||||
if (editOperation.value && editOperation.value.base.id !== accessKey.id) {
|
||||
checkEditOperation()
|
||||
return
|
||||
}
|
||||
selected.value = accessKey
|
||||
restoreFocus = trigger
|
||||
drawerOpen.value = true
|
||||
@@ -71,6 +85,10 @@ function setCreateOperation(operation: PendingAccessKeyCreateOperation | null):
|
||||
createOperation.value = operation
|
||||
}
|
||||
|
||||
function setEditOperation(operation: PendingAccessKeyEditOperation | null): void {
|
||||
editOperation.value = operation
|
||||
}
|
||||
|
||||
function checkCreateOperation(): void {
|
||||
if (!createOperation.value) return
|
||||
selected.value = null
|
||||
@@ -78,6 +96,16 @@ function checkCreateOperation(): void {
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function checkEditOperation(): void {
|
||||
const operation = editOperation.value
|
||||
if (!operation) return
|
||||
selected.value =
|
||||
accessKeysQuery.data.value?.find((accessKey) => accessKey.id === operation.base.id) ??
|
||||
operation.base
|
||||
restoreFocus = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function setDrawerOpen(open: boolean): Promise<void> {
|
||||
drawerOpen.value = open
|
||||
if (!open) {
|
||||
@@ -119,7 +147,9 @@ async function focusCreateAfterDelete(name: string): Promise<void> {
|
||||
:groups="groupsQuery.data.value ?? []"
|
||||
:group-catalog-state="groupCatalogState"
|
||||
:create-operation="createOperation"
|
||||
:edit-operation="selected?.id === editOperation?.base.id ? editOperation : null"
|
||||
@update:create-operation="setCreateOperation"
|
||||
@update:edit-operation="setEditOperation"
|
||||
@update:open="setDrawerOpen"
|
||||
>
|
||||
<template #trigger>
|
||||
@@ -147,6 +177,24 @@ async function focusCreateAfterDelete(name: string): Promise<void> {
|
||||
</AppButton>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="editOperation"
|
||||
class="access-keys__operation"
|
||||
data-test="access-key-edit-operation-notice"
|
||||
aria-live="polite"
|
||||
>
|
||||
<InlineFeedback tone="warning">{{
|
||||
t(editOperationNoticeKey, { name: editOperationName })
|
||||
}}</InlineFeedback>
|
||||
<AppButton
|
||||
data-test="access-key-edit-operation-check"
|
||||
variant="secondary"
|
||||
@click="checkEditOperation"
|
||||
>
|
||||
{{ t('accessKeys.operation.checkResult') }}
|
||||
</AppButton>
|
||||
</section>
|
||||
|
||||
<p
|
||||
class="sr-only"
|
||||
data-test="access-key-delete-announcement"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { UpdateAccessKeyRequest } from '@/api/control/access-keys'
|
||||
import type { AccessKeyDto } from '@/api/control/types'
|
||||
|
||||
export interface PendingAccessKeyEditOperation {
|
||||
base: AccessKeyDto
|
||||
patch: UpdateAccessKeyRequest
|
||||
state: 'indeterminate' | 'reconciling'
|
||||
}
|
||||
@@ -24,6 +24,10 @@ export default {
|
||||
indeterminate:
|
||||
'The AccessKey create outcome is unknown. Reconcile it before starting another create.',
|
||||
reconciling: 'The AccessKey was committed and runtime recovery is still in progress.',
|
||||
editIndeterminate:
|
||||
'The update to AccessKey “{name}” has an unknown outcome. Check current metadata before editing another AccessKey.',
|
||||
editReconciling:
|
||||
'The update to AccessKey “{name}” was committed and still needs metadata reconciliation.',
|
||||
checkResult: 'Check result',
|
||||
},
|
||||
status: {
|
||||
|
||||
@@ -24,6 +24,10 @@ export default {
|
||||
indeterminate:
|
||||
'AccessKey の作成結果は不明です。新しい作成を始める前に、この操作の結果を確認してください。',
|
||||
reconciling: 'AccessKey は確定しましたが、ランタイムの復旧処理が続いています。',
|
||||
editIndeterminate:
|
||||
'AccessKey「{name}」の更新結果は不明です。別の AccessKey を編集する前に現在のメタデータを確認してください。',
|
||||
editReconciling:
|
||||
'AccessKey「{name}」の更新は確定しましたが、現在のメタデータを確認する必要があります。',
|
||||
checkResult: '結果を確認',
|
||||
},
|
||||
status: {
|
||||
|
||||
@@ -23,6 +23,9 @@ export default {
|
||||
operation: {
|
||||
indeterminate: 'AccessKey 创建结果未知;开始新的创建前,请先核对本次操作。',
|
||||
reconciling: 'AccessKey 已提交,运行时仍在恢复。',
|
||||
editIndeterminate:
|
||||
'AccessKey“{name}”的更新结果未知;编辑其他 AccessKey 前请先核对当前元数据。',
|
||||
editReconciling: 'AccessKey“{name}”的更新已提交,仍需核对当前元数据。',
|
||||
checkResult: '核对结果',
|
||||
},
|
||||
status: {
|
||||
|
||||
Reference in new issue
Block a user