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 (
<>