fix: 隔离消息推送编辑会话
This commit is contained in:
parent
b42a393a41
commit
1d07233114
@ -46,7 +46,6 @@ const mocks = vi.hoisted(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Creates a fluent no-op validation rule for schema-only unit tests. */
|
|
||||||
function createRule(): any {
|
function createRule(): any {
|
||||||
const rule: any = {};
|
const rule: any = {};
|
||||||
for (const method of ['max', 'min', 'optional', 'or', 'trim']) {
|
for (const method of ['max', 'min', 'optional', 'or', 'trim']) {
|
||||||
@ -60,7 +59,6 @@ vi.mock('#/adapter/form', () => ({
|
|||||||
mocks.formOptions = options;
|
mocks.formOptions = options;
|
||||||
const Form = defineComponent({
|
const Form = defineComponent({
|
||||||
name: 'MockForm',
|
name: 'MockForm',
|
||||||
/** Renders a stable form marker without owning form state. */
|
|
||||||
setup() {
|
setup() {
|
||||||
return () => h('form', { 'data-testid': 'subscription-form' });
|
return () => h('form', { 'data-testid': 'subscription-form' });
|
||||||
},
|
},
|
||||||
@ -78,7 +76,6 @@ vi.mock('@vben/common-ui', () => ({
|
|||||||
mocks.modalOptions = options;
|
mocks.modalOptions = options;
|
||||||
const Modal = defineComponent({
|
const Modal = defineComponent({
|
||||||
name: 'MockModal',
|
name: 'MockModal',
|
||||||
/** Renders modal content while the mock API controls lifecycle calls. */
|
|
||||||
setup(_, { slots }) {
|
setup(_, { slots }) {
|
||||||
return () => h('section', slots.default?.());
|
return () => h('section', slots.default?.());
|
||||||
},
|
},
|
||||||
@ -92,7 +89,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
|
|||||||
updateMessageSubscription: mocks.update,
|
updateMessageSubscription: mocks.update,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/** Creates the immutable source catalog fixture consumed by the modal. */
|
|
||||||
function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
|
function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -106,7 +102,6 @@ function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates string-ID STUN choices including disabled API-owned reasons. */
|
|
||||||
function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsResponse {
|
function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsResponse {
|
||||||
return {
|
return {
|
||||||
ddnsRecords: [
|
ddnsRecords: [
|
||||||
@ -158,7 +153,6 @@ function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsR
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates one editable row with only the global subscription contract. */
|
|
||||||
function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
|
function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
|
||||||
return {
|
return {
|
||||||
createTime: '2026-07-24 10:00:00',
|
createTime: '2026-07-24 10:00:00',
|
||||||
@ -179,7 +173,14 @@ function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mounts the modal with page-owned source and STUN metadata. */
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
function mountModal() {
|
function mountModal() {
|
||||||
return mount(MessageSubscriptionModal, {
|
return mount(MessageSubscriptionModal, {
|
||||||
props: {
|
props: {
|
||||||
@ -265,6 +266,151 @@ describe('message subscription modal', () => {
|
|||||||
expect(setOrder).toBeLessThan(validateOrder);
|
expect(setOrder).toBeLessThan(validateOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('discards an old create confirmation before a new edit session submits its own row', async () => {
|
||||||
|
const validation = createDeferred<{ valid: boolean }>();
|
||||||
|
mocks.formApi.validate.mockReturnValueOnce(validation.promise);
|
||||||
|
const wrapper = mountModal();
|
||||||
|
(wrapper.vm as any).openCreate();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const staleConfirmation = mocks.modalOptions.onConfirm();
|
||||||
|
await flushPromises();
|
||||||
|
const editRow = {
|
||||||
|
...createRow(),
|
||||||
|
enabled: false,
|
||||||
|
id: '10000000000000002',
|
||||||
|
name: '新编辑订阅',
|
||||||
|
remark: '新编辑备注',
|
||||||
|
};
|
||||||
|
(wrapper.vm as any).openEdit(editRow);
|
||||||
|
await flushPromises();
|
||||||
|
const editValues = {
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
enabled: false,
|
||||||
|
name: ' 新编辑订阅 ',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
remark: ' 新编辑备注 ',
|
||||||
|
sourceKey: 'network.stun.mapping-port-changed',
|
||||||
|
};
|
||||||
|
mocks.formApi.getValues.mockResolvedValue(editValues);
|
||||||
|
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
|
||||||
|
|
||||||
|
validation.resolve({ valid: true });
|
||||||
|
await staleConfirmation;
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume);
|
||||||
|
expect(wrapper.emitted('saved')).toBeUndefined();
|
||||||
|
expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
enabled: false,
|
||||||
|
name: '新编辑订阅',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
remark: '新编辑备注',
|
||||||
|
sourceKey: 'network.stun.mapping-port-changed',
|
||||||
|
});
|
||||||
|
|
||||||
|
await mocks.modalOptions.onConfirm();
|
||||||
|
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.update).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.update).toHaveBeenCalledWith('10000000000000002', {
|
||||||
|
enabled: false,
|
||||||
|
name: '新编辑订阅',
|
||||||
|
remark: '新编辑备注',
|
||||||
|
sourceConfig: {
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
},
|
||||||
|
sourceKey: 'network.stun.mapping-port-changed',
|
||||||
|
});
|
||||||
|
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
|
||||||
|
closeCountBeforeResume + 1,
|
||||||
|
);
|
||||||
|
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards an old edit confirmation before a new create session submits its own payload', async () => {
|
||||||
|
const values = createDeferred<{
|
||||||
|
ddnsRecordId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
name: string;
|
||||||
|
portForwardId: string;
|
||||||
|
remark: string;
|
||||||
|
sourceKey: string;
|
||||||
|
}>();
|
||||||
|
mocks.formApi.getValues.mockReturnValueOnce(values.promise);
|
||||||
|
const wrapper = mountModal();
|
||||||
|
(wrapper.vm as any).openEdit(createRow());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const staleConfirmation = mocks.modalOptions.onConfirm();
|
||||||
|
await flushPromises();
|
||||||
|
expect(mocks.formApi.getValues).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
(wrapper.vm as any).openCreate();
|
||||||
|
await flushPromises();
|
||||||
|
const createValues = {
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
enabled: true,
|
||||||
|
name: ' 新创建订阅 ',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
remark: ' 新创建备注 ',
|
||||||
|
sourceKey: 'network.stun.mapping-port-changed',
|
||||||
|
};
|
||||||
|
mocks.formApi.getValues.mockResolvedValue(createValues);
|
||||||
|
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
|
||||||
|
|
||||||
|
values.resolve({
|
||||||
|
ddnsRecordId: '2041700000000002',
|
||||||
|
enabled: false,
|
||||||
|
name: '旧编辑订阅',
|
||||||
|
portForwardId: '2041700000000001',
|
||||||
|
remark: '旧编辑备注',
|
||||||
|
sourceKey: 'network.stun.mapping-port-changed',
|
||||||
|
});
|
||||||
|
await staleConfirmation;
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume);
|
||||||
|
expect(wrapper.emitted('saved')).toBeUndefined();
|
||||||
|
expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({
|
||||||
|
ddnsRecordId: undefined,
|
||||||
|
enabled: true,
|
||||||
|
name: '',
|
||||||
|
portForwardId: undefined,
|
||||||
|
remark: '',
|
||||||
|
sourceKey: 'network.stun.mapping-port-changed',
|
||||||
|
});
|
||||||
|
|
||||||
|
await mocks.modalOptions.onConfirm();
|
||||||
|
|
||||||
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.create).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.create).toHaveBeenCalledWith({
|
||||||
|
enabled: true,
|
||||||
|
name: '新创建订阅',
|
||||||
|
remark: '新创建备注',
|
||||||
|
sourceConfig: {
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
},
|
||||||
|
sourceKey: 'network.stun.mapping-port-changed',
|
||||||
|
});
|
||||||
|
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
|
||||||
|
closeCountBeforeResume + 1,
|
||||||
|
);
|
||||||
|
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves string IDs and exposes disabled reasons on matching options', async () => {
|
it('preserves string IDs and exposes disabled reasons on matching options', async () => {
|
||||||
const wrapper = mountModal();
|
const wrapper = mountModal();
|
||||||
(wrapper.vm as any).openEdit(createRow());
|
(wrapper.vm as any).openEdit(createRow());
|
||||||
|
|||||||
@ -48,21 +48,14 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
emits: ['saved'],
|
emits: ['saved'],
|
||||||
/**
|
|
||||||
* Owns one source-only create/edit form without fetching page metadata or lists.
|
|
||||||
*/
|
|
||||||
setup(props, { emit, expose }) {
|
setup(props, { emit, expose }) {
|
||||||
const editingRow = ref<QqbotMessagePushApi.MessageSubscriptionView>();
|
const editingRow = ref<QqbotMessagePushApi.MessageSubscriptionView>();
|
||||||
const selectedPortForwardId = ref<string>();
|
const selectedPortForwardId = ref<string>();
|
||||||
|
let sessionRevision = 0;
|
||||||
const [SubscriptionForm, formApi] = useVbenForm({
|
const [SubscriptionForm, formApi] = useVbenForm({
|
||||||
commonConfig: {
|
commonConfig: {
|
||||||
labelClass: 'w-24',
|
labelClass: 'w-24',
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Keeps the DDNS choice compatible with the selected port-forward option.
|
|
||||||
* @param values - Current form values after the change.
|
|
||||||
* @param fieldsChanged - Field names changed by this form event.
|
|
||||||
*/
|
|
||||||
async handleValuesChange(values, fieldsChanged) {
|
async handleValuesChange(values, fieldsChanged) {
|
||||||
if (!fieldsChanged.includes('portForwardId')) return;
|
if (!fieldsChanged.includes('portForwardId')) return;
|
||||||
selectedPortForwardId.value =
|
selectedPortForwardId.value =
|
||||||
@ -95,10 +88,6 @@ export default defineComponent({
|
|||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
class: 'w-[680px]',
|
class: 'w-[680px]',
|
||||||
fullscreenButton: false,
|
fullscreenButton: false,
|
||||||
/**
|
|
||||||
* Persists the current session while containing failures at ModalApi's
|
|
||||||
* non-awaited callback boundary.
|
|
||||||
*/
|
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
@ -106,10 +95,6 @@ export default defineComponent({
|
|||||||
// The request/form layer already presents the persistence error.
|
// The request/form layer already presents the persistence error.
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Restores session values after destroy-on-close modal content has mounted.
|
|
||||||
* @param isOpen - Whether the modal content is visible.
|
|
||||||
*/
|
|
||||||
async onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const { values } = modalApi.getData<MessageSubscriptionModalData>();
|
const { values } = modalApi.getData<MessageSubscriptionModalData>();
|
||||||
@ -118,8 +103,8 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Opens a fresh subscription form with no values retained from an edit. */
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
|
sessionRevision += 1;
|
||||||
editingRow.value = undefined;
|
editingRow.value = undefined;
|
||||||
modalApi
|
modalApi
|
||||||
.setData({
|
.setData({
|
||||||
@ -135,11 +120,8 @@ export default defineComponent({
|
|||||||
.open();
|
.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens an edit session with only the six user-editable values.
|
|
||||||
* @param row - Subscription row selected from the page-owned KtTable.
|
|
||||||
*/
|
|
||||||
function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) {
|
function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) {
|
||||||
|
sessionRevision += 1;
|
||||||
editingRow.value = row;
|
editingRow.value = row;
|
||||||
modalApi
|
modalApi
|
||||||
.setData({
|
.setData({
|
||||||
@ -155,21 +137,20 @@ export default defineComponent({
|
|||||||
.open();
|
.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets the mounted form before installing one isolated modal session.
|
|
||||||
* @param values - Exact editable values for the current create/edit session.
|
|
||||||
*/
|
|
||||||
async function resetForm(values: MessageSubscriptionFormValues) {
|
async function resetForm(values: MessageSubscriptionFormValues) {
|
||||||
await formApi.resetForm();
|
await formApi.resetForm();
|
||||||
await formApi.setValues(values);
|
await formApi.setValues(values);
|
||||||
await formApi.resetValidate();
|
await formApi.resetValidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persists the exact source-only payload and closes only after success. */
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
const revision = sessionRevision;
|
||||||
|
const editingId = editingRow.value?.id;
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
|
if (revision !== sessionRevision) return;
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
const values = await formApi.getValues<MessageSubscriptionFormValues>();
|
const values = await formApi.getValues<MessageSubscriptionFormValues>();
|
||||||
|
if (revision !== sessionRevision) return;
|
||||||
const payload: QqbotMessagePushApi.MessageSubscriptionInput = {
|
const payload: QqbotMessagePushApi.MessageSubscriptionInput = {
|
||||||
enabled: !!values.enabled,
|
enabled: !!values.enabled,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@ -180,12 +161,14 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
sourceKey: values.sourceKey,
|
sourceKey: values.sourceKey,
|
||||||
};
|
};
|
||||||
|
if (revision !== sessionRevision) return;
|
||||||
|
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
try {
|
try {
|
||||||
await (editingRow.value
|
await (editingId
|
||||||
? updateMessageSubscription(editingRow.value.id, payload)
|
? updateMessageSubscription(editingId, payload)
|
||||||
: createMessageSubscription(payload));
|
: createMessageSubscription(payload));
|
||||||
|
if (revision !== sessionRevision) return;
|
||||||
await modalApi.close();
|
await modalApi.close();
|
||||||
emit('saved');
|
emit('saved');
|
||||||
} finally {
|
} finally {
|
||||||
@ -203,12 +186,6 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the locked six-field schema from page-owned metadata.
|
|
||||||
* @param props - Source catalog and STUN choices loaded once by the page.
|
|
||||||
* @param selectedPortForwardId - Current port used to filter matching DDNS rows.
|
|
||||||
* @returns Vben form fields in the approved source-only order.
|
|
||||||
*/
|
|
||||||
function createFormSchema(
|
function createFormSchema(
|
||||||
props: Readonly<{
|
props: Readonly<{
|
||||||
sources: QqbotMessagePushApi.SystemMessageSourceDefinition[];
|
sources: QqbotMessagePushApi.SystemMessageSourceDefinition[];
|
||||||
@ -280,11 +257,6 @@ function createFormSchema(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats one server-evaluated port choice without coercing its string ID.
|
|
||||||
* @param option - Port-forward option returned by the source options API.
|
|
||||||
* @returns Select option preserving eligibility and stable reason code.
|
|
||||||
*/
|
|
||||||
function formatPortForwardOption(
|
function formatPortForwardOption(
|
||||||
option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['portForwards'][number],
|
option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['portForwards'][number],
|
||||||
) {
|
) {
|
||||||
@ -298,11 +270,6 @@ function formatPortForwardOption(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats one server-evaluated DDNS choice without coercing its string ID.
|
|
||||||
* @param option - DDNS option already filtered to the selected port.
|
|
||||||
* @returns Select option preserving eligibility and stable reason code.
|
|
||||||
*/
|
|
||||||
function formatDdnsOption(
|
function formatDdnsOption(
|
||||||
option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['ddnsRecords'][number],
|
option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['ddnsRecords'][number],
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -49,7 +49,6 @@ const mocks = vi.hoisted(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Creates a fluent no-op validation rule for schema behavior tests. */
|
|
||||||
function createRule(): any {
|
function createRule(): any {
|
||||||
const rule: any = {};
|
const rule: any = {};
|
||||||
for (const method of ['max', 'min', 'optional', 'or', 'trim']) {
|
for (const method of ['max', 'min', 'optional', 'or', 'trim']) {
|
||||||
@ -63,7 +62,6 @@ vi.mock('#/adapter/form', () => ({
|
|||||||
mocks.formOptions = options;
|
mocks.formOptions = options;
|
||||||
const Form = defineComponent({
|
const Form = defineComponent({
|
||||||
name: 'MockTemplateForm',
|
name: 'MockTemplateForm',
|
||||||
/** Renders the production schema's local content component with live props. */
|
|
||||||
setup() {
|
setup() {
|
||||||
return () => {
|
return () => {
|
||||||
const content = mocks.formOptions.schema.find(
|
const content = mocks.formOptions.schema.find(
|
||||||
@ -98,7 +96,6 @@ vi.mock('@vben/common-ui', () => ({
|
|||||||
mocks.modalOptions = options;
|
mocks.modalOptions = options;
|
||||||
const Modal = defineComponent({
|
const Modal = defineComponent({
|
||||||
name: 'MockTemplateModal',
|
name: 'MockTemplateModal',
|
||||||
/** Renders the actual form plus the explicit prepend-footer action slot. */
|
|
||||||
setup(_, { slots }) {
|
setup(_, { slots }) {
|
||||||
return () =>
|
return () =>
|
||||||
h('section', { 'data-testid': 'template-modal' }, [
|
h('section', { 'data-testid': 'template-modal' }, [
|
||||||
@ -118,7 +115,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
|
|||||||
updateMessageTemplate: mocks.update,
|
updateMessageTemplate: mocks.update,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/** Creates one source with a distinguishable variable catalog. */
|
|
||||||
function createSource(
|
function createSource(
|
||||||
sourceKey = 'source-a',
|
sourceKey = 'source-a',
|
||||||
variableKey = 'endpoint',
|
variableKey = 'endpoint',
|
||||||
@ -141,7 +137,6 @@ function createSource(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates one unsafe-integer string-ID editable template row. */
|
|
||||||
function createRow(): QqbotMessagePushApi.MessageTemplateView {
|
function createRow(): QqbotMessagePushApi.MessageTemplateView {
|
||||||
return {
|
return {
|
||||||
content: 'old [CQ:at,qq=12345]',
|
content: 'old [CQ:at,qq=12345]',
|
||||||
@ -157,7 +152,14 @@ function createRow(): QqbotMessagePushApi.MessageTemplateView {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mounts the modal with page-owned source labels. */
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
function mountModal(canPreview = true) {
|
function mountModal(canPreview = true) {
|
||||||
return mount(MessageTemplateModal, {
|
return mount(MessageTemplateModal, {
|
||||||
props: {
|
props: {
|
||||||
@ -260,6 +262,137 @@ describe('message template modal', () => {
|
|||||||
expect(setOrder).toBeLessThan(validateOrder);
|
expect(setOrder).toBeLessThan(validateOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('discards an old create confirmation before a new edit session submits its own row', async () => {
|
||||||
|
const validation = createDeferred<{ valid: boolean }>();
|
||||||
|
mocks.formApi.validate.mockReturnValueOnce(validation.promise);
|
||||||
|
const wrapper = mountModal();
|
||||||
|
(wrapper.vm as any).openCreate();
|
||||||
|
await flushPromises();
|
||||||
|
Object.assign(mocks.formValues, {
|
||||||
|
content: 'old create',
|
||||||
|
enabled: true,
|
||||||
|
name: 'old create',
|
||||||
|
remark: '',
|
||||||
|
sourceKey: 'source-a',
|
||||||
|
});
|
||||||
|
|
||||||
|
const staleConfirmation = mocks.modalOptions.onConfirm();
|
||||||
|
await flushPromises();
|
||||||
|
const editRow = {
|
||||||
|
...createRow(),
|
||||||
|
content: 'new edit ${{port}}',
|
||||||
|
id: '10000000000000002',
|
||||||
|
name: ' new edit template ',
|
||||||
|
remark: ' new edit note ',
|
||||||
|
};
|
||||||
|
(wrapper.vm as any).openEdit(editRow);
|
||||||
|
await flushPromises();
|
||||||
|
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
|
||||||
|
|
||||||
|
validation.resolve({ valid: true });
|
||||||
|
await staleConfirmation;
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume);
|
||||||
|
expect(wrapper.emitted('saved')).toBeUndefined();
|
||||||
|
expect(mocks.formValues).toEqual({
|
||||||
|
content: 'new edit ${{port}}',
|
||||||
|
enabled: false,
|
||||||
|
name: ' new edit template ',
|
||||||
|
remark: ' new edit note ',
|
||||||
|
sourceKey: 'source-b',
|
||||||
|
});
|
||||||
|
|
||||||
|
await mocks.modalOptions.onConfirm();
|
||||||
|
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.update).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.update).toHaveBeenCalledWith('10000000000000002', {
|
||||||
|
content: 'new edit ${{port}}',
|
||||||
|
enabled: false,
|
||||||
|
name: 'new edit template',
|
||||||
|
remark: 'new edit note',
|
||||||
|
sourceKey: 'source-b',
|
||||||
|
});
|
||||||
|
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
|
||||||
|
closeCountBeforeResume + 1,
|
||||||
|
);
|
||||||
|
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards an old edit confirmation before a new create session submits its own payload', async () => {
|
||||||
|
const values = createDeferred<{
|
||||||
|
content: string;
|
||||||
|
enabled: boolean;
|
||||||
|
name: string;
|
||||||
|
remark: string;
|
||||||
|
sourceKey: string;
|
||||||
|
}>();
|
||||||
|
mocks.formApi.getValues.mockReturnValueOnce(values.promise);
|
||||||
|
const wrapper = mountModal();
|
||||||
|
(wrapper.vm as any).openEdit(createRow());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const staleConfirmation = mocks.modalOptions.onConfirm();
|
||||||
|
await flushPromises();
|
||||||
|
expect(mocks.formApi.getValues).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
(wrapper.vm as any).openCreate();
|
||||||
|
await flushPromises();
|
||||||
|
Object.assign(mocks.formValues, {
|
||||||
|
content: 'new create ${{endpoint}}',
|
||||||
|
enabled: true,
|
||||||
|
name: ' new create template ',
|
||||||
|
remark: ' new create note ',
|
||||||
|
sourceKey: 'source-a',
|
||||||
|
});
|
||||||
|
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
|
||||||
|
|
||||||
|
values.resolve({
|
||||||
|
content: 'old edit [CQ:at,qq=12345]',
|
||||||
|
enabled: false,
|
||||||
|
name: 'old edit template',
|
||||||
|
remark: 'old edit note',
|
||||||
|
sourceKey: 'source-b',
|
||||||
|
});
|
||||||
|
await staleConfirmation;
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume);
|
||||||
|
expect(wrapper.emitted('saved')).toBeUndefined();
|
||||||
|
expect(mocks.formValues).toEqual({
|
||||||
|
content: 'new create ${{endpoint}}',
|
||||||
|
enabled: true,
|
||||||
|
name: ' new create template ',
|
||||||
|
remark: ' new create note ',
|
||||||
|
sourceKey: 'source-a',
|
||||||
|
});
|
||||||
|
|
||||||
|
await mocks.modalOptions.onConfirm();
|
||||||
|
|
||||||
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.create).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.create).toHaveBeenCalledWith({
|
||||||
|
content: 'new create ${{endpoint}}',
|
||||||
|
enabled: true,
|
||||||
|
name: 'new create template',
|
||||||
|
remark: 'new create note',
|
||||||
|
sourceKey: 'source-a',
|
||||||
|
});
|
||||||
|
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
|
||||||
|
closeCountBeforeResume + 1,
|
||||||
|
);
|
||||||
|
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('loads exact details and prevents a late source from overwriting the latest', async () => {
|
it('loads exact details and prevents a late source from overwriting the latest', async () => {
|
||||||
const deferred = new Map<
|
const deferred = new Map<
|
||||||
string,
|
string,
|
||||||
|
|||||||
@ -53,7 +53,6 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
emits: ['saved'],
|
emits: ['saved'],
|
||||||
/** Owns one isolated template edit session, source detail cache, and preview. */
|
|
||||||
setup(props, { emit, expose }) {
|
setup(props, { emit, expose }) {
|
||||||
const editingRow = ref<QqbotMessagePushApi.MessageTemplateView>();
|
const editingRow = ref<QqbotMessagePushApi.MessageTemplateView>();
|
||||||
const variables = ref<
|
const variables = ref<
|
||||||
@ -69,15 +68,11 @@ export default defineComponent({
|
|||||||
>();
|
>();
|
||||||
let sourceRevision = 0;
|
let sourceRevision = 0;
|
||||||
let previewRevision = 0;
|
let previewRevision = 0;
|
||||||
|
let sessionRevision = 0;
|
||||||
const [TemplateForm, formApi] = useVbenForm({
|
const [TemplateForm, formApi] = useVbenForm({
|
||||||
commonConfig: {
|
commonConfig: {
|
||||||
labelClass: 'w-24',
|
labelClass: 'w-24',
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Clears stale preview state and refreshes variables only for source changes.
|
|
||||||
* @param values - Current form values after the update.
|
|
||||||
* @param fieldsChanged - Exact fields changed by this form event.
|
|
||||||
*/
|
|
||||||
async handleValuesChange(values, fieldsChanged) {
|
async handleValuesChange(values, fieldsChanged) {
|
||||||
if (fieldsChanged.includes('content')) clearPreview();
|
if (fieldsChanged.includes('content')) clearPreview();
|
||||||
if (!fieldsChanged.includes('sourceKey')) return;
|
if (!fieldsChanged.includes('sourceKey')) return;
|
||||||
@ -96,17 +91,12 @@ export default defineComponent({
|
|||||||
showDefaultActions: false,
|
showDefaultActions: false,
|
||||||
wrapperClass: 'grid-cols-1',
|
wrapperClass: 'grid-cols-1',
|
||||||
});
|
});
|
||||||
/** Tracks the title from isolated create/edit identity only. */
|
|
||||||
const modalTitle = computed(() =>
|
const modalTitle = computed(() =>
|
||||||
editingRow.value ? '编辑消息模板' : '新建消息模板',
|
editingRow.value ? '编辑消息模板' : '新建消息模板',
|
||||||
);
|
);
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
class: 'w-[760px]',
|
class: 'w-[760px]',
|
||||||
fullscreenButton: false,
|
fullscreenButton: false,
|
||||||
/**
|
|
||||||
* Persists the exact payload while containing failures at ModalApi's
|
|
||||||
* non-awaited callback boundary.
|
|
||||||
*/
|
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
@ -114,10 +104,6 @@ export default defineComponent({
|
|||||||
// The request/form layer already presents the persistence error.
|
// The request/form layer already presents the persistence error.
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Resets every mounted form/session state before loading authoritative detail.
|
|
||||||
* @param isOpen - Whether destroy-on-close modal content is mounted.
|
|
||||||
*/
|
|
||||||
async onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const { values } = modalApi.getData<MessageTemplateModalData>();
|
const { values } = modalApi.getData<MessageTemplateModalData>();
|
||||||
@ -128,8 +114,8 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Opens a fresh create session without values from the preceding edit. */
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
|
sessionRevision += 1;
|
||||||
editingRow.value = undefined;
|
editingRow.value = undefined;
|
||||||
modalApi
|
modalApi
|
||||||
.setData({
|
.setData({
|
||||||
@ -144,11 +130,8 @@ export default defineComponent({
|
|||||||
.open();
|
.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens an edit session with only the five user-editable values.
|
|
||||||
* @param row - Template row selected from the page-owned KtTable.
|
|
||||||
*/
|
|
||||||
function openEdit(row: QqbotMessagePushApi.MessageTemplateView) {
|
function openEdit(row: QqbotMessagePushApi.MessageTemplateView) {
|
||||||
|
sessionRevision += 1;
|
||||||
editingRow.value = row;
|
editingRow.value = row;
|
||||||
modalApi
|
modalApi
|
||||||
.setData({
|
.setData({
|
||||||
@ -163,17 +146,12 @@ export default defineComponent({
|
|||||||
.open();
|
.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets and installs the current session values in the required order.
|
|
||||||
* @param values - Exact editable values stored before the modal opened.
|
|
||||||
*/
|
|
||||||
async function resetForm(values: MessageTemplateFormValues) {
|
async function resetForm(values: MessageTemplateFormValues) {
|
||||||
await formApi.resetForm();
|
await formApi.resetForm();
|
||||||
await formApi.setValues(values);
|
await formApi.setValues(values);
|
||||||
await formApi.resetValidate();
|
await formApi.resetValidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Invalidates stale source/preview work and clears every transient display. */
|
|
||||||
function invalidateSession() {
|
function invalidateSession() {
|
||||||
sourceRevision += 1;
|
sourceRevision += 1;
|
||||||
detailLoading.value = false;
|
detailLoading.value = false;
|
||||||
@ -181,18 +159,12 @@ export default defineComponent({
|
|||||||
clearPreview();
|
clearPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears authoritative preview data and invalidates any in-flight preview. */
|
|
||||||
function clearPreview() {
|
function clearPreview() {
|
||||||
previewRevision += 1;
|
previewRevision += 1;
|
||||||
preview.value = undefined;
|
preview.value = undefined;
|
||||||
previewLoading.value = false;
|
previewLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reuses an exact-key in-flight/resolved source detail promise.
|
|
||||||
* @param sourceKey - Stable exact source identity selected in the form.
|
|
||||||
* @returns Authoritative source detail promise, evicted when rejected.
|
|
||||||
*/
|
|
||||||
function getCachedSourceDetail(sourceKey: string) {
|
function getCachedSourceDetail(sourceKey: string) {
|
||||||
const cached = detailCache.get(sourceKey);
|
const cached = detailCache.get(sourceKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@ -206,11 +178,6 @@ export default defineComponent({
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces suggestions only when this exact source request remains latest.
|
|
||||||
* Expected detail failures stay inside the non-awaited form/modal callbacks.
|
|
||||||
* @param sourceKey - Newly selected source identity.
|
|
||||||
*/
|
|
||||||
async function loadSourceDetail(sourceKey: string) {
|
async function loadSourceDetail(sourceKey: string) {
|
||||||
const revision = ++sourceRevision;
|
const revision = ++sourceRevision;
|
||||||
clearPreview();
|
clearPreview();
|
||||||
@ -241,7 +208,6 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Requests server-authoritative preview only after an explicit valid click. */
|
|
||||||
async function handlePreview() {
|
async function handlePreview() {
|
||||||
if (!props.canPreview) return;
|
if (!props.canPreview) return;
|
||||||
const [sourceValidation, contentValidation] = await Promise.all([
|
const [sourceValidation, contentValidation] = await Promise.all([
|
||||||
@ -265,11 +231,14 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persists content byte-for-byte and closes/emits only after success. */
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
const revision = sessionRevision;
|
||||||
|
const editingId = editingRow.value?.id;
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
|
if (revision !== sessionRevision) return;
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
const values = await formApi.getValues<MessageTemplateFormValues>();
|
const values = await formApi.getValues<MessageTemplateFormValues>();
|
||||||
|
if (revision !== sessionRevision) return;
|
||||||
const payload: QqbotMessagePushApi.MessageTemplateInput = {
|
const payload: QqbotMessagePushApi.MessageTemplateInput = {
|
||||||
content: values.content,
|
content: values.content,
|
||||||
enabled: !!values.enabled,
|
enabled: !!values.enabled,
|
||||||
@ -277,11 +246,13 @@ export default defineComponent({
|
|||||||
remark: values.remark?.trim() || '',
|
remark: values.remark?.trim() || '',
|
||||||
sourceKey: values.sourceKey,
|
sourceKey: values.sourceKey,
|
||||||
};
|
};
|
||||||
|
if (revision !== sessionRevision) return;
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
try {
|
try {
|
||||||
await (editingRow.value
|
await (editingId
|
||||||
? updateMessageTemplate(editingRow.value.id, payload)
|
? updateMessageTemplate(editingId, payload)
|
||||||
: createMessageTemplate(payload));
|
: createMessageTemplate(payload));
|
||||||
|
if (revision !== sessionRevision) return;
|
||||||
await modalApi.close();
|
await modalApi.close();
|
||||||
emit('saved');
|
emit('saved');
|
||||||
} finally {
|
} finally {
|
||||||
@ -289,7 +260,6 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders the permission-gated explicit preview action. */
|
|
||||||
function renderPreviewAction() {
|
function renderPreviewAction() {
|
||||||
return props.canPreview ? (
|
return props.canPreview ? (
|
||||||
<AButton loading={previewLoading.value} onClick={handlePreview}>
|
<AButton loading={previewLoading.value} onClick={handlePreview}>
|
||||||
@ -298,7 +268,6 @@ export default defineComponent({
|
|||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders server-returned preview text and variables as escaped text nodes. */
|
|
||||||
function renderPreview() {
|
function renderPreview() {
|
||||||
const result = preview.value;
|
const result = preview.value;
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
@ -331,14 +300,6 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the exact template schema with a local controlled Mentions component.
|
|
||||||
* @param props - Page-owned source labels and preview permission.
|
|
||||||
* @param variables - Variables from the latest exact source detail.
|
|
||||||
* @param loading - Source-detail loading state forwarded to Mentions.
|
|
||||||
* @param selectedSourceKey - Current source identity used to disable no-source input.
|
|
||||||
* @returns Five fields in the locked source/name/content/enabled/remark order.
|
|
||||||
*/
|
|
||||||
function createFormSchema(
|
function createFormSchema(
|
||||||
props: Readonly<{
|
props: Readonly<{
|
||||||
canPreview: boolean;
|
canPreview: boolean;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user