H-289: add Captain Skill management UI (#47)
* H-289: add Captain Skill management UI * H-289: guard Captain Skill assistant switches --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import SkillDialog from './SkillDialog.vue';
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
const mockedStore = vi.hoisted(() => ({ dispatch: vi.fn() }));
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStore: () => mockedStore,
|
||||
}));
|
||||
|
||||
const Dialog = {
|
||||
name: 'Dialog',
|
||||
emits: ['close'],
|
||||
methods: {
|
||||
open() {},
|
||||
close() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
template: '<div><slot /><slot name="footer" /></div>',
|
||||
};
|
||||
|
||||
const Button = {
|
||||
name: 'Button',
|
||||
props: ['label'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button type="button" @click="$emit(\'click\')">{{ label }}</button>',
|
||||
};
|
||||
|
||||
const mountDialog = ({ skill = null, dispatch = vi.fn() } = {}) => {
|
||||
mockedStore.dispatch = dispatch;
|
||||
return shallowMount(SkillDialog, {
|
||||
props: { skill, assistantId: 9 },
|
||||
global: {
|
||||
stubs: {
|
||||
Dialog,
|
||||
Button,
|
||||
Input: true,
|
||||
TextArea: true,
|
||||
Editor: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const detail = {
|
||||
id: 3,
|
||||
name: 'Refunds',
|
||||
description: 'Handle refund requests',
|
||||
instructions_md: 'Ask for the order number.',
|
||||
status: 'active',
|
||||
version: 4,
|
||||
bound_assistant_count: 2,
|
||||
references: [
|
||||
{
|
||||
reference_key: 'refund-policy',
|
||||
content_md: 'Thirty days.',
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('SkillDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates a validated draft with the management API shape', async () => {
|
||||
const dispatch = vi.fn().mockResolvedValue({ id: 5 });
|
||||
const wrapper = mountDialog({ dispatch });
|
||||
Object.assign(wrapper.vm.form, {
|
||||
name: ' Refunds ',
|
||||
description: ' Handle refunds ',
|
||||
instructions_md: 'Use the policy.',
|
||||
});
|
||||
|
||||
await wrapper.vm.submit('draft');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('captainSkills/create', {
|
||||
name: 'Refunds',
|
||||
description: 'Handle refunds',
|
||||
instructions_md: 'Use the policy.',
|
||||
status: 'draft',
|
||||
references: [],
|
||||
});
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('publishes then binds to only the current assistant', async () => {
|
||||
let resolveCreate;
|
||||
const dispatch = vi.fn().mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveCreate = resolve;
|
||||
})
|
||||
);
|
||||
const wrapper = mountDialog({ dispatch });
|
||||
Object.assign(wrapper.vm.form, {
|
||||
name: 'Refunds',
|
||||
description: 'Handle refunds',
|
||||
instructions_md: 'Use the policy.',
|
||||
});
|
||||
|
||||
const publish = wrapper.vm.submit('active', true);
|
||||
await wrapper.setProps({ assistantId: 10 });
|
||||
resolveCreate({ id: 5 });
|
||||
await publish;
|
||||
|
||||
expect(dispatch).toHaveBeenLastCalledWith('captainSkills/bind', {
|
||||
assistantId: 9,
|
||||
skillId: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('warns before changing an active skill used by assistants', async () => {
|
||||
const dispatch = vi.fn();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const wrapper = mountDialog({ skill: detail, dispatch });
|
||||
|
||||
await wrapper.vm.submit('active');
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('2 assistants')
|
||||
);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the saved skill when binding fails instead of creating a duplicate', async () => {
|
||||
const dispatch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 5 })
|
||||
.mockRejectedValueOnce({ response: { status: 409 } });
|
||||
const wrapper = mountDialog({ dispatch });
|
||||
Object.assign(wrapper.vm.form, {
|
||||
name: 'Refunds',
|
||||
description: 'Handle refunds',
|
||||
instructions_md: 'Use the policy.',
|
||||
});
|
||||
|
||||
await wrapper.vm.submit('active', true);
|
||||
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps form input and reports a concurrent update', async () => {
|
||||
const dispatch = vi.fn().mockRejectedValue({
|
||||
response: { status: 409, data: { error: 'version conflict' } },
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const wrapper = mountDialog({ skill: detail, dispatch });
|
||||
|
||||
await wrapper.vm.submit('active');
|
||||
|
||||
expect(wrapper.vm.errorMessage).toContain('version conflict');
|
||||
expect(wrapper.vm.form.name).toBe('Refunds');
|
||||
expect(wrapper.emitted('saved')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
skill: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
assistantId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'saved']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
const isSaving = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const errors = reactive({});
|
||||
const forceClose = ref(false);
|
||||
|
||||
const initialForm = () => ({
|
||||
name: props.skill?.name || '',
|
||||
description: props.skill?.description || '',
|
||||
instructions_md: props.skill?.instructions_md || '',
|
||||
references: (props.skill?.references || [])
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map(({ reference_key, content_md }) => ({
|
||||
reference_key,
|
||||
content_md,
|
||||
})),
|
||||
});
|
||||
|
||||
const form = reactive(initialForm());
|
||||
const originalForm = JSON.stringify(initialForm());
|
||||
const isEditing = computed(() => Boolean(props.skill?.id));
|
||||
const currentStatus = computed(() => props.skill?.status || 'draft');
|
||||
const isDirty = computed(() => JSON.stringify(form) !== originalForm);
|
||||
const byteLength = value => new TextEncoder().encode(value).length;
|
||||
|
||||
const clearErrors = () => {
|
||||
Object.keys(errors).forEach(key => delete errors[key]);
|
||||
errorMessage.value = '';
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
clearErrors();
|
||||
if (!form.name.trim() || byteLength(form.name.trim()) > 255) {
|
||||
errors.name = t('CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.NAME');
|
||||
}
|
||||
if (
|
||||
!form.description.trim() ||
|
||||
byteLength(form.description.trim()) > 1024
|
||||
) {
|
||||
errors.description = t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.DESCRIPTION'
|
||||
);
|
||||
}
|
||||
if (!form.instructions_md || byteLength(form.instructions_md) > 32 * 1024) {
|
||||
errors.instructions = t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.INSTRUCTIONS'
|
||||
);
|
||||
}
|
||||
if (form.references.length > 20) {
|
||||
errors.references = t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.REFERENCE_LIMIT'
|
||||
);
|
||||
}
|
||||
|
||||
const seenKeys = new Set();
|
||||
form.references.forEach((reference, index) => {
|
||||
const key = reference.reference_key.trim();
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(key) || seenKeys.has(key)) {
|
||||
errors[`reference-${index}`] = t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.REFERENCE_KEY'
|
||||
);
|
||||
} else if (
|
||||
!reference.content_md ||
|
||||
byteLength(reference.content_md) > 64 * 1024
|
||||
) {
|
||||
errors[`reference-${index}`] = t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.REFERENCE_CONTENT'
|
||||
);
|
||||
}
|
||||
seenKeys.add(key);
|
||||
});
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const payloadFor = status => ({
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
instructions_md: form.instructions_md,
|
||||
status,
|
||||
references: form.references.map(reference => ({
|
||||
reference_key: reference.reference_key.trim(),
|
||||
content_md: reference.content_md,
|
||||
})),
|
||||
});
|
||||
|
||||
const apiErrorMessage = error => {
|
||||
const status = error?.response?.status;
|
||||
if (status === 403) return t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.FORBIDDEN');
|
||||
if (status === 404) return t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.NOT_FOUND');
|
||||
const parsed = parseAPIErrorResponse(error);
|
||||
if (status === 409) {
|
||||
return `${parsed} ${t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.CONFLICT')}`;
|
||||
}
|
||||
return parsed || t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.DEFAULT');
|
||||
};
|
||||
|
||||
const finishSave = () => {
|
||||
forceClose.value = true;
|
||||
emit('saved');
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const submit = async (status, bindAfterSave = false) => {
|
||||
if (!validate()) return;
|
||||
if (
|
||||
currentStatus.value === 'active' &&
|
||||
!window.confirm(
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.ACTIVE_SAVE', {
|
||||
count: props.skill?.bound_assistant_count || 0,
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedAssistantId = props.assistantId;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const payload = payloadFor(status);
|
||||
const saved = isEditing.value
|
||||
? await store.dispatch('captainSkills/update', {
|
||||
id: props.skill.id,
|
||||
...payload,
|
||||
expected_version: props.skill.version,
|
||||
})
|
||||
: await store.dispatch('captainSkills/create', payload);
|
||||
|
||||
if (bindAfterSave) {
|
||||
try {
|
||||
await store.dispatch('captainSkills/bind', {
|
||||
assistantId: requestedAssistantId,
|
||||
skillId: saved.id,
|
||||
});
|
||||
} catch (error) {
|
||||
finishSave();
|
||||
useAlert(apiErrorMessage(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.SKILLS.SUCCESS.SAVED'));
|
||||
finishSave();
|
||||
} catch (error) {
|
||||
errorMessage.value = apiErrorMessage(error);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addReference = () => {
|
||||
if (form.references.length < 20) {
|
||||
form.references.push({ reference_key: '', content_md: '' });
|
||||
}
|
||||
};
|
||||
|
||||
const removeReference = index => form.references.splice(index, 1);
|
||||
|
||||
const moveReference = (index, offset) => {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= form.references.length) return;
|
||||
const [reference] = form.references.splice(index, 1);
|
||||
form.references.splice(target, 0, reference);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (
|
||||
!forceClose.value &&
|
||||
isDirty.value &&
|
||||
!window.confirm(t('CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.DISCARD'))
|
||||
) {
|
||||
nextTick(() => dialogRef.value?.open());
|
||||
return;
|
||||
}
|
||||
emit('close');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
dialogRef,
|
||||
isDirty,
|
||||
form,
|
||||
errorMessage,
|
||||
submit,
|
||||
addReference,
|
||||
removeReference,
|
||||
moveReference,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
position="top"
|
||||
overflow-y-auto
|
||||
:title="
|
||||
isEditing
|
||||
? t('CAPTAIN.ASSISTANTS.SKILLS.DIALOG.EDIT_TITLE')
|
||||
: t('CAPTAIN.ASSISTANTS.SKILLS.DIALOG.CREATE_TITLE')
|
||||
"
|
||||
:description="t('CAPTAIN.ASSISTANTS.SKILLS.DIALOG.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="flex max-h-[65vh] flex-col gap-5 overflow-y-auto px-0.5">
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
class="rounded-lg bg-n-ruby-2 px-3 py-2 text-sm text-n-ruby-11"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<Input
|
||||
v-model="form.name"
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.FORM.NAME')"
|
||||
:message="errors.name"
|
||||
:message-type="errors.name ? 'error' : 'info'"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
id="skill-description"
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.FORM.DESCRIPTION')"
|
||||
:message="errors.description"
|
||||
:message-type="errors.description ? 'error' : 'info'"
|
||||
:disabled="isSaving"
|
||||
:max-length="1024"
|
||||
show-character-count
|
||||
/>
|
||||
<Editor
|
||||
v-model="form.instructions_md"
|
||||
editor-key="captain-skill-instructions"
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.FORM.INSTRUCTIONS')"
|
||||
:message="errors.instructions"
|
||||
:message-type="errors.instructions ? 'error' : 'info'"
|
||||
:disabled="isSaving"
|
||||
:max-length="32768"
|
||||
/>
|
||||
|
||||
<section
|
||||
class="flex flex-col gap-3"
|
||||
aria-labelledby="skill-references"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4
|
||||
id="skill-references"
|
||||
class="text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCES') }}
|
||||
</h4>
|
||||
<p class="mb-0 text-xs text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCES_HINT'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.FORM.ADD_REFERENCE')
|
||||
"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
color="slate"
|
||||
:disabled="isSaving || form.references.length >= 20"
|
||||
@click="addReference"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errors.references" class="mb-0 text-sm text-n-ruby-11">
|
||||
{{ errors.references }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="(reference, index) in form.references"
|
||||
:key="index"
|
||||
class="flex flex-col gap-3 rounded-xl border border-n-weak p-4"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<Input
|
||||
v-model="reference.reference_key"
|
||||
class="flex-1"
|
||||
:label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCE_KEY'
|
||||
)
|
||||
"
|
||||
:message="errors[`reference-${index}`]"
|
||||
:message-type="
|
||||
errors[`reference-${index}`] ? 'error' : 'info'
|
||||
"
|
||||
:disabled="isSaving"
|
||||
placeholder="refund-policy"
|
||||
/>
|
||||
<div class="flex gap-1 pt-7">
|
||||
<Button
|
||||
icon="i-lucide-arrow-up"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:aria-label="
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.FORM.MOVE_UP')
|
||||
"
|
||||
:disabled="isSaving || index === 0"
|
||||
@click="moveReference(index, -1)"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-arrow-down"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:aria-label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.MOVE_DOWN'
|
||||
)
|
||||
"
|
||||
:disabled="
|
||||
isSaving ||
|
||||
index === form.references.length - 1
|
||||
"
|
||||
@click="moveReference(index, 1)"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
color="ruby"
|
||||
size="xs"
|
||||
:aria-label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REMOVE_REFERENCE'
|
||||
)
|
||||
"
|
||||
:disabled="isSaving"
|
||||
@click="removeReference(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
v-model="reference.content_md"
|
||||
:id="`skill-reference-${index}`"
|
||||
:label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCE_CONTENT'
|
||||
)
|
||||
"
|
||||
:disabled="isSaving"
|
||||
resize
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-end gap-3 border-t border-n-weak pt-4"
|
||||
>
|
||||
<Button
|
||||
:label="t('DIALOG.BUTTONS.CANCEL')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:disabled="isSaving"
|
||||
@click="dialogRef.close()"
|
||||
/>
|
||||
<template v-if="currentStatus === 'active'">
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.SAVE_CHANGES')
|
||||
"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
@click="submit('active')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="currentStatus === 'archived'">
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.REACTIVATE_ADD'
|
||||
)
|
||||
"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
@click="submit('active', true)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.SAVE_DRAFT')
|
||||
"
|
||||
color="slate"
|
||||
:disabled="isSaving"
|
||||
@click="submit('draft')"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.PUBLISH_ADD')
|
||||
"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
@click="submit('active', true)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -420,6 +420,14 @@ const menuItems = computed(() => {
|
||||
navigationPath: 'captain_assistants_scenarios_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Skills',
|
||||
label: t('SIDEBAR.CAPTAIN_SKILLS'),
|
||||
activeOn: ['captain_assistants_skills_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_skills_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Playground',
|
||||
label: t('SIDEBAR.CAPTAIN_PLAYGROUND'),
|
||||
|
||||
Reference in New Issue
Block a user