From 9e9c3338ead272bda3765ab2182a4fda4e53dfa0 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Mon, 5 Aug 2024 12:46:36 -0400 Subject: [PATCH] [RHOAIENG-10312] Connection type table view --- .../connection-types/connectionTypeUtils.ts | 4 +- .../__mocks__/mockConnectionTypeResource.ts | 47 +++++++ .../cypress/cypress/pages/connectionTypes.ts | 114 +++++++++++++++++ .../cypress/cypress/support/commands/odh.ts | 12 ++ .../connectionTypes/connectionTypes.cy.ts | 106 ++++++++++++++++ frontend/src/app/AppRoutes.tsx | 5 + frontend/src/concepts/areas/const.ts | 6 +- frontend/src/concepts/areas/types.ts | 3 +- .../src/concepts/connectionTypes/types.ts | 4 +- .../useConnectionTypesEnabled.ts | 2 +- .../pages/connectionTypes/ConnectionTypes.tsx | 31 +++++ .../connectionTypes/ConnectionTypesTable.tsx | 83 ++++++++++++ .../ConnectionTypesTableRow.tsx | 119 ++++++++++++++++++ .../ConnectionTypesTableToolbar.tsx | 52 ++++++++ .../connectionTypes/EmptyConnectionTypes.tsx | 32 +++++ frontend/src/pages/connectionTypes/columns.ts | 78 ++++++++++++ frontend/src/pages/connectionTypes/const.ts | 20 +++ .../src/services/connectionTypesService.ts | 27 ++-- frontend/src/utilities/NavData.tsx | 10 ++ .../src/utilities/useWatchConnectionTypes.tsx | 50 ++++++++ 20 files changed, 786 insertions(+), 19 deletions(-) create mode 100644 frontend/src/__mocks__/mockConnectionTypeResource.ts create mode 100644 frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts create mode 100644 frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts create mode 100644 frontend/src/pages/connectionTypes/ConnectionTypes.tsx create mode 100644 frontend/src/pages/connectionTypes/ConnectionTypesTable.tsx create mode 100644 frontend/src/pages/connectionTypes/ConnectionTypesTableRow.tsx create mode 100644 frontend/src/pages/connectionTypes/ConnectionTypesTableToolbar.tsx create mode 100644 frontend/src/pages/connectionTypes/EmptyConnectionTypes.tsx create mode 100644 frontend/src/pages/connectionTypes/columns.ts create mode 100644 frontend/src/pages/connectionTypes/const.ts create mode 100644 frontend/src/utilities/useWatchConnectionTypes.tsx diff --git a/backend/src/routes/api/connection-types/connectionTypeUtils.ts b/backend/src/routes/api/connection-types/connectionTypeUtils.ts index a3d2110ae8..2ad55a262f 100644 --- a/backend/src/routes/api/connection-types/connectionTypeUtils.ts +++ b/backend/src/routes/api/connection-types/connectionTypeUtils.ts @@ -128,9 +128,9 @@ export const patchConnectionType = async ( const { dashboardNamespace } = getNamespaces(fastify); if ( - (partialConfigMap.metadata.labels?.[KnownLabels.DASHBOARD_RESOURCE] && + (partialConfigMap.metadata?.labels?.[KnownLabels.DASHBOARD_RESOURCE] && partialConfigMap.metadata.labels[KnownLabels.DASHBOARD_RESOURCE] !== 'true') || - (partialConfigMap.metadata.labels?.[KnownLabels.CONNECTION_TYPE] && + (partialConfigMap.metadata?.labels?.[KnownLabels.CONNECTION_TYPE] && partialConfigMap.metadata.labels[KnownLabels.CONNECTION_TYPE] !== 'true') ) { const error = 'Unable to update connection type, incorrect labels.'; diff --git a/frontend/src/__mocks__/mockConnectionTypeResource.ts b/frontend/src/__mocks__/mockConnectionTypeResource.ts new file mode 100644 index 0000000000..79ef1f1bd2 --- /dev/null +++ b/frontend/src/__mocks__/mockConnectionTypeResource.ts @@ -0,0 +1,47 @@ +import { KnownLabels } from '~/k8sTypes'; +import { genUID } from '~/__mocks__/mockUtils'; +import { ConnectionTypeConfigMap } from '~/concepts/connectionTypes/types'; + +type MockResourceConfigType = { + name?: string; + displayName?: string; + description?: string; + enabled?: 'true' | 'false'; + username?: string; + creationTimestamp?: string; + data?: { + fields?: string; + }; + uid?: string; +}; + +export const mockConnectionTypeResource = ({ + name = 'test-connection-type', + displayName = 'Test connection type', + description = 'Test connection description', + enabled = 'true', + username = 'testuser', + creationTimestamp = '2024-07-31T15:40:24.000Z', + uid = genUID('connection-type'), + data = {}, +}: MockResourceConfigType): ConnectionTypeConfigMap => ({ + kind: 'ConfigMap', + apiVersion: 'v1', + metadata: { + annotations: { + 'openshift.io/display-name': displayName, + 'openshift.io/description': description, + 'opendatahub.io/enabled': enabled, + 'opendatahub.io/username': username, + }, + name, + namespace: 'redhat-ods-applications', + creationTimestamp, + labels: { + [KnownLabels.DASHBOARD_RESOURCE]: 'true', + 'opendatahub.io/connection-type': 'true', + }, + uid, + }, + data, +}); diff --git a/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts new file mode 100644 index 0000000000..31fede4fcf --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts @@ -0,0 +1,114 @@ +import { appChrome } from '~/__tests__/cypress/cypress/pages/appChrome'; +import { TableRow } from './components/table'; +import { TableToolbar } from './components/TableToolbar'; + +class ConnectionTypesTableToolbar extends TableToolbar {} +class ConnectionTypeRow extends TableRow { + findConnectionTypeName() { + return this.find().findByTestId('connection-type-name'); + } + + shouldHaveName(name: string) { + return this.findConnectionTypeName().should('have.text', name); + } + + findConnectionTypeDescription() { + return this.find().findByTestId('connection-type-description'); + } + + findConnectionTypeCreator() { + return this.find().findByTestId('connection-type-creator'); + } + + shouldHaveDescription(description: string) { + return this.findConnectionTypeDescription().should('have.text', description); + } + + shouldHaveCreator(creator: string) { + return this.findConnectionTypeCreator().should('have.text', creator); + } + + shouldShowPreInstalledLabel() { + return this.find().findByTestId('connection-type-user-label').should('exist'); + } + + findEnabled() { + return this.find().pfSwitchValue('connection-type-enable-switch'); + } + + findEnableSwitch() { + return this.find().pfSwitch('connection-type-enable-switch'); + } + + shouldBeEnabled() { + this.findEnabled().should('be.checked'); + } + + shouldBeDisabled() { + this.findEnabled().should('not.be.checked'); + } + + findEnableStatus() { + return this.find().findByTestId('connection-type-enable-status'); + } +} + +class ConnectionTypesPage { + visit() { + cy.visitWithLogin('/connectionTypes'); + this.wait(); + } + + private wait() { + cy.findByTestId('app-page-title'); + cy.testA11y(); + } + + findNavItem() { + return appChrome.findNavItem('Connection types'); + } + + navigate() { + this.findNavItem().click(); + this.wait(); + } + + shouldHaveConnectionTypes() { + this.findTable().should('exist'); + return this; + } + + shouldReturnNotFound() { + cy.findByTestId('not-found-page').should('exist'); + return this; + } + + shouldBeEmpty() { + cy.findByTestId('connection-types-empty-state').should('exist'); + return this; + } + + findTable() { + return cy.findByTestId('connection-types-table'); + } + + getConnectionTypeRow(name: string) { + return new ConnectionTypeRow(() => + this.findTable().findAllByTestId(`connection-type-name`).contains(name).parents('tr'), + ); + } + + findEmptyFilterResults() { + return cy.findByTestId('no-result-found-title'); + } + + findSortButton(name: string) { + return this.findTable().find('thead').findByRole('button', { name }); + } + + getTableToolbar() { + return new ConnectionTypesTableToolbar(() => cy.findByTestId('connection-types-table-toolbar')); + } +} + +export const connectionTypesPage = new ConnectionTypesPage(); diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts index c02ff019ba..e74011460d 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts @@ -8,6 +8,7 @@ import type { RegisteredModel, RegisteredModelList, } from '~/concepts/modelRegistry/types'; +import type { ConnectionTypeConfigMap } from '~/concepts/connectionTypes/types'; import type { DashboardConfigKind, DataScienceClusterInitializationKindStatus, @@ -565,6 +566,17 @@ declare global { path: { namespace: string }; }, response: OdhResponse, + ) => Cypress.Chainable) & + (( + type: 'GET /api/connection-types', + response: ConnectionTypeConfigMap[], + ) => Cypress.Chainable) & + (( + type: 'PATCH /api/connection-types/:name', + options: { + path: { name: string }; + }, + response: { success: boolean; error: string }, ) => Cypress.Chainable); } } diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts new file mode 100644 index 0000000000..28f608148f --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts @@ -0,0 +1,106 @@ +import { pageNotfound } from '~/__tests__/cypress/cypress/pages/pageNotFound'; +import { + asProductAdminUser, + asProjectAdminUser, +} from '~/__tests__/cypress/cypress/utils/mockUsers'; +import { connectionTypesPage } from '~/__tests__/cypress/cypress/pages/connectionTypes'; +import { mockDashboardConfig } from '~/__mocks__'; +import { mockConnectionTypeResource } from '~/__mocks__/mockConnectionTypeResource'; + +it('Connection types should not be available for non product admins', () => { + asProjectAdminUser(); + cy.visitWithLogin('/connectionTypes'); + pageNotfound.findPage().should('exist'); + connectionTypesPage.findNavItem().should('not.exist'); +}); + +it('Connection types should be hidden by feature flag', () => { + asProductAdminUser(); + + cy.visitWithLogin('/connectionTypes'); + connectionTypesPage.shouldReturnNotFound(); + + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableConnectionTypes: false, + }), + ); + + connectionTypesPage.visit(); +}); + +describe('Connection types', () => { + beforeEach(() => { + asProductAdminUser(); + + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableConnectionTypes: false, + }), + ); + cy.interceptOdh('GET /api/connection-types', [ + mockConnectionTypeResource({}), + mockConnectionTypeResource({ + name: 'no-display-name', + displayName: '', + description: 'description 2', + username: 'Pre-installed', + enabled: 'false', + }), + mockConnectionTypeResource({ + name: 'test-2', + displayName: 'Test display name', + description: 'Test description', + }), + ]); + }); + + it('should show the connections type table', () => { + connectionTypesPage.visit(); + connectionTypesPage.shouldHaveConnectionTypes(); + }); + + it('should show the empty state when there are no results', () => { + cy.interceptOdh('GET /api/connection-types', []); + connectionTypesPage.visit(); + connectionTypesPage.shouldBeEmpty(); + }); + + it('should show the correct column values', () => { + connectionTypesPage.visit(); + + const row = connectionTypesPage.getConnectionTypeRow('Test display name'); + row.shouldHaveDescription('Test description'); + row.shouldHaveCreator('testuser'); + row.shouldBeEnabled(); + + const row2 = connectionTypesPage.getConnectionTypeRow('no-display-name'); + row2.shouldHaveDescription('description 2'); + row2.shouldShowPreInstalledLabel(); + row2.shouldBeDisabled(); + }); + + it('should show status text when switching enabled state', () => { + connectionTypesPage.visit(); + cy.interceptOdh( + 'PATCH /api/connection-types/:name', + { path: { name: 'test-2' } }, + { success: true, error: '' }, + ); + cy.interceptOdh( + 'PATCH /api/connection-types/:name', + { path: { name: 'no-display-name' } }, + { success: true, error: '' }, + ); + + const row = connectionTypesPage.getConnectionTypeRow('Test display name'); + row.findEnableSwitch().click(); + row.findEnableStatus().should('have.text', 'Disabling...'); + + const row2 = connectionTypesPage.getConnectionTypeRow('no-display-name'); + row2.findEnableSwitch().click(); + row2.findEnableStatus().should('have.text', 'Enabling...'); + }); +}); diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index cc6ee4516d..f1fc5d4e27 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -50,6 +50,7 @@ const ClusterSettingsPage = React.lazy(() => import('../pages/clusterSettings/Cl const CustomServingRuntimeRoutes = React.lazy( () => import('../pages/modelServing/customServingRuntimes/CustomServingRuntimeRoutes'), ); +const ConnectionTypesPage = React.lazy(() => import('../pages/connectionTypes/ConnectionTypes')); const GroupSettingsPage = React.lazy(() => import('../pages/groupSettings/GroupSettings')); const LearningCenterPage = React.lazy(() => import('../pages/learningCenter/LearningCenter')); const BYONImagesPage = React.lazy(() => import('../pages/BYONImages/BYONImages')); @@ -69,6 +70,7 @@ const AppRoutes: React.FC = () => { const { isAdmin, isAllowed } = useUser(); const isJupyterEnabled = useCheckJupyterEnabled(); const isHomeAvailable = useIsAreaAvailable(SupportedArea.HOME).status; + const isConnectionTypesAvailable = useIsAreaAvailable(SupportedArea.CONNECTION_TYPES).status; if (!isAllowed) { return ( @@ -123,6 +125,9 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + {isConnectionTypesAvailable ? ( + } /> + ) : null} } /> } /> diff --git a/frontend/src/concepts/areas/const.ts b/frontend/src/concepts/areas/const.ts index d10e8ad62d..b7f9652fcc 100644 --- a/frontend/src/concepts/areas/const.ts +++ b/frontend/src/concepts/areas/const.ts @@ -46,6 +46,9 @@ export const SupportedAreasStateMap: SupportedAreasState = { featureFlags: ['disableCustomServingRuntimes'], reliantAreas: [SupportedArea.MODEL_SERVING], }, + [SupportedArea.CONNECTION_TYPES]: { + featureFlags: ['disableConnectionTypes'], + }, [SupportedArea.DS_PIPELINES]: { featureFlags: ['disablePipelines'], requiredComponents: [StackComponent.DS_PIPELINES], @@ -124,7 +127,4 @@ export const SupportedAreasStateMap: SupportedAreasState = { requiredComponents: [StackComponent.MODEL_REGISTRY], requiredCapabilities: [StackCapability.SERVICE_MESH, StackCapability.SERVICE_MESH_AUTHZ], }, - [SupportedArea.DATA_CONNECTIONS_TYPES]: { - featureFlags: ['disableConnectionTypes'], - }, }; diff --git a/frontend/src/concepts/areas/types.ts b/frontend/src/concepts/areas/types.ts index bd1b4c9c10..a8372cce50 100644 --- a/frontend/src/concepts/areas/types.ts +++ b/frontend/src/concepts/areas/types.ts @@ -40,6 +40,7 @@ export enum SupportedArea { CLUSTER_SETTINGS = 'cluster-settings', USER_MANAGEMENT = 'user-management', ACCELERATOR_PROFILES = 'accelerator-profiles', + CONNECTION_TYPES = 'connections-types', /* DS Projects specific areas */ DS_PROJECTS_PERMISSIONS = 'ds-projects-permission', @@ -61,8 +62,6 @@ export enum SupportedArea { /* Model Registry areas */ MODEL_REGISTRY = 'model-registry', - - DATA_CONNECTIONS_TYPES = 'data-connections-types', } /** Components deployed by the Operator. Part of the DSC Status. */ diff --git a/frontend/src/concepts/connectionTypes/types.ts b/frontend/src/concepts/connectionTypes/types.ts index 82a5b959d5..9e22099fb8 100644 --- a/frontend/src/concepts/connectionTypes/types.ts +++ b/frontend/src/concepts/connectionTypes/types.ts @@ -87,11 +87,11 @@ export type ConnectionTypeField = export type ConnectionTypeConfigMap = K8sResourceCommon & { metadata: { name: string; - annotations: DisplayNameAnnotations & { + annotations?: DisplayNameAnnotations & { 'opendatahub.io/enabled'?: 'true' | 'false'; 'opendatahub.io/username'?: string; }; - labels: DashboardLabels & { + labels?: DashboardLabels & { 'opendatahub.io/connection-type': 'true'; }; }; diff --git a/frontend/src/concepts/connectionTypes/useConnectionTypesEnabled.ts b/frontend/src/concepts/connectionTypes/useConnectionTypesEnabled.ts index 0106da69be..35d8aa15b1 100644 --- a/frontend/src/concepts/connectionTypes/useConnectionTypesEnabled.ts +++ b/frontend/src/concepts/connectionTypes/useConnectionTypesEnabled.ts @@ -1,6 +1,6 @@ import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; const useConnectionTypesEnabled = (): boolean => - useIsAreaAvailable(SupportedArea.DATA_CONNECTIONS_TYPES).status; + useIsAreaAvailable(SupportedArea.CONNECTION_TYPES).status; export default useConnectionTypesEnabled; diff --git a/frontend/src/pages/connectionTypes/ConnectionTypes.tsx b/frontend/src/pages/connectionTypes/ConnectionTypes.tsx new file mode 100644 index 0000000000..d39abeed31 --- /dev/null +++ b/frontend/src/pages/connectionTypes/ConnectionTypes.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { PageSection } from '@patternfly/react-core'; +import { + connectionTypesPageDescription, + connectionTypesPageTitle, +} from '~/pages/connectionTypes/const'; +import ConnectionTypesTable from '~/pages/connectionTypes/ConnectionTypesTable'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import { useWatchConnectionTypes } from '~/utilities/useWatchConnectionTypes'; +import EmptyConnectionTypes from '~/pages/connectionTypes/EmptyConnectionTypes'; + +const ConnectionTypes: React.FC = () => { + const { connectionTypes, loaded, loadError, forceRefresh } = useWatchConnectionTypes(); + + return ( + } + title={connectionTypesPageTitle} + description={connectionTypesPageDescription} + > + + + + + ); +}; + +export default ConnectionTypes; diff --git a/frontend/src/pages/connectionTypes/ConnectionTypesTable.tsx b/frontend/src/pages/connectionTypes/ConnectionTypesTable.tsx new file mode 100644 index 0000000000..3c8bf5b26b --- /dev/null +++ b/frontend/src/pages/connectionTypes/ConnectionTypesTable.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { FilterDataType, initialFilterData } from '~/pages/connectionTypes/const'; +import { connectionTypeColumns } from '~/pages/connectionTypes/columns'; +import DashboardEmptyTableView from '~/concepts/dashboard/DashboardEmptyTableView'; +import ConnectionTypesTableRow from '~/pages/connectionTypes/ConnectionTypesTableRow'; +import ConnectionTypesTableToolbar from '~/pages/connectionTypes/ConnectionTypesTableToolbar'; +import { ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; +import { Table } from '~/components/table'; + +interface ConnectionTypesTableProps { + connectionTypes: ConnectionTypeConfigMapObj[]; + onUpdate: () => void; +} + +const ConnectionTypesTable: React.FC = ({ + connectionTypes, + onUpdate, +}) => { + const [filterData, setFilterData] = React.useState(initialFilterData); + const onClearFilters = React.useCallback(() => setFilterData(initialFilterData), [setFilterData]); + + const filteredConnectionTypes = connectionTypes.filter((connectionType) => { + const keywordFilter = filterData.Keyword?.toLowerCase(); + const createFilter = filterData['Created by']?.toLowerCase(); + + if ( + keywordFilter && + !( + connectionType.metadata.annotations?.['openshift.io/display-name'] || + connectionType.metadata.name + ) + .toLowerCase() + .includes(keywordFilter) && + !connectionType.metadata.annotations?.['openshift.io/description'] + ?.toLowerCase() + .includes(keywordFilter) + ) { + return false; + } + + return ( + !createFilter || + (connectionType.metadata.annotations?.['opendatahub.io/username'] || 'unknown') + .toLowerCase() + .includes(createFilter) + ); + }); + + const resetFilters = () => { + setFilterData(initialFilterData); + }; + + return ( + <> + ( + + )} + toolbarContent={ + + } + disableItemCount + emptyTableView={} + id="connectionTypes-list-table" + /> + + ); +}; + +export default ConnectionTypesTable; diff --git a/frontend/src/pages/connectionTypes/ConnectionTypesTableRow.tsx b/frontend/src/pages/connectionTypes/ConnectionTypesTableRow.tsx new file mode 100644 index 0000000000..758d21ed38 --- /dev/null +++ b/frontend/src/pages/connectionTypes/ConnectionTypesTableRow.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import { Td, Tr } from '@patternfly/react-table'; +import { + Flex, + Icon, + Label, + Switch, + Text, + TextContent, + Timestamp, + TimestampTooltipVariant, + Tooltip, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; +import { relativeTime } from '~/utilities/time'; +import { updateConnectionTypeEnabled } from '~/services/connectionTypesService'; + +type ConnectionTypesTableRowProps = { + obj: ConnectionTypeConfigMapObj; + onUpdate: () => void; +}; + +const ConnectionTypesTableRow: React.FC = ({ obj, onUpdate }) => { + const [statusMessage, setStatusMessage] = React.useState(); + const [errorMessage, setErrorMessage] = React.useState(); + const pendingEnabledState = React.useRef<'true' | 'false' | undefined>(); + const createdDate = obj.metadata.creationTimestamp + ? new Date(obj.metadata.creationTimestamp) + : undefined; + + const onUpdateEnabled = async (enabled: boolean) => { + setStatusMessage(enabled ? 'Enabling...' : 'Disabling...'); + setErrorMessage(undefined); + pendingEnabledState.current = enabled ? 'true' : 'false'; + + const response = await updateConnectionTypeEnabled(obj, enabled); + if (response.success) { + onUpdate(); + return; + } + + setStatusMessage('Failed'); + setErrorMessage(response.error || `Failed to ${enabled ? 'enable' : 'disable'}`); + }; + + React.useEffect(() => { + if ( + pendingEnabledState.current !== undefined && + obj.metadata.annotations?.['opendatahub.io/enabled'] === pendingEnabledState.current + ) { + setStatusMessage(undefined); + pendingEnabledState.current = undefined; + } + }, [obj.metadata.annotations]); + + return ( + + + + + + + ); +}; + +export default ConnectionTypesTableRow; diff --git a/frontend/src/pages/connectionTypes/ConnectionTypesTableToolbar.tsx b/frontend/src/pages/connectionTypes/ConnectionTypesTableToolbar.tsx new file mode 100644 index 0000000000..fa801d0a0d --- /dev/null +++ b/frontend/src/pages/connectionTypes/ConnectionTypesTableToolbar.tsx @@ -0,0 +1,52 @@ +import { TextInput } from '@patternfly/react-core'; +import React from 'react'; +import { FilterToolbar } from '~/concepts/pipelines/content/tables/PipelineFilterBar'; +import { ConnectionTypesOptions, FilterDataType, options } from '~/pages/connectionTypes/const'; + +type ConnectionTypesTableToolbarProps = { + filterData: Record; + setFilterData: React.Dispatch>; + onClearFilters: () => void; +}; + +const ConnectionTypesTableToolbar: React.FC = ({ + setFilterData, + filterData, + onClearFilters, +}) => { + const onFilterUpdate = React.useCallback( + (key: string, value: string | { label: string; value: string } | undefined) => + setFilterData((prevValues) => ({ ...prevValues, [key]: value })), + [setFilterData], + ); + + return ( + + data-testid="connection-types-table-toolbar" + filterOptions={options} + filterOptionRenders={{ + [ConnectionTypesOptions.keyword]: ({ onChange, ...props }) => ( + onChange(value)} + /> + ), + [ConnectionTypesOptions.createdBy]: ({ onChange, ...props }) => ( + onChange(value)} + /> + ), + }} + filterData={filterData} + onClearFilters={onClearFilters} + onFilterUpdate={onFilterUpdate} + /> + ); +}; + +export default ConnectionTypesTableToolbar; diff --git a/frontend/src/pages/connectionTypes/EmptyConnectionTypes.tsx b/frontend/src/pages/connectionTypes/EmptyConnectionTypes.tsx new file mode 100644 index 0000000000..ce62897b60 --- /dev/null +++ b/frontend/src/pages/connectionTypes/EmptyConnectionTypes.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + EmptyStateHeader, + EmptyStateIcon, + EmptyStateVariant, + PageSection, +} from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons'; + +const EmptyConnectionTypes: React.FC = () => ( + + + } + headingLevel="h1" + /> + To get started create a connection type. + + + + + +); + +export default EmptyConnectionTypes; diff --git a/frontend/src/pages/connectionTypes/columns.ts b/frontend/src/pages/connectionTypes/columns.ts new file mode 100644 index 0000000000..3549d3822f --- /dev/null +++ b/frontend/src/pages/connectionTypes/columns.ts @@ -0,0 +1,78 @@ +import { SortableData } from '~/components/table'; +import { ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; + +const sorter = ( + a: ConnectionTypeConfigMapObj, + b: ConnectionTypeConfigMapObj, + keyField: string, +): number => { + let compValue = 0; + + if (keyField === 'creator') { + const aValue = + a.metadata.annotations?.['opendatahub.io/username'] === 'Pre-installed' + ? 'Pre-installed' + : a.metadata.annotations?.['opendatahub.io/username'] || 'unknown'; + const bValue = + b.metadata.annotations?.['opendatahub.io/username'] === 'Pre-installed' + ? 'Pre-installed' + : b.metadata.annotations?.['opendatahub.io/username'] || 'unknown'; + compValue = aValue.localeCompare(bValue); + } + + if (keyField === 'created') { + const aValue = a.metadata.creationTimestamp + ? new Date(a.metadata.creationTimestamp) + : new Date(); + const bValue = b.metadata.creationTimestamp + ? new Date(b.metadata.creationTimestamp) + : new Date(); + return bValue.getTime() - aValue.getTime(); + } + + if (keyField === 'enable') { + return a.metadata.annotations?.['opendatahub.io/enabled'] === 'true' || + b.metadata.annotations?.['opendatahub.io/enabled'] !== 'true' + ? -1 + : 1; + } + + if (compValue !== 0) { + return compValue; + } + + const aValue = a.metadata.annotations?.['openshift.io/display-name'] || a.metadata.name; + const bValue = b.metadata.annotations?.['openshift.io/display-name'] || b.metadata.name; + + return aValue.localeCompare(bValue); +}; + +export const connectionTypeColumns: SortableData[] = [ + { + label: 'Name', + field: 'name', + sortable: sorter, + }, + { + label: 'Creator', + field: 'creator', + sortable: sorter, + }, + { + label: 'Created', + field: 'created', + sortable: sorter, + }, + { + label: 'Enable', + field: 'Enable', + sortable: sorter, + info: { + popover: + 'Enable users in your organization to use this connection type when adding connections. Disabling a connection type will not affect existing connections of that type.', + popoverProps: { + headerContent: 'Enable', + }, + }, + }, +]; diff --git a/frontend/src/pages/connectionTypes/const.ts b/frontend/src/pages/connectionTypes/const.ts new file mode 100644 index 0000000000..4f9c6a2ebe --- /dev/null +++ b/frontend/src/pages/connectionTypes/const.ts @@ -0,0 +1,20 @@ +export const connectionTypesPageTitle = 'Connection types'; +export const connectionTypesPageDescription = + 'Create and manage connection types for users in your organization. Connection types include customizable fields and optional default values to decrease the time required to add connections to data sources and sinks.'; + +export enum ConnectionTypesOptions { + keyword = 'Keyword', + createdBy = 'Created by', +} + +export const options = { + [ConnectionTypesOptions.keyword]: 'Keyword', + [ConnectionTypesOptions.createdBy]: 'Created By', +}; + +export type FilterDataType = Record; + +export const initialFilterData: Record = { + [ConnectionTypesOptions.keyword]: '', + [ConnectionTypesOptions.createdBy]: '', +}; diff --git a/frontend/src/services/connectionTypesService.ts b/frontend/src/services/connectionTypesService.ts index d4e9bcf63e..c59e7e3678 100644 --- a/frontend/src/services/connectionTypesService.ts +++ b/frontend/src/services/connectionTypesService.ts @@ -56,18 +56,27 @@ export const updateConnectionType = ( }; export const updateConnectionTypeEnabled = ( - name: string, + connectionType: ConnectionTypeConfigMapObj, enabled: boolean, ): Promise => { - const url = `/api/connection-types/${name}`; + const url = `/api/connection-types/${connectionType.metadata.name}`; + + const patch = []; + if (!('annotations' in connectionType.metadata)) { + patch.push({ + path: '/metadata/annotations', + op: 'add', + value: {}, + }); + } + patch.push({ + op: connectionType.metadata.annotations?.['opendatahub.io/enabled'] ? 'replace' : 'add', + path: '/metadata/annotations/opendatahub.io~1enabled', + value: String(enabled), + }); + return axios - .patch(url, [ - { - op: 'replace', - path: '/metadata/annotations/opendatahub.io~1enabled', - value: enabled, - }, - ]) + .patch(url, patch) .then((response) => response.data) .catch((e) => { throw new Error(e.response.data.message); diff --git a/frontend/src/utilities/NavData.tsx b/frontend/src/utilities/NavData.tsx index c26ea93ee8..c14ed2c25b 100644 --- a/frontend/src/utilities/NavData.tsx +++ b/frontend/src/utilities/NavData.tsx @@ -141,6 +141,15 @@ const useCustomRuntimesNav = (): NavDataHref[] => }, ]); +const useConnectionTypesNav = (): NavDataHref[] => + useAreaCheck(SupportedArea.CONNECTION_TYPES, [ + { + id: 'settings-connection-types', + label: 'Connection types', + href: '/connectionTypes', + }, + ]); + const useModelRegisterySettingsNav = (): NavDataHref[] => useAreaCheck(SupportedArea.MODEL_REGISTRY, [ { @@ -174,6 +183,7 @@ const useSettingsNav = (): NavDataGroup[] => { ...useClusterSettingsNav(), ...useAcceleratorProfilesNav(), ...useCustomRuntimesNav(), + ...useConnectionTypesNav(), ...useModelRegisterySettingsNav(), ...useUserManagementNav(), ]; diff --git a/frontend/src/utilities/useWatchConnectionTypes.tsx b/frontend/src/utilities/useWatchConnectionTypes.tsx new file mode 100644 index 0000000000..6c15afb308 --- /dev/null +++ b/frontend/src/utilities/useWatchConnectionTypes.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; +import { fetchConnectionTypes } from '~/services/connectionTypesService'; +import { POLL_INTERVAL } from './const'; +import { useDeepCompareMemoize } from './useDeepCompareMemoize'; + +export const useWatchConnectionTypes = (): { + connectionTypes: ConnectionTypeConfigMapObj[]; + loaded: boolean; + loadError: Error | undefined; + forceRefresh: (usernames?: string[]) => void; +} => { + const [loaded, setLoaded] = React.useState(false); + const [loadError, setLoadError] = React.useState(); + const [connectionTypes, setConnectionTypes] = React.useState([]); + + const getConnectionTypes = React.useCallback(() => { + fetchConnectionTypes() + .then((updatedConnectionTypes: ConnectionTypeConfigMapObj[]) => { + setLoaded(true); + setLoadError(undefined); + setConnectionTypes(updatedConnectionTypes); + }) + .catch((e) => { + setLoadError(e); + }); + }, []); + + React.useEffect(() => { + let watchHandle: ReturnType; + + const watchConnectionTypes = () => { + getConnectionTypes(); + watchHandle = setTimeout(watchConnectionTypes, POLL_INTERVAL); + }; + watchConnectionTypes(); + + return () => { + clearTimeout(watchHandle); + }; + }, [getConnectionTypes]); + + const forceRefresh = React.useCallback(() => { + getConnectionTypes(); + }, [getConnectionTypes]); + + const retConnectionTypes = useDeepCompareMemoize(connectionTypes); + + return { connectionTypes: retConnectionTypes, loaded, loadError, forceRefresh }; +};
+ + + + {obj.metadata.annotations?.['openshift.io/display-name'] || obj.metadata.name} + + + + + {obj.metadata.annotations?.['openshift.io/description']} + + + + + {obj.metadata.annotations?.['opendatahub.io/username'] === 'Pre-installed' ? ( + + ) : ( + + {obj.metadata.annotations?.['opendatahub.io/username'] || 'unknown'} + + )} + + + + {createdDate ? relativeTime(Date.now(), createdDate.getTime()) : 'Unknown'} + + + + + + onUpdateEnabled(enabled)} + data-testid="connection-type-enable-switch" + /> + + + {statusMessage} + + {errorMessage ? ( + + + + + + ) : null} + +