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:
+202
@@ -0,0 +1,202 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Index from './Index.vue';
|
||||
|
||||
const mocked = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
records: [],
|
||||
flags: { fetchingList: false, updatingItem: false },
|
||||
assistantId: 9,
|
||||
route: null,
|
||||
updateGuard: null,
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', async () => {
|
||||
const { computed } = await vi.importActual('vue');
|
||||
return {
|
||||
useStore: () => ({ dispatch: (...args) => mocked.dispatch(...args) }),
|
||||
useMapGetter: key =>
|
||||
computed(() =>
|
||||
key === 'captainSkills/getRecords'
|
||||
? mocked.records
|
||||
: mocked.flags
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual('vue-router');
|
||||
const { reactive } = await vi.importActual('vue');
|
||||
mocked.route = reactive({ params: { assistantId: mocked.assistantId } });
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => mocked.route,
|
||||
onBeforeRouteLeave: vi.fn(),
|
||||
onBeforeRouteUpdate: guard => {
|
||||
mocked.updateGuard = guard;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const PageLayout = {
|
||||
name: 'PageLayout',
|
||||
template:
|
||||
'<div><slot name="search"/><slot name="body"/><slot name="emptyState"/></div>',
|
||||
};
|
||||
|
||||
const mountPage = () =>
|
||||
shallowMount(Index, {
|
||||
global: {
|
||||
directives: { 'on-clickaway': () => {} },
|
||||
stubs: {
|
||||
PageLayout,
|
||||
CardLayout: { template: '<div><slot/></div>' },
|
||||
Button: true,
|
||||
Input: true,
|
||||
DropdownMenu: true,
|
||||
EmptyStateLayout: true,
|
||||
SkillDialog: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const activeSkill = {
|
||||
id: 3,
|
||||
name: 'Refunds',
|
||||
description: 'Handle refunds',
|
||||
status: 'active',
|
||||
version: 2,
|
||||
reference_count: 1,
|
||||
bound_assistant_count: 2,
|
||||
updated_at: 1700000000,
|
||||
};
|
||||
|
||||
describe('Assistant skills page', () => {
|
||||
beforeEach(() => {
|
||||
mocked.records = [];
|
||||
mocked.dispatch = vi.fn().mockResolvedValue([]);
|
||||
mocked.route.params.assistantId = 9;
|
||||
});
|
||||
|
||||
it('loads the library for the route assistant', async () => {
|
||||
mountPage();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocked.dispatch).toHaveBeenCalledWith('captainSkills/get', {
|
||||
assistantId: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('groups bound and available skills', () => {
|
||||
mocked.records = [
|
||||
{ ...activeSkill, bound: true },
|
||||
{ ...activeSkill, id: 4, name: 'Shipping', bound: false },
|
||||
];
|
||||
const wrapper = mountPage();
|
||||
|
||||
expect(wrapper.text()).toContain('Added to this assistant');
|
||||
expect(wrapper.text()).toContain('Available to add');
|
||||
expect(wrapper.text()).toContain('Refunds');
|
||||
expect(wrapper.text()).toContain('Shipping');
|
||||
});
|
||||
|
||||
it('binds an active skill and reloads server state', async () => {
|
||||
mocked.records = [{ ...activeSkill, bound: false }];
|
||||
const wrapper = mountPage();
|
||||
mocked.dispatch.mockClear();
|
||||
|
||||
await wrapper.vm.handleAction('bind', mocked.records[0]);
|
||||
|
||||
expect(mocked.dispatch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'captainSkills/bind',
|
||||
{
|
||||
assistantId: 9,
|
||||
skillId: 3,
|
||||
}
|
||||
);
|
||||
expect(mocked.dispatch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'captainSkills/get',
|
||||
{
|
||||
assistantId: 9,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('closes and clears the old dialog after route update', async () => {
|
||||
const wrapper = mountPage();
|
||||
wrapper.findComponent(PageLayout).vm.$emit('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.findComponent({ name: 'SkillDialog' }).exists()).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(mocked.updateGuard()).toBe(true);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.findComponent({ name: 'SkillDialog' }).exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a pending detail after route update', async () => {
|
||||
let resolveDetail;
|
||||
mocked.dispatch = vi.fn().mockImplementation(action => {
|
||||
if (action === 'captainSkills/detail') {
|
||||
return new Promise(resolve => {
|
||||
resolveDetail = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
const wrapper = mountPage();
|
||||
const edit = wrapper.vm.handleAction('edit', activeSkill);
|
||||
|
||||
expect(mocked.updateGuard()).toBe(true);
|
||||
mocked.route.params.assistantId = 10;
|
||||
resolveDetail(activeSkill);
|
||||
await edit;
|
||||
|
||||
expect(wrapper.findComponent({ name: 'SkillDialog' }).exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('reactivates and binds to the assistant captured before route update', async () => {
|
||||
let resolveUpdate;
|
||||
mocked.dispatch = vi.fn().mockImplementation(action => {
|
||||
if (action === 'captainSkills/detail') {
|
||||
return Promise.resolve({
|
||||
...activeSkill,
|
||||
status: 'archived',
|
||||
references: [],
|
||||
});
|
||||
}
|
||||
if (action === 'captainSkills/update') {
|
||||
return new Promise(resolve => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const wrapper = mountPage();
|
||||
const reactivate = wrapper.vm.handleAction('reactivate', {
|
||||
...activeSkill,
|
||||
status: 'archived',
|
||||
bound: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
mocked.updateGuard();
|
||||
mocked.route.params.assistantId = 10;
|
||||
resolveUpdate(activeSkill);
|
||||
await reactivate;
|
||||
|
||||
expect(mocked.dispatch).toHaveBeenCalledWith('captainSkills/bind', {
|
||||
assistantId: 9,
|
||||
skillId: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import SkillDialog from 'dashboard/components-next/captain/pageComponents/skill/SkillDialog.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const assistantId = computed(() => Number(route.params.assistantId));
|
||||
const skills = useMapGetter('captainSkills/getRecords');
|
||||
const uiFlags = useMapGetter('captainSkills/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const query = ref('');
|
||||
const loadError = ref('');
|
||||
const actionId = ref(null);
|
||||
const openMenuId = ref(null);
|
||||
const selectedSkill = ref(null);
|
||||
const showDialog = ref(false);
|
||||
const dialogRef = ref(null);
|
||||
let latestFetchRequest = 0;
|
||||
let routeGeneration = 0;
|
||||
|
||||
const filteredSkills = computed(() => {
|
||||
const search = query.value.trim().toLowerCase();
|
||||
if (!search) return skills.value;
|
||||
return skills.value.filter(skill =>
|
||||
`${skill.name} ${skill.description}`.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
const groups = computed(() =>
|
||||
[
|
||||
{
|
||||
key: 'bound',
|
||||
title: t('CAPTAIN.ASSISTANTS.SKILLS.GROUPS.ADDED'),
|
||||
items: filteredSkills.value.filter(skill => skill.bound),
|
||||
},
|
||||
{
|
||||
key: 'available',
|
||||
title: t('CAPTAIN.ASSISTANTS.SKILLS.GROUPS.AVAILABLE'),
|
||||
items: filteredSkills.value.filter(skill => !skill.bound),
|
||||
},
|
||||
].filter(group => group.items.length)
|
||||
);
|
||||
|
||||
const errorText = error => {
|
||||
if (error?.response?.status === 403) {
|
||||
return t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.FORBIDDEN');
|
||||
}
|
||||
if (error?.response?.status === 404) {
|
||||
return t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.NOT_FOUND');
|
||||
}
|
||||
const parsed = parseAPIErrorResponse(error);
|
||||
return error?.response?.status === 409
|
||||
? `${parsed} ${t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.CONFLICT')}`
|
||||
: parsed;
|
||||
};
|
||||
|
||||
const fetchSkills = async () => {
|
||||
const requestId = ++latestFetchRequest;
|
||||
loadError.value = '';
|
||||
try {
|
||||
await store.dispatch('captainSkills/get', {
|
||||
assistantId: assistantId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestId === latestFetchRequest) {
|
||||
loadError.value = errorText(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
showDialog.value = false;
|
||||
selectedSkill.value = null;
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
selectedSkill.value = null;
|
||||
showDialog.value = true;
|
||||
nextTick(() => dialogRef.value?.dialogRef?.open());
|
||||
};
|
||||
|
||||
const openEditDialog = async skill => {
|
||||
const requestedAssistantId = assistantId.value;
|
||||
const requestedRouteGeneration = routeGeneration;
|
||||
actionId.value = skill.id;
|
||||
try {
|
||||
const detail = await store.dispatch('captainSkills/detail', skill.id);
|
||||
if (
|
||||
requestedAssistantId !== assistantId.value ||
|
||||
requestedRouteGeneration !== routeGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
selectedSkill.value = {
|
||||
...detail,
|
||||
bound: skill.bound,
|
||||
bound_assistant_count: skill.bound_assistant_count,
|
||||
};
|
||||
showDialog.value = true;
|
||||
nextTick(() => dialogRef.value?.dialogRef?.open());
|
||||
} catch (error) {
|
||||
if (
|
||||
requestedAssistantId === assistantId.value &&
|
||||
requestedRouteGeneration === routeGeneration
|
||||
) {
|
||||
useAlert(errorText(error));
|
||||
}
|
||||
} finally {
|
||||
if (requestedRouteGeneration === routeGeneration) {
|
||||
actionId.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (skill, status, requestedAssistantId) => {
|
||||
const detail = await store.dispatch('captainSkills/detail', skill.id);
|
||||
const saved = await store.dispatch('captainSkills/update', {
|
||||
id: detail.id,
|
||||
name: detail.name,
|
||||
description: detail.description,
|
||||
instructions_md: detail.instructions_md,
|
||||
status,
|
||||
references: detail.references.map(({ reference_key, content_md }) => ({
|
||||
reference_key,
|
||||
content_md,
|
||||
})),
|
||||
expected_version: detail.version,
|
||||
});
|
||||
if (status === 'active' && !skill.bound) {
|
||||
await store.dispatch('captainSkills/bind', {
|
||||
assistantId: requestedAssistantId,
|
||||
skillId: saved.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (action, skill) => {
|
||||
openMenuId.value = null;
|
||||
if (action === 'edit') {
|
||||
await openEditDialog(skill);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmations = {
|
||||
unbind: 'CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.UNBIND',
|
||||
archive: 'CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.ARCHIVE',
|
||||
reactivate: 'CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.REACTIVATE',
|
||||
delete: 'CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.DELETE',
|
||||
};
|
||||
if (confirmations[action] && !window.confirm(t(confirmations[action])))
|
||||
return;
|
||||
|
||||
const requestedAssistantId = assistantId.value;
|
||||
actionId.value = skill.id;
|
||||
try {
|
||||
if (action === 'bind' || action === 'unbind') {
|
||||
await store.dispatch(`captainSkills/${action}`, {
|
||||
assistantId: requestedAssistantId,
|
||||
skillId: skill.id,
|
||||
});
|
||||
} else if (action === 'archive') {
|
||||
await updateStatus(skill, 'archived', requestedAssistantId);
|
||||
} else if (action === 'reactivate') {
|
||||
await updateStatus(skill, 'active', requestedAssistantId);
|
||||
} else if (action === 'delete') {
|
||||
await store.dispatch('captainSkills/delete', skill.id);
|
||||
}
|
||||
const successKeys = {
|
||||
bind: 'BOUND',
|
||||
unbind: 'UNBOUND',
|
||||
archive: 'ARCHIVED',
|
||||
reactivate: 'REACTIVATED',
|
||||
delete: 'DELETED',
|
||||
};
|
||||
useAlert(t(`CAPTAIN.ASSISTANTS.SKILLS.SUCCESS.${successKeys[action]}`));
|
||||
await fetchSkills();
|
||||
} catch (error) {
|
||||
useAlert(errorText(error));
|
||||
} finally {
|
||||
actionId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = skill => {
|
||||
const items = [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.EDIT'),
|
||||
action: 'edit',
|
||||
value: 'edit',
|
||||
icon: 'i-lucide-pencil-line',
|
||||
},
|
||||
];
|
||||
if (skill.bound) {
|
||||
items.push({
|
||||
label: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.UNBIND'),
|
||||
action: 'unbind',
|
||||
value: 'unbind',
|
||||
icon: 'i-lucide-unlink',
|
||||
});
|
||||
} else if (skill.status === 'active') {
|
||||
items.push({
|
||||
label: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.BIND'),
|
||||
action: 'bind',
|
||||
value: 'bind',
|
||||
icon: 'i-lucide-link',
|
||||
});
|
||||
}
|
||||
if (skill.status === 'active') {
|
||||
items.push({
|
||||
label: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.ARCHIVE'),
|
||||
action: 'archive',
|
||||
value: 'archive',
|
||||
icon: 'i-lucide-archive',
|
||||
});
|
||||
} else if (skill.status === 'archived') {
|
||||
items.push({
|
||||
label: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.REACTIVATE'),
|
||||
action: 'reactivate',
|
||||
value: 'reactivate',
|
||||
icon: 'i-lucide-archive-restore',
|
||||
});
|
||||
}
|
||||
|
||||
const deleteDisabled =
|
||||
skill.status !== 'draft' || skill.bound_assistant_count > 0;
|
||||
const deleteLabel =
|
||||
skill.bound_assistant_count > 0
|
||||
? t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.DELETE_BOUND')
|
||||
: skill.status !== 'draft'
|
||||
? t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.DELETE_STATUS')
|
||||
: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.DELETE');
|
||||
items.push({
|
||||
label: deleteLabel,
|
||||
action: 'delete',
|
||||
value: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
disabled: deleteDisabled,
|
||||
});
|
||||
return items;
|
||||
};
|
||||
|
||||
const statusClass = status =>
|
||||
({
|
||||
draft: 'bg-n-amber-3 text-n-amber-11',
|
||||
active: 'bg-n-teal-3 text-n-teal-11',
|
||||
archived: 'bg-n-slate-3 text-n-slate-11',
|
||||
})[status];
|
||||
|
||||
const confirmDiscard = () =>
|
||||
!dialogRef.value?.isDirty ||
|
||||
window.confirm(t('CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.DISCARD'));
|
||||
|
||||
const handleRouteUpdate = () => {
|
||||
if (!confirmDiscard()) return false;
|
||||
routeGeneration += 1;
|
||||
closeDialog();
|
||||
openMenuId.value = null;
|
||||
actionId.value = null;
|
||||
return true;
|
||||
};
|
||||
|
||||
onBeforeRouteLeave(confirmDiscard);
|
||||
onBeforeRouteUpdate(handleRouteUpdate);
|
||||
onMounted(fetchSkills);
|
||||
watch(assistantId, fetchSkills);
|
||||
|
||||
defineExpose({ handleAction, fetchSkills });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="t('CAPTAIN.ASSISTANTS.SKILLS.TITLE')"
|
||||
:button-label="t('CAPTAIN.ASSISTANTS.SKILLS.ADD_NEW')"
|
||||
:button-policy="['administrator']"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!loadError && !skills.length"
|
||||
:show-know-more="false"
|
||||
:show-pagination-footer="false"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<template #search>
|
||||
<Input
|
||||
v-if="skills.length || query"
|
||||
v-model="query"
|
||||
type="search"
|
||||
size="sm"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.SKILLS.SEARCH')"
|
||||
:aria-label="t('CAPTAIN.ASSISTANTS.SKILLS.SEARCH')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<div
|
||||
class="flex min-h-80 flex-col items-center justify-center gap-3 text-center"
|
||||
>
|
||||
<span class="i-lucide-library-big size-8 text-n-slate-10" />
|
||||
<div>
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SKILLS.EMPTY.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-1 text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SKILLS.EMPTY.SUBTITLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="openCreateDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div
|
||||
v-if="loadError"
|
||||
class="flex min-h-64 flex-col items-center justify-center gap-3 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p class="mb-0 text-sm text-n-ruby-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SKILLS.LOAD_ERROR') }}
|
||||
{{ loadError }}
|
||||
</p>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.RETRY')"
|
||||
color="slate"
|
||||
@click="fetchSkills"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="query && !filteredSkills.length"
|
||||
class="flex min-h-64 flex-col items-center justify-center gap-3 text-center"
|
||||
>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SKILLS.EMPTY.SEARCH') }}
|
||||
</p>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.CLEAR_SEARCH')"
|
||||
color="slate"
|
||||
@click="query = ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-8">
|
||||
<section
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<h2 class="text-sm font-medium text-n-slate-11">
|
||||
{{ group.title }}
|
||||
</h2>
|
||||
<CardLayout v-for="skill in group.items" :key="skill.id">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3
|
||||
class="truncate text-base font-medium text-n-slate-12"
|
||||
>
|
||||
{{ skill.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="rounded-md px-2 py-0.5 text-xs font-medium"
|
||||
:class="statusClass(skill.status)"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
`CAPTAIN.ASSISTANTS.SKILLS.STATUS.${skill.status.toUpperCase()}`
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-0 mt-1 text-sm text-n-slate-11">
|
||||
{{ skill.description }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-n-slate-10"
|
||||
>
|
||||
<span>{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.META.VERSION',
|
||||
{ count: skill.version }
|
||||
)
|
||||
}}</span>
|
||||
<span>{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.META.REFERENCES',
|
||||
{ count: skill.reference_count }
|
||||
)
|
||||
}}</span>
|
||||
<span>{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.META.ASSISTANTS',
|
||||
{
|
||||
count: skill.bound_assistant_count,
|
||||
}
|
||||
)
|
||||
}}</span>
|
||||
<span>{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.META.UPDATED',
|
||||
{
|
||||
time: dynamicTime(
|
||||
skill.updated_at
|
||||
),
|
||||
}
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Policy
|
||||
v-on-clickaway="() => (openMenuId = null)"
|
||||
:permissions="['administrator']"
|
||||
class="relative self-end sm:self-start"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:is-loading="actionId === skill.id"
|
||||
:disabled="actionId !== null"
|
||||
:aria-label="`${skill.name} actions`"
|
||||
@click="
|
||||
openMenuId =
|
||||
openMenuId === skill.id
|
||||
? null
|
||||
: skill.id
|
||||
"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="openMenuId === skill.id"
|
||||
:menu-items="menuItems(skill)"
|
||||
class="top-full mt-1 ltr:right-0 rtl:left-0"
|
||||
@action="
|
||||
({ action }) =>
|
||||
handleAction(action, skill)
|
||||
"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
|
||||
<SkillDialog
|
||||
v-if="showDialog"
|
||||
ref="dialogRef"
|
||||
:skill="selectedSkill"
|
||||
:assistant-id="assistantId"
|
||||
@close="closeDialog"
|
||||
@saved="fetchSkills"
|
||||
/>
|
||||
</template>
|
||||
@@ -12,6 +12,7 @@ import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
|
||||
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
|
||||
import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||
import AssistantSkillsIndex from './assistants/skills/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
@@ -60,6 +61,12 @@ const assistantRoutes = [
|
||||
name: 'captain_assistants_scenarios_index',
|
||||
meta: metaV2,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/skills'),
|
||||
component: AssistantSkillsIndex,
|
||||
name: 'captain_assistants_skills_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/playground'),
|
||||
component: AssistantPlaygroundIndex,
|
||||
|
||||
@@ -10,4 +10,9 @@ describe('Captain routes', () => {
|
||||
expect(source).not.toContain("permissions: ['administrator', 'agent']");
|
||||
expect(source.match(/permissions: \['administrator'\]/g)?.length).toBe(4);
|
||||
});
|
||||
|
||||
it('registers the assistant skill management route', () => {
|
||||
expect(source).toContain("name: 'captain_assistants_skills_index'");
|
||||
expect(source).toContain("'accounts/:accountId/captain/:assistantId/skills'");
|
||||
});
|
||||
});
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ const routeToLastActiveAssistant = () => {
|
||||
'captain_assistants_responses_index', // Faq page
|
||||
'captain_assistants_documents_index', // Document page
|
||||
'captain_assistants_scenarios_index', // Scenario page
|
||||
'captain_assistants_skills_index', // Skills page
|
||||
'captain_assistants_playground_index', // Playground page
|
||||
'captain_assistants_inboxes_index', // Inboxes page
|
||||
'captain_tools_index', // Tools page
|
||||
|
||||
Reference in New Issue
Block a user