From c32440e0ebedad826cbf205753b417beb9c49d1d Mon Sep 17 00:00:00 2001 From: Christoph Jerolimov Date: Thu, 20 Oct 2022 09:47:21 +0200 Subject: [PATCH 1/4] Add cluster configuration extension and configuration page --- .../console-app/locales/en/console-app.json | 6 + .../console-app/locales/ja/console-app.json | 1 + .../console-app/locales/ko/console-app.json | 1 + .../console-app/locales/zh/console-app.json | 1 + .../ClusterConfigurationCheckboxField.tsx | 34 +++++ .../ClusterConfigurationCustomField.tsx | 29 ++++ .../ClusterConfigurationDropdownField.tsx | 67 ++++++++ .../ClusterConfigurationField.tsx | 21 +++ .../ClusterConfigurationPage.scss | 48 ++++++ .../ClusterConfigurationPage.tsx | 114 ++++++++++++++ .../ClusterConfigurationTextField.tsx | 51 +++++++ .../components/cluster-configuration/hooks.ts | 1 + .../components/cluster-configuration/index.ts | 1 + .../components/cluster-configuration/types.ts | 20 +++ .../useClusterConfigurationGroups.ts | 28 ++++ .../useClusterConfigurationItems.ts | 68 +++++++++ .../user-preferences/UserPreferencePage.tsx | 2 +- .../__tests__/userPreferences.data.tsx | 3 +- .../src/components/user-preferences/const.ts | 3 +- .../console-dynamic-plugin-sdk/docs/api.md | 4 +- .../docs/console-extensions.md | 14 +- .../src/extensions/cluster-configuration.ts | 143 ++++++++++++++++++ .../src/extensions/index.ts | 1 + .../src/extensions/user-preferences.ts | 22 +-- .../utils/k8s/hooks/useK8sWatchResource.ts | 2 +- .../utils/k8s/hooks/useK8sWatchResources.ts | 2 +- .../locales/en/console-shared.json | 4 + .../cluster-configuration/FormLayout.tsx | 26 ++++ .../cluster-configuration/LoadError.tsx | 17 +++ .../cluster-configuration/SaveStatus.tsx | 29 ++++ .../__tests__/path-utils.spec.ts | 58 +++++++ .../components/cluster-configuration/index.ts | 8 + .../patchConsoleOperatorConfig.ts | 22 +++ .../cluster-configuration/path-utils.ts | 32 ++++ .../useConsoleOperatorConfig.ts | 15 ++ .../public/components/factory/details.tsx | 3 +- frontend/public/locales/en/public.json | 1 - frontend/public/locales/ja/public.json | 1 - frontend/public/locales/ko/public.json | 1 - frontend/public/locales/zh/public.json | 1 - 40 files changed, 875 insertions(+), 30 deletions(-) create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCheckboxField.tsx create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCustomField.tsx create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationDropdownField.tsx create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationField.tsx create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationPage.scss create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationPage.tsx create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationTextField.tsx create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/hooks.ts create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/index.ts create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/types.ts create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/useClusterConfigurationGroups.ts create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/useClusterConfigurationItems.ts create mode 100644 frontend/packages/console-dynamic-plugin-sdk/src/extensions/cluster-configuration.ts create mode 100644 frontend/packages/console-shared/src/components/cluster-configuration/FormLayout.tsx create mode 100644 frontend/packages/console-shared/src/components/cluster-configuration/LoadError.tsx create mode 100644 frontend/packages/console-shared/src/components/cluster-configuration/SaveStatus.tsx create mode 100644 frontend/packages/console-shared/src/components/cluster-configuration/__tests__/path-utils.spec.ts create mode 100644 frontend/packages/console-shared/src/components/cluster-configuration/index.ts create mode 100644 frontend/packages/console-shared/src/components/cluster-configuration/patchConsoleOperatorConfig.ts create mode 100644 frontend/packages/console-shared/src/components/cluster-configuration/path-utils.ts create mode 100644 frontend/packages/console-shared/src/components/cluster-configuration/useConsoleOperatorConfig.ts diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index 08d410b7ced..cc7b6bf5fdd 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -226,6 +226,11 @@ "This Project will be used to initialize your command line terminal": "This Project will be used to initialize your command line terminal", "Project name": "Project name", "Connecting to your OpenShift command line terminal ...": "Connecting to your OpenShift command line terminal ...", + "Cluster configuration": "Cluster configuration", + "Set cluster-wide configuration for the console experience. Your changes will be autosaved and will affect after a refresh.": "Set cluster-wide configuration for the console experience. Your changes will be autosaved and will affect after a refresh.", + "Insufficient permissions": "Insufficient permissions", + "You do not have sufficient permissions to read any cluster configuration.": "You do not have sufficient permissions to read any cluster configuration.", + "{{section}} not found": "{{section}} not found", "Enabled": "Enabled", "Disabled": "Disabled", "Name": "Name", @@ -519,6 +524,7 @@ "Select a perspective": "Select a perspective", "Select an option": "Select an option", "User Preferences": "User Preferences", + "Set your individual preferences for the console experience. Any changes will be autosaved.": "Set your individual preferences for the console experience. Any changes will be autosaved.", "Only {{volumeMode}} volume mode is available for {{storageClass}} with {{accessMode}} access mode": "Only {{volumeMode}} volume mode is available for {{storageClass}} with {{accessMode}} access mode", "VolumeSnapshotClass with same provisioner as claim": "VolumeSnapshotClass with same provisioner as claim", "Select volume snapshot class": "Select volume snapshot class", diff --git a/frontend/packages/console-app/locales/ja/console-app.json b/frontend/packages/console-app/locales/ja/console-app.json index eecd0fcd54f..33f7797a831 100644 --- a/frontend/packages/console-app/locales/ja/console-app.json +++ b/frontend/packages/console-app/locales/ja/console-app.json @@ -505,6 +505,7 @@ "Select a perspective": "パースペクティブの選択", "Select an option": "オプションの選択", "User Preferences": "ユーザー設定", + "Set your individual preferences for the console experience. Any changes will be autosaved.": "コンソールエクスペリエンスに個別の設定を行います。変更は自動保存されます。", "Only {{volumeMode}} volume mode is available for {{storageClass}} with {{accessMode}} access mode": "{{accessMode}} アクセスモードの {{storageClass}} には {{volumeMode}} ボリュームモードのみを使用できます", "VolumeSnapshotClass with same provisioner as claim": "要求と同じプロビジョナーの VolumeSnapshotClass", "Select volume snapshot class": "ボリュームスナップショットクラスの選択", diff --git a/frontend/packages/console-app/locales/ko/console-app.json b/frontend/packages/console-app/locales/ko/console-app.json index 21f2ee889f1..c9b91f4928d 100644 --- a/frontend/packages/console-app/locales/ko/console-app.json +++ b/frontend/packages/console-app/locales/ko/console-app.json @@ -505,6 +505,7 @@ "Select a perspective": "화면 선택", "Select an option": "옵션 선택", "User Preferences": "사용자 기본 설정", + "Set your individual preferences for the console experience. Any changes will be autosaved.": "콘솔 환경에 대한 개별 기본 설정을 지정합니다. 모든 변경 사항은 자동 저장됩니다.", "Only {{volumeMode}} volume mode is available for {{storageClass}} with {{accessMode}} access mode": "{{accessMode}} 액세스 모드가 있는 {{storageClass}}에서는 {{volumeMode}} 볼륨 모드만 사용할 수 있습니다.", "VolumeSnapshotClass with same provisioner as claim": "클레임과 동일한 프로비저너를 사용하는 볼륨 스냅샷 클래스", "Select volume snapshot class": "볼륨 스냅 샷 클래스 선택", diff --git a/frontend/packages/console-app/locales/zh/console-app.json b/frontend/packages/console-app/locales/zh/console-app.json index ae1fe967520..0df9633946b 100644 --- a/frontend/packages/console-app/locales/zh/console-app.json +++ b/frontend/packages/console-app/locales/zh/console-app.json @@ -505,6 +505,7 @@ "Select a perspective": "选择一个视角", "Select an option": "选择一个选项", "User Preferences": "用户首选项", + "Set your individual preferences for the console experience. Any changes will be autosaved.": "为增强控制台体验设置您的个人首选项。任何更改都将自动保存。", "Only {{volumeMode}} volume mode is available for {{storageClass}} with {{accessMode}} access mode": "使用 {{accessMode}} 访问模式的 {{storageClass}} 只有 {{volumeMode}} 卷模式可用", "VolumeSnapshotClass with same provisioner as claim": "带有与声明相同的置备程序的卷快照类", "Select volume snapshot class": "选择卷快照类", diff --git a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCheckboxField.tsx b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCheckboxField.tsx new file mode 100644 index 00000000000..8c82fa326b0 --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCheckboxField.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { FormGroup, Checkbox } from '@patternfly/react-core'; +import { ClusterConfigurationCheckboxField } from '@console/dynamic-plugin-sdk/src'; +import { FormLayout } from '@console/shared/src/components/cluster-configuration'; +import { ResolvedClusterConfigurationItem } from './types'; + +type ClusterConfigurationCheckboxFieldProps = { + item: ResolvedClusterConfigurationItem; + field: ClusterConfigurationCheckboxField; +}; + +const ClusterConfigurationCheckboxField: React.FC = ({ + item, + // field, +}) => { + const handleOnChange = (checked: boolean) => { + // eslint-disable-next-line no-console + console.log('xxx onChange', checked); + }; + return ( + + + + + + ); +}; + +export default ClusterConfigurationCheckboxField; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCustomField.tsx b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCustomField.tsx new file mode 100644 index 00000000000..9189710835f --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCustomField.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +// import { UserPreferenceCustomField as CustomFieldType } from '@console/dynamic-plugin-sdk/src'; +import { ClusterConfigurationCustomField } from '@console/dynamic-plugin-sdk/src'; +import { ResolvedCodeRefProperties } from '@console/dynamic-plugin-sdk/src/types'; +import { FormLayout } from '@console/shared/src/components/cluster-configuration'; +import { ErrorBoundaryInline } from '@console/shared/src/components/error'; +import { ResolvedClusterConfigurationItem } from './types'; + +type ClusterConfigurationCustomFieldProps = { + item: ResolvedClusterConfigurationItem; + field: ResolvedCodeRefProperties; +}; + +const ClusterConfigurationCustomField: React.FC = ({ + item, + field, +}) => { + const CustomComponent = field.component; + + return ( + + + + + + ); +}; + +export default ClusterConfigurationCustomField; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationDropdownField.tsx b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationDropdownField.tsx new file mode 100644 index 00000000000..f1ad6c225ac --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationDropdownField.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { FormGroup, Select, SelectOption, SelectVariant } from '@patternfly/react-core'; +import { ClusterConfigurationDropdownField } from '@console/dynamic-plugin-sdk/src'; +import { FormLayout } from '@console/shared/src/components/cluster-configuration'; +import { useDebounceCallback } from './hooks'; +import { ResolvedClusterConfigurationItem } from './types'; + +type ClusterConfigurationTextFieldProps = { + item: ResolvedClusterConfigurationItem; + field: ClusterConfigurationDropdownField; +}; + +const ClusterConfigurationTextField: React.FC = ({ + item, + field, +}) => { + const [value, setValue] = React.useState(field.defaultValue); + + const [isOpen, setIsOpen] = React.useState(false); + const handleToggle = (open: boolean) => setIsOpen(open); + + const save = useDebounceCallback(() => { + // eslint-disable-next-line no-console + console.log('xxx save'); + }, 2000); + const handleChange = (_, newValue: string) => { + setIsOpen(false); + setValue(newValue); + // eslint-disable-next-line no-console + console.log('xxx handleChange', newValue); + save(newValue); + }; + + const options = field.options.map((option) => ( + + )); + + return ( + + + + + + ); +}; + +export default ClusterConfigurationTextField; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationField.tsx b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationField.tsx new file mode 100644 index 00000000000..1f4704323a4 --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationField.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { ClusterConfigurationFieldType } from '@console/dynamic-plugin-sdk/src'; +import ClusterConfigurationCustomField from './ClusterConfigurationCustomField'; +import { ResolvedClusterConfigurationItem } from './types'; + +const componentForFieldType = { + // WIP: + // [ClusterConfigurationFieldType.text]: ClusterConfigurationTextField, + // [ClusterConfigurationFieldType.checkbox]: ClusterConfigurationCheckboxField, + // [ClusterConfigurationFieldType.dropdown]: ClusterConfigurationDropdownField, + [ClusterConfigurationFieldType.custom]: ClusterConfigurationCustomField, +}; + +const ClusterConfigurationField: React.FC<{ item: ResolvedClusterConfigurationItem }> = ({ + item, +}) => { + const Field = componentForFieldType[item.field.type]; + return Field ? : null; +}; + +export default ClusterConfigurationField; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationPage.scss b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationPage.scss new file mode 100644 index 00000000000..6380877b93b --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationPage.scss @@ -0,0 +1,48 @@ +.co-cluster-configuration-page { + display: flex; + flex-direction: column; + min-height: 100%; + + // Fix missing white background color above the header. + .ocs-page-layout__title { + margin-top: 0; + padding-top: var(--pf-global--spacer--lg); + } + + .ocs-page-layout__content { + display: flex; + } + + .pf-c-empty-state { + background-color: var(--pf-global--BackgroundColor--100); + flex: 1; + align-items: flex-start; + } + + .pf-c-tabs.pf-m-vertical { + background-color: var(--pf-global--BackgroundColor--100); + + @media (min-width: 769px) { + max-width: 100%; + } + + @media (min-width: 992px) { + min-width: 220px; + } + + @media (min-width: 1200px) { + max-width: 220px; + } + } + + // Fix padding which is causes the buttons to show even when isVertical prop is true + .pf-c-tabs__scroll-button { + display: none; + } + + .pf-c-tab-content { + background-color: var(--pf-global--BackgroundColor--100); + width: 100%; + padding: var(--pf-global--spacer--lg); + } +} diff --git a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationPage.tsx b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationPage.tsx new file mode 100644 index 00000000000..38979e6e48b --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationPage.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import { + Tabs, + Tab, + TabProps, + Form, + EmptyState, + EmptyStateIcon, + EmptyStateBody, + Title, +} from '@patternfly/react-core'; +import { LockIcon } from '@patternfly/react-icons'; +import Helmet from 'react-helmet'; +import { useTranslation } from 'react-i18next'; +import { RouteComponentProps } from 'react-router'; +import { LoadingBox, history } from '@console/internal/components/utils'; +import { PageLayout, isModifiedEvent } from '@console/shared'; +import ClusterConfigurationField from './ClusterConfigurationField'; +import useClusterConfigurationGroups from './useClusterConfigurationGroups'; +import useClusterConfigurationItems from './useClusterConfigurationItems'; +import './ClusterConfigurationPage.scss'; + +export type ClusterConfigurationPageProps = RouteComponentProps<{ group: string }>; + +const ClusterConfigurationPage: React.FC = ({ match }) => { + const { t } = useTranslation(); + + const groupId = match.params.group || 'general'; + const onSelect = (event: React.MouseEvent, newGroupId: string) => { + if (isModifiedEvent(event)) { + return; + } + event.preventDefault(); + const path = match.path.includes(':group') + ? match.path.replace(':group', newGroupId) + : `${match.path}/${newGroupId}`; + history.replace(path); + }; + + const [ + clusterConfigurationGroups, + clusterConfigurationGroupsResolved, + ] = useClusterConfigurationGroups(); + + const [ + clusterConfigurationItems, + clusterConfigurationItemsResolved, + ] = useClusterConfigurationItems(); + + const loaded = clusterConfigurationGroupsResolved && clusterConfigurationItemsResolved; + + const tabs: React.ReactElement[] = clusterConfigurationGroups + .filter((group) => clusterConfigurationItems.some((item) => group.id === item.groupId)) + .map((group) => { + const items = + groupId === group.id + ? clusterConfigurationItems + .filter((item) => item.groupId === group.id) + .map((item) => ) + : null; + + return ( + +
event.preventDefault()}>{items}
+
+ ); + }); + + const groupNotFound = !clusterConfigurationGroups.some((group) => group.id === groupId); + + return ( +
+ + {t('console-app~Cluster configuration')} + + + {!loaded ? ( + + ) : tabs.length === 0 ? ( + + + + {t('console-app~Insufficient permissions')} + + + {t( + 'console-app~You do not have sufficient permissions to read any cluster configuration.', + )} + + + ) : ( + <> + + {tabs} + + {groupNotFound ? ( + /* Similar to a TabContent */ +
+

{t('console-app~{{section}} not found', { section: groupId })}

+
+ ) : null} + + )} +
+
+ ); +}; + +export default ClusterConfigurationPage; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationTextField.tsx b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationTextField.tsx new file mode 100644 index 00000000000..fd13d2d7ba2 --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationTextField.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { FormGroup, TextInput } from '@patternfly/react-core'; +import { ClusterConfigurationTextField } from '@console/dynamic-plugin-sdk/src'; +import { FormLayout } from '@console/shared/src/components/cluster-configuration'; +import { useDebounceCallback } from './hooks'; +import { ResolvedClusterConfigurationItem } from './types'; + +type ClusterConfigurationTextFieldProps = { + item: ResolvedClusterConfigurationItem; + field: ClusterConfigurationTextField; +}; + +const ClusterConfigurationTextField: React.FC = ({ + item, + field, +}) => { + const [value, setValue] = React.useState(field.defaultValue || ''); + + const save = useDebounceCallback(() => { + // eslint-disable-next-line no-console + console.log('xxx save'); + + // k8s patch + }, 2000); + const handleOnChange = (newValue: string) => { + // eslint-disable-next-line no-console + console.log('xxx onChange', newValue); + setValue(newValue); + save(); + }; + + return ( + + + + + + ); +}; + +export default ClusterConfigurationTextField; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/hooks.ts b/frontend/packages/console-app/src/components/cluster-configuration/hooks.ts new file mode 100644 index 00000000000..96cef9371bd --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/hooks.ts @@ -0,0 +1 @@ +export { useDebounceCallback } from '@console/shared/src/hooks/debounce'; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/index.ts b/frontend/packages/console-app/src/components/cluster-configuration/index.ts new file mode 100644 index 00000000000..900c0a66ba2 --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/index.ts @@ -0,0 +1 @@ +export { default as ClusterConfigurationPage } from './ClusterConfigurationPage'; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/types.ts b/frontend/packages/console-app/src/components/cluster-configuration/types.ts new file mode 100644 index 00000000000..9c9805f354f --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/types.ts @@ -0,0 +1,20 @@ +import { + ResolvedExtension, + ClusterConfigurationGroup, + ClusterConfigurationItem, +} from '@console/dynamic-plugin-sdk/src'; + +export { + ClusterConfigurationGroup, + ClusterConfigurationItem, +} from '@console/dynamic-plugin-sdk/src'; + +export type ResolvedClusterConfigurationGroup = Omit< + ResolvedExtension['properties'], + 'insertBefore' | 'insertAfter' +>; + +export type ResolvedClusterConfigurationItem = Omit< + ResolvedExtension['properties'], + 'insertBefore' | 'insertAfter' | 'readAccessReview' | 'writeAccessReview' +> & { readonly: boolean }; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/useClusterConfigurationGroups.ts b/frontend/packages/console-app/src/components/cluster-configuration/useClusterConfigurationGroups.ts new file mode 100644 index 00000000000..78b268c53aa --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/useClusterConfigurationGroups.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { + ClusterConfigurationGroup, + isClusterConfigurationGroup, + useResolvedExtensions, +} from '@console/dynamic-plugin-sdk/src'; +import { orderExtensionBasedOnInsertBeforeAndAfter } from '@console/shared/src'; +import { ResolvedClusterConfigurationGroup } from './types'; + +const useClusterConfigurationGroups = (): [ + ResolvedClusterConfigurationGroup[], + boolean, + Error[], +] => { + const [resolvedExtensions, resolved, errors] = useResolvedExtensions( + isClusterConfigurationGroup, + ); + + const sortedGroups = React.useMemo(() => { + return orderExtensionBasedOnInsertBeforeAndAfter( + resolvedExtensions.map((resolvedExtension) => resolvedExtension.properties), + ); + }, [resolvedExtensions]); + + return [sortedGroups, resolved, errors]; +}; + +export default useClusterConfigurationGroups; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/useClusterConfigurationItems.ts b/frontend/packages/console-app/src/components/cluster-configuration/useClusterConfigurationItems.ts new file mode 100644 index 00000000000..2083a2fc113 --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/useClusterConfigurationItems.ts @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { + checkAccess, + ClusterConfigurationItem, + isClusterConfigurationItem, + useResolvedExtensions, +} from '@console/dynamic-plugin-sdk/src'; +import { orderExtensionBasedOnInsertBeforeAndAfter } from '@console/shared/src'; +import { ResolvedClusterConfigurationItem } from './types'; + +const useClusterConfigurationItems = (): [ResolvedClusterConfigurationItem[], boolean, Error[]] => { + const [resolvedExtensions, resolved, errors] = useResolvedExtensions( + isClusterConfigurationItem, + ); + + // Sort + const sortedItems = React.useMemo(() => { + return orderExtensionBasedOnInsertBeforeAndAfter( + resolvedExtensions.map((resolvedExtension) => resolvedExtension.properties), + ); + }, [resolvedExtensions]); + + // Filter based on permission checks + const [canRead, updateCanRead] = React.useState>({}); + const [canWrite, updateCanWrite] = React.useState>({}); + React.useEffect(() => { + sortedItems.forEach((item) => { + if (item.readAccessReview?.length > 0) { + Promise.all(item.readAccessReview.map((accessReview) => checkAccess(accessReview))) + .then((result) => { + const allowed = result.every((r) => r.status.allowed); + updateCanRead((x) => ({ ...x, [item.id]: allowed })); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.warn(`readAccessReview check failed for "${item.id}"`, error); + }); + } + + if (item.writeAccessReview?.length > 0) { + Promise.all(item.writeAccessReview.map((accessReview) => checkAccess(accessReview))) + .then((result) => { + const allowed = result.every((r) => r.status.allowed); + updateCanWrite((x) => ({ ...x, [item.id]: allowed })); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.warn(`writeAccessReview check failed for "${item.id}"`, error); + }); + } + }); + }, [sortedItems]); + + const filteredItems = React.useMemo(() => { + return sortedItems + .filter((item) => (item.readAccessReview?.length > 0 ? canRead[item.id] : true)) + .map((item) => { + return { + ...item, + readonly: item.writeAccessReview?.length > 0 ? !canWrite[item.id] : false, + }; + }); + }, [sortedItems, canRead, canWrite]); + + return [filteredItems, resolved, errors]; +}; + +export default useClusterConfigurationItems; diff --git a/frontend/packages/console-app/src/components/user-preferences/UserPreferencePage.tsx b/frontend/packages/console-app/src/components/user-preferences/UserPreferencePage.tsx index f124af24cf9..ede161787d4 100644 --- a/frontend/packages/console-app/src/components/user-preferences/UserPreferencePage.tsx +++ b/frontend/packages/console-app/src/components/user-preferences/UserPreferencePage.tsx @@ -121,7 +121,7 @@ const UserPreferencePage: React.FC = ({ match }) => { {userPreferenceItemResolved ? ( diff --git a/frontend/packages/console-app/src/components/user-preferences/__tests__/userPreferences.data.tsx b/frontend/packages/console-app/src/components/user-preferences/__tests__/userPreferences.data.tsx index c4b794b5823..a17ac932db7 100644 --- a/frontend/packages/console-app/src/components/user-preferences/__tests__/userPreferences.data.tsx +++ b/frontend/packages/console-app/src/components/user-preferences/__tests__/userPreferences.data.tsx @@ -56,9 +56,10 @@ export const userPreferenceItemWithCheckboxField: ResolvedUserPreferenceItem = { export const userPreferenceItemWithUnknownField: ResolvedUserPreferenceItem = { id: 'console.unknown', + groupId: '', label: 'Unknown Input', field: { - type: 'text' as any, + type: 'unknown field type' as any, userSettingsKey: 'console.unknown', label: 'This is an invalid input type', trueValue: 'Invalid true messsage', diff --git a/frontend/packages/console-app/src/components/user-preferences/const.ts b/frontend/packages/console-app/src/components/user-preferences/const.ts index 43f37e58f28..f252081b6c1 100644 --- a/frontend/packages/console-app/src/components/user-preferences/const.ts +++ b/frontend/packages/console-app/src/components/user-preferences/const.ts @@ -4,7 +4,8 @@ import UserPreferenceCustomField from './UserPreferenceCustomField'; import UserPreferenceDropdownField from './UserPreferenceDropdownField'; export const USER_PREFERENCES_BASE_URL = '/user-preferences'; -export const componentForFieldType = { + +export const componentForFieldType: Record> = { [UserPreferenceFieldType.dropdown]: UserPreferenceDropdownField, [UserPreferenceFieldType.checkbox]: UserPreferenceCheckboxField, [UserPreferenceFieldType.custom]: UserPreferenceCustomField, diff --git a/frontend/packages/console-dynamic-plugin-sdk/docs/api.md b/frontend/packages/console-dynamic-plugin-sdk/docs/api.md index 6c0eb59e2c9..1512cb3ed18 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/docs/api.md +++ b/frontend/packages/console-dynamic-plugin-sdk/docs/api.md @@ -1079,7 +1079,7 @@ const Component: React.FC = () => { const watchRes = { ... } - const [data, loaded, error] = UseK8sWatchResource(watchRes) + const [data, loaded, error] = useK8sWatchResource(watchRes) return ... } ``` @@ -1121,7 +1121,7 @@ const Component: React.FC = () => { 'pod': {...} ... } - const {deployment, pod} = UseK8sWatchResources(watchResources) + const {deployment, pod} = useK8sWatchResources(watchResources) return ... } ``` diff --git a/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md b/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md index 430983e96d0..0a0c2cf4e0d 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md +++ b/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md @@ -1218,9 +1218,9 @@ Topology relationship provider connector extension | Name | Value Type | Optional | Description | | ---- | ---------- | -------- | ----------- | | `id` | `string` | no | ID used to identify the user preference group. | -| `label` | `string` | no | The label of the user preference group | -| `insertBefore` | `string` | yes | ID of user preference group before which this group should be placed | -| `insertAfter` | `string` | yes | ID of user preference group after which this group should be placed | +| `label` | `string` | no | The label of the user preference group. | +| `insertBefore` | `string` | yes | ID of user preference group before which this group should be placed. | +| `insertAfter` | `string` | yes | ID of user preference group after which this group should be placed. | --- @@ -1235,12 +1235,12 @@ Topology relationship provider connector extension | Name | Value Type | Optional | Description | | ---- | ---------- | -------- | ----------- | | `id` | `string` | no | ID used to identify the user preference item and referenced in insertAfter and insertBefore to define the item order. | -| `label` | `string` | no | The label of the user preference | +| `groupId` | `string` | no | IDs used to identify the user preference groups the item would belong to. | +| `label` | `string` | no | The label of the user preference. | | `description` | `string` | no | The description of the user preference. | | `field` | `UserPreferenceField` | no | The input field options used to render the values to set the user preference. | -| `groupId` | `string` | yes | IDs used to identify the user preference groups the item would belong to. | -| `insertBefore` | `string` | yes | ID of user preference item before which this item should be placed | -| `insertAfter` | `string` | yes | ID of user preference item after which this item should be placed | +| `insertBefore` | `string` | yes | ID of user preference item before which this item should be placed. | +| `insertAfter` | `string` | yes | ID of user preference item after which this item should be placed. | --- diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/cluster-configuration.ts b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/cluster-configuration.ts new file mode 100644 index 00000000000..612dc055b84 --- /dev/null +++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/cluster-configuration.ts @@ -0,0 +1,143 @@ +import * as React from 'react'; +import { JSONSchema7Type } from 'json-schema'; +import { Extension, ExtensionDeclaration, CodeRef } from '../types'; +import { AccessReviewResourceAttributes } from './console-types'; + +export type Resource = { + /** Resource API group and version */ + api: string; + /** Resource kind */ + kind: string; + /** Resource name, or namespace and name for namespaced-scoped resources */ + resource: { + metadata: { + namespace?: string; + name: string; + }; + }; +}; + +export type Path = string; + +export type ClusterConfigurationFieldProps = { + readonly: boolean; +}; + +export enum ClusterConfigurationFieldType { + text = 'text', + checkbox = 'checkbox', + dropdown = 'dropdown', + custom = 'custom', +} + +export type ClusterConfigurationTextField = { + type: ClusterConfigurationFieldType.text; + defaultValue?: string; + /** + * Update operation that is used to save the latest text value. + * If `update.patch.value` is not defined the text value will be used. + * If `update.patch.value` is defined is must be a string, object or array. + * The text value will be automatically inserted into the placeholder `$value`. + */ + resource: Resource; + path: Path; +}; + +export type ClusterConfigurationCheckboxFieldValue = string | number | boolean; + +export type ClusterConfigurationCheckboxField = { + type: ClusterConfigurationFieldType.checkbox; + defaultValue?: ClusterConfigurationCheckboxFieldValue; + trueValue?: ClusterConfigurationCheckboxFieldValue; + falseValue?: ClusterConfigurationCheckboxFieldValue; + /** + * A patch operation that is used to save if the checkbox is checked. + * if `update.patch.value` is not defined a true (boolean) is automatically set. + * If `update.patch.value` is defined is must be a string, object or array. + * The text value will be automatically inserted into the placeholder `$value`. + */ + resource: Resource; + path: Path; +}; + +export type ClusterConfigurationDropdownFieldValue = string; + +export type ClusterConfigurationDropdownField = { + type: ClusterConfigurationFieldType.dropdown; + defaultValue?: ClusterConfigurationDropdownFieldValue; + options: { + value: ClusterConfigurationDropdownFieldValue; + label: string; + description?: string; + }[]; + /** + * A patch operation that is used to save if the checkbox is checked. + * if `update.patch.value` is not defined a true (boolean) is automatically set. + * If `update.patch.value` is defined is must be a string, object or array. + * The text value will be automatically inserted into the placeholder `$value`. + */ + resource: Resource; + path: Path; +}; + +export type ClusterConfigurationCustomField = { + type: ClusterConfigurationFieldType.custom; + component: CodeRef>; + props?: { [key: string]: JSONSchema7Type }; +}; + +export type ClusterConfigurationField = ClusterConfigurationCustomField; + +export type ClusterConfigurationGroup = ExtensionDeclaration< + 'console.cluster-configuration/group', + { + /** ID used to identify the cluster configuration group. */ + id: string; + /** The label of the cluster configuration group. */ + label: string; + /** ID of cluster configuration group before which this group should be placed. */ + insertBefore?: string; + /** ID of cluster configuration group after which this group should be placed. */ + insertAfter?: string; + } +>; + +export type ClusterConfigurationItem = ExtensionDeclaration< + 'console.cluster-configuration/item', + { + /** ID used to identify the cluster configuration item and referenced in insertAfter and insertBefore to define the item order. */ + id: string; + /** IDs used to identify the cluster configuration groups the item would belong to. */ + groupId: string; + /** The label of the cluster configuration */ + label: string; + /** The description of the cluster configuration. */ + description: string; + /** The UI field configuration to render input field or custom components that allow the user to change the cluster configuration. */ + field: ClusterConfigurationField; + /** ID of cluster configuration item before which this item should be placed. */ + insertBefore?: string; + /** ID of cluster configuration item after which this item should be placed. */ + insertAfter?: string; + /** + * Optional list of resources that are neccessary to render the input field with the current configuration state. + * If the user has not access to all required fields the input field is not rendered at all. + */ + readAccessReview?: AccessReviewResourceAttributes[]; + /** + * Optional list of resources that are neccessary to update the configuration. + * If The user has access to all readAccessReview resources, but not all writeAccessReview the input field is rendered read-only. + */ + writeAccessReview?: AccessReviewResourceAttributes[]; + } +>; + +// Type guards + +export const isClusterConfigurationGroup = (e: Extension): e is ClusterConfigurationGroup => { + return e.type === 'console.cluster-configuration/group'; +}; + +export const isClusterConfigurationItem = (e: Extension): e is ClusterConfigurationItem => { + return e.type === 'console.cluster-configuration/item'; +}; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts index a40b950b83d..c49f857f44f 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts @@ -1,5 +1,6 @@ export * from './add-actions'; export * from './catalog'; +export * from './cluster-configuration'; export * from './cluster-settings'; export * from './context-providers'; export * from './dashboards'; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/user-preferences.ts b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/user-preferences.ts index 9f7915e16df..2d7fc5dc705 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/user-preferences.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/user-preferences.ts @@ -48,11 +48,11 @@ export type UserPreferenceGroup = ExtensionDeclaration< { /** ID used to identify the user preference group. */ id: string; - /** The label of the user preference group */ + /** The label of the user preference group. */ label: string; - /** ID of user preference group before which this group should be placed */ + /** ID of user preference group before which this group should be placed. */ insertBefore?: string; - /** ID of user preference group after which this group should be placed */ + /** ID of user preference group after which this group should be placed. */ insertAfter?: string; } >; @@ -63,26 +63,26 @@ export type UserPreferenceItem = ExtensionDeclaration< /** ID used to identify the user preference item and referenced in insertAfter and insertBefore to define the item order. */ id: string; /** IDs used to identify the user preference groups the item would belong to. */ - groupId?: string; - /** The label of the user preference */ + groupId: string; + /** The label of the user preference. */ label: string; /** The description of the user preference. */ description: string; /** The input field options used to render the values to set the user preference. */ field: UserPreferenceField; - /** ID of user preference item before which this item should be placed */ + /** ID of user preference item before which this item should be placed. */ insertBefore?: string; - /** ID of user preference item after which this item should be placed */ + /** ID of user preference item after which this item should be placed. */ insertAfter?: string; } >; // Type guards -export const isUserPreferenceItem = (e: Extension): e is UserPreferenceItem => { - return e.type === 'console.user-preference/item'; -}; - export const isUserPreferenceGroup = (e: Extension): e is UserPreferenceGroup => { return e.type === 'console.user-preference/group'; }; + +export const isUserPreferenceItem = (e: Extension): e is UserPreferenceItem => { + return e.type === 'console.user-preference/item'; +}; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource.ts b/frontend/packages/console-dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource.ts index 2a3ccd0ec3a..53546d14939 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource.ts @@ -23,7 +23,7 @@ import { useModelsLoaded } from './useModelsLoaded'; * const watchRes = { ... } - * const [data, loaded, error] = UseK8sWatchResource(watchRes) + * const [data, loaded, error] = useK8sWatchResource(watchRes) * return ... * } * ``` diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResources.ts b/frontend/packages/console-dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResources.ts index dfd32279f86..b39451122f9 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResources.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResources.ts @@ -33,7 +33,7 @@ import { usePrevious } from './usePrevious'; 'pod': {...} ... } - * const {deployment, pod} = UseK8sWatchResources(watchResources) + * const {deployment, pod} = useK8sWatchResources(watchResources) * return ... * } * ``` diff --git a/frontend/packages/console-shared/locales/en/console-shared.json b/frontend/packages/console-shared/locales/en/console-shared.json index 1da6b65b329..afa6e3257b3 100644 --- a/frontend/packages/console-shared/locales/en/console-shared.json +++ b/frontend/packages/console-shared/locales/en/console-shared.json @@ -27,6 +27,10 @@ "Get support": "Get support", "Refer documentation": "Refer documentation", "Description": "Description", + "Could not load configuration.": "Could not load configuration.", + "Saved.": "Saved.", + "This config update requires a console rollout, this can take up to a minute and require a browser refresh.": "This config update requires a console rollout, this can take up to a minute and require a browser refresh.", + "Could not save configuration.": "Could not save configuration.", "No resources found": "No resources found", "Started": "Started", "There are no recent events.": "There are no recent events.", diff --git a/frontend/packages/console-shared/src/components/cluster-configuration/FormLayout.tsx b/frontend/packages/console-shared/src/components/cluster-configuration/FormLayout.tsx new file mode 100644 index 00000000000..5c792a44b78 --- /dev/null +++ b/frontend/packages/console-shared/src/components/cluster-configuration/FormLayout.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { FormProps } from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; +import formStyles from '@patternfly/react-styles/css/components/Form/form'; + +export type FormLayoutProps = Pick; + +const FormLayout: React.FC = ({ + children, + isHorizontal = false, + isWidthLimited = true, +}) => { + return ( +
+ {children} +
+ ); +}; + +export default FormLayout; diff --git a/frontend/packages/console-shared/src/components/cluster-configuration/LoadError.tsx b/frontend/packages/console-shared/src/components/cluster-configuration/LoadError.tsx new file mode 100644 index 00000000000..b7847434796 --- /dev/null +++ b/frontend/packages/console-shared/src/components/cluster-configuration/LoadError.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { Alert } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; + +const LoadError: React.FC<{ error?: Error }> = ({ error }) => { + const { t } = useTranslation(); + if (!error) { + return null; + } + return ( + + {error.message?.toString?.() || error.toString?.()} + + ); +}; + +export default LoadError; diff --git a/frontend/packages/console-shared/src/components/cluster-configuration/SaveStatus.tsx b/frontend/packages/console-shared/src/components/cluster-configuration/SaveStatus.tsx new file mode 100644 index 00000000000..01250038d92 --- /dev/null +++ b/frontend/packages/console-shared/src/components/cluster-configuration/SaveStatus.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Alert } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; + +export type SaveStatusProps = { + status: 'pending' | 'in-progress' | 'successful' | 'error'; + error?: Error; +}; + +export const SaveStatus: React.FC = ({ status, error }) => { + const { t } = useTranslation(); + if (status === 'successful') { + return ( + + {t( + 'console-shared~This config update requires a console rollout, this can take up to a minute and require a browser refresh.', + )} + + ); + } + if (status === 'error') { + return ( + + {error?.message?.toString?.() || error?.toString?.()} + + ); + } + return null; +}; diff --git a/frontend/packages/console-shared/src/components/cluster-configuration/__tests__/path-utils.spec.ts b/frontend/packages/console-shared/src/components/cluster-configuration/__tests__/path-utils.spec.ts new file mode 100644 index 00000000000..93f9d9fe863 --- /dev/null +++ b/frontend/packages/console-shared/src/components/cluster-configuration/__tests__/path-utils.spec.ts @@ -0,0 +1,58 @@ +import { extractValue, wrapValue } from '../path-utils'; + +describe('extractValue', () => { + it('should return the value when no path is defined', () => { + expect(extractValue('value', '')).toBe('value'); + }); + + it('should return the value when path contains just empty parts', () => { + expect(extractValue('value', '/')).toBe('value'); + expect(extractValue('value', '//')).toBe('value'); + }); + + it('should return the value when path is correct', () => { + expect(extractValue({ a: 'value' }, 'a')).toBe('value'); + expect(extractValue({ a: 'value' }, '/a')).toBe('value'); + expect(extractValue({ a: { b: 'value' } }, 'a/b')).toBe('value'); + expect(extractValue({ a: { b: 'value' } }, '/a/b/')).toBe('value'); + }); + + it('should return null if the property is not defined', () => { + expect(extractValue({ a: 'value' }, 'b')).toBe(null); + expect(extractValue({ a: 'value' }, '/b')).toBe(null); + expect(extractValue({ a: { b: 'value' } }, 'a/c')).toBe(null); + expect(extractValue({ a: { b: 'value' } }, '/a/c/')).toBe(null); + expect(extractValue({ a: { b: 'value' } }, 'b/b')).toBe(null); + expect(extractValue({ a: { b: 'value' } }, '/b/b/')).toBe(null); + }); + + it('should support alternative separator', () => { + expect(extractValue({ a: { b: 'value' } }, 'a.b', '.')).toBe('value'); + }); + + it('must not return toString or other functions from the object', () => { + expect(extractValue({ a: 'value' }, '/toString')).toBe(null); + }); +}); + +describe('wrapValue', () => { + it('should return the value when no path is defined', () => { + expect(wrapValue('value', '')).toBe('value'); + }); + + it('should return the value when path contains just empty parts', () => { + expect(wrapValue('value', '/')).toBe('value'); + expect(wrapValue('value', '//')).toBe('value'); + }); + + it('should wrap the value when path is correct', () => { + expect(wrapValue('value', 'a')).toEqual({ a: 'value' }); + expect(wrapValue('value', '/a')).toEqual({ a: 'value' }); + expect(wrapValue('value', 'a/b')).toEqual({ a: { b: 'value' } }); + expect(wrapValue('value', '/a/b/')).toEqual({ a: { b: 'value' } }); + }); + + it('should support alternative separator', () => { + expect(wrapValue('value', 'a.b', '.')).toEqual({ a: { b: 'value' } }); + }); +}); diff --git a/frontend/packages/console-shared/src/components/cluster-configuration/index.ts b/frontend/packages/console-shared/src/components/cluster-configuration/index.ts new file mode 100644 index 00000000000..b8e0f52a0b9 --- /dev/null +++ b/frontend/packages/console-shared/src/components/cluster-configuration/index.ts @@ -0,0 +1,8 @@ +export { useDebounceCallback } from '../../hooks/debounce'; + +export { default as useConsoleOperatorConfig } from './useConsoleOperatorConfig'; +export { default as patchConsoleOperatorConfig } from './patchConsoleOperatorConfig'; +export { default as FormLayout } from './FormLayout'; +export { default as LoadError } from './LoadError'; +export * from './SaveStatus'; +export * from './path-utils'; diff --git a/frontend/packages/console-shared/src/components/cluster-configuration/patchConsoleOperatorConfig.ts b/frontend/packages/console-shared/src/components/cluster-configuration/patchConsoleOperatorConfig.ts new file mode 100644 index 00000000000..c6d164fbac6 --- /dev/null +++ b/frontend/packages/console-shared/src/components/cluster-configuration/patchConsoleOperatorConfig.ts @@ -0,0 +1,22 @@ +import { consoleFetchJSON } from '@console/dynamic-plugin-sdk/src/utils/fetch/console-fetch'; +import { ConsoleOperatorConfigModel } from '@console/internal/models'; +import { K8sResourceKind, resourceURL } from '@console/internal/module/k8s'; +import { CONSOLE_OPERATOR_CONFIG_NAME } from '../../constants/resource'; + +/** + * JSON Merge Patch instead of JSON patch to update also properties that doesn't exist yet. + * + * See https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/#use-a-json-merge-patch-to-update-a-deployment + */ +const patchConsoleOperatorConfig = (resource: R): Promise => { + const url = resourceURL(ConsoleOperatorConfigModel, { name: CONSOLE_OPERATOR_CONFIG_NAME }); + return consoleFetchJSON(url, 'PATCH', { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/merge-patch+json;charset=UTF-8', + }, + body: JSON.stringify(resource), + }); +}; + +export default patchConsoleOperatorConfig; diff --git a/frontend/packages/console-shared/src/components/cluster-configuration/path-utils.ts b/frontend/packages/console-shared/src/components/cluster-configuration/path-utils.ts new file mode 100644 index 00000000000..45b3ffab6a0 --- /dev/null +++ b/frontend/packages/console-shared/src/components/cluster-configuration/path-utils.ts @@ -0,0 +1,32 @@ +/** + * Extracts a values based on a path like value from `{ a: b: value } }` for the path `/a/b/` + */ +export const extractValue = (value: any, path: string, separator = '/'): T => { + let result = value; + path.split(separator).forEach((part) => { + if (part) { + if (result?.hasOwnProperty(part)) { + result = result[part]; + } else { + result = null; + } + } + }); + return result; +}; + +/** + * Creates an object like `{ a: { b: value } }` for the path `/a/b/`. + */ +export const wrapValue = (value: any, path: string, separator = '/'): T => { + let result = value; + path + .split(separator) + .reverse() + .forEach((part) => { + if (part) { + result = { [part]: result }; + } + }); + return result; +}; diff --git a/frontend/packages/console-shared/src/components/cluster-configuration/useConsoleOperatorConfig.ts b/frontend/packages/console-shared/src/components/cluster-configuration/useConsoleOperatorConfig.ts new file mode 100644 index 00000000000..2e1c006563a --- /dev/null +++ b/frontend/packages/console-shared/src/components/cluster-configuration/useConsoleOperatorConfig.ts @@ -0,0 +1,15 @@ +import { getGroupVersionKindForModel } from '@console/dynamic-plugin-sdk/src/lib-core'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource'; +import { ConsoleOperatorConfigModel } from '@console/internal/models'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { CONSOLE_OPERATOR_CONFIG_NAME } from '../../constants/resource'; + +const useConsoleOperatorConfig = () => { + return useK8sWatchResource({ + groupVersionKind: getGroupVersionKindForModel(ConsoleOperatorConfigModel), + isList: false, + name: CONSOLE_OPERATOR_CONFIG_NAME, + }); +}; + +export default useConsoleOperatorConfig; diff --git a/frontend/public/components/factory/details.tsx b/frontend/public/components/factory/details.tsx index e14ba7f4d0d..a1b97429b58 100644 --- a/frontend/public/components/factory/details.tsx +++ b/frontend/public/components/factory/details.tsx @@ -29,6 +29,7 @@ import { Page, AsyncComponent, PageComponentProps, + KebabAction, } from '../utils'; import { K8sResourceKindReference, @@ -190,7 +191,7 @@ export type DetailsPageProps = { match: match; title?: string | JSX.Element; titleFunc?: (obj: K8sResourceKind) => string | JSX.Element; - menuActions?: Function[] | KebabOptionsCreator; // FIXME should be "KebabAction[] |" refactor pipeline-actions.tsx, etc. + menuActions?: KebabAction[] | KebabOptionsCreator; buttonActions?: any[]; createRedirect?: boolean; customActionMenu?: diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index b86166e71c3..1ea8a019098 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -1934,7 +1934,6 @@ "Exec command": "Exec command", "HTTP GET": "HTTP GET", "TCP socket (port)": "TCP socket (port)", - "Set your individual preferences for the console experience. Any changes will be autosaved.": "Set your individual preferences for the console experience. Any changes will be autosaved.", "Loading {{title}} status": "Loading {{title}} status", "Disable": "Disable", "default": "default", diff --git a/frontend/public/locales/ja/public.json b/frontend/public/locales/ja/public.json index 99d6e4f8f00..a178ab226b2 100644 --- a/frontend/public/locales/ja/public.json +++ b/frontend/public/locales/ja/public.json @@ -1894,7 +1894,6 @@ "Exec command": "実行コマンド", "HTTP GET": "HTTP GET", "TCP socket (port)": "TCP ソケット (ポート)", - "Set your individual preferences for the console experience. Any changes will be autosaved.": "コンソールエクスペリエンスに個別の設定を行います。変更は自動保存されます。", "Loading {{title}} status": "{{title}} ステータスの読み込み", "Disable": "無効にする", "default": "デフォルト", diff --git a/frontend/public/locales/ko/public.json b/frontend/public/locales/ko/public.json index eb40b513b31..07db9d00ce1 100644 --- a/frontend/public/locales/ko/public.json +++ b/frontend/public/locales/ko/public.json @@ -1894,7 +1894,6 @@ "Exec command": "Exec 명령", "HTTP GET": "HTTP GET", "TCP socket (port)": "TCP 소켓 (포트)", - "Set your individual preferences for the console experience. Any changes will be autosaved.": "콘솔 환경에 대한 개별 기본 설정을 지정합니다. 모든 변경 사항은 자동 저장됩니다.", "Loading {{title}} status": "{{title}} 상태 로딩", "Disable": "비활성화", "default": "기본", diff --git a/frontend/public/locales/zh/public.json b/frontend/public/locales/zh/public.json index 20f63f006f4..5e75db592b5 100644 --- a/frontend/public/locales/zh/public.json +++ b/frontend/public/locales/zh/public.json @@ -1894,7 +1894,6 @@ "Exec command": "Exec 命令", "HTTP GET": "HTTP GET", "TCP socket (port)": "TCP 套接字(端口)", - "Set your individual preferences for the console experience. Any changes will be autosaved.": "为增强控制台体验设置您的个人首选项。任何更改都将自动保存。", "Loading {{title}} status": "加载 {{title}} 状态", "Disable": "禁用", "default": "默认", From afce33cc1892df1138cb10897c633a157e16614c Mon Sep 17 00:00:00 2001 From: Debsmita1 Date: Thu, 20 Oct 2022 09:51:21 +0200 Subject: [PATCH 2/4] Add customize option to console detail page (#7) --- .../console-app/locales/en/console-app.json | 1 + .../ConsoleOperatorConfig.tsx | 28 +++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index cc7b6bf5fdd..7cbb135a71d 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -239,6 +239,7 @@ "Console plugins table": "Console plugins table", "console plugins": "console plugins", "Console plugins": "Console plugins", + "Customize": "Customize", "Updating cluster to {{version}}": "Updating cluster to {{version}}", "API Servers": "API Servers", "Controller Managers": "Controller Managers", diff --git a/frontend/packages/console-app/src/components/console-operator/ConsoleOperatorConfig.tsx b/frontend/packages/console-app/src/components/console-operator/ConsoleOperatorConfig.tsx index 45a6518ddd4..01f7e7d60a4 100644 --- a/frontend/packages/console-app/src/components/console-operator/ConsoleOperatorConfig.tsx +++ b/frontend/packages/console-app/src/components/console-operator/ConsoleOperatorConfig.tsx @@ -12,13 +12,19 @@ import { TableVariant, } from '@patternfly/react-table'; import { useTranslation } from 'react-i18next'; -import { WatchK8sResource } from '@console/dynamic-plugin-sdk'; +import { useAccessReview, WatchK8sResource } from '@console/dynamic-plugin-sdk'; import { breadcrumbsForGlobalConfig } from '@console/internal/components/cluster-settings/global-config'; import { DetailsForKind } from '@console/internal/components/default-resource'; import { DetailsPage } from '@console/internal/components/factory'; -import { EmptyBox, LoadingBox, navFactory, ResourceLink } from '@console/internal/components/utils'; +import { + asAccessReview, + EmptyBox, + KebabAction, + LoadingBox, + navFactory, + ResourceLink, +} from '@console/internal/components/utils'; import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; -import { useAccessReview } from '@console/internal/components/utils/rbac'; import { ConsoleOperatorConfigModel, ConsolePluginModel } from '@console/internal/models'; import { ConsolePluginKind, @@ -197,11 +203,27 @@ export const ConsoleOperatorConfigDetailsPage: React.FC ({ + // t('console-app~Customize') + labelKey: 'console-app~Customize', + labelKind: { kind: ConsoleOperatorConfigModel.kind }, + dataTest: `Customize`, + href: '/cluster-configuration', + accessReview: asAccessReview( + ConsoleOperatorConfigModel, + { spec: { name: 'cluster' } }, + 'patch', + ), + }), + ]; + return ( breadcrumbsForGlobalConfig(ConsoleOperatorConfigModel.label, props.match.url) } From e524425f79a1ed1dc77858634b82b0ca55015fab Mon Sep 17 00:00:00 2001 From: Christoph Jerolimov Date: Thu, 20 Oct 2022 09:52:30 +0200 Subject: [PATCH 3/4] Implement cluster configuration options --- .../console-app/console-extensions.json | 97 +++++++ .../console-app/locales/en/console-app.json | 18 +- frontend/packages/console-app/package.json | 3 + .../PerspectiveConfiguration.tsx | 273 ++++++++++++++++++ .../quick-starts/QuickStartConfiguration.tsx | 170 +++++++++++ .../src/hooks/perspective-utils.ts | 4 +- .../dev-console/console-extensions.json | 123 ++++++++ .../dev-console/locales/en/devconsole.json | 18 +- frontend/packages/dev-console/package.json | 3 +- .../components/add/AddPageConfiguration.tsx | 166 +++++++++++ .../dev-console/src/components/add/index.ts | 1 + .../CatalogCategoriesConfiguration.tsx | 129 +++++++++ .../catalog/CatalogTypesConfiguration.tsx | 249 ++++++++++++++++ .../src/components/catalog/index.ts | 2 + .../ProjectAccessRolesConfiguration.tsx | 187 ++++++++++++ .../src/components/project-access/index.ts | 1 + 16 files changed, 1438 insertions(+), 6 deletions(-) create mode 100644 frontend/packages/console-app/src/components/detect-perspective/PerspectiveConfiguration.tsx create mode 100644 frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx create mode 100644 frontend/packages/dev-console/src/components/add/AddPageConfiguration.tsx create mode 100644 frontend/packages/dev-console/src/components/add/index.ts create mode 100644 frontend/packages/dev-console/src/components/catalog/CatalogCategoriesConfiguration.tsx create mode 100644 frontend/packages/dev-console/src/components/catalog/CatalogTypesConfiguration.tsx create mode 100644 frontend/packages/dev-console/src/components/project-access/ProjectAccessRolesConfiguration.tsx diff --git a/frontend/packages/console-app/console-extensions.json b/frontend/packages/console-app/console-extensions.json index 6a826fb2214..36d1262ceb4 100644 --- a/frontend/packages/console-app/console-extensions.json +++ b/frontend/packages/console-app/console-extensions.json @@ -218,6 +218,103 @@ "provider": { "$codeRef": "actions.useReplicationControllerActionsProvider" } } }, + + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": ["/cluster-configuration", "/cluster-configuration/:group"], + "component": { "$codeRef": "clusterConfiguration.ClusterConfigurationPage" } + } + }, + + { + "type": "console.cluster-configuration/group", + "properties": { + "id": "general", + "label": "%console-app~General%" + } + }, + { + "type": "console.cluster-configuration/group", + "properties": { + "id": "projects", + "label": "%console-app~Projects%", + "insertAfter": "general" + } + }, + { + "type": "console.cluster-configuration/group", + "properties": { + "id": "developer", + "label": "%console-app~Developer%", + "insertAfter": "projects" + } + }, + { + "type": "console.cluster-configuration/item", + "properties": { + "id": "console-app.customization.perspectives", + "groupId": "general", + "label": "%console-app~Perspectives%", + "description": "%console-app~Show or hide perspectives by enabling, disabling or adding access review checks.%", + "field": { + "type": "custom", + "component": { "$codeRef": "perspectiveConfiguration" } + }, + "readAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "get", + "name": "cluster" + } + ], + "writeAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "patch", + "name": "cluster" + } + ] + } + }, + { + "type": "console.cluster-configuration/item", + "properties": { + "id": "console-app.quick-start.QuickStartConfiguration", + "label": "%console-app~Quick Starts%", + "groupId": "general", + "description": "%console-app~Configure a list of Quick Starts that are not shown to users.%", + "field": { + "type": "custom", + "component": { "$codeRef": "quickStartConfiguration" } + }, + "readAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "get", + "name": "cluster" + }, + { + "group": "console.openshift.io/v1", + "resource": "ConsoleQuickStart", + "verb": "list" + } + ], + "writeAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "patch", + "name": "cluster" + } + ] + } + }, + { "type": "console.user-preference/group", "properties": { diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index 7cbb135a71d..c38e3651142 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -2,6 +2,12 @@ "Administrator": "Administrator", "VolumeSnapshotContents": "VolumeSnapshotContents", "General": "General", + "Projects": "Projects", + "Developer": "Developer", + "Perspectives": "Perspectives", + "Show or hide perspectives by enabling, disabling or adding access review checks.": "Show or hide perspectives by enabling, disabling or adding access review checks.", + "Quick Starts": "Quick Starts", + "Configure a list of Quick Starts that are not shown to users.": "Configure a list of Quick Starts that are not shown to users.", "Language": "Language", "Notifications": "Notifications", "Applications": "Applications", @@ -34,7 +40,6 @@ "Administration": "Administration", "Observe": "Observe", "Overview": "Overview", - "Projects": "Projects", "Search": "Search", "API Explorer": "API Explorer", "Events": "Events", @@ -255,6 +260,15 @@ "{{enabledCount}}/{{totalCount}} enabled": "{{enabledCount}}/{{totalCount}} enabled", "View all": "View all", "Single master": "Single master", + "Perspectives are enabled by default.": "Perspectives are enabled by default.", + "Only visible for privileged users": "Only visible for privileged users", + "Privileged users can list all namespaces.": "Privileged users can list all namespaces.", + "Only visible for unprivileged users": "Only visible for unprivileged users", + "Unprivileged users cannot list all namespaces.": "Unprivileged users cannot list all namespaces.", + "Disable this perspectives for all users.": "Disable this perspectives for all users.", + "Custom": "Custom", + "This perspective is shown based on custom access review rules. Please open the console configuration resource to inspect or update this rules.": "This perspective is shown based on custom access review rules. Please open the console configuration resource to inspect or update this rules.", + "Access review rules": "Access review rules", "Incompatible file type": "Incompatible file type", "{{fileName}} cannot be uploaded. Only {{fileExtensions}} files are supported currently. Try another file.": "{{fileName}} cannot be uploaded. Only {{fileExtensions}} files are supported currently. Try another file.", "Clone": "Clone", @@ -463,8 +477,8 @@ "{helpText}": "{helpText}", "Create PodDiscruptionBudget": "Create PodDiscruptionBudget", "No PodDisruptionBudgets": "No PodDisruptionBudgets", - "Quick Starts": "Quick Starts", "Learn how to create, import, and run applications on OpenShift with step-by-step instructions and tasks.": "Learn how to create, import, and run applications on OpenShift with step-by-step instructions and tasks.", + "Quick starts": "Quick starts", "No results found": "No results found", "No results match the filter criteria. Remove filters or clear all filters to show results.": "No results match the filter criteria. Remove filters or clear all filters to show results.", "Clear all filters": "Clear all filters", diff --git a/frontend/packages/console-app/package.json b/frontend/packages/console-app/package.json index 2e868755926..d3fe870b4f3 100644 --- a/frontend/packages/console-app/package.json +++ b/frontend/packages/console-app/package.json @@ -37,11 +37,14 @@ "exposedModules": { "tourContext": "src/components/tour/tour-context.ts", "quickStartContext": "src/components/quick-starts/utils/quick-start-context.tsx", + "quickStartConfiguration": "src/components/quick-starts/QuickStartConfiguration.tsx", "fileUploadContext": "src/components/file-upload/file-upload-context.ts", "reduxReducer": "src/redux/reducer.ts", "actions": "src/actions", + "clusterConfiguration": "src/components/cluster-configuration", "userPreferences": "src/components/user-preferences", "perspective": "src/utils/perspective", + "perspectiveConfiguration": "src/components/detect-perspective/PerspectiveConfiguration.tsx", "dynamicPluginsHealthResource": "src/components/dashboards-page/dynamic-plugins-health-resource", "storageProvisioners": "src/components/storage/StorageClassProviders", "storageProvisionerDocs": "src/components/storage/Documentation" diff --git a/frontend/packages/console-app/src/components/detect-perspective/PerspectiveConfiguration.tsx b/frontend/packages/console-app/src/components/detect-perspective/PerspectiveConfiguration.tsx new file mode 100644 index 00000000000..09190c4eee0 --- /dev/null +++ b/frontend/packages/console-app/src/components/detect-perspective/PerspectiveConfiguration.tsx @@ -0,0 +1,273 @@ +import * as React from 'react'; +import { + FormGroup, + FormSection, + Select, + SelectOption, + ExpandableSection, + CodeBlock, + CodeBlockCode, +} from '@patternfly/react-core'; +import { safeDump } from 'js-yaml'; +import { useTranslation } from 'react-i18next'; +import { + isPerspective, + Perspective as PerspectiveExtension, + AccessReviewResourceAttributes, +} from '@console/dynamic-plugin-sdk/src'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { useExtensions } from '@console/plugin-sdk'; +import { + useDebounceCallback, + useConsoleOperatorConfig, + patchConsoleOperatorConfig, + FormLayout, + LoadError, + SaveStatus, + SaveStatusProps, +} from '@console/shared/src/components/cluster-configuration'; + +enum PerspectiveVisibilityState { + Enabled = 'Enabled', + Disabled = 'Disabled', + AccessReview = 'AccessReview', +} + +type PerspectiveAccessReview = { + required?: AccessReviewResourceAttributes[]; + missing?: AccessReviewResourceAttributes[]; +}; + +type PerspectiveVisibility = { + state: PerspectiveVisibilityState; + accessReview?: PerspectiveAccessReview; +}; + +type Perspective = { + id: string; + visibility: PerspectiveVisibility; +}; + +type PerspectivesConsoleConfig = K8sResourceKind & { + spec: { + customization?: { + perspectives: Perspective[]; + }; + }; +}; + +type PerspectiveVisibilitySelectOptions = { + value: string; + title: string; + description: string; + visibility?: PerspectiveVisibility; + isSelected: boolean; +}; + +const PerspectiveVisibilitySelect: React.FC<{ + toggleId: string; + disabled: boolean; + value?: PerspectiveVisibility; + onChange: (selectedOption: PerspectiveVisibilitySelectOptions) => void; +}> = ({ toggleId, disabled, value, onChange }) => { + const { t } = useTranslation(); + + const options: PerspectiveVisibilitySelectOptions[] = [ + { + value: 'Enabled', + title: t('console-app~Enabled'), + description: t('console-app~Perspectives are enabled by default.'), + visibility: { state: PerspectiveVisibilityState.Enabled }, + isSelected: !value || !value.state || value.state === PerspectiveVisibilityState.Enabled, + }, + { + value: 'RequiredNamespace', + title: t('console-app~Only visible for privileged users'), + description: t('console-app~Privileged users can list all namespaces.'), + visibility: { + state: PerspectiveVisibilityState.AccessReview, + accessReview: { + required: [ + { + resource: 'namespaces', + verb: 'get', + }, + ], + }, + }, + isSelected: + value?.state === PerspectiveVisibilityState.AccessReview && + value.accessReview?.required?.length === 1 && + value.accessReview.required[0].resource === 'namespaces' && + value.accessReview.required[0].verb === 'get' && + Object.values(value.accessReview.required[0]).filter(Boolean).length === 2 && + !value.accessReview?.missing?.length, + }, + { + value: 'MissingNamespace', + title: t('console-app~Only visible for unprivileged users'), + description: t('console-app~Unprivileged users cannot list all namespaces.'), + visibility: { + state: PerspectiveVisibilityState.AccessReview, + accessReview: { + missing: [ + { + resource: 'namespaces', + verb: 'get', + }, + ], + }, + }, + isSelected: + value?.state === PerspectiveVisibilityState.AccessReview && + value.accessReview?.missing?.length === 1 && + value.accessReview.missing[0].resource === 'namespaces' && + value.accessReview.missing[0].verb === 'get' && + Object.values(value.accessReview.missing[0]).filter(Boolean).length === 2 && + !value.accessReview?.required?.length, + }, + { + value: 'Disabled', + title: t('console-app~Disabled'), + description: t('console-app~Disable this perspectives for all users.'), + visibility: { state: PerspectiveVisibilityState.Disabled }, + isSelected: value?.state === PerspectiveVisibilityState.Disabled, + }, + ]; + + if (!options.some((option) => option.isSelected)) { + options.push({ + value: 'Custom', + title: t('console-app~Custom'), + description: t( + 'console-app~This perspective is shown based on custom access review rules. Please open the console configuration resource to inspect or update this rules.', + ), + isSelected: true, + }); + } + + const [isOpen, setIsOpen] = React.useState(false); + const selection = options.find((option) => option.isSelected)?.value; + + return ( + <> + + {selection === 'Custom' && value?.accessReview && ( + + + {safeDump(value.accessReview)} + + + )} + + ); +}; + +const PerspectiveConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { + const { t } = useTranslation(); + + // All available perspectives + const perspectiveExtensions = useExtensions(isPerspective); + + // Current configuration + const [consoleConfig, consoleConfigLoaded, consoleConfigError] = useConsoleOperatorConfig< + PerspectivesConsoleConfig + >(); + const [configuredPerspectives, setConfiguredPerspectives] = React.useState(); + React.useEffect(() => { + if (consoleConfig && consoleConfigLoaded && !configuredPerspectives) { + setConfiguredPerspectives(consoleConfig?.spec?.customization?.perspectives); + } + }, [configuredPerspectives, consoleConfig, consoleConfigLoaded]); + + // Save the latest changes + const [saveStatus, setSaveStatus] = React.useState(); + const save = useDebounceCallback(() => { + setSaveStatus({ status: 'in-progress' }); + + const patch: PerspectivesConsoleConfig = { + spec: { + customization: { + perspectives: configuredPerspectives, + }, + }, + }; + patchConsoleOperatorConfig(patch) + .then(() => setSaveStatus({ status: 'successful' })) + .catch((error) => setSaveStatus({ status: 'error', error })); + }, 2000); + + const disabled = + readonly || !perspectiveExtensions || !consoleConfigLoaded || !!consoleConfigError; + + return ( + + + {perspectiveExtensions.map((perspectiveExtension) => { + const fieldId = perspectiveExtension.uid; + const perspectiveId = perspectiveExtension.properties.id; + const value = configuredPerspectives?.find((p) => p.id === perspectiveId)?.visibility; + const onChange = (selectedOption: PerspectiveVisibilitySelectOptions) => { + if (selectedOption.visibility) { + setConfiguredPerspectives((oldConfiguredPerspectives) => { + const newConfiguredPerspectives = oldConfiguredPerspectives + ? [...oldConfiguredPerspectives] + : []; + const index = newConfiguredPerspectives.findIndex((p) => p.id === perspectiveId); + if (index === -1) { + newConfiguredPerspectives.push({ + id: perspectiveId, + visibility: selectedOption.visibility, + }); + } else { + newConfiguredPerspectives[index].visibility = selectedOption.visibility; + } + return newConfiguredPerspectives; + }); + } + save(); + }; + + return ( + + + + ); + })} + + + + + + ); +}; + +export default PerspectiveConfiguration; diff --git a/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx b/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx new file mode 100644 index 00000000000..c029bdcba37 --- /dev/null +++ b/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx @@ -0,0 +1,170 @@ +import * as React from 'react'; +import { QuickStart } from '@patternfly/quickstarts'; +import { DualListSelector, FormSection } from '@patternfly/react-core'; +import * as fuzzy from 'fuzzysearch'; +import { useTranslation } from 'react-i18next'; +import { + getGroupVersionKindForModel, + ResourceIcon, +} from '@console/dynamic-plugin-sdk/src/lib-core'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { + useDebounceCallback, + useConsoleOperatorConfig, + patchConsoleOperatorConfig, + LoadError, + SaveStatus, + SaveStatusProps, +} from '@console/shared/src/components/cluster-configuration'; +import { QuickStartModel } from '../../models'; + +type DisabledQuickStartsConsoleConfig = K8sResourceKind & { + spec: { + customization?: { + quickStarts?: { + disabled: string[]; + }; + }; + }; +}; + +type ItemProps = { id: string; quickStart?: QuickStart }; + +const Item: React.FC = ({ id, quickStart }) => ( +
+ {quickStart ? ( + <> + +
+
{quickStart.spec.displayName || quickStart.metadata.name}
+ {quickStart.spec.displayName ?
{quickStart.metadata.name}
: null} +
+ + ) : ( + id + )} +
+); + +const QuickStartConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { + const { t } = useTranslation(); + + // All available quick starts + const [allQuickStarts, allQuickStartsLoaded, allQuickStartsError] = useK8sWatchResource< + QuickStart[] + >({ + groupVersionKind: getGroupVersionKindForModel(QuickStartModel), + isList: true, + }); + + // Current configuration + const [consoleConfig, consoleConfigLoaded, consoleConfigError] = useConsoleOperatorConfig< + DisabledQuickStartsConsoleConfig + >(); + const [disabled, setDisabled] = React.useState(); + React.useEffect(() => { + if (consoleConfig && consoleConfigLoaded && !disabled) { + setDisabled(consoleConfig?.spec?.customization?.quickStarts?.disabled || []); + } + }, [consoleConfig, consoleConfigLoaded, disabled]); + + // Calculate options + const enabledOptions = React.useMemo[]>(() => { + if (!consoleConfigLoaded || !allQuickStartsLoaded || allQuickStartsError || !disabled) { + return []; + } + return allQuickStarts + .filter((quickStart) => !disabled || !disabled.includes(quickStart.metadata.name)) + .sort((quickStartA, quickStartB) => { + const displayNameA = quickStartA.spec.displayName || quickStartA.metadata.name; + const displayNameB = quickStartB.spec.displayName || quickStartB.metadata.name; + return displayNameA.localeCompare(displayNameB); + }) + .map((quickStart) => ( + + )); + }, [allQuickStarts, allQuickStartsError, allQuickStartsLoaded, consoleConfigLoaded, disabled]); + const disabledOptions = React.useMemo[]>(() => { + if (!disabled) { + return []; + } + const quickStartsByName = allQuickStarts.reduce>( + (acc, quickStart) => { + acc[quickStart.metadata.name] = quickStart; + return acc; + }, + {}, + ); + const sortedIds = [...disabled]; + sortedIds.sort((idA, idB) => { + const quickStartA = quickStartsByName[idA]; + const quickStartB = quickStartsByName[idB]; + const displayNameA = quickStartA?.spec.displayName || quickStartA?.metadata.name || idA; + const displayNameB = quickStartB?.spec.displayName || quickStartB?.metadata.name || idB; + return displayNameA.localeCompare(displayNameB); + }); + return sortedIds.map((id) => ); + }, [allQuickStarts, disabled]); + + // Save the latest value (disabled string array) + const [saveStatus, setSaveStatus] = React.useState(); + const save = useDebounceCallback(() => { + setSaveStatus({ status: 'in-progress' }); + + const patch: DisabledQuickStartsConsoleConfig = { + spec: { + customization: { + quickStarts: { + disabled, + }, + }, + }, + }; + patchConsoleOperatorConfig(patch) + .then(() => setSaveStatus({ status: 'successful' })) + .catch((error) => setSaveStatus({ status: 'error', error })); + }, 2000); + + // Extract disabled string array from Items + const onListChange = ( + newEnabledOptions: React.ReactElement[], + newDisabledOptions: React.ReactElement[], + ) => { + setDisabled(newDisabledOptions.map((node) => node.props.id)); + setSaveStatus({ status: 'pending' }); + save(); + }; + + const filterOption = (option: React.ReactElement, input: string): boolean => { + const displayName = + option.props.quickStart?.spec.displayName || + option.props.quickStart?.metadata.name || + option.props.id; + return fuzzy(input.toLocaleLowerCase(), displayName.toLocaleLowerCase()); + }; + + return ( + + + + + + + ); +}; + +export default QuickStartConfiguration; diff --git a/frontend/packages/console-shared/src/hooks/perspective-utils.ts b/frontend/packages/console-shared/src/hooks/perspective-utils.ts index c4bdb57257f..d6e69034837 100644 --- a/frontend/packages/console-shared/src/hooks/perspective-utils.ts +++ b/frontend/packages/console-shared/src/hooks/perspective-utils.ts @@ -151,12 +151,12 @@ export const usePerspectives = (): LoadedExtension[] => { } }, [perspectiveExtensions, handleResults]); const perspectives = React.useMemo(() => { - const filteredExtensions = perspectiveExtensions.filter((e) => results[e.properties.id]); - if (!window.SERVER_FLAGS.perspectives) { return perspectiveExtensions; } + const filteredExtensions = perspectiveExtensions.filter((e) => results[e.properties.id]); + return filteredExtensions.length === 0 && Object.keys(results).length === perspectiveExtensions.length ? perspectiveExtensions.filter((p) => p.properties.id === 'admin') diff --git a/frontend/packages/dev-console/console-extensions.json b/frontend/packages/dev-console/console-extensions.json index 58d22b1e776..29b04a2b582 100644 --- a/frontend/packages/dev-console/console-extensions.json +++ b/frontend/packages/dev-console/console-extensions.json @@ -22,6 +22,129 @@ "usePerspectiveDetection": { "$codeRef": "perspective.usePerspectiveDetection" } } }, + + { + "type": "console.cluster-configuration/item", + "properties": { + "id": "dev-console.project-access.ProjectAccessRolesConfiguration", + "groupId": "projects", + "label": "%devconsole~Project access Cluster Roles%", + "description": "%devconsole~Define a list of ClusterRole names that are assignable to users on the project access page.%", + "field": { + "type": "custom", + "component": { "$codeRef": "projectAccess.ProjectAccessRolesConfiguration" } + }, + "readAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "get", + "name": "cluster" + }, + { + "resource": "clusterroles", + "verb": "list" + } + ], + "writeAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "patch", + "name": "cluster" + } + ] + } + }, + { + "type": "console.cluster-configuration/item", + "properties": { + "id": "dev-console.AddPageConfiguration", + "groupId": "developer", + "label": "%devconsole~Add page actions%", + "description": "%devconsole~A list of actions that are not shown to users on the add page.%", + "field": { + "type": "custom", + "component": { "$codeRef": "add.AddPageConfiguration" } + }, + "readAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "get", + "name": "cluster" + } + ], + "writeAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "patch", + "name": "cluster" + } + ] + } + }, + /* + { + "type": "console.cluster-configuration/item", + "properties": { + "id": "dev-console.CatalogCategoriesConfiguration", + "groupId": "developer", + "label": "%devconsole~Developer Catalog Types%", + "description": "%devconsole~Categories which are shown in the developer catalog.%", + "field": { + "type": "custom", + "component": { "$codeRef": "catalog.CatalogCategoriesConfiguration" } + }, + "readAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "get", + "name": "cluster" + } + ], + "writeAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "patch", + "name": "cluster" + } + ] + } + }, + */ + { + "type": "console.cluster-configuration/item", + "properties": { + "id": "dev-console.CatalogTypesConfiguration", + "groupId": "developer", + "label": "%devconsole~Developer Catalog Types%", + "description": "%devconsole~A list of developer catalog types that are not shown to users.%", + "field": { + "type": "custom", + "component": { "$codeRef": "catalog.CatalogTypesConfiguration" } + }, + "readAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "get", + "name": "cluster" + } + ], + "writeAccessReview": [ + { + "group": "operator.openshift.io/v1", + "resource": "consoles", + "verb": "patch", + "name": "cluster" + } + ] + } + }, { "type": "console.flag/hookProvider", "properties": { diff --git a/frontend/packages/dev-console/locales/en/devconsole.json b/frontend/packages/dev-console/locales/en/devconsole.json index 2562a52d981..3f6ac6fff0b 100644 --- a/frontend/packages/dev-console/locales/en/devconsole.json +++ b/frontend/packages/dev-console/locales/en/devconsole.json @@ -1,5 +1,11 @@ { "Developer": "Developer", + "Project access Cluster Roles": "Project access Cluster Roles", + "Define a list of ClusterRole names that are assignable to users on the project access page.": "Define a list of ClusterRole names that are assignable to users on the project access page.", + "Add page actions": "Add page actions", + "A list of actions that are not shown to users on the add page.": "A list of actions that are not shown to users on the add page.", + "Developer Catalog Types": "Developer Catalog Types", + "A list of developer catalog types that are not shown to users.": "A list of developer catalog types that are not shown to users.", "Developer Catalog": "Developer Catalog", "Git Repository": "Git Repository", "Container images": "Container images", @@ -69,6 +75,9 @@ "Add options failed to load. Check your connection and reload the page.": "Add options failed to load. Check your connection and reload the page.", "Add": "Add", "Select a Project to start adding to it or <2>create a Project.": "Select a Project to start adding to it or <2>create a Project.", + "Add page": "Add page", + "Enabled actions": "Enabled actions", + "Disabled actions": "Disabled actions", "Show or hide details about each item": "Show or hide details about each item", "Show add card details": "Show add card details", "Hide add card details": "Hide add card details", @@ -159,8 +168,13 @@ "Shipwright Build": "Shipwright Build", "Shipwright Builds": "Shipwright Builds", "Select a Project to view the list of builds or <2>create a Project.": "Select a Project to view the list of builds or <2>create a Project.", + "Developer catalog": "Developer catalog", + "Enabled categories": "Enabled categories", + "Disabled categories": "Disabled categories", "Add shared applications, services, event sources, or source-to-image builders to your Project from the developer catalog. Cluster administrators can customize the content made available in the catalog.": "Add shared applications, services, event sources, or source-to-image builders to your Project from the developer catalog. Cluster administrators can customize the content made available in the catalog.", "Select a Project to view the developer catalog or <2>create a Project.": "Select a Project to view the developer catalog or <2>create a Project.", + "Enabled types": "Enabled types", + "Disabled types": "Disabled types", "Bindable": "Bindable", "Create": "Create", "Sample repository": "Sample repository", @@ -593,6 +607,8 @@ "Reload": "Reload", "This list has been updated.": "This list has been updated.", "Click reload to see the new list.": "Click reload to see the new list.", + "Available Cluster Roles": "Available Cluster Roles", + "Chosen Cluster Roles": "Chosen Cluster Roles", "Overview": "Overview", "Details": "Details", "Project Details": "Project Details", @@ -601,7 +617,7 @@ "The server doesn't have a resource type {{missingType}}. Try refreshing the page if it was recently added.": "The server doesn't have a resource type {{missingType}}. Try refreshing the page if it was recently added.", "Select a Project to view the list of {{projectLabelPlural}} or <4>create a Project.": "Select a Project to view the list of {{projectLabelPlural}} or <4>create a Project.", "{{count}} resource reached quota": "{{count}} resource reached quota", - "{{count}} resource reached quota_plural": "{{count}} resources reached quota", + "{{count}} resource reached quota_plural": "{{count}} resource reached quotas", "Select a Project to search inside or <2>create a Project.": "Select a Project to search inside or <2>create a Project.", "BindableKinds": "BindableKinds", "Secure route": "Secure route", diff --git a/frontend/packages/dev-console/package.json b/frontend/packages/dev-console/package.json index e2667dd4387..9073f3e1891 100644 --- a/frontend/packages/dev-console/package.json +++ b/frontend/packages/dev-console/package.json @@ -33,7 +33,8 @@ "perspective": "src/utils/perspective.tsx", "userPreferences": "src/components/user-preferences", "serviceBindingContext": "src/components/topology/bindable-services/bindable-service-context.ts", - "projectAccess": "src/components/project-access" + "projectAccess": "src/components/project-access", + "add": "src/components/add" } } } diff --git a/frontend/packages/dev-console/src/components/add/AddPageConfiguration.tsx b/frontend/packages/dev-console/src/components/add/AddPageConfiguration.tsx new file mode 100644 index 00000000000..7c9d99881b6 --- /dev/null +++ b/frontend/packages/dev-console/src/components/add/AddPageConfiguration.tsx @@ -0,0 +1,166 @@ +import * as React from 'react'; +import { DualListSelector, FormSection } from '@patternfly/react-core'; +import * as fuzzy from 'fuzzysearch'; +import { useTranslation } from 'react-i18next'; +import { + AddAction, + isAddAction, + ResolvedExtension, + useResolvedExtensions, +} from '@console/dynamic-plugin-sdk/src'; +import './AddCardItem.scss'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { + useDebounceCallback, + useConsoleOperatorConfig, + patchConsoleOperatorConfig, + LoadError, + SaveStatus, + SaveStatusProps, +} from '@console/shared/src/components/cluster-configuration'; + +type DeveloperCatalogAddPageConfig = K8sResourceKind & { + spec: { + customization?: { + addPage?: { + disabledActions?: string[]; + }; + }; + }; +}; + +type ItemProps = { id: string; addAction?: ResolvedExtension }; + +const Item: React.FC = ({ id, addAction }) => ( +
+ {typeof addAction?.properties.icon === 'string' ? ( + + ) : typeof addAction?.properties.icon !== 'string' && + addAction?.properties.icon && + React.isValidElement(addAction.properties.icon) ? ( + + ) : null} +
{addAction?.properties.label || id}
+
+); + +const AddPageConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { + const { t } = useTranslation(); + + // Available add page items + const [addActionExtensions, addActionExtensionsResolved] = useResolvedExtensions( + isAddAction, + ); + + // Current configuration + const [consoleConfig, consoleConfigLoaded, consoleConfigError] = useConsoleOperatorConfig< + DeveloperCatalogAddPageConfig + >(); + const [disabled, setDisabled] = React.useState(); + React.useEffect(() => { + if (consoleConfig && consoleConfigLoaded && !disabled) { + setDisabled(consoleConfig?.spec?.customization?.addPage?.disabledActions || []); + } + }, [consoleConfig, consoleConfigLoaded, disabled]); + + // Calculate options + const enabledOptions = React.useMemo[]>(() => { + if (!consoleConfigLoaded || !addActionExtensions || !addActionExtensionsResolved || !disabled) { + return []; + } + return addActionExtensions + .filter((addAction) => !disabled || !disabled.includes(addAction.properties.id)) + .sort((addActionA, addActionB) => { + const displayNameA = addActionA.properties.label; + const displayNameB = addActionB.properties.label; + return displayNameA.localeCompare(displayNameB); + }) + .map((addAction) => ( + + )); + }, [addActionExtensions, addActionExtensionsResolved, consoleConfigLoaded, disabled]); + const disabledOptions = React.useMemo[]>(() => { + if (!disabled) { + return []; + } + const addActionsById = addActionExtensions.reduce>>( + (acc, addAction) => { + acc[addAction.properties.id] = addAction; + return acc; + }, + {}, + ); + return [...disabled] + .sort((idA, idB) => { + const addActionA = addActionsById[idA]; + const addActionB = addActionsById[idB]; + const displayNameA = addActionA?.properties.label || idA; + const displayNameB = addActionB?.properties.label || idB; + return displayNameA.localeCompare(displayNameB); + }) + .map((id) => ); + }, [addActionExtensions, disabled]); + + // Save the latest value (disabled string array) + const [saveStatus, setSaveStatus] = React.useState(); + const save = useDebounceCallback(() => { + setSaveStatus({ status: 'in-progress' }); + + const patch: DeveloperCatalogAddPageConfig = { + spec: { + customization: { + addPage: { + disabledActions: disabled?.length > 0 ? disabled : null, + }, + }, + }, + }; + patchConsoleOperatorConfig(patch) + .then(() => setSaveStatus({ status: 'successful' })) + .catch((error) => setSaveStatus({ status: 'error', error })); + }, 2000); + + // Extract disabled string array from Items + const onListChange = ( + newEnabledOptions: React.ReactElement[], + newDisabledOptions: React.ReactElement[], + ) => { + setDisabled(newDisabledOptions.map((node) => node.props.id)); + setSaveStatus({ status: 'pending' }); + save(); + }; + + const filterOption = (option: React.ReactElement, input: string): boolean => { + const title = option.props.addAction?.properties.label || option.props.id; + return fuzzy(input.toLocaleLowerCase(), title.toLocaleLowerCase()); + }; + + return ( + + + + + + + ); +}; + +export default AddPageConfiguration; diff --git a/frontend/packages/dev-console/src/components/add/index.ts b/frontend/packages/dev-console/src/components/add/index.ts new file mode 100644 index 00000000000..e01bbf13a72 --- /dev/null +++ b/frontend/packages/dev-console/src/components/add/index.ts @@ -0,0 +1 @@ +export { default as AddPageConfiguration } from './AddPageConfiguration'; diff --git a/frontend/packages/dev-console/src/components/catalog/CatalogCategoriesConfiguration.tsx b/frontend/packages/dev-console/src/components/catalog/CatalogCategoriesConfiguration.tsx new file mode 100644 index 00000000000..e451e4a3183 --- /dev/null +++ b/frontend/packages/dev-console/src/components/catalog/CatalogCategoriesConfiguration.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { + DualListSelector, + DualListSelectorTreeItemData, + FormSection, +} from '@patternfly/react-core'; +import * as fuzzy from 'fuzzysearch'; +import { useTranslation } from 'react-i18next'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { CatalogCategory } from '@console/shared/src/components/catalog/utils/types'; +import { + useDebounceCallback, + useConsoleOperatorConfig, + patchConsoleOperatorConfig, + LoadError, + SaveStatus, + SaveStatusProps, +} from '@console/shared/src/components/cluster-configuration'; +import { defaultCatalogCategories } from '@console/shared/src/utils/default-categories'; + +type DeveloperCatalogTypesConsoleConfig = K8sResourceKind & { + spec: { + customization?: { + developerCatalog?: { + categories?: CatalogCategory[]; + }; + }; + }; +}; + +const CatalogCategoriesConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { + const { t } = useTranslation(); + + // Current configuration + const [consoleConfig, consoleConfigLoaded, consoleConfigError] = useConsoleOperatorConfig< + DeveloperCatalogTypesConsoleConfig + >(); + const [currentCategories, setCurrentCategories] = React.useState(); + React.useEffect(() => { + if (consoleConfig && consoleConfigLoaded && !currentCategories) { + setCurrentCategories( + consoleConfig?.spec?.customization?.developerCatalog?.categories || + defaultCatalogCategories, + ); + } + }, [consoleConfig, consoleConfigLoaded, currentCategories]); + + // Calculate options + const [enabledOptions, disabledOptions] = React.useMemo< + [DualListSelectorTreeItemData[], DualListSelectorTreeItemData[]] + >(() => { + if (!consoleConfigLoaded) { + return [[], []]; + } + + const catalogCategoryToTreeItemData = ( + catalogCategory: CatalogCategory, + ): DualListSelectorTreeItemData => ({ + id: catalogCategory.id, + text: catalogCategory.label, + isChecked: false, + children: catalogCategory.subcategories?.map(catalogCategoryToTreeItemData), + defaultExpanded: true, + }); + + const x = defaultCatalogCategories.map(catalogCategoryToTreeItemData); + + return [x, []]; + }, [consoleConfigLoaded]); + + // Save the latest value (types) + const [saveStatus, setSaveStatus] = React.useState(); + const save = useDebounceCallback(() => { + setSaveStatus({ status: 'in-progress' }); + + const patch: DeveloperCatalogTypesConsoleConfig = { + spec: { + customization: { + developerCatalog: { + categories: currentCategories, + // types: types.length > 0 ? types : null + }, + }, + }, + }; + patchConsoleOperatorConfig(patch) + .then(() => setSaveStatus({ status: 'successful' })) + .catch((error) => setSaveStatus({ status: 'error', error })); + }, 2000); + + // Extract types from Items + const onListChange = ( + newEnabledOptions: DualListSelectorTreeItemData[], + newDisabledOptions: DualListSelectorTreeItemData[], + ) => { + // eslint-disable-next-line no-console + console.log('xxx categories onListChange', newEnabledOptions, newDisabledOptions); + setSaveStatus({ status: 'pending' }); + save(); + }; + + const filterOption = (option: DualListSelectorTreeItemData, input: string): boolean => { + return fuzzy(input.toLocaleLowerCase(), option.text.toLocaleLowerCase()); + }; + + return ( + + + + + + + ); +}; + +export default CatalogCategoriesConfiguration; diff --git a/frontend/packages/dev-console/src/components/catalog/CatalogTypesConfiguration.tsx b/frontend/packages/dev-console/src/components/catalog/CatalogTypesConfiguration.tsx new file mode 100644 index 00000000000..99c621ea42c --- /dev/null +++ b/frontend/packages/dev-console/src/components/catalog/CatalogTypesConfiguration.tsx @@ -0,0 +1,249 @@ +import * as React from 'react'; +import { DualListSelector, FormSection } from '@patternfly/react-core'; +import * as fuzzy from 'fuzzysearch'; +import { useTranslation } from 'react-i18next'; +import { CatalogItemType, isCatalogItemType } from '@console/dynamic-plugin-sdk/src/extensions'; +import { useResolvedExtensions } from '@console/dynamic-plugin-sdk/src/lib-core'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { + useDebounceCallback, + useConsoleOperatorConfig, + patchConsoleOperatorConfig, + LoadError, + SaveStatus, + SaveStatusProps, +} from '@console/shared/src/components/cluster-configuration'; + +type Types = { + state: 'Enabled' | 'Disabled'; + enabled?: string[]; + disabled?: string[]; +}; + +type DeveloperCatalogTypesConsoleConfig = K8sResourceKind & { + spec: { + customization?: { + developerCatalog?: { + types?: Types; + }; + }; + }; +}; + +type ItemProps = { type: string; title: string }; + +const Item: React.FC = ({ title }) => <>{title}; + +const CatalogTypesConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { + const { t } = useTranslation(); + + // Available catalog types + const [catalogTypesExtensions, catalogTypesExtensionsLoaded] = useResolvedExtensions< + CatalogItemType + >(isCatalogItemType); + const sortedCatalogTypeExtensions = React.useMemo(() => { + return [...catalogTypesExtensions].sort((catalogTypeExtensionA, catalogTypeExtensionB) => { + const titleA = catalogTypeExtensionA.properties.title; + const titleB = catalogTypeExtensionB.properties.title; + return titleA.localeCompare(titleB); + }); + }, [catalogTypesExtensions]); + const catalogTypesByType = React.useMemo>( + () => + catalogTypesExtensions.reduce((acc, catalogItemType) => { + acc[catalogItemType.properties.type] = catalogItemType; + return acc; + }, {}), + [catalogTypesExtensions], + ); + + // Current configuration + const [consoleConfig, consoleConfigLoaded, consoleConfigError] = useConsoleOperatorConfig< + DeveloperCatalogTypesConsoleConfig + >(); + const [types, setTypes] = React.useState(); + React.useEffect(() => { + if (consoleConfig && consoleConfigLoaded && !types) { + setTypes(consoleConfig?.spec?.customization?.developerCatalog?.types); + } + }, [consoleConfig, consoleConfigLoaded, types]); + + // Calculate options + const [enabledOptions, disabledOptions] = React.useMemo< + [React.ReactElement[], React.ReactElement[]] + >(() => { + if (!consoleConfigLoaded) { + return [[], []]; + } + if (!types?.state || types.state === 'Enabled') { + if (types?.enabled?.length > 0) { + return [ + [...types.enabled] + .sort((typeA, typeB) => { + const catalogTypeExtensionA = catalogTypesByType[typeA]; + const catalogTypeExtensionB = catalogTypesByType[typeB]; + const titleA = catalogTypeExtensionA?.properties.title || typeA; + const titleB = catalogTypeExtensionB?.properties.title || typeB; + return titleA.localeCompare(titleB); + }) + .map((type) => ( + + )), + sortedCatalogTypeExtensions + .filter((catalogItemType) => !types.enabled.includes(catalogItemType.properties.type)) + .map((catalogItemType) => ( + + )), + ]; + } + return [ + sortedCatalogTypeExtensions.map((catalogItemType) => ( + + )), + [], + ]; + } + if (types?.state === 'Disabled') { + if (types.disabled?.length > 0) { + return [ + sortedCatalogTypeExtensions + .filter((catalogItemType) => !types.disabled.includes(catalogItemType.properties.type)) + .map((catalogItemType) => ( + + )), + [...types.disabled] + .sort((typeA, typeB) => { + const catalogTypeExtensionA = catalogTypesByType[typeA]; + const catalogTypeExtensionB = catalogTypesByType[typeB]; + const titleA = catalogTypeExtensionA?.properties.title || typeA; + const titleB = catalogTypeExtensionB?.properties.title || typeB; + return titleA.localeCompare(titleB); + }) + .map((type) => ( + + )), + ]; + } + return [ + [], + sortedCatalogTypeExtensions.map((catalogItemType) => ( + + )), + ]; + } + return [[], []]; + }, [consoleConfigLoaded, types, sortedCatalogTypeExtensions, catalogTypesByType]); + + // Save the latest value (types) + const [saveStatus, setSaveStatus] = React.useState(); + const save = useDebounceCallback(() => { + setSaveStatus({ status: 'in-progress' }); + + const patch: DeveloperCatalogTypesConsoleConfig = { + spec: { + customization: { + developerCatalog: { + types: + // Force null (clear types) when state is enabled and no enabled option is defined. + types && !(types.state === 'Enabled' && !types.enabled?.length) + ? { + state: types.state, + // Force null (clear both lists) when they are undefined. + enabled: types.enabled || null, + disabled: types.disabled || null, + } + : null, + }, + }, + }, + }; + patchConsoleOperatorConfig(patch) + .then(() => setSaveStatus({ status: 'successful' })) + .catch((error) => setSaveStatus({ status: 'error', error })); + }, 2000); + + // Extract types from Items + const onListChange = ( + newEnabledOptions: React.ReactElement[], + newDisabledOptions: React.ReactElement[], + ) => { + if (types?.state === 'Enabled') { + if (newEnabledOptions.length === 0) { + setTypes({ state: 'Disabled' }); + } else if (!types.enabled?.length) { + // When there was NO enabled option before, we assume the admin want to disable + // only the selected options: + setTypes({ + state: 'Disabled', + disabled: newDisabledOptions.map((node) => node.props.type), + }); + } else { + // Otherwise the state was enabled and contains a list of enabled options. + // In this case we just drop the this option. + setTypes({ state: 'Enabled', enabled: newEnabledOptions.map((node) => node.props.type) }); + } + } + if (!types?.state || types?.state === 'Disabled') { + if (newDisabledOptions.length === 0) { + setTypes({ state: 'Enabled' }); + } else { + setTypes({ + state: 'Disabled', + disabled: newDisabledOptions.map((node) => node.props.type), + }); + } + } + setSaveStatus({ status: 'pending' }); + save(); + }; + + const filterOption = (option: React.ReactElement, input: string): boolean => { + return fuzzy(input.toLocaleLowerCase(), option.props.title.toLocaleLowerCase()); + }; + + return ( + + + + + + + ); +}; + +export default CatalogTypesConfiguration; diff --git a/frontend/packages/dev-console/src/components/catalog/index.ts b/frontend/packages/dev-console/src/components/catalog/index.ts index 254ec8d9f00..4ebae12f074 100644 --- a/frontend/packages/dev-console/src/components/catalog/index.ts +++ b/frontend/packages/dev-console/src/components/catalog/index.ts @@ -1 +1,3 @@ export * from './providers'; +export { default as CatalogCategoriesConfiguration } from './CatalogCategoriesConfiguration'; +export { default as CatalogTypesConfiguration } from './CatalogTypesConfiguration'; diff --git a/frontend/packages/dev-console/src/components/project-access/ProjectAccessRolesConfiguration.tsx b/frontend/packages/dev-console/src/components/project-access/ProjectAccessRolesConfiguration.tsx new file mode 100644 index 00000000000..1f7a78760a4 --- /dev/null +++ b/frontend/packages/dev-console/src/components/project-access/ProjectAccessRolesConfiguration.tsx @@ -0,0 +1,187 @@ +import * as React from 'react'; +import { DualListSelector, FormSection } from '@patternfly/react-core'; +import * as fuzzy from 'fuzzysearch'; +import { useTranslation } from 'react-i18next'; +import { + getGroupVersionKindForModel, + K8sResourceCommon, + ResourceIcon, +} from '@console/dynamic-plugin-sdk/src/lib-core'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource'; +import { ClusterRoleModel } from '@console/internal/models'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { + useDebounceCallback, + useConsoleOperatorConfig, + patchConsoleOperatorConfig, + LoadError, + SaveStatus, + SaveStatusProps, +} from '@console/shared/src/components/cluster-configuration'; + +const defaultClusterRoleNames = ['admin', 'edit', 'view']; + +type DeveloperCatalogClusterRolesConfig = K8sResourceKind & { + spec: { + customization?: { + projectAccess?: { + availableClusterRoles?: string[]; + }; + }; + }; +}; + +type ItemProps = { name: string; clusterRole?: K8sResourceCommon }; + +const getDisplayName = (clusterRole?: K8sResourceCommon, name?: string) => + clusterRole?.metadata.annotations?.['console.openshift.io/display-name'] || + clusterRole?.metadata.name || + name; + +const Item: React.FC = ({ name, clusterRole }) => ( +
+ {clusterRole ? ( + + ) : null} +
{getDisplayName(clusterRole, name)}
+
+); + +const ProjectAccessRolesConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { + const { t } = useTranslation(); + + // Available cluster roles + const [allClusterRoles, allClusterRolesLoaded, allClusterRolesError] = useK8sWatchResource< + K8sResourceCommon[] + >({ + groupVersionKind: getGroupVersionKindForModel(ClusterRoleModel), + isList: true, + }); + const sortedClusterRoles = React.useMemo(() => { + const clusterRoles = allClusterRoles ? [...allClusterRoles] : []; + clusterRoles.sort((clusterRoleA, clusterRoleB) => { + const displayNameA = getDisplayName(clusterRoleA); + const displayNameB = getDisplayName(clusterRoleB); + return displayNameA.localeCompare(displayNameB); + }); + return clusterRoles; + }, [allClusterRoles]); + + // Current configuration + const [consoleConfig, consoleConfigLoaded, consoleConfigError] = useConsoleOperatorConfig< + DeveloperCatalogClusterRolesConfig + >(); + const [selectedClusterRoles, setSelectedClusterRoles] = React.useState(); + React.useEffect(() => { + if (consoleConfig && consoleConfigLoaded && !selectedClusterRoles) { + setSelectedClusterRoles( + consoleConfig?.spec?.customization?.projectAccess?.availableClusterRoles || [], + ); + } + }, [selectedClusterRoles, consoleConfig, consoleConfigLoaded]); + + // Calculate options + const availableOptions = React.useMemo[]>(() => { + if ( + !consoleConfigLoaded || + !allClusterRolesLoaded || + allClusterRolesError || + !selectedClusterRoles + ) { + return []; + } + const hideClusterRoleNames = + selectedClusterRoles.length === 0 ? defaultClusterRoleNames : selectedClusterRoles; + return sortedClusterRoles + .filter((clusterRole) => !hideClusterRoleNames.includes(clusterRole.metadata.name)) + .map((clusterRole) => ( + + )); + }, [ + sortedClusterRoles, + allClusterRolesError, + allClusterRolesLoaded, + selectedClusterRoles, + consoleConfigLoaded, + ]); + const chosenOptions = React.useMemo[]>(() => { + if (!selectedClusterRoles) { + return []; + } + const allClusterRolesByName = allClusterRoles.reduce>( + (acc, clusterRole) => { + acc[clusterRole.metadata.name] = clusterRole; + return acc; + }, + {}, + ); + const clusterRoleNames = + selectedClusterRoles.length === 0 ? defaultClusterRoleNames : selectedClusterRoles; + return clusterRoleNames.map((name) => ( + + )); + }, [allClusterRoles, selectedClusterRoles]); + + // Save the latest value (disabled string array) + const [saveStatus, setSaveStatus] = React.useState(); + const save = useDebounceCallback(() => { + setSaveStatus({ status: 'in-progress' }); + + const patch: DeveloperCatalogClusterRolesConfig = { + spec: { + customization: { + projectAccess: { + availableClusterRoles: selectedClusterRoles?.length > 0 ? selectedClusterRoles : null, + }, + }, + }, + }; + patchConsoleOperatorConfig(patch) + .then(() => setSaveStatus({ status: 'successful' })) + .catch((error) => setSaveStatus({ status: 'error', error })); + }, 2000); + + // Extract disabled string array from Items + const onListChange = ( + newEnabledOptions: React.ReactElement[], + newDisabledOptions: React.ReactElement[], + ) => { + setSelectedClusterRoles(newDisabledOptions.map((node) => node.props.name)); + setSaveStatus({ status: 'pending' }); + save(); + }; + + const filterOption = (option: React.ReactElement, input: string): boolean => { + const displayName = getDisplayName(option.props.clusterRole, option.props.name); + return fuzzy(input.toLocaleLowerCase(), displayName.toLocaleLowerCase()); + }; + + return ( + + + + + + + ); +}; + +export default ProjectAccessRolesConfiguration; diff --git a/frontend/packages/dev-console/src/components/project-access/index.ts b/frontend/packages/dev-console/src/components/project-access/index.ts index 31c930c7736..3eafd882510 100644 --- a/frontend/packages/dev-console/src/components/project-access/index.ts +++ b/frontend/packages/dev-console/src/components/project-access/index.ts @@ -1 +1,2 @@ +export { default as ProjectAccessRolesConfiguration } from './ProjectAccessRolesConfiguration'; export { default as ProjectAccessPage } from './ProjectAccessPage'; From 8812f023e0e90a66956334def4cb2ce6e4ca4ebb Mon Sep 17 00:00:00 2001 From: Debsmita1 Date: Tue, 18 Oct 2022 09:10:54 +0200 Subject: [PATCH 4/4] add telemetry event for cluster configuration changes (#8) --- .../detect-perspective/PerspectiveConfiguration.tsx | 8 ++++++++ .../quick-starts/QuickStartConfiguration.tsx | 6 ++++++ .../src/components/add/AddPageConfiguration.tsx | 6 ++++++ .../catalog/CatalogCategoriesConfiguration.tsx | 5 +++++ .../components/catalog/CatalogTypesConfiguration.tsx | 12 ++++++++++++ .../ProjectAccessRolesConfiguration.tsx | 6 ++++++ 6 files changed, 43 insertions(+) diff --git a/frontend/packages/console-app/src/components/detect-perspective/PerspectiveConfiguration.tsx b/frontend/packages/console-app/src/components/detect-perspective/PerspectiveConfiguration.tsx index 09190c4eee0..877ef8ea40e 100644 --- a/frontend/packages/console-app/src/components/detect-perspective/PerspectiveConfiguration.tsx +++ b/frontend/packages/console-app/src/components/detect-perspective/PerspectiveConfiguration.tsx @@ -17,6 +17,7 @@ import { } from '@console/dynamic-plugin-sdk/src'; import { K8sResourceKind } from '@console/internal/module/k8s'; import { useExtensions } from '@console/plugin-sdk'; +import { useTelemetry } from '@console/shared/src'; import { useDebounceCallback, useConsoleOperatorConfig, @@ -183,6 +184,7 @@ const PerspectiveVisibilitySelect: React.FC<{ const PerspectiveConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { const { t } = useTranslation(); + const fireTelemetryEvent = useTelemetry(); // All available perspectives const perspectiveExtensions = useExtensions(isPerspective); @@ -226,6 +228,12 @@ const PerspectiveConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) const perspectiveId = perspectiveExtension.properties.id; const value = configuredPerspectives?.find((p) => p.id === perspectiveId)?.visibility; const onChange = (selectedOption: PerspectiveVisibilitySelectOptions) => { + fireTelemetryEvent('Console cluster configuration changed', { + customize: 'Perspective', + id: perspectiveExtension.properties.id, + name: perspectiveExtension.properties.name, + visibility: selectedOption.value, + }); if (selectedOption.visibility) { setConfiguredPerspectives((oldConfiguredPerspectives) => { const newConfiguredPerspectives = oldConfiguredPerspectives diff --git a/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx b/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx index c029bdcba37..1266532ff0d 100644 --- a/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx +++ b/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx @@ -9,6 +9,7 @@ import { } from '@console/dynamic-plugin-sdk/src/lib-core'; import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource'; import { K8sResourceKind } from '@console/internal/module/k8s'; +import { useTelemetry } from '@console/shared/src'; import { useDebounceCallback, useConsoleOperatorConfig, @@ -49,6 +50,7 @@ const Item: React.FC = ({ id, quickStart }) => ( const QuickStartConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { const { t } = useTranslation(); + const fireTelemetryEvent = useTelemetry(); // All available quick starts const [allQuickStarts, allQuickStartsLoaded, allQuickStartsError] = useK8sWatchResource< @@ -114,6 +116,10 @@ const QuickStartConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) // Save the latest value (disabled string array) const [saveStatus, setSaveStatus] = React.useState(); const save = useDebounceCallback(() => { + fireTelemetryEvent('Console cluster configuration changed', { + customize: 'Quick Starts', + disabled, + }); setSaveStatus({ status: 'in-progress' }); const patch: DisabledQuickStartsConsoleConfig = { diff --git a/frontend/packages/dev-console/src/components/add/AddPageConfiguration.tsx b/frontend/packages/dev-console/src/components/add/AddPageConfiguration.tsx index 7c9d99881b6..d0ff127247c 100644 --- a/frontend/packages/dev-console/src/components/add/AddPageConfiguration.tsx +++ b/frontend/packages/dev-console/src/components/add/AddPageConfiguration.tsx @@ -10,6 +10,7 @@ import { } from '@console/dynamic-plugin-sdk/src'; import './AddCardItem.scss'; import { K8sResourceKind } from '@console/internal/module/k8s'; +import { useTelemetry } from '@console/shared/src'; import { useDebounceCallback, useConsoleOperatorConfig, @@ -53,6 +54,7 @@ const Item: React.FC = ({ id, addAction }) => ( const AddPageConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { const { t } = useTranslation(); + const fireTelemetryEvent = useTelemetry(); // Available add page items const [addActionExtensions, addActionExtensionsResolved] = useResolvedExtensions( @@ -111,6 +113,10 @@ const AddPageConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => // Save the latest value (disabled string array) const [saveStatus, setSaveStatus] = React.useState(); const save = useDebounceCallback(() => { + fireTelemetryEvent('Console cluster configuration changed', { + customize: 'Add page actions', + disabledActions: disabled?.length > 0 ? disabled : null, + }); setSaveStatus({ status: 'in-progress' }); const patch: DeveloperCatalogAddPageConfig = { diff --git a/frontend/packages/dev-console/src/components/catalog/CatalogCategoriesConfiguration.tsx b/frontend/packages/dev-console/src/components/catalog/CatalogCategoriesConfiguration.tsx index e451e4a3183..2b93bd1cac7 100644 --- a/frontend/packages/dev-console/src/components/catalog/CatalogCategoriesConfiguration.tsx +++ b/frontend/packages/dev-console/src/components/catalog/CatalogCategoriesConfiguration.tsx @@ -7,6 +7,7 @@ import { import * as fuzzy from 'fuzzysearch'; import { useTranslation } from 'react-i18next'; import { K8sResourceKind } from '@console/internal/module/k8s'; +import { useTelemetry } from '@console/shared/src'; import { CatalogCategory } from '@console/shared/src/components/catalog/utils/types'; import { useDebounceCallback, @@ -30,6 +31,7 @@ type DeveloperCatalogTypesConsoleConfig = K8sResourceKind & { const CatalogCategoriesConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { const { t } = useTranslation(); + const fireTelemetryEvent = useTelemetry(); // Current configuration const [consoleConfig, consoleConfigLoaded, consoleConfigError] = useConsoleOperatorConfig< @@ -71,6 +73,9 @@ const CatalogCategoriesConfiguration: React.FC<{ readonly: boolean }> = ({ reado // Save the latest value (types) const [saveStatus, setSaveStatus] = React.useState(); const save = useDebounceCallback(() => { + fireTelemetryEvent('Console cluster configuration changed', { + customize: 'Developer Catalog categories', + }); setSaveStatus({ status: 'in-progress' }); const patch: DeveloperCatalogTypesConsoleConfig = { diff --git a/frontend/packages/dev-console/src/components/catalog/CatalogTypesConfiguration.tsx b/frontend/packages/dev-console/src/components/catalog/CatalogTypesConfiguration.tsx index 99c621ea42c..ba7810ce5ec 100644 --- a/frontend/packages/dev-console/src/components/catalog/CatalogTypesConfiguration.tsx +++ b/frontend/packages/dev-console/src/components/catalog/CatalogTypesConfiguration.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { CatalogItemType, isCatalogItemType } from '@console/dynamic-plugin-sdk/src/extensions'; import { useResolvedExtensions } from '@console/dynamic-plugin-sdk/src/lib-core'; import { K8sResourceKind } from '@console/internal/module/k8s'; +import { useTelemetry } from '@console/shared/src'; import { useDebounceCallback, useConsoleOperatorConfig, @@ -36,6 +37,7 @@ const Item: React.FC = ({ title }) => <>{title}; const CatalogTypesConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { const { t } = useTranslation(); + const fireTelemetryEvent = useTelemetry(); // Available catalog types const [catalogTypesExtensions, catalogTypesExtensionsLoaded] = useResolvedExtensions< @@ -161,6 +163,16 @@ const CatalogTypesConfiguration: React.FC<{ readonly: boolean }> = ({ readonly } // Save the latest value (types) const [saveStatus, setSaveStatus] = React.useState(); const save = useDebounceCallback(() => { + fireTelemetryEvent('Console cluster configuration changed', { + customize: 'Developer Catalog types', + state: types?.state, + types: + types?.state === 'Enabled' + ? types.enabled || [] + : types?.state === 'Disabled' + ? types.disabled || [] + : null, + }); setSaveStatus({ status: 'in-progress' }); const patch: DeveloperCatalogTypesConsoleConfig = { diff --git a/frontend/packages/dev-console/src/components/project-access/ProjectAccessRolesConfiguration.tsx b/frontend/packages/dev-console/src/components/project-access/ProjectAccessRolesConfiguration.tsx index 1f7a78760a4..9be950a4c71 100644 --- a/frontend/packages/dev-console/src/components/project-access/ProjectAccessRolesConfiguration.tsx +++ b/frontend/packages/dev-console/src/components/project-access/ProjectAccessRolesConfiguration.tsx @@ -10,6 +10,7 @@ import { import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource'; import { ClusterRoleModel } from '@console/internal/models'; import { K8sResourceKind } from '@console/internal/module/k8s'; +import { useTelemetry } from '@console/shared/src'; import { useDebounceCallback, useConsoleOperatorConfig, @@ -49,6 +50,7 @@ const Item: React.FC = ({ name, clusterRole }) => ( const ProjectAccessRolesConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) => { const { t } = useTranslation(); + const fireTelemetryEvent = useTelemetry(); // Available cluster roles const [allClusterRoles, allClusterRolesLoaded, allClusterRolesError] = useK8sWatchResource< @@ -129,6 +131,10 @@ const ProjectAccessRolesConfiguration: React.FC<{ readonly: boolean }> = ({ read // Save the latest value (disabled string array) const [saveStatus, setSaveStatus] = React.useState(); const save = useDebounceCallback(() => { + fireTelemetryEvent('Console cluster configuration changed', { + customize: 'Project Access cluster roles', + roles: selectedClusterRoles?.length > 0 ? selectedClusterRoles : null, + }); setSaveStatus({ status: 'in-progress' }); const patch: DeveloperCatalogClusterRolesConfig = {