From b24c7359f7c6c1b480093a0c687b7ef4021f785f Mon Sep 17 00:00:00 2001 From: Christian Karidas <105549337+chriskari@users.noreply.github.com> Date: Tue, 27 Aug 2024 11:24:41 +0200 Subject: [PATCH 1/6] fix: cluster-wizard UI & oidc-scopes (#3209) * fix: improved margin of oidc switch * fix: display multiple scopes in cluster review * fix: improved css of first wizard step * fix: display multiple scopes in cluster review * fix: wizard and dialog size * fix: further css adjustments * fix: further css adjustments * fix: further css adjustments * fix: form ui adjustments * fix: list input instead of formfield for scopes * test: adjust unit test to previous changes * fix: openid scope always present * fix: openid scope always present --- .../Clusters/components/AddClusterDialog.js | 2 +- .../Clusters/components/AddClusterWizard.js | 25 +- .../Clusters/components/AddClusterWizard.scss | 37 ++- .../Clusters/components/AuthForm.js | 19 +- .../Clusters/components/ChooseStorage.js | 6 +- .../Clusters/components/ClusterPreview.js | 236 +++++++++--------- .../Clusters/components/ClusterPreview.scss | 3 +- .../KubeconfigUpload/KubeconfigFileUpload.js | 4 +- .../KubeconfigUpload/KubeconfigUpload.js | 11 +- .../KubeconfigUpload/KubeconfigUpload.scss | 17 +- .../Clusters/components/oidc-params.ts | 25 +- .../components/test/oidc-params.test.js | 8 +- src/components/Clusters/shared.ts | 2 +- .../EditCluster/AuthenticationDropdown.js | 1 - .../Clusters/views/EditCluster/EditCluster.js | 36 ++- src/resources/RoleBindings/RoleForm.js | 47 ++-- src/resources/RoleBindings/SubjectForm.js | 2 +- .../ResourceForm/components/FormField.js | 36 ++- src/shared/components/Dropdown/Dropdown.js | 6 +- src/state/authDataAtom.ts | 6 +- 20 files changed, 297 insertions(+), 232 deletions(-) diff --git a/src/components/Clusters/components/AddClusterDialog.js b/src/components/Clusters/components/AddClusterDialog.js index 19bf84562d..bf23a5f039 100644 --- a/src/components/Clusters/components/AddClusterDialog.js +++ b/src/components/Clusters/components/AddClusterDialog.js @@ -27,7 +27,7 @@ export function AddClusterDialog() { return ( setShowWizard(false)} > diff --git a/src/components/Clusters/components/AddClusterWizard.js b/src/components/Clusters/components/AddClusterWizard.js index 7d6578b69b..eac0818225 100644 --- a/src/components/Clusters/components/AddClusterWizard.js +++ b/src/components/Clusters/components/AddClusterWizard.js @@ -49,11 +49,13 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { const setIsFormOpen = useSetRecoilState(isFormOpenState); useEffect(() => { - const contentContainer = document - .getElementsByTagName('ui5-wizard')[0] - ?.shadowRoot?.querySelectorAll('.ui5-wiz-content-item-wrapper')[ - selected - 1 - ]; + const wizard = document.getElementsByTagName('ui5-wizard')[0]; + const wizardContent = wizard?.shadowRoot?.querySelector('.ui5-wiz-content'); + + const contentContainer = wizardContent?.querySelectorAll( + '.ui5-wiz-content-item-wrapper', + )[selected - 1]; + if (contentContainer) { contentContainer.style['background-color'] = 'transparent'; contentContainer.style['padding'] = '0'; @@ -166,7 +168,7 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { firstStep={true} onCancel={() => setShowWizard(false)} validation={!kubeconfig} - className="cluster-wizard__buttons" + className="cluster-wizard__buttons__sticky" /> @@ -197,7 +199,7 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { setSelected={setSelected} onCancel={() => setShowWizard(false)} validation={!authValid} - className="cluster-wizard__buttons" + className="cluster-wizard__buttons__absolute" /> )} @@ -217,14 +219,14 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { data-step={!hasAuth || !hasOneContext ? '3' : '2'} >
- + <Title level="H5" style={spacing.sapUiSmallMarginBottom}> {t('clusters.storage.choose-storage.label')} <> <Button id="storageDescriptionOpener" icon="hint" design="Transparent" - style={spacing.sapUiTinyMargin} + style={spacing.sapUiTinyMarginBegin} onClick={() => setShowTitleDescription(true)} /> {createPortal( @@ -248,7 +250,7 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { setSelected={setSelected} validation={!storage} onCancel={() => setShowWizard(false)} - className="cluster-wizard__buttons" + className="cluster-wizard__buttons__absolute" /> </div> </WizardStep> @@ -269,7 +271,6 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { <ClusterPreview storage={storage} kubeconfig={kubeconfig} - token={hasAuth ? hasKubeconfigAuth(kubeconfig) : null} setSelected={setSelected} hasAuth={hasAuth} /> @@ -284,7 +285,7 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { setIsFormOpen({ formOpen: false }); }} validation={!storage} - className="cluster-wizard__buttons" + className="cluster-wizard__buttons__sticky" /> </WizardStep> </Wizard> diff --git a/src/components/Clusters/components/AddClusterWizard.scss b/src/components/Clusters/components/AddClusterWizard.scss index 1a294f9337..5e5f0cc198 100644 --- a/src/components/Clusters/components/AddClusterWizard.scss +++ b/src/components/Clusters/components/AddClusterWizard.scss @@ -1,3 +1,7 @@ +.add-cluster-wizard-dialog::part(content) { + height: 90vh; +} + .add-cluster { &__content-container { background-color: var(--sapGroup_TitleBackground); @@ -11,20 +15,17 @@ } ui5-wizard { - height: 90vh; - min-width: 80vw; + height: 100%; + width: 75vw; .cluster-wizard { - &__buttons { - position: absolute; + @mixin cluster-wizard-buttons-style { + z-index: 100; bottom: 1rem; - left: 0; display: flex; justify-content: flex-end; box-sizing: border-box; - width: calc(100% - 20px); - margin: 0 10px; - padding: 7px; + padding: 0.5rem; border: 1px solid var(--sapGroup_TitleBorderColor); border-radius: var(--sapElement_BorderCornerRadius); background-color: var(--sapGroup_TitleBackground); @@ -34,6 +35,23 @@ ui5-wizard { color-mix(in srgb, var(--sapContent_ShadowColor) 16%, transparent); } + &__buttons { + &__sticky { + position: sticky; + width: calc(100% + 32px); + margin: 0 -16px; + @include cluster-wizard-buttons-style; + } + + &__absolute { + position: absolute; + left: 0; + width: calc(100% - 32px); + margin: 0 16px; + @include cluster-wizard-buttons-style; + } + } + &__token-info, &__storage-preference { color: var(--sapContent_LabelColor); @@ -44,7 +62,6 @@ ui5-wizard { display: flex; flex-flow: column; overflow: auto; - height: calc(90vh - 8rem); .cluster-wizard__auth-form { height: unset; @@ -53,7 +70,7 @@ ui5-wizard { } .resource-form { - padding: 1rem 0; + padding: 0; .form-field { justify-content: start; diff --git a/src/components/Clusters/components/AuthForm.js b/src/components/Clusters/components/AuthForm.js index dfa9a613b2..eebcecc9c8 100644 --- a/src/components/Clusters/components/AuthForm.js +++ b/src/components/Clusters/components/AuthForm.js @@ -9,10 +9,7 @@ import * as Inputs from 'shared/ResourceForm/inputs'; import { getUser, getUserIndex } from '../shared'; import { spacing } from '@ui5/webcomponents-react-base'; - -export const AUTH_FORM_TOKEN = 'Token'; -export const AUTH_FORM_OIDC = 'OIDC'; -export const DEFAULT_SCOPE_VALUE = 'openid'; +import { TextArrayInput } from 'shared/ResourceForm/fields'; const OIDCform = ({ resource, setResource, ...props }) => { const { t } = useTranslation(); @@ -57,11 +54,11 @@ const OIDCform = ({ resource, setResource, ...props }) => { input={Inputs.Text} aria-label="client-secret" /> - <ResourceForm.FormField + <TextArrayInput required - propertyPath="$.scope" - label={t('clusters.wizard.auth.scopes')} - input={Inputs.Text} + defaultOpen + propertyPath="$.scopes" + title={t('clusters.wizard.auth.scopes')} aria-label="scopes" /> </ResourceForm.Wrapper> @@ -147,7 +144,11 @@ export function AuthForm({ <ResourceForm.FormField label={t('clusters.wizard.auth.using-oidc')} input={() => ( - <Switch checked={useOidc} onChange={switchAuthVariant} /> + <Switch + style={spacing.sapUiTinyMarginTop} + checked={useOidc} + onChange={switchAuthVariant} + /> )} className="oidc-switch" /> diff --git a/src/components/Clusters/components/ChooseStorage.js b/src/components/Clusters/components/ChooseStorage.js index 9e6bf1874c..9c90938c68 100644 --- a/src/components/Clusters/components/ChooseStorage.js +++ b/src/components/Clusters/components/ChooseStorage.js @@ -1,5 +1,6 @@ import { FlexBox, RadioButton } from '@ui5/webcomponents-react'; import { useTranslation } from 'react-i18next'; +import { spacing } from '@ui5/webcomponents-react-base'; import './ChooseStorage.scss'; export function ChooseStorage({ storage, setStorage }) { @@ -7,7 +8,10 @@ export function ChooseStorage({ storage, setStorage }) { return ( <> - <p className="cluster-wizard__storage-preference"> + <p + className="cluster-wizard__storage-preference" + style={spacing.sapUiTinyMarginBottom} + > {`${t('clusters.storage.storage-preference')}:`} </p> <FlexBox direction="Column"> diff --git a/src/components/Clusters/components/ClusterPreview.js b/src/components/Clusters/components/ClusterPreview.js index 9e46293aec..6c60cad017 100644 --- a/src/components/Clusters/components/ClusterPreview.js +++ b/src/components/Clusters/components/ClusterPreview.js @@ -3,8 +3,12 @@ import { Button, FlexBox, RadioButton, Title } from '@ui5/webcomponents-react'; import { useTranslation } from 'react-i18next'; import './ClusterPreview.scss'; import { spacing } from '@ui5/webcomponents-react-base'; -import { findInitialValue } from '../views/EditCluster/EditCluster'; +import { + findInitialValue, + findInitialValues, +} from '../views/EditCluster/EditCluster'; import { getUserIndex } from '../shared'; +import { Tokens } from 'shared/components/Tokens'; export function ClusterPreview({ kubeconfig, storage, setSelected, hasAuth }) { const { t } = useTranslation(); @@ -25,7 +29,7 @@ export function ClusterPreview({ kubeconfig, storage, setSelected, hasAuth }) { 'oidc-client-secret', userIndex, ); - const extraScope = findInitialValue( + const extraScopes = findInitialValues( kubeconfig, 'oidc-extra-scope', userIndex, @@ -75,7 +79,7 @@ export function ClusterPreview({ kubeconfig, storage, setSelected, hasAuth }) { <div>{clientSecret}</div> </> )} - {extraScope && ( + {extraScopes && ( <> <p className="cluster-preview__data-header" @@ -86,7 +90,7 @@ export function ClusterPreview({ kubeconfig, storage, setSelected, hasAuth }) { > {t('clusters.labels.scopes')}: </p> - <div>{extraScope}</div> + {<Tokens tokens={extraScopes} />} </> )} </> @@ -112,123 +116,125 @@ export function ClusterPreview({ kubeconfig, storage, setSelected, hasAuth }) { }; return ( - <div className="cluster-preview add-cluster__content-container"> - <Title level="H5" style={spacing.sapUiMediumMarginBottom}> - {t('clusters.wizard.review')} - - {`1. ${t('configuration.title')}`} -

- {t('clusters.name_singular')}: -

-
-
{kubeconfig?.['current-context']}
- -
- {`2. ${t('clusters.wizard.authentication')}`} - -
-
- {authenticationType === 'token' ? : } + {t('clusters.name_singular')}: +

+
+
{kubeconfig?.['current-context']}
+
- -
- {`3. ${t('clusters.wizard.storage')}`} -

- {`${t('clusters.storage.storage-preference')}:`} -

-
- + {authenticationType === 'token' ? : } +
+ +
+ {`3. ${t('clusters.wizard.storage')}`} +

- - - - - + + + + + + +

); diff --git a/src/components/Clusters/components/ClusterPreview.scss b/src/components/Clusters/components/ClusterPreview.scss index 7711785972..f9adae7937 100644 --- a/src/components/Clusters/components/ClusterPreview.scss +++ b/src/components/Clusters/components/ClusterPreview.scss @@ -2,7 +2,8 @@ overflow-y: scroll; overflow-x: hidden; height: 100%; - max-height: calc(90vh - 12rem); + min-height: calc(90vh - 11.85rem); + margin-bottom: 1rem; &__subtitle { padding-bottom: 1rem; diff --git a/src/components/Clusters/components/KubeconfigUpload/KubeconfigFileUpload.js b/src/components/Clusters/components/KubeconfigUpload/KubeconfigFileUpload.js index 2b6a06d271..a844d462c5 100644 --- a/src/components/Clusters/components/KubeconfigUpload/KubeconfigFileUpload.js +++ b/src/components/Clusters/components/KubeconfigUpload/KubeconfigFileUpload.js @@ -24,14 +24,14 @@ export function KubeconfigFileUpload({ onKubeconfigTextAdded }) { return (
- + <Title level="H5" style={spacing.sapUiTinyMarginBottom}> {t('clusters.wizard.kubeconfig')} <> <Button id="descriptionOpener" icon="hint" design="Transparent" - style={spacing.sapUiTinyMargin} + style={spacing.sapUiTinyMarginBegin} onClick={() => { setShowTitleDescription(true); }} diff --git a/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.js b/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.js index 2a38e58c3f..6350ea2005 100644 --- a/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.js +++ b/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.js @@ -35,7 +35,10 @@ export function KubeconfigUpload({ kubeconfig, setKubeconfig, formRef }) { return ( <div className="kubeconfig-upload"> - <div className="add-cluster__content-container"> + <div + className="add-cluster__content-container" + style={spacing.sapUiSmallMarginBottom} + > <KubeconfigFileUpload onKubeconfigTextAdded={text => { updateKubeconfig(text); @@ -58,11 +61,7 @@ export function KubeconfigUpload({ kubeconfig, setKubeconfig, formRef }) { yamlHideDisabled /> {error && ( - <MessageStrip - design="Negative" - hideCloseButton - style={spacing.sapUiSmallMarginTop} - > + <MessageStrip design="Negative" hideCloseButton> {t('common.create-form.editor-error', { error })} </MessageStrip> )} diff --git a/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.scss b/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.scss index ff992e00cc..703b304d31 100644 --- a/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.scss +++ b/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.scss @@ -1,15 +1,23 @@ .kubeconfig-upload { + border-radius: 12px; display: flex; - flex-flow: column; - flex-grow: 1; - height: calc(90vh - 10rem); + flex-direction: column; + gap: 1rem; + min-height: calc(90vh - 11.85rem); + margin-bottom: 1rem; .editor-label { text-align: center; } .resource-form { - padding: 1rem 0 0 0; + padding: 0; + + .resource-form--panel, + .resource-form--panel::part(content) { + margin: 0 !important; + min-height: unset !important; + } &__wrapper { max-height: calc(90vh - 16rem - 290px); @@ -23,7 +31,6 @@ } &__form { - height: 100% !important; padding-bottom: 0 !important; } } diff --git a/src/components/Clusters/components/oidc-params.ts b/src/components/Clusters/components/oidc-params.ts index 6cf79aa571..087169beb8 100644 --- a/src/components/Clusters/components/oidc-params.ts +++ b/src/components/Clusters/components/oidc-params.ts @@ -4,7 +4,7 @@ const OIDC_PARAM_NAMES = new Map([ ['--oidc-issuer-url', 'issuerUrl'], ['--oidc-client-id', 'clientId'], ['--oidc-client-secret', 'clientSecret'], - ['--oidc-extra-scope', 'scope'], + ['--oidc-extra-scope', 'scopes'], ]); export function parseOIDCparams({ exec: commandData }: KubeconfigOIDCAuth) { @@ -40,8 +40,19 @@ export function parseOIDCparams({ exec: commandData }: KubeconfigOIDCAuth) { if (!OIDC_PARAM_NAMES.has(argKey)) return; const outputKey = OIDC_PARAM_NAMES.get(argKey)!; - if (output[outputKey]) output[outputKey] += ' ' + argValue; - else output[outputKey] = argValue; + if (output[outputKey]) { + if (outputKey === 'scopes') { + output[outputKey].push(argValue); + } else { + output[outputKey] += ' ' + argValue; + } + } else { + if (outputKey === 'scopes') { + output[outputKey] = [argValue]; + } else { + output[outputKey] = argValue; + } + } }); return output; @@ -60,7 +71,7 @@ export function createLoginCommand( issuerUrl: string; clientId: string; clientSecret?: string; - scope: string; + scopes: string[]; }, execRest: object, ): LoginCommand { @@ -74,9 +85,9 @@ export function createLoginCommand( `--oidc-issuer-url=${oidcConfig.issuerUrl || ''}`, `--oidc-client-id=${oidcConfig.clientId || ''}`, `--oidc-client-secret=${oidcConfig.clientSecret || ''}`, - oidcConfig.scope - ? `--oidc-extra-scope=${oidcConfig.scope || ''}` - : `--oidc-extra-scope=openid ${oidcConfig.scope || ''}`, + ...(oidcConfig.scopes?.length + ? oidcConfig.scopes.map(scope => `--oidc-extra-scope=${scope || ''}`) + : [`--oidc-extra-scope=openid`]), '--grant-type=auto', ], }; diff --git a/src/components/Clusters/components/test/oidc-params.test.js b/src/components/Clusters/components/test/oidc-params.test.js index 8ad7eb46e9..5d8d9f4c7d 100644 --- a/src/components/Clusters/components/test/oidc-params.test.js +++ b/src/components/Clusters/components/test/oidc-params.test.js @@ -21,7 +21,7 @@ describe('parseOIDCparams', () => { clientId: 'hasselhoff', clientSecret: 'hasselhoffsecret', issuerUrl: 'https://coastguard.gov.us', - scope: 'peach', + scopes: ['peach'], }); }); @@ -40,11 +40,11 @@ describe('parseOIDCparams', () => { clientId: 'hasselhoff', clientSecret: 'hasselhoff=secret', issuerUrl: 'https://coastguard.gov.us', - scope: 'peach', + scopes: ['peach'], }); }); - it('Concatinates params', () => { + it('Multiple scopes', () => { const input = { exec: { args: [ @@ -55,7 +55,7 @@ describe('parseOIDCparams', () => { }, }; expect(parseOIDCparams(input)).toMatchObject({ - scope: 'peach melon plum', + scopes: ['peach', 'melon', 'plum'], }); }); diff --git a/src/components/Clusters/shared.ts b/src/components/Clusters/shared.ts index bd6b5777e5..7286527314 100644 --- a/src/components/Clusters/shared.ts +++ b/src/components/Clusters/shared.ts @@ -128,7 +128,7 @@ export function hasKubeconfigAuth(kubeconfig: Kubeconfig) { } const oidcData = tryParseOIDCparams(user as KubeconfigOIDCAuth); - if (oidcData?.issuerUrl && oidcData?.clientId && oidcData?.scope) { + if (oidcData?.issuerUrl && oidcData?.clientId && oidcData?.scopes) { return oidcData; } } catch (e) { diff --git a/src/components/Clusters/views/EditCluster/AuthenticationDropdown.js b/src/components/Clusters/views/EditCluster/AuthenticationDropdown.js index 70f150d4fe..1f0fb9ae6c 100644 --- a/src/components/Clusters/views/EditCluster/AuthenticationDropdown.js +++ b/src/components/Clusters/views/EditCluster/AuthenticationDropdown.js @@ -14,7 +14,6 @@ export function AuthenticationTypeDropdown({ type, setType }) { options={options} selectedKey={type} onSelect={(_, selected) => setType(selected.key)} - fullWidth required /> ); diff --git a/src/components/Clusters/views/EditCluster/EditCluster.js b/src/components/Clusters/views/EditCluster/EditCluster.js index 5417ebc78a..cd362609e6 100644 --- a/src/components/Clusters/views/EditCluster/EditCluster.js +++ b/src/components/Clusters/views/EditCluster/EditCluster.js @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useSetRecoilState } from 'recoil'; import { ResourceForm } from 'shared/ResourceForm'; -import { K8sNameField } from 'shared/ResourceForm/fields'; +import { K8sNameField, TextArrayInput } from 'shared/ResourceForm/fields'; import { ChooseStorage } from 'components/Clusters/components/ChooseStorage'; import { ErrorBoundary } from 'shared/components/ErrorBoundary/ErrorBoundary'; import { useNotification } from 'shared/contexts/NotificationContext'; @@ -19,11 +19,29 @@ import { addCluster, getContext, deleteCluster } from '../../shared'; import { spacing } from '@ui5/webcomponents-react-base'; import { getUserIndex } from '../../shared'; +export const findInitialValues = (kubeconfig, id, userIndex = 0) => { + const elementsWithId = + kubeconfig?.users?.[userIndex]?.user?.exec?.args.filter(el => + el?.includes(id), + ) || []; + const regex = new RegExp(`${id}=(?<value>.*)`); + const values = []; + + for (const element of elementsWithId) { + const match = regex.exec(element); + if (match?.groups?.value) { + values.push(match.groups.value); + } + } + + return values; +}; + export const findInitialValue = (kubeconfig, id, userIndex = 0) => { if (kubeconfig?.users?.[userIndex]?.user?.exec?.args) { const elementWithId = kubeconfig?.users?.[ userIndex - ]?.user?.exec?.args.find(el => el.includes(id)); + ]?.user?.exec?.args.find(el => el?.includes(id)); const regex = new RegExp(`${id}=(?<value>.*)`); return regex.exec(elementWithId)?.groups?.value || ''; } @@ -56,7 +74,7 @@ export const ClusterDataForm = ({ 'oidc-client-secret', userIndex, ); - const scopes = findInitialValue(kubeconfig, 'oidc-extra-scope', userIndex); + const scopes = findInitialValues(kubeconfig, 'oidc-extra-scope', userIndex); useEffect(() => { setAuthenticationType( @@ -89,9 +107,9 @@ export const ClusterDataForm = ({ `--oidc-issuer-url=${config.issuerUrl}`, `--oidc-client-id=${config.clientId}`, `--oidc-client-secret=${config.clientSecret}`, - findInitialValue(kubeconfig, 'oidc-extra-scope', userIndex) - ? `--oidc-extra-scope=${config.scopes}` - : `--oidc-extra-scope=openid ${config.scopes}`, + ...(config.scopes?.length + ? config.scopes.map(scope => `--oidc-extra-scope=${scope || ''}`) + : [`--oidc-extra-scope=openid`]), '--grant-type=auto', ], }; @@ -127,10 +145,10 @@ export const ClusterDataForm = ({ createOIDC('clientSecret', val); }} /> - <ResourceForm.FormField - label={t('clusters.labels.scopes')} - input={Inputs.Text} + <TextArrayInput required + defaultOpen + title={t('clusters.labels.scopes')} value={scopes} setValue={val => { createOIDC('scopes', val); diff --git a/src/resources/RoleBindings/RoleForm.js b/src/resources/RoleBindings/RoleForm.js index 2325c27076..0aa5c0527a 100644 --- a/src/resources/RoleBindings/RoleForm.js +++ b/src/resources/RoleBindings/RoleForm.js @@ -61,31 +61,28 @@ export const RoleForm = ({ label={t('role-bindings.create-modal.role')} propertyPath="$.roleRef.name" input={props => ( - <div className="bsl-col-md--11"> - <ComboBox - id="role" - aria-label="Role Combobox" - disabled={props.disabled || !options?.length} - filter="Contains" - inputRef={props.inputRef} - placeholder={t('common.messages.type-to-select', { - value: t( - binding.roleRef?.kind === 'ClusterRole' - ? 'cluster-roles.name_singular' - : 'roles.name_singular', - ), - })} - value={ - options.find(o => o.key === props.value)?.text ?? props.value - } - onChange={event => onChange(event, props)} - onInput={event => onChange(event, props)} - > - {options.map(option => ( - <ComboBoxItem id={option.key} text={option.text} /> - ))} - </ComboBox> - </div> + <ComboBox + className="bsl-col-md--12" + id="role" + aria-label="Role Combobox" + disabled={props.disabled || !options?.length} + filter="Contains" + inputRef={props.inputRef} + placeholder={t('common.messages.type-to-select', { + value: t( + binding.roleRef?.kind === 'ClusterRole' + ? 'cluster-roles.name_singular' + : 'roles.name_singular', + ), + })} + value={options.find(o => o.key === props.value)?.text ?? props.value} + onChange={event => onChange(event, props)} + onInput={event => onChange(event, props)} + > + {options.map(option => ( + <ComboBoxItem id={option.key} text={option.text} /> + ))} + </ComboBox> )} /> ); diff --git a/src/resources/RoleBindings/SubjectForm.js b/src/resources/RoleBindings/SubjectForm.js index 87380e3158..463f252b9c 100644 --- a/src/resources/RoleBindings/SubjectForm.js +++ b/src/resources/RoleBindings/SubjectForm.js @@ -65,7 +65,7 @@ export function SingleSubjectForm({ tooltipContent={t('role-bindings.create-modal.tooltips.kind')} label={t('role-bindings.create-modal.kind')} input={() => ( - <Select onChange={onChange} className="bsl-col-md--11"> + <Select onChange={onChange} className="bsl-col-md--12"> {SUBJECT_KINDS.map(kind => ( <Option value={kind} selected={(subject.kind || '') === kind}> {kind} diff --git a/src/shared/ResourceForm/components/FormField.js b/src/shared/ResourceForm/components/FormField.js index b8ae44fbe4..97c49f4777 100644 --- a/src/shared/ResourceForm/components/FormField.js +++ b/src/shared/ResourceForm/components/FormField.js @@ -49,25 +49,23 @@ export function FormField({ )} </div> </FlexBox> - <FlexBox wrap="Wrap" alignItems="Center"> - <div className="bsl-col-md--12"> - <FlexBox wrap="Wrap" alignItems="Center"> - {messageStrip - ? messageStrip - : input({ - updatesOnInput, - required, - disabled, - className: 'full-width', - ...inputProps, - })} - {inputInfo && ( - <Label wrappingType="Normal" style={{ marginTop: '5px' }}> - {inputInfoLink} - </Label> - )} - </FlexBox> - </div> + <FlexBox wrap="Wrap" alignItems="Center" className="full-width"> + <FlexBox wrap="Wrap" alignItems="Center" className="bsl-col-md--12"> + {messageStrip + ? messageStrip + : input({ + updatesOnInput, + required, + disabled, + className: 'full-width', + ...inputProps, + })} + {inputInfo && ( + <Label wrappingType="Normal" style={{ marginTop: '5px' }}> + {inputInfoLink} + </Label> + )} + </FlexBox> </FlexBox> </FlexBox> ); diff --git a/src/shared/components/Dropdown/Dropdown.js b/src/shared/components/Dropdown/Dropdown.js index 7e3eca275c..e6de3522f4 100644 --- a/src/shared/components/Dropdown/Dropdown.js +++ b/src/shared/components/Dropdown/Dropdown.js @@ -60,7 +60,11 @@ export function Dropdown({ ); return ( - <FlexBox className="flexbox-gap" justifyContent="Center" direction="Column"> + <FlexBox + className="flexbox-gap full-width" + justifyContent="Center" + direction="Column" + > {label && <Label forElement={id}>{label}</Label>} {inlineHelp ? ( <Tooltip content={inlineHelp}>{combobox}</Tooltip> diff --git a/src/state/authDataAtom.ts b/src/state/authDataAtom.ts index 395f71f06f..81535a6a0f 100644 --- a/src/state/authDataAtom.ts +++ b/src/state/authDataAtom.ts @@ -40,9 +40,11 @@ export function createUserManager( userCredentials: KubeconfigOIDCAuth, redirectPath = '', ) { - const { issuerUrl, clientId, clientSecret, scope } = parseOIDCparams( + const { issuerUrl, clientId, clientSecret, scopes } = parseOIDCparams( userCredentials, ); + const uniqueScopes = new Set(scopes || []); + uniqueScopes.delete('openid'); return new UserManager({ redirect_uri: window.location.origin + redirectPath, @@ -51,7 +53,7 @@ export function createUserManager( client_id: clientId, authority: issuerUrl, client_secret: clientSecret, - scope: scope || 'openid', + scope: `openid ${[...uniqueScopes].join(' ')}`, response_type: 'code', response_mode: 'query', }); From 8b1a8f145da2a9dc2b44bb6353c68e26993f53e0 Mon Sep 17 00:00:00 2001 From: Oliwia Gowor <72342415+OliwiaGowor@users.noreply.github.com> Date: Tue, 27 Aug 2024 11:38:40 +0200 Subject: [PATCH 2/6] feat: Enhance Service Accounts (#3301) * add Events * add config and adjust token * fix naming --- .../ServiceAccounts/ServiceAccountDetails.js | 42 +++++++--- .../components/ServiceAccountTokenStatus.js | 32 -------- .../components/ServiceAccountTokenStatus.tsx | 77 +++++++++++++++++++ 3 files changed, 108 insertions(+), 43 deletions(-) delete mode 100644 src/shared/components/ServiceAccountTokenStatus.js create mode 100644 src/shared/components/ServiceAccountTokenStatus.tsx diff --git a/src/resources/ServiceAccounts/ServiceAccountDetails.js b/src/resources/ServiceAccounts/ServiceAccountDetails.js index b03ba75fff..ca8d7c41ab 100644 --- a/src/resources/ServiceAccounts/ServiceAccountDetails.js +++ b/src/resources/ServiceAccounts/ServiceAccountDetails.js @@ -7,6 +7,10 @@ import ServiceAccountCreate from './ServiceAccountCreate'; import { Button } from '@ui5/webcomponents-react'; import { TokenRequestModal } from './TokenRequestModal/TokenRequestModal'; import { ResourceDescription } from 'resources/ServiceAccounts'; +import { EventsList } from 'shared/components/EventsList'; +import { filterByResource } from 'hooks/useMessageList'; +import { UI5Panel } from 'shared/components/UI5Panel/UI5Panel'; +import { LayoutPanelRow } from 'shared/components/LayoutPanelRow/LayoutPanelRow'; const ServiceAccountSecrets = serviceAccount => { const namespace = serviceAccount.metadata.namespace; @@ -59,17 +63,32 @@ const ServiceAccountImagePullSecrets = serviceAccount => { export default function ServiceAccountDetails(props) { const { t } = useTranslation(); const [isTokenModalOpen, setTokenModalOpen] = useState(false); - const customColumns = [ - { - header: t('service-accounts.headers.auto-mount-token'), - value: value => ( - <ServiceAccountTokenStatus - automount={value.automountServiceAccountToken} - /> - ), - }, - ]; + const Events = () => ( + <EventsList + key="events" + namespace={props.namespace} + filter={filterByResource('ServiceAccount', props.resourceName)} + hideInvolvedObjects={true} + /> + ); + + const Configuration = value => ( + <UI5Panel + fixed + keyComponent={'serviceaccount-configuration'} + title={t('configuration.title')} + > + <LayoutPanelRow + name={t('service-accounts.headers.auto-mount-token')} + value={ + <ServiceAccountTokenStatus + automount={value.automountServiceAccountToken} + /> + } + /> + </UI5Panel> + ); const headerActions = [ <Button key="generate-token-request" @@ -83,10 +102,11 @@ export default function ServiceAccountDetails(props) { <> <ResourceDetails customComponents={[ + Configuration, ServiceAccountSecrets, ServiceAccountImagePullSecrets, + Events, ]} - customColumns={customColumns} createResourceForm={ServiceAccountCreate} description={ResourceDescription} headerActions={headerActions} diff --git a/src/shared/components/ServiceAccountTokenStatus.js b/src/shared/components/ServiceAccountTokenStatus.js deleted file mode 100644 index 7bf68bdc97..0000000000 --- a/src/shared/components/ServiceAccountTokenStatus.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { StatusBadge } from 'shared/components/StatusBadge/StatusBadge'; -import { useTranslation } from 'react-i18next'; - -export const ServiceAccountTokenStatus = ({ automount }) => { - const { t } = useTranslation(); - - const accountTokenValues = automount - ? { - type: 'Warning', - tooltipContent: t( - 'service-accounts.auto-mount-token.descriptions.enabled', - ), - status: t('service-accounts.auto-mount-token.enabled'), - } - : { - type: 'Information', - tooltipContent: t( - 'service-accounts.auto-mount-token.descriptions.disabled', - ), - status: t('service-accounts.auto-mount-token.disabled'), - }; - - return ( - <StatusBadge - type={accountTokenValues.type} - tooltipContent={accountTokenValues.tooltipContent} - > - {accountTokenValues.status} - </StatusBadge> - ); -}; diff --git a/src/shared/components/ServiceAccountTokenStatus.tsx b/src/shared/components/ServiceAccountTokenStatus.tsx new file mode 100644 index 0000000000..558156c714 --- /dev/null +++ b/src/shared/components/ServiceAccountTokenStatus.tsx @@ -0,0 +1,77 @@ +import { StatusBadge } from 'shared/components/StatusBadge/StatusBadge'; +import { useTranslation } from 'react-i18next'; +import { Popover, Token } from '@ui5/webcomponents-react'; +import { createPortal } from 'react-dom'; +import { useRef, useState } from 'react'; + +export const ServiceAccountTokenStatus = ({ + automount, +}: { + automount: boolean; +}) => { + const { t } = useTranslation(); + const [openPopover, setOpenPopover] = useState(false); + const popoverRef = useRef<any>(null); + const openerRef = useRef(null); + + const handleOpenerClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (popoverRef.current) { + popoverRef.current.opener = openerRef.current; + setOpenPopover(prev => !prev); + } + }; + + const accountTokenValues = automount + ? { + type: 'Warning', + tooltipContent: t( + 'service-accounts.auto-mount-token.descriptions.enabled', + ), + status: t('service-accounts.auto-mount-token.enabled'), + } + : { + type: 'Neutral', + tooltipContent: t( + 'service-accounts.auto-mount-token.descriptions.disabled', + ), + status: t('service-accounts.auto-mount-token.disabled'), + }; + + const tokenElement = ( + <button ref={openerRef} onClick={handleOpenerClick} className="badge-wrap"> + <Token + style={{ textTransform: 'capitalize' }} + text={accountTokenValues.status} + readonly + ></Token> + </button> + ); + + return automount ? ( + <StatusBadge + type={accountTokenValues.type} + tooltipContent={accountTokenValues.tooltipContent} + > + {accountTokenValues.status} + </StatusBadge> + ) : ( + <> + {createPortal( + <Popover + ref={popoverRef} + open={openPopover} + onAfterClose={e => { + e.stopPropagation(); + setOpenPopover(false); + }} + placementType="Right" + > + {accountTokenValues.tooltipContent} + </Popover>, + document.body, + )} + {tokenElement} + </> + ); +}; From d5197683b876cfb430ac1efa5f9d5d06bf7a9f9d Mon Sep 17 00:00:00 2001 From: Mateusz Wisniewski <mwisnia97@gmail.com> Date: Tue, 27 Aug 2024 13:44:42 +0200 Subject: [PATCH 3/6] feat: add latest tag to builds (#3305) --- .github/workflows/busola-backend-build.yml | 2 +- .github/workflows/busola-local-build.yml | 1 + .github/workflows/busola-web-build.yml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/busola-backend-build.yml b/.github/workflows/busola-backend-build.yml index e422c264a8..35186b5bae 100644 --- a/.github/workflows/busola-backend-build.yml +++ b/.github/workflows/busola-backend-build.yml @@ -27,4 +27,4 @@ jobs: dockerfile: Dockerfile context: backend export-tags: true - + tags: latest diff --git a/.github/workflows/busola-local-build.yml b/.github/workflows/busola-local-build.yml index 38614b062e..aba96241fb 100644 --- a/.github/workflows/busola-local-build.yml +++ b/.github/workflows/busola-local-build.yml @@ -37,3 +37,4 @@ jobs: dockerfile: Dockerfile.local context: . export-tags: true + tags: latest diff --git a/.github/workflows/busola-web-build.yml b/.github/workflows/busola-web-build.yml index 6b0af58b84..e7ffb6b249 100644 --- a/.github/workflows/busola-web-build.yml +++ b/.github/workflows/busola-web-build.yml @@ -33,4 +33,4 @@ jobs: dockerfile: Dockerfile context: . export-tags: true - + tags: latest From 4bb55b78fe25faf265d4e0b78ddce9efb16a6842 Mon Sep 17 00:00:00 2001 From: Christian Volk <christian.volk@sap.com> Date: Tue, 27 Aug 2024 14:58:40 +0200 Subject: [PATCH 4/6] feat: support `--oidc-use-access-token` flag (#3292) * feat: support --oidc-use-access-token flag * resolve merge conflicts --- .../Clusters/components/oidc-params.ts | 5 +- .../components/test/oidc-params.test.js | 2 + src/components/Clusters/shared.ts | 3 +- src/components/Gardener/GardenerLogin.tsx | 6 ++- src/state/authDataAtom.ts | 46 ++++++++++++------- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/components/Clusters/components/oidc-params.ts b/src/components/Clusters/components/oidc-params.ts index 087169beb8..fd013dfdf3 100644 --- a/src/components/Clusters/components/oidc-params.ts +++ b/src/components/Clusters/components/oidc-params.ts @@ -5,6 +5,7 @@ const OIDC_PARAM_NAMES = new Map([ ['--oidc-client-id', 'clientId'], ['--oidc-client-secret', 'clientSecret'], ['--oidc-extra-scope', 'scopes'], + ['--oidc-use-access-token', 'useAccessToken'], ]); export function parseOIDCparams({ exec: commandData }: KubeconfigOIDCAuth) { @@ -27,7 +28,7 @@ export function parseOIDCparams({ exec: commandData }: KubeconfigOIDCAuth) { * For an interactive example that demonstrates this regex pattern with various inputs and explains each part of the * expression in detail, visit the provided link: https://regex101.com/r/3hc8qX/2 */ - const regex = new RegExp('^(?<key>[^=]+)(?:=(?<value>.*$))'); + const regex = new RegExp('^(?<key>[^=]+)(?:=(?<value>.*$))?'); const match = arg.match(regex); if (!match) { @@ -35,7 +36,7 @@ export function parseOIDCparams({ exec: commandData }: KubeconfigOIDCAuth) { } const argKey: string = match.groups?.key || ''; - const argValue: string = match.groups?.value || ''; + const argValue: string | boolean = match.groups?.value ?? true; if (!OIDC_PARAM_NAMES.has(argKey)) return; diff --git a/src/components/Clusters/components/test/oidc-params.test.js b/src/components/Clusters/components/test/oidc-params.test.js index 5d8d9f4c7d..b74d551fde 100644 --- a/src/components/Clusters/components/test/oidc-params.test.js +++ b/src/components/Clusters/components/test/oidc-params.test.js @@ -14,6 +14,7 @@ describe('parseOIDCparams', () => { '--oidc-client-id=hasselhoff', '--oidc-client-secret=hasselhoffsecret', '--oidc-extra-scope=peach', + '--oidc-use-access-token', ], }, }; @@ -22,6 +23,7 @@ describe('parseOIDCparams', () => { clientSecret: 'hasselhoffsecret', issuerUrl: 'https://coastguard.gov.us', scopes: ['peach'], + useAccessToken: true, }); }); diff --git a/src/components/Clusters/shared.ts b/src/components/Clusters/shared.ts index 7286527314..49a772fda6 100644 --- a/src/components/Clusters/shared.ts +++ b/src/components/Clusters/shared.ts @@ -14,6 +14,7 @@ import { createUserManager } from 'state/authDataAtom'; import { useNavigate } from 'react-router-dom'; import { useSetRecoilState } from 'recoil'; import { removePreviousPath } from 'state/useAfterInitHook'; +import { parseOIDCparams } from 'components/Clusters/components/oidc-params'; function addCurrentCluster( params: NonNullable<ActiveClusterState>, @@ -56,7 +57,7 @@ export function deleteCluster( const prevCredentials = prev?.[clusterName]?.currentContext.user.user; if (!hasNonOidcAuth(prevCredentials)) { const userManager = createUserManager( - prevCredentials as KubeconfigOIDCAuth, + parseOIDCparams(prevCredentials as KubeconfigOIDCAuth), ); userManager.removeUser().catch(console.warn); } diff --git a/src/components/Gardener/GardenerLogin.tsx b/src/components/Gardener/GardenerLogin.tsx index f517dd8df1..8ee46855ec 100644 --- a/src/components/Gardener/GardenerLogin.tsx +++ b/src/components/Gardener/GardenerLogin.tsx @@ -7,6 +7,7 @@ import { createUserManager } from 'state/authDataAtom'; import { KubeconfigOIDCAuth } from 'types'; import { GardenerLoginFeature } from './GardenerLoginFeature'; import { useGardenerLogin } from './useGardenerLoginFunction'; +import { parseOIDCparams } from 'components/Clusters/components/oidc-params'; import { spacing } from '@ui5/webcomponents-react-base'; @@ -29,7 +30,10 @@ export default function GardenerLogin() { setToken(user.token); } else { const auth = user as KubeconfigOIDCAuth; - const userManager = createUserManager(auth, '/gardener-login'); + const userManager = createUserManager( + parseOIDCparams(auth), + '/gardener-login', + ); try { const storedUser = await userManager.getUser(); const user = diff --git a/src/state/authDataAtom.ts b/src/state/authDataAtom.ts index 81535a6a0f..c785692151 100644 --- a/src/state/authDataAtom.ts +++ b/src/state/authDataAtom.ts @@ -37,22 +37,24 @@ type handleLoginProps = { }; export function createUserManager( - userCredentials: KubeconfigOIDCAuth, + oidcParams: { + issuerUrl: string; + clientId: string; + clientSecret: string; + scopes: string[]; + }, redirectPath = '', ) { - const { issuerUrl, clientId, clientSecret, scopes } = parseOIDCparams( - userCredentials, - ); - const uniqueScopes = new Set(scopes || []); + const uniqueScopes = new Set(oidcParams.scopes || []); uniqueScopes.delete('openid'); return new UserManager({ redirect_uri: window.location.origin + redirectPath, post_logout_redirect_uri: window.location.origin + '/logout.html', loadUserInfo: true, - client_id: clientId, - authority: issuerUrl, - client_secret: clientSecret, + client_id: oidcParams.clientId, + authority: oidcParams.issuerUrl, + client_secret: oidcParams.clientSecret, scope: `openid ${[...uniqueScopes].join(' ')}`, response_type: 'code', response_mode: 'query', @@ -65,11 +67,15 @@ async function handleLogin({ onAfterLogin, onError, }: handleLoginProps): Promise<void> { - const setupAuthEventsHooks = (userManager: UserManager) => { + const setupAuthEventsHooks = ( + userManager: UserManager, + useAccessToken: boolean, + ) => { userManager.events.addAccessTokenExpiring(async () => { const user = await userManager.signinSilent(); - const token = user?.id_token!; - setAuth({ token }); + setAuth({ + token: useAccessToken ? user?.access_token! : user?.id_token!, + }); }); userManager.events.addSilentRenewError(e => { console.warn('silent renew failed', e); @@ -81,28 +87,34 @@ async function handleLogin({ const setupVisibilityEventsHooks = ( userManager: UserManager, user: User | null, + useAccessToken: boolean, ) => { document.addEventListener('visibilitychange', async event => { if (document.visibilityState === 'visible') { if (!!user?.expired || (user?.expires_in && user?.expires_in <= 2)) { user = await userManager.signinSilent(); - const token = user?.id_token!; - setAuth({ token }); + setAuth({ + token: useAccessToken ? user?.access_token! : user?.id_token!, + }); } } }); }; - const userManager = createUserManager(userCredentials); + const oidcParams = parseOIDCparams(userCredentials); + const userManager = createUserManager(oidcParams); + + const useAccessToken: boolean = oidcParams?.useAccessToken ?? false; + try { const storedUser = await userManager.getUser(); const user = storedUser && !storedUser.expired ? storedUser : await userManager.signinRedirectCallback(window.location.href); - setAuth({ token: user?.id_token! }); - setupAuthEventsHooks(userManager); - setupVisibilityEventsHooks(userManager, user); + setAuth({ token: useAccessToken ? user?.access_token : user?.id_token! }); + setupAuthEventsHooks(userManager, useAccessToken); + setupVisibilityEventsHooks(userManager, user, useAccessToken); onAfterLogin(); } catch (e) { // ignore 'No state in response' error - it means we didn't fire login request yet From 7026a9f58bc8997b7241701bca391e6ea987b5d9 Mon Sep 17 00:00:00 2001 From: kevin-kho <kevin.ho01@sap.com> Date: Tue, 27 Aug 2024 23:22:41 -0700 Subject: [PATCH 5/6] feat: added Number widget and documentation (#3303) * first pass of Number widget impl * update docs, schema, and editMode check * added extra newline at end of schema.yaml * removed console.log --- docs/extensibility/60-form-widgets.md | 29 ++++++++++++++++++ .../assets/form-widgets/Number.png | Bin 0 -> 5944 bytes .../assets/form-widgets/Number2.png | Bin 0 -> 5579 bytes public/schemas/schema-form.yaml | 15 +++++++++ .../components-form/NumberRenderer.js | 4 +++ .../Extensibility/components-form/index.js | 1 + 6 files changed, 49 insertions(+) create mode 100644 docs/extensibility/assets/form-widgets/Number.png create mode 100644 docs/extensibility/assets/form-widgets/Number2.png diff --git a/docs/extensibility/60-form-widgets.md b/docs/extensibility/60-form-widgets.md index 7f5184a0e0..1b5ebdeb8d 100644 --- a/docs/extensibility/60-form-widgets.md +++ b/docs/extensibility/60-form-widgets.md @@ -4,6 +4,7 @@ You can use form widgets in the create and/or edit pages in the user interface c - [Simple widgets](#simple-widgets) that represent a single scalar value - [`Text`](#text) + - [`Number`](#number) - [`Switch`](#switch) - [`Name`](#name) - [`CodeEditor`](#codeeditor) @@ -73,6 +74,34 @@ See the following examples: <img src="./assets/form-widgets/Dropdown.png" alt="Example of a dropdown text widget with a tooltip" style="border: 1px solid #D2D5D9"> <img src="./assets/form-widgets/Dropdown2.png" alt="Example of a dropdown text widget" style="border: 1px solid #D2D5D9"> +### `Number` + +The `Number` widgets render a field as a number field. They are used by default for all number and integer values. + +| Parameter | Required | Type | Description | +| ----------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **description** | No | string | A string displayed in a tooltip when you hover over a question mark icon, next to the input's label. The default value is taken from the CustomResourceDefintion (CRD). | +| **disableOnEdit** | No | boolean | Disables a number field in edit mode, defaults to `false`. | + +See the following examples: + +```yaml +- path: spec.capacity + name: spec.capacity + widget: Number +``` + +<img src="./assets/form-widgets/Number.png" alt="Example of a number widget" style="border: 1px solid #D2D5D9"> + +```yaml +- path: spec.capacity + name: spec.capacity + widget: Number + disableOnEdit: true +``` + +<img src="./assets/form-widgets/Number2.png" alt="Example of a number widget" style="border: 1px solid #D2D5D9"> + ### `Switch` The `Switch` widgets render a switch button that is used to control boolean values. diff --git a/docs/extensibility/assets/form-widgets/Number.png b/docs/extensibility/assets/form-widgets/Number.png new file mode 100644 index 0000000000000000000000000000000000000000..4b6d0b47c2e1ce30ffdea8060870058367eb3d39 GIT binary patch literal 5944 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 SIgIu{mYNtq^ec4TZu}RrbRwGo literal 0 HcmV?d00001 diff --git a/docs/extensibility/assets/form-widgets/Number2.png b/docs/extensibility/assets/form-widgets/Number2.png new file mode 100644 index 0000000000000000000000000000000000000000..4003b66f5420a2e2579c3fe6e452c98f8c9747b7 GIT binary patch literal 5579 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 literal 0 HcmV?d00001 diff --git a/public/schemas/schema-form.yaml b/public/schemas/schema-form.yaml index 08310f235e..32df824e17 100644 --- a/public/schemas/schema-form.yaml +++ b/public/schemas/schema-form.yaml @@ -326,3 +326,18 @@ $widgets: type: boolean description: a boolean which specifies if field is disabled on edit. default: false + Number: + if: + properties: + widget: + const: Number + then: + properties: + description: + type: string + description: A string displayed in a tooltip when you hover over a question mark icon, next to the input's label. The default value is taken from the CustomResourceDefintion (CRD). + disableOnEdit: + type: boolean + description: a boolean which specifies if field is disabled on edit. + default: false + diff --git a/src/components/Extensibility/components-form/NumberRenderer.js b/src/components/Extensibility/components-form/NumberRenderer.js index db4f4a85c9..7e8528c817 100644 --- a/src/components/Extensibility/components-form/NumberRenderer.js +++ b/src/components/Extensibility/components-form/NumberRenderer.js @@ -16,6 +16,7 @@ export function NumberRenderer({ required, compact, placeholder, + editMode, ...props }) { const { tFromStoreKeys, t: tExt } = useGetTranslation(); @@ -26,6 +27,8 @@ export function NumberRenderer({ ['min', 'max'].map(prop => [prop, schema.get(prop)]), ); + const disableOnEdit = schema.get('disableOnEdit'); + return ( <ResourceForm.FormField value={value} @@ -44,6 +47,7 @@ export function NumberRenderer({ data-testid={storeKeys.join('.') || tFromStoreKeys(storeKeys, schema)} input={Inputs.Number} compact={compact} + disabled={disableOnEdit && editMode} {...numberProps} {...getPropsFromSchema(schema, required, tExt)} /> diff --git a/src/components/Extensibility/components-form/index.js b/src/components/Extensibility/components-form/index.js index 5b25fb0612..4552d7e79b 100644 --- a/src/components/Extensibility/components-form/index.js +++ b/src/components/Extensibility/components-form/index.js @@ -77,6 +77,7 @@ export const widgets = { */ Text: StringRenderer, Switch: SwitchRenderer, + Number: NumberRenderer, Jsonata, /* Text: TextRenderer, From d1d3398d4bf5f3404af3e6e7a2f217491cd6ff4a Mon Sep 17 00:00:00 2001 From: Oliwia Gowor <72342415+OliwiaGowor@users.noreply.github.com> Date: Wed, 28 Aug 2024 08:42:40 +0200 Subject: [PATCH 6/6] fix setting layout (#3309) --- .../ResourceGraph/DetailsCard/DetailsCard.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/shared/components/ResourceGraph/DetailsCard/DetailsCard.tsx b/src/shared/components/ResourceGraph/DetailsCard/DetailsCard.tsx index f1f66b339d..cf074a5cbb 100644 --- a/src/shared/components/ResourceGraph/DetailsCard/DetailsCard.tsx +++ b/src/shared/components/ResourceGraph/DetailsCard/DetailsCard.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useUrl } from 'hooks/useUrl'; -import { useRecoilValue } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { Button } from '@ui5/webcomponents-react'; import { Labels } from 'shared/components/Labels/Labels'; @@ -12,6 +12,7 @@ import pluralize from 'pluralize'; import { spacing } from '@ui5/webcomponents-react-base'; import './DetailsCard.scss'; +import { columnLayoutState } from 'state/columnLayoutAtom'; export function DetailsCard({ resource, @@ -24,6 +25,7 @@ export function DetailsCard({ const navigate = useNavigate(); const { clusterUrl } = useUrl(); const nodes = useRecoilValue(allNodesSelector); + const [, setLayoutColumn] = useRecoilState(columnLayoutState); return ( <div className="details-card-wrapper"> @@ -64,6 +66,12 @@ export function DetailsCard({ `${namespacePart}${node.resourceType}/${resource.metadata.name}`, ), ); + + setLayoutColumn({ + layout: 'OneColumn', + midColumn: null, + endColumn: null, + }); }} > {t('resource-graph.buttons.go-to-details')}