From 386ac305e1a5deb0946d04141a43875db652fdde Mon Sep 17 00:00:00 2001 From: Harish Date: Tue, 24 Nov 2020 10:17:30 -0500 Subject: [PATCH] Modify CatalogSource Details view and include new Operators tab Signed-off-by: Harish --- .../tests/catalog-source-details.spec.ts | 82 ++++++ .../locales/en/catalog-source.json | 7 +- .../en/edit-registry-poll-interval-modal.json | 3 + .../operator-lifecycle-manager/mocks.ts | 3 +- .../src/components/catalog-source.spec.tsx | 82 +++--- .../src/components/catalog-source.tsx | 232 +++++++++------ .../src/components/index.tsx | 10 +- .../edit-registry-poll-interval-modal.tsx | 85 ++++++ .../operator-hub/operator-hub-subscribe.tsx | 5 +- .../src/components/package-manifest.spec.tsx | 174 ++++------- .../src/components/package-manifest.tsx | 277 ++++++------------ .../operator-lifecycle-manager/src/plugin.tsx | 18 ++ .../src/utils/useOperatorHubConfig.tsx | 13 + frontend/public/components/factory/table.tsx | 6 + .../public/components/utils/details-item.tsx | 39 ++- 15 files changed, 565 insertions(+), 471 deletions(-) create mode 100644 frontend/packages/operator-lifecycle-manager/integration-tests-cypress/tests/catalog-source-details.spec.ts create mode 100644 frontend/packages/operator-lifecycle-manager/locales/en/edit-registry-poll-interval-modal.json create mode 100644 frontend/packages/operator-lifecycle-manager/src/components/modals/edit-registry-poll-interval-modal.tsx create mode 100644 frontend/packages/operator-lifecycle-manager/src/utils/useOperatorHubConfig.tsx diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests-cypress/tests/catalog-source-details.spec.ts b/frontend/packages/operator-lifecycle-manager/integration-tests-cypress/tests/catalog-source-details.spec.ts new file mode 100644 index 00000000000..68d94efbd6b --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/integration-tests-cypress/tests/catalog-source-details.spec.ts @@ -0,0 +1,82 @@ +import { checkErrors, testName } from '../../../integration-tests-cypress/support'; +import { detailsPage } from '../../../integration-tests-cypress/views/details-page'; +import { modal } from '../../../integration-tests-cypress/views/modal'; + +const catalogSource = 'redhat-operators'; + +describe(`Interacting with CatalogSource page`, () => { + before(() => { + cy.login(); + cy.createProject(testName); + }); + + beforeEach(() => { + cy.log('navigate to Catalog Source page'); + cy.visit(`/settings/cluster`); + cy.byLegacyTestID('horizontal-link-Global configuration').click(); + cy.byLegacyTestID('OperatorHub').click(); + + // verfiy operatorHub details page is open + detailsPage.sectionHeaderShouldExist('OperatorHub details'); + cy.byLegacyTestID('horizontal-link-Sources').click(); + cy.byLegacyTestID(catalogSource).click(); + + // verfiy catalogSource details page is open + detailsPage.sectionHeaderShouldExist('CatalogSource details'); + }); + + afterEach(() => { + checkErrors(); + }); + + after(() => { + cy.deleteProject(testName); + cy.logout(); + }); + + it(`renders details about the ${catalogSource} catalog source`, () => { + // validate Name field + cy.byTestSelector('details-item-label__Name').should('be.visible'); + cy.byTestSelector('details-item-value__Name').should('have.text', catalogSource); + + // validate Status field + cy.byTestSelector('details-item-label__Status').should('be.visible'); + cy.byTestSelector('details-item-value__Status').should('have.text', 'READY'); + + // validate DisplayName field + cy.byTestSelector('details-item-label__Display Name').should('be.visible'); + cy.byTestSelector('details-item-value__Display Name').should('have.text', 'Red Hat Operators'); + + // validate RegistryPollInterval field + cy.byTestID('Registry Poll Interval') + .scrollIntoView() + .should('be.visible'); + cy.byTestSelector('details-item-value__Registry Poll Interval') + .scrollIntoView() + .should('be.visible'); + + // validate NumberOfOperators field + cy.byTestSelector('details-item-label__Number of Operators') + .scrollIntoView() + .should('be.visible'); + cy.byTestSelector('details-item-value__Number of Operators') + .scrollIntoView() + .should('be.visible'); + }); + + it('allows modifying registry poll interval', () => { + cy.byTestID('Registry Poll Interval-details-item__edit-button').click(); + modal.modalTitleShouldContain('Edit registry poll interval'); + cy.byLegacyTestID('dropdown-button').click(); + cy.byTestDropDownMenu('30m0s').click(); + modal.submit(); + + // verify that registryPollInterval is updated + cy.byTestSelector('details-item-value__Registry Poll Interval').should('have.text', '30m0s'); + }); + + it(`lists all the package manifests for ${catalogSource} under Operators tab`, () => { + cy.byLegacyTestID('horizontal-link-catalog-source~Operators').click(); + cy.get('[data-label=Name]').should('exist'); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/locales/en/catalog-source.json b/frontend/packages/operator-lifecycle-manager/locales/en/catalog-source.json index b8d506d417a..8480833a13e 100644 --- a/frontend/packages/operator-lifecycle-manager/locales/en/catalog-source.json +++ b/frontend/packages/operator-lifecycle-manager/locales/en/catalog-source.json @@ -1,12 +1,13 @@ { "Disable": "Disable", "Enable": "Enable", - "Name": "Name", - "Publisher": "Publisher", - "Packages": "Packages", + "CatalogSource details": "CatalogSource details", + "Operators": "Operators", "Package not found": "Package not found", "Cannot create a Subscription to a non-existent package.": "Cannot create a Subscription to a non-existent package.", + "Name": "Name", "Status": "Status", + "Publisher": "Publisher", "Availability": "Availability", "Endpoint": "Endpoint", "Registry poll interval": "Registry poll interval", diff --git a/frontend/packages/operator-lifecycle-manager/locales/en/edit-registry-poll-interval-modal.json b/frontend/packages/operator-lifecycle-manager/locales/en/edit-registry-poll-interval-modal.json new file mode 100644 index 00000000000..578ac9b1455 --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/locales/en/edit-registry-poll-interval-modal.json @@ -0,0 +1,3 @@ +{ + "Registry Poll Interval": "Registry Poll Interval" +} \ No newline at end of file diff --git a/frontend/packages/operator-lifecycle-manager/mocks.ts b/frontend/packages/operator-lifecycle-manager/mocks.ts index 0ecda20f1d1..bbb7d4e429a 100644 --- a/frontend/packages/operator-lifecycle-manager/mocks.ts +++ b/frontend/packages/operator-lifecycle-manager/mocks.ts @@ -245,6 +245,7 @@ export const testPackageManifest: PackageManifestKind = { metadata: { name: 'test-package', namespace: 'default', + creationTimestamp: '2018-05-02T18:10:38Z', }, spec: {}, status: { @@ -689,7 +690,7 @@ const svcatPackageManifest = { }, }; -const dummyPackageManifest = { +export const dummyPackageManifest = { apiVersion: 'packages.operators.coreos.com/v1' as PackageManifestKind['apiVersion'], kind: 'PackageManifest' as PackageManifestKind['kind'], metadata: { diff --git a/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.spec.tsx index dfb91f57c6f..fa15f4a2562 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.spec.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.spec.tsx @@ -5,7 +5,7 @@ import { safeLoad } from 'js-yaml'; import { referenceForModel } from '@console/internal/module/k8s'; import { DetailsPage } from '@console/internal/components/factory'; import { ErrorBoundary } from '@console/shared/src/components/error/error-boundary'; -import { Firehose, LoadingBox } from '@console/internal/components/utils'; +import { Firehose, LoadingBox, DetailsItem } from '@console/internal/components/utils'; import { CreateYAML, CreateYAMLProps } from '@console/internal/components/create-yaml'; import { SubscriptionModel, @@ -13,7 +13,7 @@ import { PackageManifestModel, OperatorGroupModel, } from '../models'; -import { testCatalogSource, testPackageManifest } from '../../mocks'; +import { testCatalogSource, testPackageManifest, dummyPackageManifest } from '../../mocks'; import { CatalogSourceDetails, CatalogSourceDetailsProps, @@ -21,8 +21,9 @@ import { CatalogSourceDetailsPageProps, CreateSubscriptionYAML, CreateSubscriptionYAMLProps, + CatalogSourceOperatorsPage, } from './catalog-source'; -import { PackageManifestList } from './package-manifest'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; jest.mock('react-i18next', () => { const reactI18next = require.requireActual('react-i18next'); @@ -33,40 +34,33 @@ jest.mock('react-i18next', () => { }); const i18nNS = 'details-page'; +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ + useK8sWatchResource: jest.fn(), +})); + describe(CatalogSourceDetails.displayName, () => { let wrapper: ShallowWrapper; let obj: CatalogSourceDetailsProps['obj']; beforeEach(() => { obj = _.cloneDeep(testCatalogSource); - - wrapper = shallow( - , - ); - }); - - it('renders nothing if not all resources are loaded', () => { - wrapper = wrapper.setProps({ obj: null }); - - expect(wrapper.find('.co-catalog-details').exists()).toBe(false); + wrapper = shallow(); }); it('renders name and publisher of the catalog', () => { - expect(wrapper.find('[data-test-id="catalog-source-name"]').text()).toEqual( - obj.spec.displayName, - ); - expect(wrapper.find('[data-test-id="catalog-source-publisher"]').text()).toEqual( - obj.spec.publisher, - ); - }); + expect( + wrapper + .find(DetailsItem) + .at(1) + .props().obj.spec.displayName, + ).toEqual(obj.spec.displayName); - it('renders a `PackageManifestList` component', () => { - expect(wrapper.find(PackageManifestList).props().data).toEqual([testPackageManifest]); + expect( + wrapper + .find(DetailsItem) + .at(2) + .props().obj.spec.publisher, + ).toEqual(obj.spec.publisher); }); }); @@ -75,41 +69,31 @@ describe(CatalogSourceDetailsPage.displayName, () => { let match: CatalogSourceDetailsPageProps['match']; beforeEach(() => { + (useK8sWatchResource as jest.Mock).mockReturnValue([dummyPackageManifest, true, null]); match = { isExact: true, params: { ns: 'default', name: 'some-catalog' }, path: '', url: '' }; wrapper = shallow(); }); it('renders `DetailsPage` with correct props', () => { - const selector = { matchLabels: { catalog: match.params.name } }; - expect(wrapper.find(DetailsPage).props().kind).toEqual(referenceForModel(CatalogSourceModel)); - expect( - wrapper - .find(DetailsPage) - .props() - .pages.map((p) => p.nameKey), - ).toEqual([`${i18nNS}~Details`, `${i18nNS}~YAML`]); - expect(wrapper.find(DetailsPage).props().pages[0].component).toEqual(CatalogSourceDetails); + + const detailsPage = wrapper.find(DetailsPage); + const { pages } = detailsPage.props(); + expect(pages.length).toEqual(3); + expect(pages[0].nameKey).toEqual(`${i18nNS}~Details`); + expect(pages[1].nameKey).toEqual(`${i18nNS}~YAML`); + expect(pages[2].nameKey).toEqual(`catalog-source~Operators`); + + expect(pages[0].component).toEqual(CatalogSourceDetails); + expect(pages[2].component).toEqual(CatalogSourceOperatorsPage); + expect(wrapper.find(DetailsPage).props().resources).toEqual([ { kind: referenceForModel(PackageManifestModel), isList: true, namespace: match.params.ns, - selector, prop: 'packageManifests', }, - { - kind: referenceForModel(SubscriptionModel), - isList: true, - namespace: match.params.ns, - prop: 'subscriptions', - }, - { - kind: referenceForModel(OperatorGroupModel), - isList: true, - namespace: match.params.ns, - prop: 'operatorGroups', - }, ]); }); }); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.tsx b/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.tsx index 172721b9aef..534d0a8ab25 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/catalog-source.tsx @@ -25,6 +25,8 @@ import { SectionHeading, asAccessReview, KebabOption, + ResourceSummary, + DetailsItem, } from '@console/internal/components/utils'; import { DetailsPage, @@ -44,17 +46,14 @@ import { OperatorGroupModel, OperatorHubModel, } from '../models'; -import { - CatalogSourceKind, - SubscriptionKind, - PackageManifestKind, - OperatorGroupKind, -} from '../types'; +import { CatalogSourceKind, PackageManifestKind, OperatorGroupKind } from '../types'; import { requireOperatorGroup } from './operator-group'; -import { PackageManifestList } from './package-manifest'; import { deleteCatalogSourceModal } from './modals/delete-catalog-source-modal'; import { disableDefaultSourceModal } from './modals/disable-default-source-modal'; import { OperatorHubKind } from './operator-hub'; +import { editRegitryPollInterval } from './modals/edit-registry-poll-interval-modal'; +import { PackageManifestsPage } from './package-manifest'; +import useOperatorHubConfig from '../utils/useOperatorHubConfig'; const DEFAULT_SOURCE_NAMESPACE = 'openshift-marketplace'; const catalogSourceModelReference = referenceForModel(CatalogSourceModel); @@ -112,77 +111,152 @@ const DefaultSourceKebab: React.FC = ({ return ; }; +const getOperatorCount = ( + catalogSource: CatalogSourceKind, + packageManifests: PackageManifestKind[], +): number => + packageManifests.filter( + (p) => + p.status?.catalogSource === catalogSource.metadata.name && + p.status?.catalogSourceNamespace === catalogSource.metadata.namespace, + ).length; + +const getEndpoint = (catalogSource: CatalogSourceKind): React.ReactNode => { + if (catalogSource.spec.configmap) { + return ( + + ); + } + return catalogSource.spec.image || catalogSource.spec.address; +}; + export const CatalogSourceDetails: React.FC = ({ - obj, + obj: catalogSource, packageManifests, - subscriptions, - operatorGroups, }) => { - const toData = (data: T[]) => ({ loaded: true, data }); const { t } = useTranslation(); - return !_.isEmpty(obj) ? ( -
-
-
-
-
{t('catalog-source~Name')}
-
{obj.spec.displayName}
-
+ const operatorCount = getOperatorCount(catalogSource, packageManifests); + + const catsrcNamespace = + catalogSource.metadata.namespace === DEFAULT_SOURCE_NAMESPACE + ? 'Cluster wide' + : catalogSource.metadata.namespace; + + return !_.isEmpty(catalogSource) ? ( +
+ +
+
+
-
-
-
{t('catalog-source~Publisher')}
-
{obj.spec.publisher}
-
+
+
+ + + + + {catsrcNamespace} + + + {getEndpoint(catalogSource)} + + editRegitryPollInterval({ catalogSource })} + /> + + {operatorCount} + +
-
- - -
) : (
); }; -export const CatalogSourceDetailsPage: React.FC = (props) => ( - -); +export const CatalogSourceOperatorsPage: React.FC = (props) => { + return ; +}; + +export const CatalogSourceDetailsPage: React.FC = (props) => { + const [operatorHub, operatorHubLoaded, operatorHubLoadError] = useOperatorHubConfig(); + + const isDefaultSource = React.useMemo( + () => + DEFAULT_SOURCE_NAMESPACE === props.match.params.ns && + operatorHub?.status?.sources?.some((source) => source.name === props.match.params.name), + [operatorHub, props.match.params.name, props.match.params.ns], + ); + + const menuActions = isDefaultSource + ? [ + Kebab.factory.Edit, + () => disableSourceModal(OperatorHubModel, operatorHub, props.match.params.name), + ] + : Kebab.factory.common; + + return ( + + ); +}; export const CreateSubscriptionYAML: React.FC = (props) => { type CreateProps = { @@ -262,30 +336,6 @@ const tableColumnClasses = [ Kebab.columnClass, ]; -const getEndpoint = (catalogSource: CatalogSourceKind): React.ReactNode => { - if (catalogSource.spec.configmap) { - return ( - - ); - } - return catalogSource.spec.image || catalogSource.spec.address; -}; - -const getOperatorCount = ( - catalogSource: CatalogSourceKind, - packageManifests: PackageManifestKind[], -): number => - _.filter(packageManifests, { - status: { - catalogSource: catalogSource.metadata.name, - catalogSourceNamespace: catalogSource.metadata.namespace, - }, - } as any).length; // Type inferred to prevent Lodash TypeScript error. - const CatalogSourceTableRow: RowFunction = ({ obj: { availability = '-', @@ -579,9 +629,7 @@ type FlattenArgType = { export type CatalogSourceDetailsProps = { obj: CatalogSourceKind; - subscriptions: SubscriptionKind[]; packageManifests: PackageManifestKind[]; - operatorGroups: OperatorGroupKind[]; }; export type CatalogSourceDetailsPageProps = { @@ -597,6 +645,10 @@ export type CreateSubscriptionYAMLProps = { location: Location; }; +export type CatalogSourceOperatorsPageProps = { + obj: CatalogSourceKind; +} & ListPageProps; + CatalogSourceDetails.displayName = 'CatalogSourceDetails'; CatalogSourceDetailsPage.displayName = 'CatalogSourceDetailPage'; CreateSubscriptionYAML.displayName = 'CreateSubscriptionYAML'; diff --git a/frontend/packages/operator-lifecycle-manager/src/components/index.tsx b/frontend/packages/operator-lifecycle-manager/src/components/index.tsx index ae5bb7830b2..bc64717e538 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/index.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/index.tsx @@ -44,7 +44,15 @@ export const referenceForProvidedAPI = ( export const referenceForStepResource = (resource: StepResource): GroupVersionKind => referenceForGroupVersionKind(resource.group || 'core')(resource.version)(resource.kind); -export const defaultChannelFor = (pkg: PackageManifestKind) => +export const defaultChannelFor = (packageManifest: PackageManifestKind) => { + const channel = !_.isEmpty(packageManifest.status.defaultChannel) + ? packageManifest.status.channels.find( + (ch) => ch.name === packageManifest.status.defaultChannel, + ) + : packageManifest.status.channels[0]; + return channel; +}; +export const defaultChannelNameFor = (pkg: PackageManifestKind) => pkg.status.defaultChannel || pkg?.status?.channels?.[0]?.name; export const installModesFor = (pkg: PackageManifestKind) => (channel: string) => pkg.status.channels.find((ch) => ch.name === channel)?.currentCSVDesc?.installModes || []; diff --git a/frontend/packages/operator-lifecycle-manager/src/components/modals/edit-registry-poll-interval-modal.tsx b/frontend/packages/operator-lifecycle-manager/src/components/modals/edit-registry-poll-interval-modal.tsx new file mode 100644 index 00000000000..ca1effce0cc --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/components/modals/edit-registry-poll-interval-modal.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { k8sPatch } from '@console/internal/module/k8s'; +import { + ModalTitle, + ModalBody, + createModalLauncher, + ModalComponentProps, + ModalSubmitFooter, +} from '@console/internal/components/factory/modal'; +import { + withHandlePromise, + HandlePromiseProps, + Dropdown, +} from '@console/internal/components/utils'; +import { CatalogSourceKind } from '../../types'; +import { CatalogSourceModel } from '../../models'; +import { Form, FormGroup } from '@patternfly/react-core'; + +const availablePollIntervals = { + '10m0s': '10m', + '15m0s': '15m', + '30m0s': '30m', + '45m0s': '45m', + '60m0s': '60m', +}; + +const EditRegistryPollIntervalModal: React.FC = ({ + cancel, + close, + catalogSource, + handlePromise, + errorMessage, +}) => { + const [pollInterval, setPollInterval] = React.useState( + catalogSource.spec?.updateStrategy?.registryPoll?.interval, + ); + + const submit: React.FormEventHandler = (e) => { + e.preventDefault(); + const patch = [ + { op: 'add', path: '/spec/updateStrategy/registryPoll/interval', value: pollInterval }, + ]; + return handlePromise(k8sPatch(CatalogSourceModel, catalogSource, patch), close); + }; + + return ( +
+
+ Edit registry poll interval + + + setPollInterval(selectedInterval)} + selectedKey={pollInterval} + /> + + + +
+
+ ); +}; + +export const editRegitryPollInterval = createModalLauncher( + withHandlePromise(EditRegistryPollIntervalModal), +); + +type EditRegistryPollIntervalModalProps = { + catalogSource: CatalogSourceKind; +} & ModalComponentProps & + HandlePromiseProps; diff --git a/frontend/packages/operator-lifecycle-manager/src/components/operator-hub/operator-hub-subscribe.tsx b/frontend/packages/operator-lifecycle-manager/src/components/operator-hub/operator-hub-subscribe.tsx index 39f6370850a..771af433a52 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/operator-hub/operator-hub-subscribe.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/operator-hub/operator-hub-subscribe.tsx @@ -41,7 +41,7 @@ import { } from '../../types'; import { ClusterServiceVersionLogo, - defaultChannelFor, + defaultChannelNameFor, getManualSubscriptionsInNamespace, iconFor, NamespaceIncludesManualApproval, @@ -85,7 +85,8 @@ export const OperatorHubSubscribeForm: React.FC = )}-${new URLSearchParams(window.location.search).get('catalogNamespace')}`, }); - const selectedUpdateChannel = updateChannel || defaultChannelFor(props.packageManifest.data[0]); + const selectedUpdateChannel = + updateChannel || defaultChannelNameFor(props.packageManifest.data[0]); const selectedInstallMode = installMode || supportedInstallModesFor(props.packageManifest.data[0])(selectedUpdateChannel).reduce( diff --git a/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.spec.tsx index 0cab0a3ad58..24bd23ef4f5 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.spec.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.spec.tsx @@ -1,20 +1,16 @@ import * as React from 'react'; -import { Link } from 'react-router-dom'; import { shallow, ShallowWrapper } from 'enzyme'; -import * as _ from 'lodash'; -import { Table, TableRow } from '@console/internal/components/factory'; +import { TableRow, RowFunction } from '@console/internal/components/factory'; import * as UIActions from '@console/internal/actions/ui'; -import { testPackageManifest, testCatalogSource, testSubscription } from '../../mocks'; -import { PackageManifestKind } from '../types'; +import { testPackageManifest, testCatalogSource } from '../../mocks'; import { PackageManifestTableHeader, PackageManifestTableRow, - PackageManifestTableRowProps, - PackageManifestList, - PackageManifestListProps, + PackageManifestTableHeaderWithCatalogSource, } from './package-manifest'; import { ClusterServiceVersionLogo } from '.'; -import { Button } from '@patternfly/react-core'; +import { ResourceLink, Timestamp } from '@console/internal/components/utils'; +import { PackageManifestKind, CatalogSourceKind } from '../types'; describe(PackageManifestTableHeader.displayName, () => { it('renders column header for package name', () => { @@ -22,30 +18,39 @@ describe(PackageManifestTableHeader.displayName, () => { }); it('renders column header for latest CSV version for package in catalog', () => { - expect(PackageManifestTableHeader()[1].title).toEqual('Latest Version'); + expect(PackageManifestTableHeader()[1].title).toEqual('Latest version'); }); - it('renders column header for subscriptions', () => { - expect(PackageManifestTableHeader()[2].title).toEqual('Subscriptions'); + it('renders column header for creation timestamp', () => { + expect(PackageManifestTableHeader()[2].title).toEqual('Created'); }); }); -describe(PackageManifestTableRow.displayName, () => { - let wrapper: ShallowWrapper; +describe(PackageManifestTableHeaderWithCatalogSource.displayName, () => { + it('renders column header for catalog source', () => { + expect(PackageManifestTableHeaderWithCatalogSource()[3].title).toEqual('CatalogSource'); + }); +}); + +describe('PackageManifestTableRow', () => { + let wrapper: ShallowWrapper>; beforeEach(() => { jest.spyOn(UIActions, 'getActiveNamespace').mockReturnValue('default'); + + const columns: any[] = []; wrapper = shallow( , ); }); @@ -75,120 +80,41 @@ describe(PackageManifestTableRow.displayName, () => { ).toEqual(`${version} (${name})`); }); - it('does not render link if no subscriptions exist in the current namespace', () => { - wrapper = wrapper.setProps({ subscription: null }); - + it('renders column for creation timestamp', () => { + const pkgManifestCreationTimestamp = testPackageManifest.metadata.creationTimestamp; expect( wrapper .find(TableRow) .childAt(2) .dive() - .text(), - ).toContain('None'); + .find(Timestamp) + .props().timestamp, + ).toEqual(`${pkgManifestCreationTimestamp}`); }); - it('renders column with link to subscriptions', () => { - expect( - wrapper - .find(TableRow) - .childAt(2) - .dive() - .find(Link) - .at(0) - .props().to, - ).toEqual(`/operatormanagement/ns/default?name=${testSubscription.metadata.name}`); - expect( - wrapper - .find(TableRow) - .childAt(2) - .dive() - .find(Link) - .at(0) - .childAt(0) - .text(), - ).toEqual('View'); - }); - - it('renders button to create new subscription if `canSubscribe` is true', () => { - expect( - wrapper - .find(TableRow) - .childAt(2) - .dive() - .find(Link) - .at(1) - .find(Button) - .render() - .text(), - ).toEqual('Create Subscription'); - }); - - it('does not render button to create new subscription if `canSubscribe` is false', () => { - wrapper = wrapper.setProps({ canSubscribe: false }); + // This is to verify cataloSource column gets rendered on the Search page for PackageManifest resource + it('renders column for catalog source for a package when no catalog source is defined', () => { + const catalogSourceName = testPackageManifest.status.catalogSource; + const columns: any[] = []; + wrapper = shallow( + , + ); expect( wrapper .find(TableRow) - .childAt(2) + .childAt(3) .dive() - .find(Link) - .at(1) - .exists(), - ).toBe(false); - }); -}); - -describe(PackageManifestList.displayName, () => { - let wrapper: ShallowWrapper; - let packages: PackageManifestKind[]; - - beforeEach(() => { - const otherPackageManifest = _.cloneDeep(testPackageManifest); - otherPackageManifest.status.catalogSource = 'another-catalog-source'; - otherPackageManifest.status.catalogSourceDisplayName = 'Another Catalog Source'; - otherPackageManifest.status.catalogSourcePublisher = 'Some Publisher'; - packages = [otherPackageManifest, testPackageManifest]; - - wrapper = shallow( - , - ); - }); - - it('renders a section for each unique `CatalogSource` for the given packages', () => { - expect(wrapper.find('.co-catalogsource-list__section').length).toEqual(2); - packages.forEach(({ status }, i) => { - expect( - wrapper - .find('.co-catalogsource-list__section') - .at(i) - .find('h3') - .text(), - ).toEqual(status.catalogSourceDisplayName); - }); - }); - - it('renders `Table` component with correct props for each section', () => { - expect(wrapper.find(Table).length).toEqual(2); - packages.forEach((pkg, i) => { - expect( - wrapper - .find('.co-catalogsource-list__section') - .at(i) - .find(Table) - .props().Header, - ).toEqual(PackageManifestTableHeader); - expect( - wrapper - .find('.co-catalogsource-list__section') - .at(i) - .find(Table) - .props().data.length, - ).toEqual(1); - }); + .find(ResourceLink) + .props().name, + ).toEqual(`${catalogSourceName}`); }); }); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.tsx b/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.tsx index d2f9f2c86f2..d6a10de3f53 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/package-manifest.tsx @@ -2,93 +2,71 @@ import * as React from 'react'; import * as _ from 'lodash'; import { Link, match } from 'react-router-dom'; import * as classNames from 'classnames'; -import { Button } from '@patternfly/react-core'; import { referenceForModel, K8sResourceKind } from '@console/internal/module/k8s'; -import { StatusBox, MsgBox } from '@console/internal/components/utils'; +import { MsgBox, Timestamp, ResourceLink } from '@console/internal/components/utils'; import { MultiListPage, Table, TableRow, TableData, - RowFunctionArgs, + RowFunction, } from '@console/internal/components/factory'; -import { getActiveNamespace } from '@console/internal/actions/ui'; -import { ALL_NAMESPACES_KEY, OPERATOR_HUB_LABEL } from '@console/shared'; -import { - PackageManifestModel, - SubscriptionModel, - CatalogSourceModel, - OperatorGroupModel, -} from '../models'; -import { PackageManifestKind, SubscriptionKind, OperatorGroupKind } from '../types'; -import { requireOperatorGroup, installedFor, supports } from './operator-group'; -import { - ClusterServiceVersionLogo, - visibilityLabel, - installModesFor, - defaultChannelFor, -} from './index'; +import { OPERATOR_HUB_LABEL } from '@console/shared'; +import { PackageManifestModel, CatalogSourceModel } from '../models'; +import { PackageManifestKind, CatalogSourceKind } from '../types'; +import { ClusterServiceVersionLogo, visibilityLabel, iconFor, defaultChannelFor } from './index'; +import { sortable } from '@patternfly/react-table'; const tableColumnClasses = [ - classNames('col-lg-4', 'col-md-4', 'col-sm-4', 'col-xs-6'), - classNames('col-lg-3', 'col-md-3', 'col-sm-4', 'hidden-xs'), - classNames('col-lg-5', 'col-md-5', 'col-sm-4', 'col-xs-6'), + '', + classNames('pf-m-hidden', 'pf-m-visible-on-lg'), + classNames('pf-m-hidden', 'pf-m-visible-on-lg'), + '', ]; export const PackageManifestTableHeader = () => [ { title: 'Name', + sortFunc: 'sortPackageManifestByDefaultChannelName', + transforms: [sortable], props: { className: tableColumnClasses[0] }, }, { - title: 'Latest Version', + title: 'Latest version', props: { className: tableColumnClasses[1] }, }, { - title: 'Subscriptions', + title: 'Created', + sortField: 'metadata.creationTimestamp', + transforms: [sortable], props: { className: tableColumnClasses[2] }, }, ]; -export const PackageManifestTableRow: React.SFC = ({ - obj, - index, - rowKey, - style, - catalogSourceName, - catalogSourceNamespace, - subscription, - defaultNS, - canSubscribe, -}) => { - const ns = getActiveNamespace(); - const channel = !_.isEmpty(obj.status.defaultChannel) - ? obj.status.channels.find((ch) => ch.name === obj.status.defaultChannel) - : obj.status.channels[0]; - const { displayName, icon = [], version, provider } = channel.currentCSVDesc; +export const PackageManifestTableHeaderWithCatalogSource = () => [ + ...PackageManifestTableHeader(), + { + title: 'CatalogSource', + sortField: 'status.catalogSource', + transforms: [sortable], + props: { className: tableColumnClasses[3] }, + }, +]; - const subscriptionLink = () => - ns !== ALL_NAMESPACES_KEY ? ( - - View subscription - - ) : ( - - View subscriptions - - ); +export const PackageManifestTableRow: RowFunction< + PackageManifestKind, + { catalogSource: CatalogSourceKind } +> = ({ obj: packageManifest, index, key, style, customData }) => { + const channel = defaultChannelFor(packageManifest); - const createSubscriptionLink = () => - `/k8s/ns/${ns === ALL_NAMESPACES_KEY ? defaultNS : ns}/${SubscriptionModel.plural}/~new?pkg=${ - obj.metadata.name - }&catalog=${catalogSourceName}&catalogNamespace=${catalogSourceNamespace}`; + const { displayName, version, provider } = channel?.currentCSVDesc; return ( - + @@ -96,122 +74,56 @@ export const PackageManifestTableRow: React.SFC = {version} ({channel.name}) - {subscription ? subscriptionLink() : None} - {canSubscribe && ( - - - - )} + + {!customData.catalogSource && ( + + + + )} ); }; -export const PackageManifestList = requireOperatorGroup((props: PackageManifestListProps) => { - type CatalogSourceInfo = { - displayName: string; - name: string; - publisher: string; - namespace: string; - }; - const catalogs = (props.data || []).reduce( - (allCatalogs, { status }) => - allCatalogs.set(status.catalogSource, { - displayName: status.catalogSourceDisplayName, - name: status.catalogSource, - publisher: status.catalogSourcePublisher, - namespace: status.catalogSourceNamespace, - }), - new Map(), - ); +export const PackageManifestList = (props: PackageManifestListProps) => { + const { customData } = props; + + // If the CatalogSource is not present, display PackageManifests along with their CatalogSources (used in PackageManifest Search page) + const TableHeader = customData.catalogSource + ? PackageManifestTableHeader + : PackageManifestTableHeaderWithCatalogSource; return ( - ( )} - > - {_.sortBy([...catalogs.values()], 'displayName').map((catalog) => ( -
-
-
-

{catalog.displayName}

- Packaged by {catalog.publisher} -
- {props.showDetailsLink && ( - - View catalog details - - )} -
- pkg.status.catalogSource === catalog.name)} - filters={props.filters} - Header={PackageManifestTableHeader} - Row={(rowArgs: RowFunctionArgs) => ( - - _.isEmpty(props.namespace) || sub.metadata.namespace === props.namespace, - ) - .find((sub) => sub.spec.name === rowArgs.obj.metadata.name)} - canSubscribe={ - props.canSubscribe && - !installedFor(props.subscription.data)(props.operatorGroup.data)( - rowArgs.obj.status.packageName, - )(getActiveNamespace()) && - props.operatorGroup.data - .filter( - (og) => - _.isEmpty(props.namespace) || og.metadata.namespace === props.namespace, - ) - .some((og) => - supports(installModesFor(rowArgs.obj)(defaultChannelFor(rowArgs.obj)))(og), - ) - } - defaultNS={_.get(props.operatorGroup, 'data[0].metadata.namespace')} - /> - )} - EmptyMsg={() => ( - - )} - virtualize - /> - - ))} - + virtualize + /> ); -}); +}; export const PackageManifestsPage: React.SFC = (props) => { + const { catalogSource } = props; const namespace = _.get(props.match, 'params.ns'); + type Flatten = (resources: { [kind: string]: { data: K8sResourceKind[] } }) => K8sResourceKind[]; const flatten: Flatten = (resources) => _.get(resources.packageManifest, 'data', []); + const helpText = ( <> Catalogs are groups of Operators you can make available on the cluster. Use{' '} @@ -223,12 +135,11 @@ export const PackageManifestsPage: React.SFC = (props return ( ( - - )} + ListComponent={PackageManifestList} textFilter="packagemanifest-name" flatten={flatten} resources={[ @@ -239,63 +150,47 @@ export const PackageManifestsPage: React.SFC = (props prop: 'packageManifest', selector: { matchExpressions: [ + ...(catalogSource + ? [ + { + key: 'catalog', + operator: 'In', + values: [catalogSource?.metadata.name], + }, + { + key: 'catalog-namespace', + operator: 'In', + values: [catalogSource?.metadata.namespace], + }, + ] + : []), { key: visibilityLabel, operator: 'DoesNotExist' }, { key: OPERATOR_HUB_LABEL, operator: 'DoesNotExist' }, ], }, }, - { - kind: referenceForModel(CatalogSourceModel), - isList: true, - namespaced: true, - prop: 'catalogSource', - }, - { - kind: referenceForModel(SubscriptionModel), - isList: true, - namespaced: true, - prop: 'subscription', - }, - { - kind: referenceForModel(OperatorGroupModel), - isList: true, - namespaced: true, - prop: 'operatorGroup', - }, ]} /> ); }; export type PackageManifestsPageProps = { + catalogSource: CatalogSourceKind; namespace?: string; match?: match<{ ns?: string }>; }; export type PackageManifestListProps = { + customData?: { catalogSource: CatalogSourceKind }; namespace?: string; data: PackageManifestKind[]; filters?: { [name: string]: string }; - subscription: { loaded: boolean; data?: SubscriptionKind[] }; - operatorGroup: { loaded: boolean; data?: OperatorGroupKind[] }; loaded: boolean; loadError?: string | Record; showDetailsLink?: boolean; - canSubscribe?: boolean; -}; - -export type PackageManifestTableRowProps = { - obj: PackageManifestKind; - index: number; - rowKey: string; - style: object; - catalogSourceName: string; - catalogSourceNamespace: string; - subscription?: SubscriptionKind; - defaultNS: string; - canSubscribe: boolean; }; PackageManifestTableHeader.displayName = 'PackageManifestTableHeader'; -PackageManifestTableRow.displayName = 'PackageManifestTableRow'; +PackageManifestTableHeaderWithCatalogSource.displayName = + 'PackageManifestTableHeaderWithCatalogSource'; PackageManifestList.displayName = 'PackageManifestList'; diff --git a/frontend/packages/operator-lifecycle-manager/src/plugin.tsx b/frontend/packages/operator-lifecycle-manager/src/plugin.tsx index e8d1b80a84d..9a2d4cde50d 100644 --- a/frontend/packages/operator-lifecycle-manager/src/plugin.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/plugin.tsx @@ -205,6 +205,15 @@ const plugin: Plugin = [ ).ClusterServiceVersionsPage, }, }, + { + type: 'Page/Resource/List', + properties: { + model: models.PackageManifestModel, + loader: async () => + (await import('./components/package-manifest' /* webpackChunkName: "package-manifest" */)) + .PackageManifestsPage, + }, + }, { type: 'Page/Resource/Details', properties: { @@ -290,6 +299,15 @@ const plugin: Plugin = [ (await import('./components/operator-hub/operator-hub-details')).OperatorHubDetailsPage, }, }, + { + type: 'Page/Resource/Details', + properties: { + model: models.CatalogSourceModel, + loader: async () => + (await import('./components/catalog-source' /* webpackChunkName: "catalog-source" */)) + .CatalogSourceDetailsPage, + }, + }, { type: 'Page/Route', properties: { diff --git a/frontend/packages/operator-lifecycle-manager/src/utils/useOperatorHubConfig.tsx b/frontend/packages/operator-lifecycle-manager/src/utils/useOperatorHubConfig.tsx new file mode 100644 index 00000000000..13de240867c --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager/src/utils/useOperatorHubConfig.tsx @@ -0,0 +1,13 @@ +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { OperatorHubKind } from '../components/operator-hub'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { OperatorHubModel } from '../models'; + +const useOperatorHubConfig = () => + useK8sWatchResource({ + kind: referenceForModel(OperatorHubModel), + name: 'cluster', + isList: false, + }); + +export default useOperatorHubConfig; diff --git a/frontend/public/components/factory/table.tsx b/frontend/public/components/factory/table.tsx index 5351cedb6dc..5b2727059e8 100644 --- a/frontend/public/components/factory/table.tsx +++ b/frontend/public/components/factory/table.tsx @@ -72,6 +72,8 @@ import { } from '@patternfly/react-virtualized-extension'; import { tableFilters } from './table-filters'; +import { PackageManifestKind } from '@console/operator-lifecycle-manager/src/types'; +import { defaultChannelFor } from '@console/operator-lifecycle-manager/src/components'; const rowFiltersToFilterFuncs = (rowFilters) => { return (rowFilters || []) @@ -172,6 +174,10 @@ const sorts = { volumeSnapshotSource: (snapshot: VolumeSnapshotKind): string => snapshotSource(snapshot), snapshotLastRestore: (snapshot: K8sResourceKind, { restores }) => restores[getName(snapshot)]?.status?.restoreTime, + sortPackageManifestByDefaultChannelName: (packageManifest: PackageManifestKind): string => { + const channel = defaultChannelFor(packageManifest); + return channel?.currentCSVDesc?.displayName; + }, }; const stateToProps = ( diff --git a/frontend/public/components/utils/details-item.tsx b/frontend/public/components/utils/details-item.tsx index 492657474fe..066e06c7a38 100644 --- a/frontend/public/components/utils/details-item.tsx +++ b/frontend/public/components/utils/details-item.tsx @@ -36,14 +36,22 @@ export const PropertyPath: React.FC<{ kind: string; path: string | string[] }> = ); }; -const EditButton: React.SFC<{ onClick: (e: React.MouseEvent) => void }> = ( - props, -) => ( - -); +const EditButton: React.SFC = (props) => { + return ( + + ); +}; export const DetailsItem: React.FC = ({ children, @@ -86,7 +94,7 @@ export const DetailsItem: React.FC = ({ {...(path && { footerContent: })} maxWidth="30rem" > - @@ -110,7 +118,13 @@ export const DetailsItem: React.FC = ({ })} data-test-selector={`details-item-value__${label}`} > - {editable && !editAsGroup ? {value} : value} + {editable && !editAsGroup ? ( + + {value} + + ) : ( + value + )} ); @@ -130,6 +144,11 @@ export type DetailsItemProps = { valueClassName?: string; }; +type EditButtonProps = { + onClick: (e: React.MouseEvent) => void; + testID?: string; +}; + DetailsItem.displayName = 'DetailsItem'; PropertyPath.displayName = 'PropertyPath'; EditButton.displayName = 'EditButton';