diff --git a/packages/utils/src/utils/router.js b/packages/utils/src/utils/router.js index 7a6abd4f6..cafde225c 100644 --- a/packages/utils/src/utils/router.js +++ b/packages/utils/src/utils/router.js @@ -51,6 +51,9 @@ export const paths = { }, byNamespace() { return byNamespace({ path: '/customruns' }); + }, + create() { + return '/customruns/create'; } }, eventListeners: { diff --git a/src/api/customRuns.js b/src/api/customRuns.js index f9f8a9405..0e08598c6 100644 --- a/src/api/customRuns.js +++ b/src/api/customRuns.js @@ -18,6 +18,8 @@ import { deleteRequest, get, patch, post } from './comms'; import { getQueryParams, getTektonAPI, + removeSystemAnnotations, + removeSystemLabels, useCollection, useResource } from './utils'; @@ -39,6 +41,47 @@ function getCustomRunsAPI({ filters, isWebSocket, name, namespace }) { ); } +export function getCustomRunPayload({ + customRunName = `run-${Date.now()}`, + labels, + namespace, + params, + serviceAccount, + timeout +}) { + const payload = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'CustomRun', + metadata: { + name: customRunName, + namespace + }, + spec: { + customRef: { + apiVersion: '', + kind: '' + } + } + }; + if (labels) { + payload.metadata.labels = labels; + } + if (params) { + payload.spec.params = Object.keys(params).map(name => ({ + name, + value: params[name] + })); + } + if (serviceAccount) { + payload.spec.serviceAccountName = serviceAccount; + } + if (timeout) { + payload.spec.timeout = timeout; + } + + return payload; +} + export function getCustomRuns({ filters = [], namespace } = {}) { const uri = getCustomRunsAPI({ filters, namespace }); return get(uri); @@ -107,3 +150,47 @@ export function rerunCustomRun(run) { const uri = getTektonAPI('customruns', { namespace, version: 'v1beta1' }); return post(uri, payload).then(({ body }) => body); } + +export function createCustomRunRaw({ namespace, payload }) { + const uri = getTektonAPI('customruns', { namespace, version: 'v1beta1' }); + return post(uri, payload).then(({ body }) => body); +} + +export function generateNewCustomRunPayload({ customRun, rerun }) { + const { annotations, labels, name, namespace, generateName } = + customRun.metadata; + + const payload = deepClone(customRun); + payload.apiVersion = payload.apiVersion || 'tekton.dev/v1beta1'; + payload.kind = payload.kind || 'CustomRun'; + + function getGenerateName() { + if (rerun) { + return getGenerateNamePrefixForRerun(name); + } + + return generateName || `${name}-`; + } + + payload.metadata = { + annotations: annotations || {}, + generateName: getGenerateName(), + labels: labels || {}, + namespace + }; + if (rerun) { + payload.metadata.labels['dashboard.tekton.dev/rerunOf'] = name; + } + + removeSystemAnnotations(payload); + removeSystemLabels(payload); + + Object.keys(payload.metadata).forEach( + i => payload.metadata[i] === undefined && delete payload.metadata[i] + ); + + delete payload.status; + + delete payload.spec?.status; + return { namespace, payload }; +} diff --git a/src/containers/App/App.jsx b/src/containers/App/App.jsx index e232038d9..e5e30327b 100644 --- a/src/containers/App/App.jsx +++ b/src/containers/App/App.jsx @@ -42,6 +42,7 @@ import { ClusterTasks, ClusterTriggerBinding, ClusterTriggerBindings, + CreateCustomRun, CreatePipelineRun, CreateTaskRun, CustomResourceDefinition, @@ -322,6 +323,11 @@ export function App({ lang }) { + + + + + diff --git a/src/containers/CreateCustomRun/CreateCustomRun.jsx b/src/containers/CreateCustomRun/CreateCustomRun.jsx new file mode 100644 index 000000000..f89d953a6 --- /dev/null +++ b/src/containers/CreateCustomRun/CreateCustomRun.jsx @@ -0,0 +1,152 @@ +/* +Copyright 2023 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/* istanbul ignore file */ + +import React, { Suspense, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; +import yaml from 'js-yaml'; +import { ALL_NAMESPACES, urls, useTitleSync } from '@tektoncd/dashboard-utils'; +import { Loading } from '@tektoncd/dashboard-components'; +import { useIntl } from 'react-intl'; +import { + createCustomRunRaw, + generateNewCustomRunPayload, + getCustomRunPayload, + useCustomRun, + useSelectedNamespace +} from '../../api'; + +const YAMLEditor = React.lazy(() => import('../YAMLEditor')); + +const initialState = { + creating: false, + kind: 'CustomRun', + labels: [], + params: {}, + validationError: false, + validCustomRunName: true +}; + +function CreateCustomRun() { + const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const { selectedNamespace: defaultNamespace } = useSelectedNamespace(); + + function getCustomRunName() { + const urlSearchParams = new URLSearchParams(location.search); + return urlSearchParams.get('customRunName') || ''; + } + + function getNamespace() { + const urlSearchParams = new URLSearchParams(location.search); + return ( + urlSearchParams.get('namespace') || + (defaultNamespace !== ALL_NAMESPACES ? defaultNamespace : '') + ); + } + + const [{ kind, labels, namespace, params }] = useState({ + ...initialState, + customRef: '', + kind: 'Custom', + namespace: getNamespace() + }); + + useTitleSync({ + page: intl.formatMessage({ + id: 'dashboard.createCustomRun.title', + defaultMessage: 'Create CustomRun' + }) + }); + + function handleCloseYAMLEditor() { + let url = urls.customRuns.all(); + if (defaultNamespace && defaultNamespace !== ALL_NAMESPACES) { + url = urls.customRuns.byNamespace({ namespace: defaultNamespace }); + } + navigate(url); + } + + function handleCreate({ resource }) { + const resourceNamespace = resource?.metadata?.namespace; + return createCustomRunRaw({ + namespace: resourceNamespace, + payload: resource + }).then(() => { + navigate(urls.customRuns.byNamespace({ namespace: resourceNamespace })); + }); + } + + const externalCustomRunName = getCustomRunName(); + if (externalCustomRunName) { + const { data: customRunObject, isLoading } = useCustomRun( + { + name: externalCustomRunName, + namespace: getNamespace() + }, + { disableWebSocket: true } + ); + let payloadYaml = null; + if (customRunObject) { + const { payload } = generateNewCustomRunPayload({ + customRun: customRunObject, + rerun: false + }); + payloadYaml = yaml.dump(payload); + } + const loadingMessage = intl.formatMessage( + { + id: 'dashboard.loading.resource', + defaultMessage: 'Loading {kind}…' + }, + { kind: 'CustomRun' } + ); + + return ( + }> + + + ); + } + + const customRun = getCustomRunPayload({ + kind, + labels: labels.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}), + namespace, + params + }); + + return ( + }> + + + ); +} + +export default CreateCustomRun; diff --git a/src/containers/CreateCustomRun/CreateCustomRun.test.jsx b/src/containers/CreateCustomRun/CreateCustomRun.test.jsx new file mode 100644 index 000000000..5340f8a5e --- /dev/null +++ b/src/containers/CreateCustomRun/CreateCustomRun.test.jsx @@ -0,0 +1,159 @@ +/* +Copyright 2023 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; + +import { renderWithRouter } from '../../utils/test'; + +import CreateCustomRun from './CreateCustomRun'; +import * as APIUtils from '../../api/utils'; +import * as CustomRunsAPI from '../../api/customRuns'; + +const submitButton = allByText => allByText('Create')[0]; + +const customRunRawGenerateName = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'CustomRun', + metadata: { + annotations: {}, + generateName: 'test-custom-run-name-', + labels: {}, + namespace: 'test-namespace' + }, + spec: { + customRef: { + apiVersion: 'example.dev/v1beta1' + } + } +}; + +const expectedCustomRun = `apiVersion: tekton.dev/v1beta1 +kind: CustomRun +metadata: + name: run-1111111111 + namespace: test-namespace + labels: {} +spec: + customRef: + apiVersion: '' + kind: '' + params: []`; + +const expectedCustomRunOneLine = expectedCustomRun.replace(/\r?\n|\r/g, ''); + +const findNameRegexp = /name: run-\S+/; + +describe('CreateCustomRun yaml mode', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window.history, 'pushState'); + + // Workaround for codemirror vs jsdom https://github.com/jsdom/jsdom/issues/3002#issuecomment-1118039915 + // for textRange(...).getClientRects is not a function + Range.prototype.getBoundingClientRect = () => ({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0 + }); + Range.prototype.getClientRects = () => ({ + item: () => null, + length: 0, + [Symbol.iterator]: vi.fn() + }); + }); + + it('renders with namespace', async () => { + vi.spyOn(CustomRunsAPI, 'createCustomRunRaw').mockImplementation(() => + Promise.resolve({ data: {} }) + ); + vi.spyOn(CustomRunsAPI, 'useCustomRun').mockImplementation(() => ({ + data: customRunRawGenerateName + })); + + const { getByRole, queryAllByText } = renderWithRouter( + , + { + path: '/customruns/create', + route: '/customruns/create?mode=yaml&namespace=test-namespace' + } + ); + + await waitFor( + () => { + expect(queryAllByText(/Loading/).length).toBe(0); + }, + { + timeout: 3000 + } + ); + await waitFor(() => { + expect(getByRole(/textbox/)).toBeTruthy(); + }); + let actual = getByRole(/textbox/).textContent; + actual = actual.replace(findNameRegexp, 'name: run-1111111111'); + expect(actual.trim()).toEqual(expectedCustomRunOneLine); + }); + + it('handle submit with customrun and namespace', async () => { + vi.spyOn(CustomRunsAPI, 'createCustomRunRaw').mockImplementation(() => + Promise.resolve({ data: {} }) + ); + vi.spyOn(CustomRunsAPI, 'useCustomRun').mockImplementation(() => ({ + data: customRunRawGenerateName + })); + + const { queryAllByText } = renderWithRouter(, { + path: '/customruns/create', + route: + '/customruns/create?mode=yaml&customRunName=test-custom-run-name&namespace=test-namespace' + }); + + await waitFor( + () => { + expect(queryAllByText(/Loading/).length).toBe(0); + }, + { timeout: 3000 } + ); + expect(submitButton(queryAllByText)).toBeTruthy(); + + fireEvent.click(submitButton(queryAllByText)); + + await waitFor(() => { + expect(CustomRunsAPI.createCustomRunRaw).toHaveBeenCalledTimes(1); + }); + expect(CustomRunsAPI.createCustomRunRaw).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'test-namespace', + payload: customRunRawGenerateName + }) + ); + await waitFor(() => { + expect(window.history.pushState).toHaveBeenCalledTimes(2); + }); + }); + + it('handles onClose event', () => { + vi.spyOn(APIUtils, 'useSelectedNamespace').mockImplementation(() => ({ + selectedNamespace: 'namespace-1' + })); + vi.spyOn(window.history, 'pushState'); + const { getByText } = renderWithRouter(); + fireEvent.click(getByText(/cancel/i)); + // will be called once for render (from test utils) and once on navigation + expect(window.history.pushState).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/containers/CreateCustomRun/index.js b/src/containers/CreateCustomRun/index.js new file mode 100644 index 000000000..f1c1880a8 --- /dev/null +++ b/src/containers/CreateCustomRun/index.js @@ -0,0 +1,15 @@ +/* +Copyright 2023 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/* istanbul ignore file */ + +export { default } from './CreateCustomRun'; diff --git a/src/containers/CustomRun/CustomRun.jsx b/src/containers/CustomRun/CustomRun.jsx index 7cf6a4efe..123eeb60f 100644 --- a/src/containers/CustomRun/CustomRun.jsx +++ b/src/containers/CustomRun/CustomRun.jsx @@ -202,6 +202,14 @@ function CustomRun() { }); } + function editAndRun() { + navigate( + `${urls.customRuns.create()}?mode=yaml&customRunName=${ + run.metadata.name + }&namespace=${run.metadata.namespace}` + ); + } + function runActions() { if (isReadOnly) { return []; @@ -215,6 +223,13 @@ function CustomRun() { }), disable: resource => !!resource.metadata.labels?.['tekton.dev/pipeline'] }, + { + actionText: intl.formatMessage({ + id: 'dashboard.editAndRun.actionText', + defaultMessage: 'Edit and run' + }), + action: editAndRun + }, { actionText: intl.formatMessage({ id: 'dashboard.actions.deleteButton', diff --git a/src/containers/CustomRuns/CustomRuns.jsx b/src/containers/CustomRuns/CustomRuns.jsx index 70385bba5..5c42cb719 100644 --- a/src/containers/CustomRuns/CustomRuns.jsx +++ b/src/containers/CustomRuns/CustomRuns.jsx @@ -14,7 +14,11 @@ limitations under the License. import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { useLocation, useParams } from 'react-router-dom-v5-compat'; +import { + useLocation, + useNavigate, + useParams +} from 'react-router-dom-v5-compat'; import { useIntl } from 'react-intl'; import keyBy from 'lodash.keyby'; import { @@ -34,6 +38,7 @@ import { Table } from '@tektoncd/dashboard-components'; import { + Add16 as Add, Calendar16 as CalendarIcon, TrashCan32 as DeleteIcon, Time16 as TimeIcon, @@ -107,6 +112,7 @@ function getRunStatusTooltip(run) { function CustomRuns() { const intl = useIntl(); + const navigate = useNavigate(); const location = useLocation(); const params = useParams(); const filters = getFilters(location); @@ -188,6 +194,14 @@ function CustomRuns() { rerunCustomRun(run); } + function editAndRun(run) { + navigate( + `${urls.customRuns.create()}?mode=yaml&customRunName=${ + run.metadata.name + }&namespace=${run.metadata.namespace}` + ); + } + function deleteResource(run) { const { name, namespace: resourceNamespace } = run.metadata; return deleteCustomRun({ name, namespace: resourceNamespace }).catch( @@ -225,6 +239,13 @@ function CustomRuns() { }), disable: resource => !!resource.metadata.labels?.['tekton.dev/pipeline'] }, + { + actionText: intl.formatMessage({ + id: 'dashboard.editAndRun.actionText', + defaultMessage: 'Edit and run' + }), + action: editAndRun + }, { actionText: intl.formatMessage({ id: 'dashboard.cancelTaskRun.actionText', @@ -294,6 +315,28 @@ function CustomRuns() { ]; } + const toolbarButtons = isReadOnly + ? [] + : [ + { + onClick: () => { + const queryString = new URLSearchParams({ + ...(namespace !== ALL_NAMESPACES && { namespace }), + // currently default is yaml mode + mode: 'yaml' + }).toString(); + navigate( + urls.customRuns.create() + (queryString ? `?${queryString}` : '') + ); + }, + text: intl.formatMessage({ + id: 'dashboard.actions.createButton', + defaultMessage: 'Create' + }), + icon: Add + } + ]; + const batchActionButtons = isReadOnly ? [] : [ @@ -482,6 +525,7 @@ function CustomRuns() { return ( <>