diff --git a/src/api/worker.ts b/src/api/worker.ts index 2f10cde3cbbdd0b496f7badbdf9b2b3e8f1e48ad..0f8fc10d218cca4bb051ef252ca2b06cfe2c649d 100644 --- a/src/api/worker.ts +++ b/src/api/worker.ts @@ -3,12 +3,16 @@ import { PageNumberPaginationParameters, unique } from '.' import { PageNumberPagination, UUID } from '@/types' import { Worker, WorkerType, WorkerVersion } from '@/types/worker' +export class DuplicatedWorker extends Error {} + export interface ListWorkersParameters extends PageNumberPaginationParameters { name?: string repository_id?: UUID type?: string } +export interface CreateWorkerPayload extends Pick<Worker, 'name' | 'slug'> { type: WorkerType['slug'] } + // List executable workers export const listWorkers = unique(async (params: ListWorkersParameters): Promise<PageNumberPagination<Worker>> => (await axios.get('/workers/', { params })).data) @@ -27,3 +31,13 @@ export const retrieveWorkerVersion = unique(async (id: UUID): Promise<WorkerVers // Retrieve a worker export const retrieveWorker = unique(async (id: UUID): Promise<Worker> => (await axios.get(`/workers/${id}/`)).data) + +/** + * Create a local worker. + * This call will throw a DuplicatedWorker error in case a worker already exists with this slug and is executable by the user. + */ +export const createWorker = unique(async (params: CreateWorkerPayload): Promise<Worker> => { + const result = await axios.post(`/workers/`, params) + if (result.status === 200) throw new DuplicatedWorker() + return result.data +}) diff --git a/src/components/Process/Workers/CreateForm.vue b/src/components/Process/Workers/CreateForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..a8c49c53e62ec77297c0aa4867aaf632e4cf6fe2 --- /dev/null +++ b/src/components/Process/Workers/CreateForm.vue @@ -0,0 +1,138 @@ +<template> + <button + class="button is-primary" + v-on:click="openModal = true" + v-bind="$attrs" + > + Create + </button> + + <Modal v-model="openModal" title="Create a local worker"> + <form class="form" v-on:submit.prevent="create"> + <div class="field"> + <label class="label">Name</label> + <div class="control"> + <input + class="input" + :class="{ 'is-danger': fieldErrors.name }" + type="text" + placeholder="…" + maxlength="100" + v-model.trim="payload.name" + /> + </div> + <p v-if="fieldErrors.name" class="help is-danger">{{ fieldErrors.name.join(', ') }}</p> + </div> + + <div class="field"> + <label class="label">Slug</label> + <div class="control"> + <input + class="input" + :class="{ 'is-danger': fieldErrors.slug }" + type="text" + placeholder="…" + maxlength="100" + v-model.trim="payload.slug" + /> + </div> + <p v-if="fieldErrors.slug" class="help is-danger">{{ fieldErrors.slug.join(', ') }}</p> + </div> + + <div class="field"> + <label class="label">Type</label> + <div class="control"> + <input + class="input" + :class="{ 'is-danger': fieldErrors.type }" + type="text" + placeholder="…" + maxlength="100" + v-model.trim="payload.type" + /> + </div> + <p v-if="fieldErrors.type" class="help is-danger">{{ fieldErrors.type.join(', ') }}</p> + </div> + </form> + + <template v-slot:footer="{ close }"> + <button class="button" v-on:click="close">Cancel</button> + <button + type="submit" + v-on:click="create" + class="button is-primary ml-auto" + :class="{ 'is-loading': loading }" + :disabled="!canSave" + :title="saveTitle" + > + Create + </button> + </template> + </Modal> +</template> + +<script lang="ts"> +import { mapActions } from 'pinia' +import { errorParser, ensureArray } from '@/helpers' +import Modal from '@/components/Modal.vue' +import { useNotificationStore, useWorkerStore } from '@/stores' +import { CreateWorkerPayload, DuplicatedWorker } from '@/api' +import { defineComponent } from 'vue' +import { isAxiosError } from 'axios' + +export default defineComponent({ + components: { + Modal + }, + data: () => ({ + openModal: false, + payload: { + name: '', + slug: '', + type: '' + } as CreateWorkerPayload, + loading: false, + fieldErrors: {} as Partial<Record<keyof CreateWorkerPayload, string[]>> + }), + computed: { + canSave (): boolean { + return !Object.values(this.payload).some(v => v === '') + }, + saveTitle () { + return this.canSave ? 'Create a new local worker' : 'All fields are required' + } + }, + methods: { + ...mapActions(useWorkerStore, ['createWorker']), + ...mapActions(useNotificationStore, ['notify']), + async create () { + if (!this.canSave) return + this.loading = true + this.fieldErrors = {} + try { + await this.createWorker(this.payload) + this.notify({ type: 'info', text: 'Worker created successfully.' }) + this.openModal = false + this.payload = { + name: '', + slug: '', + type: '' + } + } catch (err) { + if (err instanceof DuplicatedWorker) { + this.fieldErrors.slug = [`A worker with slug "${this.payload.slug}" already exists`] + } else { + this.notify({ type: 'error', text: errorParser(err) }) + if (isAxiosError(err)) { + this.fieldErrors = err?.response?.data + ? Object.fromEntries(Object.entries(err?.response?.data).map(([k, v]) => [k, ensureArray(v)])) + : {} + } + } + } finally { + this.loading = false + } + } + } +}) +</script> diff --git a/src/stores/workers.ts b/src/stores/workers.ts index acf7e552f6e9f648830dfaaca21af0919ae7ac99..196242c6c0443f5e8346920685850bec78276f94 100644 --- a/src/stores/workers.ts +++ b/src/stores/workers.ts @@ -18,7 +18,9 @@ import { WorkerConfigurationUpdatePayload, retrieveWorkerVersion, retrieveWorker, - retrieveWorkerRun + retrieveWorkerRun, + CreateWorkerPayload, + createWorker } from '@/api' import { isAxiosError } from 'axios' import { useNotificationStore } from './notification' @@ -62,6 +64,13 @@ export const useWorkerStore = defineStore('worker', { return resp }, + async createWorker (params: CreateWorkerPayload) { + const resp = await createWorker(params) + this.workers[resp.id] = resp + // No need to fetch the worker type, as local workers cannot be used to build a worker process + return resp + }, + async listWorkerTypes (params: PageNumberPaginationParameters = {}) { const resp = await listWorkerTypes(params) this.workerTypes = { @@ -162,4 +171,4 @@ export const useWorkerStore = defineStore('worker', { this.workerConfigurations[data.worker_version.worker.id][data.configuration.id] = data.configuration } } -}) \ No newline at end of file +}) diff --git a/src/views/Process/Workers/List.vue b/src/views/Process/Workers/List.vue index 9509ef34c43b99dd530a4bcae657f0b608e9091e..190f782f1fed25fefdde59d71123b199f56d86e1 100644 --- a/src/views/Process/Workers/List.vue +++ b/src/views/Process/Workers/List.vue @@ -2,6 +2,7 @@ <main class="container is-fluid"> <div class="columns"> <div class="field column is-one-third"> + <CreateForm class="is-pulled-right" /> <div class="title is-4">Available Workers</div> <div class="field has-addons"> <!-- Selection of a worker is required to list versions --> @@ -106,13 +107,15 @@ import VersionList from '@/components/Process/Workers/Versions/List' import Paginator from '@/components/Paginator.vue' import ListMembers from '@/components/Memberships/ListMembers.vue' import WorkerTag from '@/components/Process/Workers/WorkerRuns/WorkerTag' +import CreateForm from '@/components/Process/Workers/CreateForm.vue' export default { components: { Paginator, VersionList, ListMembers, - WorkerTag + WorkerTag, + CreateForm }, mixins: [ truncateMixin diff --git a/tests/unit/stores/workers.spec.js b/tests/unit/stores/workers.spec.js index 75a0ff3c71bea36b8c170cf8aaa9e42c06eb6b21..42567e2fa2a905737f6d6f0fc7de1ce7452d2c04 100644 --- a/tests/unit/stores/workers.spec.js +++ b/tests/unit/stores/workers.spec.js @@ -2,7 +2,8 @@ import { assert } from 'chai' import axios from 'axios' import { pick } from 'lodash' import { useWorkerStore, useNotificationStore } from '@/stores' -import { FakeAxios, setUpTestPinia, actionsCompletedPlugin } from '../testhelpers' +import { FakeAxios, setUpTestPinia, actionsCompletedPlugin, assertRejects } from '../testhelpers' +import { DuplicatedWorker } from '@/api' import { workerConfigurationsSample, workersSample, workerSample, workerRunsSample } from '../samples' describe('workers', () => { @@ -438,5 +439,46 @@ describe('workers', () => { }) }) }) + + describe('createLocalWorker', () => { + it("creates a user's local worker", async () => { + const payload = { + name: workerSample.name, + type: workerSample.type, + slug: workerSample.slug + } + mock.onPost('/workers/', payload).reply(201, workerSample) + + await store.createWorker(payload) + assert.deepStrictEqual(mock.history.all.map(req => pick(req, ['method', 'url'])), [ + { + method: 'post', + url: '/workers/' + } + ]) + assert.deepStrictEqual(store.workers, { + [workerSample.id]: workerSample + }) + }) + + it('throws a specific error in case the worker already exists', async () => { + const payload = { + name: workerSample.name, + type: workerSample.type, + slug: workerSample.slug + } + mock.onPost('/workers/', payload).reply(200, workerSample) + + const err = await assertRejects(async () => { await store.createWorker(payload) }) + assert.ok(err instanceof DuplicatedWorker) + assert.deepStrictEqual(mock.history.all.map(req => pick(req, ['method', 'url'])), [ + { + method: 'post', + url: '/workers/' + } + ]) + assert.deepStrictEqual(store.workers, {}) + }) + }) }) })