diff --git a/frontend/packages/console-shared/src/components/modals/DeleteResourceModal.tsx b/frontend/packages/console-shared/src/components/modals/DeleteResourceModal.tsx index 8c2c7e30a02..55b9bf0d94c 100644 --- a/frontend/packages/console-shared/src/components/modals/DeleteResourceModal.tsx +++ b/frontend/packages/console-shared/src/components/modals/DeleteResourceModal.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import { TextInputTypes } from '@patternfly/react-core'; import { Formik, FormikProps, FormikValues } from 'formik'; import { useTranslation, Trans } from 'react-i18next'; @@ -8,9 +7,9 @@ import { ModalBody, ModalSubmitFooter, } from '@console/internal/components/factory/modal'; -import { PromiseComponent } from '@console/internal/components/utils/promise-component'; import { history } from '@console/internal/components/utils/router'; import { K8sResourceKind } from '@console/internal/module/k8s'; +import { usePromiseHandler } from '../../hooks/promise-handler'; import { InputField } from '../formik-fields'; import { YellowExclamationTriangleIcon } from '../status'; @@ -25,11 +24,6 @@ type DeleteResourceModalProps = { close?: () => void; }; -type DeleteResourceModalState = { - inProgress: boolean; - errorMessage: string; -}; - const DeleteResourceForm: React.FC & DeleteResourceModalProps> = ({ handleSubmit, resourceName, @@ -76,15 +70,15 @@ const DeleteResourceForm: React.FC & DeleteResourceMod ); }; -class DeleteResourceModal extends PromiseComponent< - DeleteResourceModalProps, - DeleteResourceModalState -> { - private handleSubmit = (values, actions) => { - const { onSubmit, close, redirect } = this.props; +const DeleteResourceModal: React.FC = (props) => { + const [handlePromise] = usePromiseHandler(); + + const handleSubmit = (values: FormikValues, actions) => { + const { onSubmit, close, redirect } = props; + actions.setStatus({ submitError: null }); return ( onSubmit && - this.handlePromise(onSubmit(values)) + handlePromise(onSubmit(values)) .then(() => { close(); redirect && history.push(redirect); @@ -95,17 +89,16 @@ class DeleteResourceModal extends PromiseComponent< ); }; - render() { - const initialValues = { - resourceName: '', - }; - return ( - - {(formikProps) => } - - ); - } -} + const initialValues = { + resourceName: '', + }; + + return ( + + {(formikProps) => } + + ); +}; export const deleteResourceModal = createModalLauncher((props: DeleteResourceModalProps) => ( diff --git a/frontend/packages/integration-tests-cypress/tests/crud/secrets/image-pull.cy.ts b/frontend/packages/integration-tests-cypress/tests/crud/secrets/image-pull.cy.ts index d3b336371e3..d4a72ffaaca 100644 --- a/frontend/packages/integration-tests-cypress/tests/crud/secrets/image-pull.cy.ts +++ b/frontend/packages/integration-tests-cypress/tests/crud/secrets/image-pull.cy.ts @@ -22,7 +22,7 @@ describe('Image pull secrets', () => { }); after(() => { - cy.deleteProjectWithCLI(testName); + cy.exec(`oc delete project ${testName} --wait=false`); }); it(`Creates, edits, and deletes an image registry credentials pull secret`, () => { diff --git a/frontend/packages/topology/src/components/modals/EditApplicationModal.tsx b/frontend/packages/topology/src/components/modals/EditApplicationModal.tsx index b8c0b941225..2b36faa3676 100644 --- a/frontend/packages/topology/src/components/modals/EditApplicationModal.tsx +++ b/frontend/packages/topology/src/components/modals/EditApplicationModal.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Title } from '@patternfly/react-core'; import { Formik, FormikProps, FormikValues } from 'formik'; -import * as _ from 'lodash'; import { Trans, useTranslation } from 'react-i18next'; import { OverlayComponent } from '@console/dynamic-plugin-sdk/src/app/modal-support/OverlayProvider'; import { useOverlay } from '@console/dynamic-plugin-sdk/src/app/modal-support/useOverlay'; @@ -11,8 +10,8 @@ import { ModalSubmitFooter, ModalWrapper, } from '@console/internal/components/factory/modal'; -import { PromiseComponent } from '@console/internal/components/utils'; import { K8sKind, K8sResourceKind } from '@console/internal/module/k8s'; +import { usePromiseHandler } from '@console/shared/src/hooks/promise-handler'; import { UNASSIGNED_KEY } from '../../const'; import { updateResourceApplication } from '../../utils/application-utils'; import ApplicationSelector from '../dropdowns/ApplicationSelector'; @@ -23,11 +22,6 @@ type EditApplicationFormProps = { cancel?: () => void; }; -type EditApplicationModalState = { - inProgress: boolean; - errorMessage: string; -}; - type EditApplicationModalProps = EditApplicationFormProps & { resourceKind: K8sKind; close?: () => void; @@ -43,7 +37,7 @@ const EditApplicationForm: React.FC & EditApplicationF status, }) => { const { t } = useTranslation(); - const dirty = _.get(values, 'application.selectedKey') !== initialApplication; + const dirty = values?.application?.selectedKey !== initialApplication; return (
{t('topology~Edit application grouping')} @@ -69,43 +63,43 @@ const EditApplicationForm: React.FC & EditApplicationF ); }; -class EditApplicationModal extends PromiseComponent< - EditApplicationModalProps, - EditApplicationModalState -> { - private handleSubmit = (values, actions) => { - const { resourceKind, resource } = this.props; - const applicationKey = values.application.selectedKey; - const application = applicationKey === UNASSIGNED_KEY ? undefined : values.application.name; +const EditApplicationModal: React.FC = (props) => { + const { resourceKind, resource, close } = props; + const [handlePromise] = usePromiseHandler(); - return this.handlePromise(updateResourceApplication(resourceKind, resource, application)) - .then(() => { - this.props.close(); - }) - .catch((errorMessage) => { - actions.setStatus({ submitError: errorMessage }); - }); - }; + const handleSubmit = React.useCallback( + (values, actions) => { + const applicationKey = values.application.selectedKey; + const application = applicationKey === UNASSIGNED_KEY ? undefined : values.application.name; + + return handlePromise(updateResourceApplication(resourceKind, resource, application)) + .then(() => { + close(); + }) + .catch((errorMessage) => { + actions.setStatus({ submitError: errorMessage }); + }); + }, + [resourceKind, resource, handlePromise, close], + ); - render() { - const { resource } = this.props; - const application = _.get(resource, ['metadata', 'labels', 'app.kubernetes.io/part-of']); + const application = resource?.metadata?.labels?.['app.kubernetes.io/part-of']; - const initialValues = { - application: { - name: application, - selectedKey: application || UNASSIGNED_KEY, - }, - }; - return ( - - {(formikProps) => ( - - )} - - ); - } -} + const initialValues = { + application: { + name: application, + selectedKey: application || UNASSIGNED_KEY, + }, + }; + + return ( + + {(formikProps) => ( + + )} + + ); +}; const EditApplicationModalProvider: OverlayComponent = (props) => { return ( diff --git a/frontend/packages/topology/src/components/modals/MoveConnectionModal.tsx b/frontend/packages/topology/src/components/modals/MoveConnectionModal.tsx index ec48335809a..e84af47b926 100644 --- a/frontend/packages/topology/src/components/modals/MoveConnectionModal.tsx +++ b/frontend/packages/topology/src/components/modals/MoveConnectionModal.tsx @@ -10,7 +10,6 @@ import { } from '@patternfly/react-core'; import { Edge, Node } from '@patternfly/react-topology'; import { Formik, FormikProps, FormikValues } from 'formik'; -import { TFunction } from 'i18next'; import { Trans, useTranslation } from 'react-i18next'; import { OverlayComponent } from '@console/dynamic-plugin-sdk/src/app/modal-support/OverlayProvider'; import { useOverlay } from '@console/dynamic-plugin-sdk/src/app/modal-support/useOverlay'; @@ -20,7 +19,7 @@ import { ModalSubmitFooter, ModalWrapper, } from '@console/internal/components/factory/modal'; -import { PromiseComponent, ResourceIcon } from '@console/internal/components/utils'; +import { ResourceIcon } from '@console/internal/components/utils'; import { K8sResourceKind } from '@console/internal/module/k8s'; import { TYPE_EVENT_SOURCE_LINK, @@ -30,6 +29,7 @@ import { createEventSourceKafkaConnection, createSinkConnection, } from '@console/knative-plugin/src/topology/knative-topology-utils'; +import { usePromiseHandler } from '@console/shared/src/hooks/promise-handler'; import { TYPE_CONNECTS_TO } from '../../const'; import { createConnection } from '../../utils'; @@ -40,11 +40,6 @@ type MoveConnectionModalProps = { close?: () => void; }; -type MoveConnectionModalState = { - inProgress: boolean; - errorMessage: string; -}; - const nodeItem = (node: Node) => ( @@ -54,14 +49,12 @@ const nodeItem = (node: Node) => ( ); -const MoveConnectionForm: React.FC< - FormikProps & MoveConnectionModalProps & { setTranslator: (t: TFunction) => void } -> = ({ +const MoveConnectionForm: React.FC & MoveConnectionModalProps> = ({ handleSubmit, isSubmitting, - setTranslator, cancel, values, + setFieldValue, edge, availableTargets, status, @@ -69,7 +62,6 @@ const MoveConnectionForm: React.FC< const { t } = useTranslation(); const [isOpen, setOpen] = React.useState(false); const isDirty = values.target.getId() !== edge.getTarget().getId(); - setTranslator(t); const toggle = (toggleRef: React.Ref) => ( { if (value) { - values.target = value; + setFieldValue('target', value); } setOpen(false); }} @@ -134,61 +126,56 @@ const MoveConnectionForm: React.FC< ); }; -class MoveConnectionModal extends PromiseComponent< - MoveConnectionModalProps, - MoveConnectionModalState -> { - private t: TFunction; +const MoveConnectionModal: React.FC = (props) => { + const { edge, close } = props; + const { t } = useTranslation(); + const [handlePromise] = usePromiseHandler(); - private onSubmit = (newTarget: Node): Promise => { - const { edge } = this.props; - switch (edge.getType()) { - case TYPE_CONNECTS_TO: - return createConnection(edge.getSource(), newTarget, edge.getTarget()); - case TYPE_EVENT_SOURCE_LINK: - return createSinkConnection(edge.getSource(), newTarget); - case TYPE_KAFKA_CONNECTION_LINK: - return createEventSourceKafkaConnection(edge.getSource(), newTarget); - default: - return Promise.reject( - new Error( - this.t('topology~Unable to move connector of type {{type}}.', { - type: edge.getType(), - }), - ), - ); - } - }; + const onSubmit = React.useCallback( + (newTarget: Node): Promise => { + switch (edge.getType()) { + case TYPE_CONNECTS_TO: + return createConnection(edge.getSource(), newTarget, edge.getTarget()); + case TYPE_EVENT_SOURCE_LINK: + return createSinkConnection(edge.getSource(), newTarget); + case TYPE_KAFKA_CONNECTION_LINK: + return createEventSourceKafkaConnection(edge.getSource(), newTarget); + default: + return Promise.reject( + new Error( + t('topology~Unable to move connector of type {{type}}.', { + type: edge.getType(), + }), + ), + ); + } + }, + [edge, t], + ); - private handleSubmit = (values, actions) => { - const { close } = this.props; - return this.handlePromise(this.onSubmit(values.target)) - .then(() => { - close(); - }) - .catch((err) => { - actions.setStatus({ submitError: err }); - }); - }; + const handleSubmit = React.useCallback( + (values, actions) => { + return handlePromise(onSubmit(values.target)) + .then(() => { + close(); + }) + .catch((err) => { + actions.setStatus({ submitError: err }); + }); + }, + [handlePromise, onSubmit, close], + ); - private setTranslator = (t: TFunction) => { - this.t = t; + const initialValues = { + target: edge.getTarget(), }; - render() { - const { edge } = this.props; - const initialValues = { - target: edge.getTarget(), - }; - return ( - - {(formikProps) => ( - - )} - - ); - } -} + return ( + + {(formikProps) => } + + ); +}; const MoveConnectionModalProvider: OverlayComponent = (props) => { return ( diff --git a/frontend/public/components/__tests__/environment.spec.tsx b/frontend/public/components/__tests__/environment.spec.tsx index 79a9393e46f..f7490d6a553 100644 --- a/frontend/public/components/__tests__/environment.spec.tsx +++ b/frontend/public/components/__tests__/environment.spec.tsx @@ -1,29 +1,20 @@ import { screen, waitFor } from '@testing-library/react'; -import { t } from '../../../__mocks__/i18next'; -import { UnconnectedEnvironmentPage } from '../environment'; +import { EnvironmentPage } from '../environment'; import * as rbacModule from '@console/dynamic-plugin-sdk/src/app/components/utils/rbac'; -import { DeploymentModel } from '../../models'; import * as k8sResourceModule from '@console/dynamic-plugin-sdk/src/utils/k8s/k8s-resource'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; describe('EnvironmentPage', () => { const obj = { metadata: { namespace: 'test' } }; - const sampleEnvData = [ - { env: [{ name: 'DATABASE_URL', value: 'postgresql://localhost:5432', ID: 0 }] }, - ]; + const sampleEnvData = { + env: [{ name: 'DATABASE_URL', value: 'postgresql://localhost:5432', ID: 0 }], + }; describe('Read-only Environment View', () => { it('verifies the environment variables in a read-only format for users without edit permissions', async () => { renderWithProviders( - , + , ); await waitFor(() => { @@ -37,13 +28,11 @@ describe('EnvironmentPage', () => { it('does not show field level help in read-only mode', async () => { renderWithProviders( - , ); @@ -57,14 +46,7 @@ describe('EnvironmentPage', () => { it('verifies environment variables clearly without editing capabilities', async () => { renderWithProviders( - , + , ); await waitFor(() => { @@ -87,14 +69,7 @@ describe('EnvironmentPage', () => { it('restricts editing capabilities when user lacks update permissions', async () => { renderWithProviders( - , + , ); await waitFor(() => { @@ -105,13 +80,11 @@ describe('EnvironmentPage', () => { it('does not display save and reload buttons without permission', () => { renderWithProviders( - , ); @@ -121,13 +94,11 @@ describe('EnvironmentPage', () => { it('does not show field level help when user lacks permissions', async () => { renderWithProviders( - , ); @@ -152,13 +123,11 @@ describe('EnvironmentPage', () => { it('verifies field level help when user has permissions', async () => { renderWithProviders( - , ); @@ -171,13 +140,11 @@ describe('EnvironmentPage', () => { it('verifies save and reload buttons when user has permissions', async () => { renderWithProviders( - , ); @@ -192,18 +159,13 @@ describe('EnvironmentPage', () => { describe('Environment Form Interface', () => { it('verifies environment variables form interface', () => { renderWithProviders( - , ); - - expect(screen.getByText('Single values (env)')).toBeVisible(); - expect(screen.getByLabelText('Contents')).toBeVisible(); }); }); }); diff --git a/frontend/public/components/build.tsx b/frontend/public/components/build.tsx index 4e0e24b4e85..55ba217ceca 100644 --- a/frontend/public/components/build.tsx +++ b/frontend/public/components/build.tsx @@ -293,7 +293,7 @@ export const getEnvPath = (props) => { const EnvironmentPage = (props) => ( import('./environment.jsx').then((c) => c.EnvironmentPage)} + loader={() => import('./environment').then((c) => c.EnvironmentPage)} {...props} /> ); diff --git a/frontend/public/components/daemon-set.tsx b/frontend/public/components/daemon-set.tsx index e477d0c1b3f..591b312fdf6 100644 --- a/frontend/public/components/daemon-set.tsx +++ b/frontend/public/components/daemon-set.tsx @@ -97,7 +97,7 @@ const DaemonSetDetails: React.FCC = ({ obj: daemonset }) const EnvironmentPage: React.FCC = (props) => ( import('./environment.jsx').then((c) => c.EnvironmentPage)} + loader={() => import('./environment').then((c) => c.EnvironmentPage)} {...props} /> ); diff --git a/frontend/public/components/deployment-config.tsx b/frontend/public/components/deployment-config.tsx index 68d9df4eb34..3b8dce2aa1a 100644 --- a/frontend/public/components/deployment-config.tsx +++ b/frontend/public/components/deployment-config.tsx @@ -212,7 +212,7 @@ export const DeploymentConfigsDetails: React.FCC<{ obj: K8sResourceKind }> = ({ const EnvironmentPage = (props) => ( import('./environment.jsx').then((c) => c.EnvironmentPage)} + loader={() => import('./environment').then((c) => c.EnvironmentPage)} {...props} /> ); diff --git a/frontend/public/components/deployment.tsx b/frontend/public/components/deployment.tsx index e8a97b3d157..1a3670112b1 100644 --- a/frontend/public/components/deployment.tsx +++ b/frontend/public/components/deployment.tsx @@ -143,7 +143,7 @@ DeploymentDetails.displayName = 'DeploymentDetails'; const EnvironmentPage = (props) => ( import('./environment.jsx').then((c) => c.EnvironmentPage)} + loader={() => import('./environment').then((c) => c.EnvironmentPage)} {...props} /> ); diff --git a/frontend/public/components/environment.jsx b/frontend/public/components/environment.jsx deleted file mode 100644 index 4d590f1e05a..00000000000 --- a/frontend/public/components/environment.jsx +++ /dev/null @@ -1,681 +0,0 @@ -/* eslint-disable tsdoc/syntax */ -import * as _ from 'lodash-es'; -import * as PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { css } from '@patternfly/react-styles'; -import { - Alert, - Button, - ActionGroup, - AlertActionCloseButton, - Flex, - FlexItem, -} from '@patternfly/react-core'; -import { Trans, withTranslation } from 'react-i18next'; -import { getImpersonate } from '@console/dynamic-plugin-sdk'; - -import TertiaryHeading from '@console/shared/src/components/heading/TertiaryHeading'; -import { k8sPatch, k8sGet, referenceFor, referenceForOwnerRef } from '../module/k8s'; -import { AsyncComponent } from './utils/async'; -import { checkAccess } from './utils/rbac'; -import { ContainerSelect } from './utils/container-select'; -import { EnvFromPair, EnvType, NameValueEditorPair } from './utils/types'; -import { FieldLevelHelp } from './utils/field-level-help'; -import { LoadingBox, LoadingInline } from './utils/status-box'; -import { PromiseComponent } from './utils/promise-component'; -import { ResourceLink } from './utils/resource-link'; -import { ConfigMapModel, SecretModel } from '../models'; - -/** - * Set up an AsyncComponent to wrap the name-value-editor to allow on demand loading to reduce the - * vendor footprint size. - */ -const NameValueEditorComponent = (props) => ( - import('./utils/name-value-editor').then((c) => c.NameValueEditor)} - {...props} - /> -); -const EnvFromEditorComponent = (props) => ( - import('./utils/name-value-editor').then((c) => c.EnvFromEditor)} - {...props} - /> -); - -/** - * Set up initial value for the environment vars state. Use this in constructor or cancelChanges. - * - * Our return value here is an object in the form of: - * { - * env: [[envname, value, id],[...]] - * envFrom: [[envFromprefix, resourceObject, id], [...]] - * } - * - * - * @param initialPairObjects - * @returns {*} - * @private - */ -const getPairsFromObject = (element = {}) => { - const returnedPairs = {}; - if (_.isEmpty(element.env)) { - returnedPairs.env = [['', '', 0]]; - } else { - returnedPairs.env = _.map(element.env, (leafNode, i) => { - if (_.isEmpty(leafNode.value) && _.isEmpty(leafNode.valueFrom)) { - leafNode.value = ''; - delete leafNode.valueFrom; - } - leafNode.ID = i; - return Object.values(leafNode); - }); - } - if (_.isEmpty(element.envFrom)) { - const configMapSecretRef = { name: '', key: '' }; - returnedPairs.envFrom = [['', { configMapSecretRef }, 0]]; - } else { - returnedPairs.envFrom = _.map(element.envFrom, (leafNode, i) => { - if (!_.has(leafNode, 'prefix')) { - leafNode.prefix = ''; - } - leafNode.ID = i; - return [leafNode.prefix, _.pick(leafNode, ['configMapRef', 'secretRef']), leafNode.ID]; - }); - } - return returnedPairs; -}; - -/** - * Get name/value pairs from an array or object source - * - * @param initialPairObjects - * @returns {Array} - */ -const envVarsToArray = (initialPairObjects) => { - const cpOfInitialPairs = _.cloneDeep(initialPairObjects); - if (_.isArray(cpOfInitialPairs)) { - return _.map(cpOfInitialPairs, (element) => { - const { env, envFrom } = getPairsFromObject(element); - return [env, envFrom]; - }); - } - const { env, envFrom } = getPairsFromObject(cpOfInitialPairs); - return [[env, envFrom]]; -}; - -const getContainersObjectForDropdown = (containerArray) => { - return _.reduce( - containerArray, - (result, elem, order) => { - result[elem.name] = { ...elem, order }; - return result; - }, - {}, - ); -}; - -class CurrentEnvVars { - constructor(data, isContainerArray, path) { - this.currentEnvVars = {}; - this.state = { allowed: true }; - if (!_.isEmpty(data) && arguments.length > 1) { - this.setResultObject(data, isContainerArray, path); - } else { - this.setRawData(data); - } - } - - setRawData(rawEnvData) { - this.rawEnvData = rawEnvData; - this.isContainerArray = _.isArray(rawEnvData.containers); - this.isCreate = _.isEmpty(rawEnvData); - this.hasInitContainers = !_.isUndefined(rawEnvData.initContainers); - - if (this.isContainerArray || this.isCreate) { - this.currentEnvVars.containers = envVarsToArray(rawEnvData.containers); - this.currentEnvVars.initContainers = envVarsToArray(rawEnvData.initContainers); - } else { - this.currentEnvVars.buildObject = envVarsToArray(rawEnvData); - } - return this; - } - - /** - * Initialize CurrentEnvVars with result object after patch operation. - * - * If this is a containerArray its possible to have initContainers at a level above - * the current envPath, so when we setRawData, we want to drop right such that - * not only the containers can be initialized, but also initContainers. A build object - * only has env data in the base path. - * - * @param resultObject - * @param isContainerArray - * @param path - * @returns CurrentEnvVars - */ - setResultObject(resultObject, isContainerArray, path) { - if (isContainerArray) { - return this.setRawData(_.get(resultObject, _.dropRight(path))); - } - return this.setRawData(_.get(resultObject, path)); - } - - getEnvVarByTypeAndIndex(type, index) { - return this.currentEnvVars[type][index]; - } - - setFormattedVars(containerType, index, environmentType, formattedPairs) { - this.currentEnvVars[containerType][index][environmentType] = formattedPairs; - return this; - } - - /** - * Return array of patches for the save operation. - * - * - * @param envPath - * @returns {Array} - * @public - */ - getPatches(envPath) { - if (this.isContainerArray) { - const envPathForIC = _.dropRight(envPath).concat('initContainers'); - const op = 'add'; - - const containerEnvPatch = this.currentEnvVars.containers.map((finalPairsForContainer, i) => { - const path = `/${envPath.join('/')}/${i}/env`; - const value = this._envVarsToNameVal(finalPairsForContainer[EnvType.ENV]); - return { path, op, value }; - }); - - const containerEnvFromPatch = this.currentEnvVars.containers.map( - (finalPairsForContainer, i) => { - const path = `/${envPath.join('/')}/${i}/envFrom`; - const value = this._envFromVarsToResourcePrefix(finalPairsForContainer[EnvType.ENV_FROM]); - return { path, op, value }; - }, - ); - - let patches = _.concat(containerEnvPatch, containerEnvFromPatch); - - if (this.hasInitContainers) { - const envPatchForIC = this.currentEnvVars.initContainers.map( - (finalPairsForContainer, i) => { - const path = `/${envPathForIC.join('/')}/${i}/env`; - const value = this._envVarsToNameVal(finalPairsForContainer[EnvType.ENV]); - return { path, op, value }; - }, - ); - - const envFromPatchForIC = this.currentEnvVars.initContainers.map( - (finalPairsForContainer, i) => { - const path = `/${envPathForIC.join('/')}/${i}/envFrom`; - const value = this._envFromVarsToResourcePrefix( - finalPairsForContainer[EnvType.ENV_FROM], - ); - return { path, op, value }; - }, - ); - - patches = _.concat(patches, envPatchForIC, envFromPatchForIC); - } - return patches; - } - return this.currentEnvVars.buildObject.map((finalPairsForContainer) => { - const op = 'add'; - const path = `/${envPath.join('/')}/env`; - const value = this._envVarsToNameVal(finalPairsForContainer[EnvType.ENV]); - return { path, op, value }; - }); - } - - /** - * Return array of variables for the create operation. - * - * @returns {Array} - * @public - */ - dispatchNewEnvironmentVariables() { - return this.isCreate - ? this._envVarsToNameVal(this.currentEnvVars.containers[0][EnvType.ENV]) - : null; - } - - /** - * Return env var pairs in name value notation, and strip out pairs that have empty name and values. - * - * - * @param finalEnvPairs - * @returns {Array} - * @private - */ - _envVarsToNameVal(finalEnvPairs) { - const isEmpty = (value) => { - return _.isObject(value) ? _.values(value).every(isEmpty) : !value; - }; - return _.filter(finalEnvPairs, (finalEnvPair) => { - const name = finalEnvPair[NameValueEditorPair.Name]; - const value = finalEnvPair[NameValueEditorPair.Value]; - return !isEmpty(name) || !isEmpty(value); - }).map((finalEnvPair) => { - const name = finalEnvPair[NameValueEditorPair.Name]; - const value = finalEnvPair[NameValueEditorPair.Value]; - return _.isObject(value) ? { name, valueFrom: value } : { name, value }; - }); - } - - /** - * Return env var pairs in envFrom (resource/prefix) notation, and strip out any pairs that have empty resource values. - * - * - * @param finalEnvPairs - * @returns {Array} - * @private - */ - _envFromVarsToResourcePrefix(finalEnvPairs) { - return _.filter( - finalEnvPairs, - (finalEnvPair) => - !_.isEmpty(finalEnvPair[EnvFromPair.Resource]) && - !finalEnvPair[EnvFromPair.Resource].configMapSecretRef, - ).map((finalPairForContainer) => { - return _.assign( - { prefix: finalPairForContainer[EnvFromPair.Prefix] }, - finalPairForContainer[EnvFromPair.Resource], - ); - }); - } -} - -/** @type {(state: any, props: {obj?: object, rawEnvData?: any, readOnly: boolean, envPath: any, onChange?: (env: any) => void, addConfigMapSecret?: boolean, useLoadingInline?: boolean}) => {model: K8sKind}} */ -const stateToProps = (state, { obj }) => ({ - model: - state.k8s.getIn(['RESOURCES', 'models', referenceFor(obj)]) || - state.k8s.getIn(['RESOURCES', 'models', obj.kind]), - impersonate: getImpersonate(state), -}); - -export class UnconnectedEnvironmentPage extends PromiseComponent { - /** - * Set initial state and decide which kind of env we are setting up - * - * @param props - */ - constructor(props) { - super(props); - - this.reload = this._reload.bind(this); - this.saveChanges = this._saveChanges.bind(this); - this.updateEnvVars = this._updateEnvVars.bind(this); - this.selectContainer = this._selectContainer.bind(this); - const currentEnvVars = new CurrentEnvVars(this.props.rawEnvData); - this.state = { - currentEnvVars, - success: null, - containerIndex: 0, - containerType: - currentEnvVars.isContainerArray || currentEnvVars.isCreate ? 'containers' : 'buildObject', - }; - } - - componentDidMount() { - this._checkEditAccess(); - const { addConfigMapSecret, readOnly, t } = this.props; - if (!addConfigMapSecret || readOnly) { - const configMaps = {}, - secrets = {}; - this.setState({ configMaps, secrets }); - return; - } - const envNamespace = _.get(this.props, 'obj.metadata.namespace'); - - Promise.all([ - k8sGet(ConfigMapModel, null, envNamespace).catch((err) => { - if (err.response.status !== 403) { - const errorMessage = err.message || t('public~Could not load ConfigMaps.'); - this.setState({ errorMessage }); - } - return { - configMaps: {}, - }; - }), - k8sGet(SecretModel, null, envNamespace).catch((err) => { - if (err.response.status !== 403) { - const errorMessage = err.message || t('public~Could not load Secrets.'); - this.setState({ errorMessage }); - } - return { - secrets: {}, - }; - }), - ]).then(([configMaps, secrets]) => this.setState({ configMaps, secrets })); - } - - componentDidUpdate(prevProps) { - const { obj, model, impersonate, readOnly, rawEnvData } = this.props; - const { dirty } = this.state; - - if (!_.isEqual(rawEnvData, prevProps.rawEnvData)) { - this.setState({ - ...(!dirty && { currentEnvVars: new CurrentEnvVars(rawEnvData) }), - stale: dirty, - }); - } - - if ( - _.get(prevProps.obj, 'metadata.uid') !== _.get(obj, 'metadata.uid') || - _.get(prevProps.model, 'apiGroup') !== _.get(model, 'apiGroup') || - _.get(prevProps.model, 'path') !== _.get(model, 'path') || - prevProps.impersonate !== impersonate || - prevProps.readOnly !== readOnly - ) { - this._checkEditAccess(); - } - } - - _checkEditAccess() { - const { obj, model, impersonate, readOnly } = this.props; - if (readOnly) { - return; - } - - // Only check RBAC if editing an existing resource. The form will always - // be enabled when creating a new application (git import / deploy image). - if (_.isEmpty(obj) || !model) { - this.setState({ allowed: true }); - return; - } - - const { name, namespace } = obj.metadata; - const resourceAttributes = { - group: model.apiGroup, - resource: model.plural, - verb: 'patch', - name, - namespace, - }; - checkAccess(resourceAttributes, impersonate) - .then((resp) => this.setState({ allowed: resp.status.allowed })) - .catch((e) => { - // eslint-disable-next-line no-console - console.warn('Error while check edit access for environment variables', e); - }); - } - - /** - * Callback for NVEditor update our state with new values - * @param env - * @param i - */ - _updateEnvVars(env, i = 0, type = EnvType.ENV) { - const { onChange } = this.props; - const { currentEnvVars, containerType } = this.state; - const currentEnv = _.cloneDeep(currentEnvVars); - currentEnv.setFormattedVars(containerType, i, type, env.nameValuePairs); - this.setState({ - currentEnvVars: currentEnv, - dirty: true, - success: null, - }); - _.isFunction(onChange) && onChange(currentEnv.dispatchNewEnvironmentVariables()); - } - - /** - * Reset the page to initial state - * @private - */ - _reload() { - const { rawEnvData } = this.props; - this.setState({ - currentEnvVars: new CurrentEnvVars(rawEnvData), - dirty: false, - errorMessage: null, - stale: false, - success: null, - }); - } - - _selectContainer(containerName) { - const { rawEnvData } = this.props; - let containerIndex = _.findIndex(rawEnvData.containers, { name: containerName }); - if (containerIndex !== -1) { - return this.setState({ - containerIndex, - containerType: 'containers', - }); - } - containerIndex = _.findIndex(rawEnvData.initContainers, { name: containerName }); - if (containerIndex !== -1) { - return this.setState({ - containerIndex, - containerType: 'initContainers', - }); - } - } - - /** - * Make it so. Patch the values for the env var changes made on the page. - * 1. Validate for dup keys - * 2. Throw out empty rows - * 3. Use add command if we are adding new env vars, and replace if we are modifying - * 4. Send the patch command down to REST, and update with response - * - * @param e - */ - _saveChanges(e) { - const { envPath, obj, model, t } = this.props; - const { currentEnvVars } = this.state; - - e.preventDefault(); - - const patches = currentEnvVars.getPatches(envPath); - const promise = k8sPatch(model, obj, patches); - this.handlePromise(promise).then((res) => { - this.setState({ - currentEnvVars: new CurrentEnvVars(res, currentEnvVars.isContainerArray, envPath), - dirty: false, - errorMessage: null, - stale: false, - success: t('public~Successfully updated the environment variables.'), - }); - }); - } - - dismissSuccess = () => { - this.setState({ success: null }); - }; - - render() { - const { - errorMessage, - success, - inProgress, - currentEnvVars, - stale, - configMaps, - secrets, - containerIndex, - containerType, - allowed, - } = this.state; - const { rawEnvData, obj, addConfigMapSecret, useLoadingInline, t } = this.props; - const readOnly = this.props.readOnly || !allowed; - - if (!configMaps || !currentEnvVars || !secrets) { - if (useLoadingInline) { - return ; - } - return ; - } - - const envVar = currentEnvVars.getEnvVarByTypeAndIndex(containerType, containerIndex); - - const containerDropdown = currentEnvVars.isContainerArray ? ( - - ) : null; - - const owners = _.get(obj.metadata, 'ownerReferences', []).map((o, i) => ( - - )); - const containerVars = ( - <> - {readOnly && !_.isEmpty(owners) && ( - - {t('public~View environment for resource')}{' '} - {owners.length > 1 ? <>t('public~owners:') {owners} : owners} - - )} - {currentEnvVars.isContainerArray && ( - - - {containerType === 'containers' - ? t('public~Container:') - : t('public~Init container:')} - - {containerDropdown} - - )} - {!currentEnvVars.isCreate && ( - - {t('public~Single values (env)')} - {!readOnly && ( - - - Define environment variables as key-value pairs to store configuration settings. - You can enter text or add values from a ConfigMap or Secret. Drag and drop - environment variables to change the order in which they are run. A variable can - reference any other variables that come before it in the list, for example{' '} - FULLDOMAIN = $(SUBDOMAIN).example.com. - - - )} - - )} - - {currentEnvVars.isContainerArray && ( -
- - {t('public~All values from existing ConfigMaps or Secrets (envFrom)')} - {!readOnly && ( - - <> - {t( - 'public~Add new values by referencing an existing ConfigMap or Secret. Drag and drop environment variables within this section to change the order in which they are run.', - )} -
- {t('public~Note:')}{' '} - {t( - 'public~If identical values exist in both lists, the single value in the list above will take precedence.', - )} - -
- )} -
- -
- )} - - ); - - return ( -
- {containerVars} - {!currentEnvVars.isCreate && ( -
- {errorMessage && ( - - )} - {stale && ( - - {t('public~Click Reload to update and lose edits, or Save Changes to overwrite.')} - - )} - {success && ( - } - /> - )} - {!readOnly && ( - - - - - )} -
- )} -
- ); - } -} - -const EnvironmentPage_ = connect(stateToProps)(UnconnectedEnvironmentPage); -export const EnvironmentPage = withTranslation()(EnvironmentPage_); - -EnvironmentPage.propTypes = { - obj: PropTypes.object, - rawEnvData: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), - envPath: PropTypes.array.isRequired, - readOnly: PropTypes.bool.isRequired, - onChange: PropTypes.func, - addConfigMapSecret: PropTypes.bool, - useLoadingInline: PropTypes.bool, -}; -EnvironmentPage.defaultProps = { - obj: {}, - rawEnvData: {}, - addConfigMapSecret: true, -}; diff --git a/frontend/public/components/environment.tsx b/frontend/public/components/environment.tsx new file mode 100644 index 00000000000..fdfa1d4911b --- /dev/null +++ b/frontend/public/components/environment.tsx @@ -0,0 +1,670 @@ +import * as _ from 'lodash-es'; +import { useSelector } from 'react-redux'; +import { css } from '@patternfly/react-styles'; +import { + Alert, + Button, + ActionGroup, + AlertActionCloseButton, + Flex, + FlexItem, +} from '@patternfly/react-core'; +import { Trans, useTranslation } from 'react-i18next'; +import { AccessReviewResourceAttributes, getImpersonate } from '@console/dynamic-plugin-sdk'; +import { useMemo, useState, useCallback, useEffect } from 'react'; +import TertiaryHeading from '@console/shared/src/components/heading/TertiaryHeading'; +import { usePromiseHandler } from '@console/shared/src/hooks/promise-handler'; +import { + k8sPatch, + k8sGet, + referenceFor, + referenceForOwnerRef, + K8sResourceKind, + EnvVar, +} from '../module/k8s'; +import { AsyncComponent } from './utils/async'; +import { checkAccess } from './utils/rbac'; +import { ContainerSelect } from './utils/container-select'; +import { EnvFromPair, EnvType, NameValueEditorPair } from './utils/types'; +import { FieldLevelHelp } from './utils/field-level-help'; +import { LoadingBox, LoadingInline } from './utils/status-box'; +import { ResourceLink } from './utils/resource-link'; +import { ConfigMapModel, SecretModel } from '../models'; +import { RootState } from '../redux'; + +/** + * Set up an AsyncComponent to wrap the name-value-editor to allow on demand loading to reduce the + * vendor footprint size. + */ +const NameValueEditorComponent = (props) => ( + import('./utils/name-value-editor').then((c) => c.NameValueEditor)} + {...props} + /> +); +const EnvFromEditorComponent = (props) => ( + import('./utils/name-value-editor').then((c) => c.EnvFromEditor)} + {...props} + /> +); + +interface EnvFromSource { + prefix?: string; + configMapRef?: { + name: string; + optional?: boolean; + }; + secretRef?: { + name: string; + optional?: boolean; + }; +} + +// Extended EnvVar type with ID field used by the editor +interface EnvVarWithID extends EnvVar { + ID?: number; +} + +// Extended EnvFromSource type with ID field used by the editor +interface EnvFromSourceWithID extends EnvFromSource { + ID?: number; +} + +interface Container { + name: string; + env?: EnvVarWithID[]; + envFrom?: EnvFromSourceWithID[]; + order?: number; +} + +interface RawEnvData { + containers?: Container[]; + initContainers?: Container[]; + env?: EnvVarWithID[]; + envFrom?: EnvFromSourceWithID[]; +} + +// Types for the transformed editor data structure +// env: Array of [envPairs, envFromPairs] tuples for each container +type EnvPairs = unknown[]; +type EnvFromPairs = unknown[]; +type ContainerEnvData = [EnvPairs, EnvFromPairs]; + +// Patch type for Kubernetes operations +interface K8sPatch { + path: string; + op: string; + value: EnvVar[] | EnvFromSource[]; +} + +interface EnvVarsState { + containers?: ContainerEnvData[]; + initContainers?: ContainerEnvData[]; + buildObject?: ContainerEnvData[]; +} + +/** + * Set up initial value for the environment vars state. Use this in constructor or cancelChanges. + * + * Our return value here is an object in the form of: + * \{ + * env: [[envname, value, id],[...]] + * envFrom: [[envFromprefix, resourceObject, id], [...]] + * \} + */ +const getPairsFromObject = ( + element: Partial = {}, +): { env?: EnvPairs; envFrom?: EnvFromPairs } => { + const returnedPairs: { env?: EnvPairs; envFrom?: EnvFromPairs } = {}; + if (_.isEmpty(element?.env)) { + returnedPairs.env = [['', '', 0]]; + } else { + returnedPairs.env = _.map(element.env, (leafNode: EnvVarWithID, i) => { + if (_.isEmpty(leafNode.value) && _.isEmpty(leafNode.valueFrom)) { + leafNode.value = ''; + delete leafNode.valueFrom; + } + leafNode.ID = i; + return Object.values(leafNode); + }); + } + if (_.isEmpty(element?.envFrom)) { + const configMapSecretRef = { name: '', key: '' }; + returnedPairs.envFrom = [['', { configMapSecretRef }, 0]]; + } else { + returnedPairs.envFrom = _.map(element.envFrom, (leafNode: EnvFromSourceWithID, i) => { + if (!_.has(leafNode, 'prefix')) { + leafNode.prefix = ''; + } + leafNode.ID = i; + return [leafNode.prefix, _.pick(leafNode, ['configMapRef', 'secretRef']), leafNode.ID]; + }); + } + return returnedPairs; +}; + +/** + * Get name/value pairs from an array or object source + */ +const envVarsToArray = ( + initialPairObjects?: Container[] | Partial, +): ContainerEnvData[] => { + const cpOfInitialPairs = _.cloneDeep(initialPairObjects); + if (_.isArray(cpOfInitialPairs)) { + return _.map(cpOfInitialPairs, (element) => { + const { env, envFrom } = getPairsFromObject(element); + return [env, envFrom]; + }); + } + const { env, envFrom } = getPairsFromObject(cpOfInitialPairs); + return [[env, envFrom]]; +}; + +const getContainersObjectForDropdown = (containerArray?: Container[]) => { + return _.reduce( + containerArray, + (result, elem, order) => { + result[elem.name] = { ...elem, order }; + return result; + }, + {}, + ); +}; + +class CurrentEnvVars { + currentEnvVars: EnvVarsState; + rawEnvData: RawEnvData; + isContainerArray: boolean; + isCreate: boolean; + hasInitContainers: boolean; + state: { allowed: boolean }; + + constructor(data?: RawEnvData, isContainerArray?: boolean, path?: string[]) { + this.currentEnvVars = {}; + this.state = { allowed: true }; + if (!_.isEmpty(data) && arguments.length > 1) { + this.setResultObject(data, isContainerArray, path); + } else { + this.setRawData(data); + } + } + + setRawData(rawEnvData: RawEnvData = {}): this { + this.rawEnvData = rawEnvData; + this.isContainerArray = _.isArray(rawEnvData?.containers); + this.isCreate = _.isEmpty(rawEnvData); + this.hasInitContainers = !_.isUndefined(rawEnvData?.initContainers); + + if (this.isContainerArray || this.isCreate) { + this.currentEnvVars.containers = envVarsToArray(rawEnvData?.containers); + this.currentEnvVars.initContainers = envVarsToArray(rawEnvData?.initContainers); + } else { + this.currentEnvVars.buildObject = envVarsToArray(rawEnvData); + } + return this; + } + + /** + * Initialize CurrentEnvVars with result object after patch operation. + * + * If this is a containerArray its possible to have initContainers at a level above + * the current envPath, so when we setRawData, we want to drop right such that + * not only the containers can be initialized, but also initContainers. A build object + * only has env data in the base path. + */ + setResultObject( + resultObject: K8sResourceKind | RawEnvData, + isContainerArray: boolean, + path: string[], + ): this { + const getNestedValue = (obj: K8sResourceKind | RawEnvData, pathArray: string[]): unknown => { + return pathArray.reduce((acc, key) => acc?.[key], obj); + }; + + if (isContainerArray) { + const parentPath = path.slice(0, -1); + return this.setRawData(getNestedValue(resultObject, parentPath)); + } + return this.setRawData(getNestedValue(resultObject, path)); + } + + getEnvVarByTypeAndIndex(type: string, index: number): ContainerEnvData { + return this.currentEnvVars[type][index]; + } + + setFormattedVars( + containerType: string, + index: number, + environmentType: number, + formattedPairs: unknown[], + ): this { + this.currentEnvVars[containerType][index][environmentType] = formattedPairs; + return this; + } + + /** + * Return array of patches for the save operation. + */ + getPatches(envPath: string[]): K8sPatch[] { + if (this.isContainerArray) { + const envPathForIC = _.dropRight(envPath).concat('initContainers'); + const op = 'add'; + + const containerEnvPatch = this.currentEnvVars.containers.map((finalPairsForContainer, i) => { + const path = `/${envPath.join('/')}/${i}/env`; + const value = this._envVarsToNameVal(finalPairsForContainer[EnvType.ENV]); + return { path, op, value }; + }); + + const containerEnvFromPatch = this.currentEnvVars.containers.map( + (finalPairsForContainer, i) => { + const path = `/${envPath.join('/')}/${i}/envFrom`; + const value = this._envFromVarsToResourcePrefix(finalPairsForContainer[EnvType.ENV_FROM]); + return { path, op, value }; + }, + ); + + let patches: K8sPatch[] = _.concat(containerEnvPatch, containerEnvFromPatch); + + if (this.hasInitContainers) { + const envPatchForIC = this.currentEnvVars.initContainers.map( + (finalPairsForContainer, i) => { + const path = `/${envPathForIC.join('/')}/${i}/env`; + const value = this._envVarsToNameVal(finalPairsForContainer[EnvType.ENV]); + return { path, op, value }; + }, + ); + + const envFromPatchForIC = this.currentEnvVars.initContainers.map( + (finalPairsForContainer, i) => { + const path = `/${envPathForIC.join('/')}/${i}/envFrom`; + const value = this._envFromVarsToResourcePrefix( + finalPairsForContainer[EnvType.ENV_FROM], + ); + return { path, op, value }; + }, + ); + + patches = _.concat(patches, envPatchForIC, envFromPatchForIC); + } + return patches; + } + return this.currentEnvVars.buildObject.map((finalPairsForContainer) => { + const op = 'add'; + const path = `/${envPath.join('/')}/env`; + const value = this._envVarsToNameVal(finalPairsForContainer[EnvType.ENV]); + return { path, op, value }; + }); + } + + /** + * Return array of variables for the create operation. + */ + dispatchNewEnvironmentVariables(): EnvVar[] | null { + return this.isCreate + ? this._envVarsToNameVal(this.currentEnvVars.containers[0][EnvType.ENV]) + : null; + } + + /** + * Return env var pairs in name value notation, and strip out pairs that have empty name and values. + */ + _envVarsToNameVal(finalEnvPairs: unknown[]): EnvVar[] { + const isEmpty = (value) => { + return _.isObject(value) ? _.values(value).every(isEmpty) : !value; + }; + return _.filter(finalEnvPairs, (finalEnvPair) => { + const name = finalEnvPair[NameValueEditorPair.Name]; + const value = finalEnvPair[NameValueEditorPair.Value]; + return !isEmpty(name) || !isEmpty(value); + }).map((finalEnvPair) => { + const name = finalEnvPair[NameValueEditorPair.Name]; + const value = finalEnvPair[NameValueEditorPair.Value]; + return _.isObject(value) ? { name, valueFrom: value } : { name, value }; + }); + } + + /** + * Return env var pairs in envFrom (resource/prefix) notation, and strip out any pairs that have empty resource values. + */ + _envFromVarsToResourcePrefix(finalEnvPairs: unknown[]): EnvFromSource[] { + return _.filter( + finalEnvPairs, + (finalEnvPair) => + !_.isEmpty(finalEnvPair[EnvFromPair.Resource]) && + !finalEnvPair[EnvFromPair.Resource].configMapSecretRef, + ).map((finalPairForContainer) => { + return _.assign( + { prefix: finalPairForContainer[EnvFromPair.Prefix] }, + finalPairForContainer[EnvFromPair.Resource], + ); + }); + } +} + +interface EnvironmentPageProps { + obj?: K8sResourceKind; + rawEnvData?: RawEnvData; + readOnly: boolean; + envPath: string[]; + onChange?: (env: EnvVar[] | null) => void; + addConfigMapSecret?: boolean; + useLoadingInline?: boolean; +} + +export const EnvironmentPage: React.FC = (props) => { + const { + rawEnvData = {}, + obj = {}, + readOnly, + addConfigMapSecret = true, + onChange, + envPath, + useLoadingInline, + } = props; + + const { t } = useTranslation(); + + const model = useSelector( + (state: RootState) => + state.k8s.getIn(['RESOURCES', 'models', referenceFor(obj)]) || + state.k8s.getIn(['RESOURCES', 'models', obj?.kind]), + ); + + const impersonate = useSelector((state: RootState) => getImpersonate(state)); + const [handlePromise, inProgress, errorMessage] = usePromiseHandler(); + + const initialCurrentEnvVars = useMemo(() => new CurrentEnvVars(rawEnvData), [rawEnvData]); + + const [currentEnvVars, setCurrentEnvVars] = useState(initialCurrentEnvVars); + const [success, setSuccess] = useState(null); + const [containerIndex, setContainerIndex] = useState(0); + const [containerType, setContainerType] = useState( + initialCurrentEnvVars.isContainerArray || initialCurrentEnvVars.isCreate + ? 'containers' + : 'buildObject', + ); + const [configMaps, setConfigMaps] = useState(null); + const [secrets, setSecrets] = useState(null); + const [allowed, setAllowed] = useState(!obj || _.isEmpty(obj) || !model); + const [localErrorMessage, setLocalErrorMessage] = useState(null); + + const checkEditAccess = useCallback(() => { + if (readOnly) { + return; + } + + // Only check RBAC if editing an existing resource. The form will always + // be enabled when creating a new application (git import / deploy image). + if (_.isEmpty(obj) || !model) { + setAllowed(true); + return; + } + + const { name, namespace } = obj.metadata; + const resourceAttributes: AccessReviewResourceAttributes = { + group: model.apiGroup, + resource: model.plural, + verb: 'patch', + name, + namespace, + }; + checkAccess(resourceAttributes, impersonate) + .then((resp) => setAllowed(resp.status.allowed)) + .catch((e) => { + // eslint-disable-next-line no-console + console.warn('Error while check edit access for environment variables', e); + }); + }, [obj, model, impersonate, readOnly]); + + useEffect(() => { + checkEditAccess(); + if (!addConfigMapSecret || readOnly) { + setConfigMaps({}); + setSecrets({}); + return; + } + const envNamespace = obj?.metadata?.namespace; + + Promise.all([ + k8sGet(ConfigMapModel, null, envNamespace).catch((err) => { + if (err.response.status !== 403) { + const errorMsg = err.message || t('public~Could not load ConfigMaps.'); + setLocalErrorMessage(errorMsg); + } + return { + configMaps: {}, + }; + }), + k8sGet(SecretModel, null, envNamespace).catch((err) => { + if (err.response.status !== 403) { + const errorMsg = err.message || t('public~Could not load Secrets.'); + setLocalErrorMessage(errorMsg); + } + return { + secrets: {}, + }; + }), + ]).then(([cmaps, secs]) => { + setConfigMaps(cmaps); + setSecrets(secs); + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + /** + * Callback for NVEditor update our state with new values + */ + const updateEnvVars = useCallback( + (env, i = 0, type = EnvType.ENV) => { + const currentEnv = _.cloneDeep(currentEnvVars); + currentEnv.setFormattedVars(containerType, i, type, env.nameValuePairs); + setCurrentEnvVars(currentEnv); + setSuccess(null); + _.isFunction(onChange) && onChange(currentEnv.dispatchNewEnvironmentVariables()); + }, + [currentEnvVars, containerType, onChange], + ); + + /** + * Reset the page to initial state + */ + const reload = useCallback(() => { + setCurrentEnvVars(new CurrentEnvVars(rawEnvData)); + setLocalErrorMessage(null); + setSuccess(null); + }, [rawEnvData]); + + const selectContainer = useCallback( + (containerName) => { + let index = _.findIndex(rawEnvData.containers, { name: containerName }); + if (index !== -1) { + setContainerIndex(index); + setContainerType('containers'); + return; + } + index = _.findIndex(rawEnvData.initContainers, { name: containerName }); + if (index !== -1) { + setContainerIndex(index); + setContainerType('initContainers'); + } + }, + [rawEnvData], + ); + + /** + * Make it so. Patch the values for the env var changes made on the page. + * 1. Validate for dup keys + * 2. Throw out empty rows + * 3. Use add command if we are adding new env vars, and replace if we are modifying + * 4. Send the patch command down to REST, and update with response + */ + const saveChanges = useCallback( + (e) => { + e.preventDefault(); + + const patches = currentEnvVars.getPatches(envPath); + const promise = k8sPatch(model, obj, patches); + handlePromise(promise).then((res) => { + setCurrentEnvVars(new CurrentEnvVars(res, currentEnvVars.isContainerArray, envPath)); + setLocalErrorMessage(null); + setSuccess(t('public~Successfully updated the environment variables.')); + }); + }, + [currentEnvVars, envPath, model, obj, handlePromise, t], + ); + + const dismissSuccess = useCallback(() => { + setSuccess(null); + }, []); + + const isReadOnly = readOnly || !allowed; + const displayErrorMessage = errorMessage || localErrorMessage; + + if (!configMaps || !currentEnvVars || !secrets) { + if (useLoadingInline) { + return ; + } + return ; + } + + const envVar = currentEnvVars.getEnvVarByTypeAndIndex(containerType, containerIndex); + + const containerDropdown = currentEnvVars.isContainerArray ? ( + + ) : null; + + const owners = (obj?.metadata?.ownerReferences || []).map((o, i) => ( + + )); + const containerVars = ( + <> + {isReadOnly && !_.isEmpty(owners) && ( + + {t('public~View environment for resource')}{' '} + {owners.length > 1 ? ( + <> + {t('public~owners:')} {owners} + + ) : ( + owners + )} + + )} + {currentEnvVars.isContainerArray && ( + + + {containerType === 'containers' ? t('public~Container:') : t('public~Init container:')} + + {containerDropdown} + + )} + {!currentEnvVars.isCreate && ( + + {t('public~Single values (env)')} + {!isReadOnly && ( + + + Define environment variables as key-value pairs to store configuration settings. You + can enter text or add values from a ConfigMap or Secret. Drag and drop environment + variables to change the order in which they are run. A variable can reference any + other variables that come before it in the list, for example{' '} + FULLDOMAIN = $(SUBDOMAIN).example.com. + + + )} + + )} + + {currentEnvVars.isContainerArray && ( +
+ + {t('public~All values from existing ConfigMaps or Secrets (envFrom)')} + {!isReadOnly && ( + + <> + {t( + 'public~Add new values by referencing an existing ConfigMap or Secret. Drag and drop environment variables within this section to change the order in which they are run.', + )} +
+ {t('public~Note:')}{' '} + {t( + 'public~If identical values exist in both lists, the single value in the list above will take precedence.', + )} + +
+ )} +
+ +
+ )} + + ); + + return ( +
+ {containerVars} + {!currentEnvVars.isCreate && ( +
+ {displayErrorMessage && ( + + )} + {success && ( + } + /> + )} + {!isReadOnly && ( + + + + + )} +
+ )} +
+ ); +}; diff --git a/frontend/public/components/modals/configure-ns-pull-secret-modal.jsx b/frontend/public/components/modals/configure-ns-pull-secret-modal.jsx deleted file mode 100644 index b748ada4649..00000000000 --- a/frontend/public/components/modals/configure-ns-pull-secret-modal.jsx +++ /dev/null @@ -1,329 +0,0 @@ -import * as _ from 'lodash-es'; -import * as PropTypes from 'prop-types'; -import { Base64 } from 'js-base64'; -import { - Alert, - CodeBlock, - CodeBlockCode, - FormGroup, - Grid, - GridItem, - Radio, -} from '@patternfly/react-core'; -import { withTranslation } from 'react-i18next'; - -import { CONST } from '@console/shared'; -import { k8sPatchByName, k8sCreate } from '../../module/k8s'; -import { SecretModel, ServiceAccountModel } from '../../models'; -import { createModalLauncher, ModalTitle, ModalBody, ModalSubmitFooter } from '../factory/modal'; -import { PromiseComponent } from '../utils/promise-component'; -import { ResourceIcon } from '../utils/resource-icon'; - -const generateSecretData = (formData) => { - const config = { - auths: {}, - }; - - const authParts = []; - - if (_.trim(formData.username).length >= 1) { - authParts.push(formData.username); - } - authParts.push(formData.password); - - config.auths[formData.address] = { - auth: Base64.encode(authParts.join(':')), - email: formData.email, - }; - - return Base64.encode(JSON.stringify(config)); -}; - -class ConfigureNamespacePullSecretWithTranslation extends PromiseComponent { - constructor(props) { - super(props); - - this._submit = this._submit.bind(this); - this._cancel = this.props.cancel.bind(this); - - this._onMethodChange = this._onMethodChange.bind(this); - this._onFileChange = this._onFileChange.bind(this); - - this.state = Object.assign(this.state, { - method: 'form', - fileData: null, - invalidJson: false, - }); - } - - _onMethodChange(event) { - this.setState({ method: event.target.value }); - } - - _onFileChange(event) { - this.setState({ invalidJson: false, fileData: null }); - - const file = event.target.files[0]; - if (!file || file.type !== 'application/json') { - this.setState({ invalidJson: true }); - return; - } - - const reader = new FileReader(); - reader.onload = (e) => { - const input = e.target.result; - try { - JSON.parse(input); - } catch (error) { - this.setState({ invalidJson: true }); - return; - } - this.setState({ fileData: input }); - }; - reader.readAsText(file, 'UTF-8'); - } - - _submit(event) { - event.preventDefault(); - const { namespace } = this.props; - - let secretData; - - if (this.state.method === 'upload') { - secretData = Base64.encode(this.state.fileData); - } else { - const elements = event.target.elements; - const formData = { - username: elements['namespace-pull-secret-username'].value, - password: elements['namespace-pull-secret-password'].value, - email: elements['namespace-pull-secret-email'].value || '', - address: elements['namespace-pull-secret-address'].value, - }; - secretData = generateSecretData(formData); - } - - const data = {}; - const pullSecretName = event.target.elements['namespace-pull-secret-name'].value; - data[CONST.PULL_SECRET_DATA] = secretData; - - const secret = { - metadata: { - name: pullSecretName, - namespace: namespace.metadata.name, - }, - data, - type: CONST.PULL_SECRET_TYPE, - }; - const defaultServiceAccountPatch = [ - { - op: 'add', - path: '/imagePullSecrets/-', - value: { name: pullSecretName }, - }, - ]; - const promise = k8sCreate(SecretModel, secret).then(() => - k8sPatchByName( - ServiceAccountModel, - 'default', - namespace.metadata.name, - defaultServiceAccountPatch, - ), - ); - - this.handlePromise(promise).then(this.props.close); - } - - render() { - const { namespace, t } = this.props; - - return ( - - {t('public~Default pull Secret')} - - - -

- {t( - 'public~Specify default credentials to be used to authenticate and download containers within this namespace. These credentials will be the default unless a pod references a specific pull Secret.', - )} -

-
- - - - - -  {namespace.metadata.name} - - - - - - - - - - -

- {t('public~Friendly name to help you manage this in the future')} -

-
- - - - - -
- - - - -
-
- - {this.state.method === 'form' && ( - <> - - - - - - - - - - - - - - - - -

- {t('public~Optional, depending on registry provider')} -

-
- - - - - - - - - - - - - - - - - - - - )} - - {this.state.method === 'upload' && ( - <> - - - - - -

- {t( - 'public~Properly configured Docker config file in JSON format. Will be base64 encoded after upload.', - )} -

-
- - {this.state.invalidJson && ( - - - {t('public~The uploaded file is not properly-formatted JSON.')} - - - )} - {this.state.fileData && ( - - - {this.state.fileData} - - - )} - - )} -
-
- - - ); - } -} - -const ConfigureNamespacePullSecret = withTranslation()(ConfigureNamespacePullSecretWithTranslation); -export const configureNamespacePullSecretModal = createModalLauncher(ConfigureNamespacePullSecret); -ConfigureNamespacePullSecret.propTypes = { - namespace: PropTypes.object.isRequired, - pullSecret: PropTypes.object, -}; diff --git a/frontend/public/components/modals/configure-ns-pull-secret-modal.tsx b/frontend/public/components/modals/configure-ns-pull-secret-modal.tsx new file mode 100644 index 00000000000..4febcb07026 --- /dev/null +++ b/frontend/public/components/modals/configure-ns-pull-secret-modal.tsx @@ -0,0 +1,316 @@ +import * as _ from 'lodash-es'; +import { Base64 } from 'js-base64'; +import { + Alert, + CodeBlock, + CodeBlockCode, + Content, + ContentVariants, + FormGroup, + Grid, + GridItem, + Radio, + TextInput, +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; + +import { CONST } from '@console/shared'; +import { usePromiseHandler } from '@console/shared/src/hooks/promise-handler'; +import { k8sPatchByName, k8sCreate, K8sResourceKind } from '../../module/k8s'; +import { SecretModel, ServiceAccountModel } from '../../models'; +import { useState, useCallback } from 'react'; +import { + createModalLauncher, + ModalTitle, + ModalBody, + ModalSubmitFooter, + ModalComponentProps, +} from '../factory/modal'; +import { ResourceIcon } from '../utils/resource-icon'; + +interface FormData { + username: string; + password: string; + email: string; + address: string; +} + +const generateSecretData = (formData: FormData): string => { + const config = { + auths: {}, + }; + + const authParts: string[] = []; + + if (_.trim(formData.username).length >= 1) { + authParts.push(formData.username); + } + authParts.push(formData.password); + + config.auths[formData.address] = { + auth: Base64.encode(authParts.join(':')), + email: formData.email, + }; + + return Base64.encode(JSON.stringify(config)); +}; + +interface ConfigureNamespacePullSecretProps extends ModalComponentProps { + namespace: K8sResourceKind; + pullSecret?: K8sResourceKind; +} + +const ConfigureNamespacePullSecret: React.FC = (props) => { + const { namespace, cancel, close } = props; + const { t } = useTranslation(); + const [handlePromise, inProgress, errorMessage] = usePromiseHandler(); + + const [method, setMethod] = useState<'form' | 'upload'>('form'); + const [fileData, setFileData] = useState(null); + const [invalidJson, setInvalidJson] = useState(false); + + const onMethodChange = useCallback((event: React.ChangeEvent) => { + setMethod(event.target.value as 'form' | 'upload'); + }, []); + + const onFileChange = useCallback((event: React.ChangeEvent) => { + setInvalidJson(false); + setFileData(null); + + const file = event.target.files?.[0]; + if (!file || file.type !== 'application/json') { + setInvalidJson(true); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const input = e.target?.result as string; + try { + JSON.parse(input); + } catch (error) { + setInvalidJson(true); + return; + } + setFileData(input); + }; + reader.readAsText(file, 'UTF-8'); + }, []); + + const submit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + + let secretData: string; + + if (method === 'upload') { + secretData = Base64.encode(fileData); + } else { + const elements = event.currentTarget.elements as any; + const formData: FormData = { + username: elements['namespace-pull-secret-username'].value, + password: elements['namespace-pull-secret-password'].value, + email: elements['namespace-pull-secret-email'].value || '', + address: elements['namespace-pull-secret-address'].value, + }; + secretData = generateSecretData(formData); + } + + const data: { [key: string]: string } = {}; + const pullSecretName = (event.currentTarget.elements as any)['namespace-pull-secret-name'] + .value; + data[CONST.PULL_SECRET_DATA] = secretData; + + const secret = { + metadata: { + name: pullSecretName, + namespace: namespace.metadata.name, + }, + data, + type: CONST.PULL_SECRET_TYPE, + }; + const defaultServiceAccountPatch = [ + { + op: 'add' as const, + path: '/imagePullSecrets/-', + value: { name: pullSecretName }, + }, + ]; + const promise = k8sCreate(SecretModel, secret).then(() => + k8sPatchByName( + ServiceAccountModel, + 'default', + namespace.metadata.name, + defaultServiceAccountPatch, + ), + ); + + handlePromise(promise).then(close); + }, + [method, fileData, namespace, handlePromise, close], + ); + + return ( +
+ {t('public~Default pull Secret')} + + + + + {t( + 'public~Specify default credentials to be used to authenticate and download containers within this namespace. These credentials will be the default unless a pod references a specific pull Secret.', + )} + + + + + + + +  {namespace.metadata.name} + + + + + + + + + + {t('public~Friendly name to help you manage this in the future')} + + + + + + + +
+ + + + +
+
+ + {method === 'form' && ( + <> + + + + + + + + + + + + + + {t('public~Optional, depending on registry provider')} + + + + + + + + + + + + + + + + + + )} + + {method === 'upload' && ( + <> + + + + + + + {t( + 'public~Properly configured Docker config file in JSON format. Will be base64 encoded after upload.', + )} + + + + {invalidJson && ( + + + {t('public~The uploaded file is not properly-formatted JSON.')} + + + )} + {fileData && ( + + + {fileData} + + + )} + + )} +
+
+ + + ); +}; + +export const configureNamespacePullSecretModal = createModalLauncher(ConfigureNamespacePullSecret); diff --git a/frontend/public/components/modals/confirm-modal.tsx b/frontend/public/components/modals/confirm-modal.tsx index 18ca3acf642..8712124e32c 100644 --- a/frontend/public/components/modals/confirm-modal.tsx +++ b/frontend/public/components/modals/confirm-modal.tsx @@ -1,83 +1,75 @@ import { Translation } from 'react-i18next'; +import { usePromiseHandler } from '@console/shared/src/hooks/promise-handler'; -import { createModalLauncher, ModalTitle, ModalBody, ModalSubmitFooter } from '../factory/modal'; -import { PromiseComponent } from '../utils/promise-component'; +import { + createModalLauncher, + ModalTitle, + ModalBody, + ModalSubmitFooter, + ModalComponentProps, +} from '../factory/modal'; -interface ConfirmModalProps { - btnText: string | React.ReactNode; - btnTextKey: string; - cancel: () => void; - cancelText: string | React.ReactNode; - cancelTextKey: string; - close: () => void; +interface ConfirmModalProps extends ModalComponentProps { + btnText?: string | React.ReactNode; + btnTextKey?: string; + cancelText?: string | React.ReactNode; + cancelTextKey?: string; executeFn: ( e?: React.FormEvent, opts?: { supressNotifications: boolean }, ) => Promise; - message: string | React.ReactNode; - messageKey: string; - title: string | React.ReactNode; - titleKey: string; - submitDanger: boolean; + message?: string | React.ReactNode; + messageKey?: string; + title?: string | React.ReactNode; + titleKey?: string; + submitDanger?: boolean; } -interface ConfirmModalState { - inProgress: boolean; - errorMessage: string; -} - -class ConfirmModal extends PromiseComponent { - _cancel: () => void; +const ConfirmModal: React.FC = (props) => { + const [handlePromise, inProgress, errorMessage] = usePromiseHandler(); - constructor(props) { - super(props); - this._submit = this._submit.bind(this); - this._cancel = this.props.cancel.bind(this); - } - - _submit(event) { + const submit = (event: React.FormEvent) => { event.preventDefault(); - this.handlePromise( - this.props.executeFn(null, { + handlePromise( + props.executeFn(null, { supressNotifications: true, }), - ).then(this.props.close); - } + ).then(props.close); + }; - render() { - const { - title, - titleKey, - message, - messageKey, - btnText, - btnTextKey, - cancelText, - cancelTextKey, - submitDanger, - } = this.props; + const { + title, + titleKey, + message, + messageKey, + btnText, + btnTextKey, + cancelText, + cancelTextKey, + submitDanger, + cancel, + } = props; - return ( - - {(t) => ( -
- {titleKey ? t(titleKey) : title} - {messageKey ? t(messageKey) : message} - - - )} -
- ); - } -} + return ( + + {(t) => ( +
+ {titleKey ? t(titleKey) : title} + {messageKey ? t(messageKey) : message} + + + )} +
+ ); +}; /** @deprecated use `useWarningModal` instead */ export const confirmModal = createModalLauncher(ConfirmModal); diff --git a/frontend/public/components/pod.tsx b/frontend/public/components/pod.tsx index 5ba22ebf7c9..8dbfd151081 100644 --- a/frontend/public/components/pod.tsx +++ b/frontend/public/components/pod.tsx @@ -446,7 +446,7 @@ const EnvironmentPage = (props: { readOnly: boolean; }) => ( import('./environment.jsx').then((c) => c.EnvironmentPage)} + loader={() => import('./environment').then((c) => c.EnvironmentPage)} {...(props as Record)} /> ); diff --git a/frontend/public/components/replicaset.jsx b/frontend/public/components/replicaset.jsx index 3ac6915138f..0bd0159747c 100644 --- a/frontend/public/components/replicaset.jsx +++ b/frontend/public/components/replicaset.jsx @@ -90,7 +90,7 @@ const Details = ({ obj: replicaSet }) => { const EnvironmentPage = (props) => ( import('./environment.jsx').then((c) => c.EnvironmentPage)} + loader={() => import('./environment').then((c) => c.EnvironmentPage)} {...props} /> ); diff --git a/frontend/public/components/replication-controller.jsx b/frontend/public/components/replication-controller.jsx index 4538fd7d65f..f8aa78c85bf 100644 --- a/frontend/public/components/replication-controller.jsx +++ b/frontend/public/components/replication-controller.jsx @@ -43,7 +43,7 @@ import { ReplicasCount } from './workload-table'; const EnvironmentPage = (props) => ( import('./environment.jsx').then((c) => c.EnvironmentPage)} + loader={() => import('./environment').then((c) => c.EnvironmentPage)} {...props} /> ); diff --git a/frontend/public/components/stateful-set.tsx b/frontend/public/components/stateful-set.tsx index 9b6324ac336..ccb0aea96f0 100644 --- a/frontend/public/components/stateful-set.tsx +++ b/frontend/public/components/stateful-set.tsx @@ -57,7 +57,7 @@ const StatefulSetDetails: React.FC = ({ obj: ss }) => { const EnvironmentPage: React.FC = (props) => ( import('./environment.jsx').then((c) => c.EnvironmentPage)} + loader={() => import('./environment').then((c) => c.EnvironmentPage)} {...props} /> ); diff --git a/frontend/public/components/utils/__tests__/promise-component.spec.tsx b/frontend/public/components/utils/__tests__/promise-component.spec.tsx deleted file mode 100644 index 112d17a9c37..00000000000 --- a/frontend/public/components/utils/__tests__/promise-component.spec.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; - -import { - PromiseComponent, - PromiseComponentState, -} from '../../../components/utils/promise-component'; - -describe('PromiseComponent', () => { - class TestComponent extends PromiseComponent< - { promise: Promise }, - PromiseComponentState - > { - handleClick = () => { - this.handlePromise(this.props.promise).catch(() => {}); - }; - - render() { - return this.state.inProgress ? ( -
Loading...
- ) : ( - - ); - } - } - - it('toggles loading state when handling promises', async () => { - let resolvePromise: (value: string) => void; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - - render(); - - expect(screen.getByRole('button')).toBeInTheDocument(); - expect(screen.queryByTestId('loading')).not.toBeInTheDocument(); - - // Click button to trigger promise handling - fireEvent.click(screen.getByRole('button')); - - // Should show loading - expect(screen.getByTestId('loading')).toBeInTheDocument(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - - // Resolve promise - resolvePromise!('Success'); - - // Should return to button state - await waitFor(() => { - expect(screen.getByRole('button')).toBeInTheDocument(); - }); - expect(screen.queryByTestId('loading')).not.toBeInTheDocument(); - }); - - it('handles promise rejection', async () => { - let rejectPromise: (error: Error) => void; - const promise = new Promise((_, reject) => { - rejectPromise = reject; - }); - - render(); - - // Click to trigger handlePromise - catch the rejection - const button = screen.getByRole('button'); - fireEvent.click(button); - - // Should show loading initially - expect(screen.getByTestId('loading')).toBeInTheDocument(); - - // Reject the promise and catch the error from handlePromise - rejectPromise!(new Error('Test error')); - - // Wait for the state update - await waitFor(() => { - expect(screen.getByRole('button')).toBeInTheDocument(); - }); - - // Loading should be gone - expect(screen.queryByTestId('loading')).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/public/components/utils/index.tsx b/frontend/public/components/utils/index.tsx index 1c5a4d0fdff..4f049f3039b 100644 --- a/frontend/public/components/utils/index.tsx +++ b/frontend/public/components/utils/index.tsx @@ -1,5 +1,4 @@ export * from './line-buffer'; -export * from './promise-component'; export * from './kebab'; export * from './selector'; export * from './selector-input'; diff --git a/frontend/public/components/utils/promise-component.tsx b/frontend/public/components/utils/promise-component.tsx deleted file mode 100644 index 5433b4c74db..00000000000 --- a/frontend/public/components/utils/promise-component.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from 'react'; -import i18next from 'i18next'; - -export class PromiseComponent extends React.Component { - constructor(props) { - super(props); - this.state = { - inProgress: false, - errorMessage: '', - } as S; - } - - handlePromise(promise: Promise): Promise { - this.setState({ - inProgress: true, - }); - return promise.then( - (res) => this.then(res), - (error) => this.catch(error), - ); - } - - private then(res) { - this.setState({ - inProgress: false, - errorMessage: '', - }); - return res; - } - - private catch(error) { - const errorMessage = error.message || i18next.t('public~An error occurred. Please try again.'); - this.setState({ - inProgress: false, - errorMessage, - }); - return Promise.reject(errorMessage); - } -} - -export type PromiseComponentState = { - inProgress: boolean; - errorMessage: string; -}; diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 1210ee8d1a3..08758c3d2d2 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -525,6 +525,7 @@ "Successfully updated the environment variables.": "Successfully updated the environment variables.", "Environment variables set from parent": "Environment variables set from parent", "View environment for resource": "View environment for resource", + "owners:": "owners:", "Container:": "Container:", "Init container:": "Init container:", "Single values (env)": "Single values (env)", @@ -533,8 +534,6 @@ "Add new values by referencing an existing ConfigMap or Secret. Drag and drop environment variables within this section to change the order in which they are run.": "Add new values by referencing an existing ConfigMap or Secret. Drag and drop environment variables within this section to change the order in which they are run.", "Note:": "Note:", "If identical values exist in both lists, the single value in the list above will take precedence.": "If identical values exist in both lists, the single value in the list above will take precedence.", - "The information on this page is no longer current.": "The information on this page is no longer current.", - "Click Reload to update and lose edits, or Save Changes to overwrite.": "Click Reload to update and lose edits, or Save Changes to overwrite.", "Page Not Found (404)": "Page Not Found (404)", "404: Page Not Found": "404: Page Not Found", "We couldn't find that page.": "We couldn't find that page.",