diff --git a/frontend/app/javascript/dashboard/api/captain/skills.js b/frontend/app/javascript/dashboard/api/captain/skills.js new file mode 100644 index 00000000..386fc5bf --- /dev/null +++ b/frontend/app/javascript/dashboard/api/captain/skills.js @@ -0,0 +1,34 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainSkills extends ApiClient { + constructor() { + super('captain/skills', { accountScoped: true }); + } + + get({ assistantId } = {}) { + return axios.get(this.url, { params: { assistant_id: assistantId } }); + } + + create(data) { + return axios.post(this.url, { skill: data }); + } + + update(id, data) { + return axios.put(`${this.url}/${id}`, { skill: data }); + } + + bind({ assistantId, skillId }) { + return axios.post( + `${this.baseUrl()}/captain/assistants/${assistantId}/skills/${skillId}` + ); + } + + unbind({ assistantId, skillId }) { + return axios.delete( + `${this.baseUrl()}/captain/assistants/${assistantId}/skills/${skillId}` + ); + } +} + +export default new CaptainSkills(); diff --git a/frontend/app/javascript/dashboard/api/captain/specs/skills.spec.js b/frontend/app/javascript/dashboard/api/captain/specs/skills.spec.js new file mode 100644 index 00000000..fbda4cd4 --- /dev/null +++ b/frontend/app/javascript/dashboard/api/captain/specs/skills.spec.js @@ -0,0 +1,56 @@ +import CaptainSkillsAPI from '../skills'; +import ApiClient from '../../ApiClient'; + +describe('#CaptainSkillsAPI', () => { + beforeEach(() => { + window.history.pushState({}, '', '/app/accounts/7/captain/2/skills'); + global.axios = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }; + }); + + it('uses the account skill management endpoints', () => { + CaptainSkillsAPI.get({ assistantId: 2 }); + CaptainSkillsAPI.show(3); + CaptainSkillsAPI.create({ name: 'Refunds' }); + CaptainSkillsAPI.update(3, { expected_version: 1 }); + CaptainSkillsAPI.delete(3); + + expect(axios.get).toHaveBeenNthCalledWith( + 1, + '/api/v1/accounts/7/captain/skills', + { params: { assistant_id: 2 } } + ); + expect(axios.get).toHaveBeenNthCalledWith( + 2, + '/api/v1/accounts/7/captain/skills/3' + ); + expect(axios.post).toHaveBeenCalledWith( + '/api/v1/accounts/7/captain/skills', + { skill: { name: 'Refunds' } } + ); + expect(axios.put).toHaveBeenCalledWith( + '/api/v1/accounts/7/captain/skills/3', + { skill: { expected_version: 1 } } + ); + expect(axios.delete).toHaveBeenCalledWith( + '/api/v1/accounts/7/captain/skills/3' + ); + }); + + it('binds and unbinds a skill for one assistant', () => { + CaptainSkillsAPI.bind({ assistantId: 2, skillId: 3 }); + CaptainSkillsAPI.unbind({ assistantId: 2, skillId: 3 }); + + const url = '/api/v1/accounts/7/captain/assistants/2/skills/3'; + expect(axios.post).toHaveBeenCalledWith(url); + expect(axios.delete).toHaveBeenCalledWith(url); + }); + + it('is an account-scoped API client', () => { + expect(CaptainSkillsAPI).toBeInstanceOf(ApiClient); + }); +}); diff --git a/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.spec.js b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.spec.js new file mode 100644 index 00000000..9f94d5d7 --- /dev/null +++ b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.spec.js @@ -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: '
', +}; + +const Button = { + name: 'Button', + props: ['label'], + emits: ['click'], + template: + '', +}; + +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(); + }); +}); diff --git a/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.vue b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.vue new file mode 100644 index 00000000..33c8ac5c --- /dev/null +++ b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.vue @@ -0,0 +1,435 @@ + + +