diff --git a/frontend/__tests__/components/catalog-data.ts b/frontend/__tests__/components/catalog-data.ts new file mode 100644 index 00000000000..873875acb36 --- /dev/null +++ b/frontend/__tests__/components/catalog-data.ts @@ -0,0 +1,221 @@ +export const developerCatalogItems = [ + { + createLabel: 'Create Application', + href: + '/catalog/source-to-image?imagestream=dotnet&imagestream-ns=openshift&preselected-ns=openshift-operators', + kind: 'ImageStream', + obj: { + metadata: { + name: 'dotnet', + namespace: 'openshift', + }, + }, + tileIconClass: null, + tileImgUrl: 'static/assets/dotnet.svg', + tileName: '.NET Core', + tileProvider: undefined, + }, + { + createLabel: 'Instantiate Template', + documentationUrl: undefined, + href: + '/catalog/instantiate-template?template=dotnet-pgsql-persistent&template-ns=openshift&preselected-ns=openshift-operators', + kind: 'Template', + obj: { + metadata: { + name: 'dotnet-pgsql-persistent', + namespace: 'openshift', + }, + }, + tileIconClass: null, + tileImgUrl: 'static/assets/dotnet.svg', + tileName: '.NET Core + PostgreSQL (Persistent)', + tileProvider: undefined, + }, + { + createLabel: 'Create', + documentationUrl: undefined, + href: + '/ns/openshift-operators/clusterserviceversions/elasticsearch-operator.4.2.9-201911261133/logging.openshift.io~v1~Elasticsearch/~new', + kind: 'InstalledOperator', + obj: { + csv: { + kind: 'ClusterServiceVersion', + metadata: { + name: 'elasticsearch-operator.4.2.9-201911261133', + namespace: 'openshift-operators', + }, + spec: { + displayName: 'Elastic Search Operator', + }, + }, + }, + tileDescription: 'An Elasticsearch cluster instance', + tileIconClass: null, + tileImgUrl: 'static/assets/operator.svg', + tileName: 'Elasticsearch', + tileProvider: 'Red Hat, Inc', + }, + { + createLabel: 'Create', + documentationUrl: undefined, + href: + '/ns/openshift-operators/clusterserviceversions/servicemeshoperator.v1.0.2/maistra.io~v1~ServiceMeshControlPlane/~new', + kind: 'InstalledOperator', + obj: { + csv: { + kind: 'ClusterServiceVersion', + metadata: { + name: 'servicemeshoperator.v1.0.2', + namespace: 'openshift-operators', + }, + spec: { + displayName: 'Service Mesh Operator', + }, + }, + }, + tileDescription: 'An Istio control plane installation', + tileIconClass: null, + tileImgUrl: 'static/assets/operator.svg', + tileName: 'Istio Service Mesh Control Plane', + tileProvider: 'Red Hat, Inc', + }, + { + createLabel: 'Create', + documentationUrl: undefined, + href: + '/ns/openshift-operators/clusterserviceversions/servicemeshoperator.v1.0.2/maistra.io~v1~ServiceMeshMemberRoll/~new', + kind: 'InstalledOperator', + obj: { + csv: { + kind: 'ClusterServiceVersion', + metadata: { + name: 'servicemeshoperator.v1.0.2', + namespace: 'openshift-operators', + }, + spec: { + displayName: 'Service Mesh Operator', + }, + }, + }, + tileDescription: 'A list of namespaces in Service Mesh', + tileIconClass: null, + tileImgUrl: 'static/assets/operator.svg', + tileName: 'Istio Service Mesh Member Roll', + tileProvider: 'Red Hat, Inc', + }, +]; + +export const groupedByType = { + 'Elastic Search Operator': [ + { + createLabel: 'Create', + documentationUrl: undefined, + href: + '/ns/openshift-operators/clusterserviceversions/elasticsearch-operator.4.2.9-201911261133/logging.openshift.io~v1~Elasticsearch/~new', + kind: 'InstalledOperator', + obj: { + csv: { + kind: 'ClusterServiceVersion', + metadata: { + name: 'elasticsearch-operator.4.2.9-201911261133', + namespace: 'openshift-operators', + }, + spec: { + displayName: 'Elastic Search Operator', + }, + }, + }, + tileDescription: 'An Elasticsearch cluster instance', + tileIconClass: null, + tileImgUrl: 'static/assets/operator.svg', + tileName: 'Elasticsearch', + tileProvider: 'Red Hat, Inc', + }, + ], + 'Service Mesh Operator': [ + { + createLabel: 'Create', + documentationUrl: undefined, + href: + '/ns/openshift-operators/clusterserviceversions/servicemeshoperator.v1.0.2/maistra.io~v1~ServiceMeshControlPlane/~new', + kind: 'InstalledOperator', + obj: { + csv: { + kind: 'ClusterServiceVersion', + metadata: { + name: 'servicemeshoperator.v1.0.2', + namespace: 'openshift-operators', + }, + spec: { + displayName: 'Service Mesh Operator', + }, + }, + }, + tileDescription: 'An Istio control plane installation', + tileIconClass: null, + tileImgUrl: 'static/assets/operator.svg', + tileName: 'Istio Service Mesh Control Plane', + tileProvider: 'Red Hat, Inc', + }, + { + createLabel: 'Create', + documentationUrl: undefined, + href: + '/ns/openshift-operators/clusterserviceversions/servicemeshoperator.v1.0.2/maistra.io~v1~ServiceMeshMemberRoll/~new', + kind: 'InstalledOperator', + obj: { + csv: { + kind: 'ClusterServiceVersion', + metadata: { + name: 'servicemeshoperator.v1.0.2', + namespace: 'openshift-operators', + }, + spec: { + displayName: 'Service Mesh Operator', + }, + }, + }, + tileDescription: 'A list of namespaces in Service Mesh', + tileIconClass: null, + tileImgUrl: 'static/assets/operator.svg', + tileName: 'Istio Service Mesh Member Roll', + tileProvider: 'Red Hat, Inc', + }, + ], + 'Non Operators': [ + { + createLabel: 'Create Application', + href: + '/catalog/source-to-image?imagestream=dotnet&imagestream-ns=openshift&preselected-ns=openshift-operators', + kind: 'ImageStream', + obj: { + metadata: { + name: 'dotnet', + namespace: 'openshift', + }, + }, + tileIconClass: null, + tileImgUrl: 'static/assets/dotnet.svg', + tileName: '.NET Core', + tileProvider: undefined, + }, + { + createLabel: 'Instantiate Template', + documentationUrl: undefined, + href: + '/catalog/instantiate-template?template=dotnet-pgsql-persistent&template-ns=openshift&preselected-ns=openshift-operators', + kind: 'Template', + obj: { + metadata: { + name: 'dotnet-pgsql-persistent', + namespace: 'openshift', + }, + }, + tileIconClass: null, + tileImgUrl: 'static/assets/dotnet.svg', + tileName: '.NET Core + PostgreSQL (Persistent)', + tileProvider: undefined, + }, + ], +}; diff --git a/frontend/__tests__/components/catalog.spec.tsx b/frontend/__tests__/components/catalog.spec.tsx index e73098244fc..8991e5a57ea 100644 --- a/frontend/__tests__/components/catalog.spec.tsx +++ b/frontend/__tests__/components/catalog.spec.tsx @@ -16,12 +16,14 @@ import { import { CatalogTileViewPage, catalogCategories as initCatalogCategories, + groupItems, } from '../../public/components/catalog/catalog-items'; import { catalogListPageProps, catalogItems, catalogCategories, } from '../../__mocks__/catalogItemsMocks'; +import { developerCatalogItems, groupedByType } from './catalog-data'; import { categorizeItems } from '../../public/components/utils/tile-view-page'; describe(CatalogTileViewPage.displayName, () => { @@ -107,4 +109,14 @@ describe(CatalogTileViewPage.displayName, () => { }); }); }); + + it('should group catalog items by Operator', () => { + const groupedByTypeResult = groupItems(developerCatalogItems, 'Operator'); + expect(groupedByTypeResult).toEqual(groupedByType); + }); + + it('should not group the items when None is selected in the Group By Dropdown', () => { + const groupedByTypeResult = groupItems(developerCatalogItems, 'None'); + expect(groupedByTypeResult).toEqual(developerCatalogItems); + }); }); diff --git a/frontend/public/components/catalog/_catalog.scss b/frontend/public/components/catalog/_catalog.scss index 3d2edf32284..8c276ee8e76 100644 --- a/frontend/public/components/catalog/_catalog.scss +++ b/frontend/public/components/catalog/_catalog.scss @@ -142,11 +142,24 @@ $co-modal-ignore-warning-icon-width: 30px; width: auto !important; } + &__btn-group__group-by { + display: inline; + margin-left: var(--pf-global--spacer--xl); + } + &__num-items { font-weight: var(--pf-global--FontWeight--bold); padding: 0 0 20px; } + &__group-title { + margin-bottom: var(--pf-global--spacer--sm); + } + + &__grouped-items { + margin-bottom: var(--pf-global--spacer--md); + } + // Enable scrolling on the modal &__overlay { .modal-body .co-hint-block { diff --git a/frontend/public/components/catalog/catalog-items.jsx b/frontend/public/components/catalog/catalog-items.jsx index 240c3b52ba5..dc86861bbfc 100644 --- a/frontend/public/components/catalog/catalog-items.jsx +++ b/frontend/public/components/catalog/catalog-items.jsx @@ -137,6 +137,11 @@ const filterValueMap = { ImageStream: 'Source-to-Image', }; +const GroupByTypes = { + Operator: 'Operator', + None: 'None', +}; + const keywordCompare = (filterString, item) => { if (!filterString) { return true; @@ -159,6 +164,17 @@ const setURLParams = (params) => { history.replace(`${url.pathname}${searchParams}`); }; +export const groupItems = (items, groupBy) => { + if (groupBy === GroupByTypes.Operator) { + const installedOperators = _.filter(items, (item) => item.kind === 'InstalledOperator'); + const nonOperators = _.filter(items, (item) => item.kind !== 'InstalledOperator'); + const groupedOperators = _.groupBy(installedOperators, (item) => item.obj.csv.spec.displayName); + const groupAllItems = { ...groupedOperators, 'Non Operators': nonOperators }; + return groupAllItems; + } + return items; +}; + export class CatalogTileViewPage extends React.Component { constructor(props) { super(props); @@ -250,6 +266,8 @@ export class CatalogTileViewPage extends React.Component { renderTile={this.renderTile} pageDescription={pageDescription} emptyStateInfo="No developer catalog items are being shown due to the filters being applied." + groupItems={groupItems} + groupByTypes={GroupByTypes} /> { if (!category.subcategories) { @@ -200,10 +203,9 @@ const filterByGroup = (items, filters) => { return _.reduce( filters, (filtered, group, key) => { - if (key === 'keyword') { + if (key === FilterTypes.keyword) { return filtered; } - // Only apply active filters const activeFilters = _.filter(group, 'active'); if (activeFilters.length) { @@ -324,7 +326,7 @@ const getActiveFilters = (keywordFilter, groupFilters, activeFilters) => { }; export const updateActiveFilters = (activeFilters, filterType, id, value) => { - if (filterType === 'keyword') { + if (filterType === FilterTypes.keyword) { _.set(activeFilters, 'keyword.value', value); _.set(activeFilters, 'keyword.active', !!value); } else { @@ -399,13 +401,13 @@ const setURLParams = (params) => { history.replace(`${url.pathname}${searchParams}`); }; -export const updateURLParams = (filterName, value) => { +export const updateURLParams = (paramName, value) => { const params = new URLSearchParams(window.location.search); if (value) { - params.set(filterName, Array.isArray(value) ? JSON.stringify(value) : value); + params.set(paramName, Array.isArray(value) ? JSON.stringify(value) : value); } else { - params.delete(filterName); + params.delete(paramName); } setURLParams(params); }; @@ -414,19 +416,21 @@ const clearFilterURLParams = (selectedCategoryId) => { const params = new URLSearchParams(); if (selectedCategoryId) { - params.set(CATEGORY_URL_PARAM, selectedCategoryId); + params.set(FilterTypes.category, selectedCategoryId); } setURLParams(params); }; -const getActiveValuesFromURL = (availableFilters, filterGroups) => { +const getActiveValuesFromURL = (availableFilters, filterGroups, groupByTypes) => { const searchParams = new URLSearchParams(window.location.search); - const categoryParam = searchParams.get(CATEGORY_URL_PARAM); - const keywordFilter = searchParams.get(KEYWORD_URL_PARAM); - + const categoryParam = searchParams.get(FilterTypes.category); + const keywordFilter = searchParams.get(FilterTypes.keyword); const selectedCategoryId = categoryParam || 'all'; - + let groupBy = ''; + if (groupByTypes) { + groupBy = searchParams.get('groupBy') || groupByTypes.None; + } const groupFilters = {}; _.each(filterGroups, (filterGroup) => { @@ -445,7 +449,7 @@ const getActiveValuesFromURL = (availableFilters, filterGroups) => { const activeFilters = getActiveFilters(keywordFilter, groupFilters, availableFilters); - return { selectedCategoryId, activeFilters }; + return { selectedCategoryId, activeFilters, groupBy }; }; export const getFilterSearchParam = (groupFilter) => { @@ -470,7 +474,7 @@ const defaultFilters = { export class TileViewPage extends React.Component { constructor(props) { super(props); - const { items, itemsSorter, getAvailableCategories } = this.props; + const { items, itemsSorter, getAvailableCategories, groupByTypes } = this.props; const categories = getAvailableCategories(items); @@ -480,20 +484,22 @@ export class TileViewPage extends React.Component { activeFilters: defaultFilters, filterCounts: null, filterGroupsShowAll: {}, + groupBy: groupByTypes ? groupByTypes.None : '', }; this.onUpdateFilters = this.onUpdateFilters.bind(this); this.onFilterChange = this.onFilterChange.bind(this); this.renderFilterGroup = this.renderFilterGroup.bind(this); this.onShowAllToggle = this.onShowAllToggle.bind(this); + this.onGroupChange = this.onGroupChange.bind(this); } componentDidMount() { - const { items, filterGroups, getAvailableFilters } = this.props; + const { items, filterGroups, getAvailableFilters, groupByTypes } = this.props; const { categories } = this.state; const availableFilters = getAvailableFilters(defaultFilters, items, filterGroups); - const activeValues = getActiveValuesFromURL(availableFilters, filterGroups); + const activeValues = getActiveValuesFromURL(availableFilters, filterGroups, groupByTypes); this.setState({ ...this.getUpdatedState( @@ -501,6 +507,7 @@ export class TileViewPage extends React.Component { activeValues.selectedCategoryId, activeValues.activeFilters, ), + groupBy: activeValues.groupBy, }); this.filterByKeywordInput.focus({ preventScroll: true }); } @@ -510,7 +517,7 @@ export class TileViewPage extends React.Component { } componentDidUpdate(prevProps) { - const { activeFilters, selectedCategoryId } = this.state; + const { activeFilters, selectedCategoryId, groupBy } = this.state; const { items, itemsSorter, @@ -527,11 +534,10 @@ export class TileViewPage extends React.Component { const newActiveFilters = _.reduce( availableFilters, (updatedFilters, filterGroup, filterGroupName) => { - if (filterGroupName === 'keyword') { + if (filterGroupName === FilterTypes.keyword) { updatedFilters.keyword = activeFilters.keyword; return updatedFilters; } - _.each(filterGroup, (filterItem, filterItemName) => { updatedFilters[filterGroupName][filterItemName].active = _.get( activeFilters, @@ -547,6 +553,7 @@ export class TileViewPage extends React.Component { this.updateMountedState({ ...this.getUpdatedState(categories, selectedCategoryId, newActiveFilters), + groupBy, }); } } @@ -605,7 +612,7 @@ export class TileViewPage extends React.Component { selectCategory(categoryId) { const { activeFilters, categories } = this.state; - updateURLParams(CATEGORY_URL_PARAM, categoryId); + updateURLParams(FilterTypes.category, categoryId); this.updateMountedState(this.getUpdatedState(categories, categoryId, activeFilters)); } @@ -617,8 +624,8 @@ export class TileViewPage extends React.Component { onFilterChange(filterType, id, value) { const { activeFilters, selectedCategoryId, categories } = this.state; - if (filterType === 'keyword') { - updateURLParams(KEYWORD_URL_PARAM, `${value}`); + if (filterType === FilterTypes.keyword) { + updateURLParams(FilterTypes.keyword, `${value}`); } else { const groupFilter = _.cloneDeep(activeFilters[filterType]); _.set(groupFilter, [id, 'active'], value); @@ -641,6 +648,12 @@ export class TileViewPage extends React.Component { this.setState({ filterGroupsShowAll: updatedShow }); } + onGroupChange(value) { + const { groupByTypes } = this.props; + updateURLParams('groupBy', value === groupByTypes.None ? `` : `${value}`); + this.updateMountedState({ groupBy: value }); + } + renderTabs(category, selectedCategoryId) { const { id, label, subcategories, numItems } = category; const active = id === selectedCategoryId; @@ -727,7 +740,7 @@ export class TileViewPage extends React.Component { return ( {_.map(activeFilters, (filterGroup, groupName) => { - if (groupName === 'keyword') { + if (groupName === FilterTypes.keyword) { return; } return renderFilterGroup( @@ -764,9 +777,37 @@ export class TileViewPage extends React.Component { ); } + renderItems(items, renderTile) { + return ( + + {_.map(items, (item) => ( + + {renderTile(item)} + + ))} + + ); + } + + renderGroupedItems(items, groupBy, renderTile, groupItems) { + const groupedItems = groupItems(items, groupBy); + return _.map( + groupedItems, + (value, key) => + value.length > 0 && ( +
+ + {key} ({_.size(value)}) + + {this.renderItems(value, renderTile)} +
+ ), + ); + } + render() { - const { renderTile } = this.props; - const { activeFilters, selectedCategoryId, categories } = this.state; + const { renderTile, groupItems, groupByTypes } = this.props; + const { activeFilters, selectedCategoryId, categories, groupBy } = this.state; let activeCategory = findActiveCategory(selectedCategoryId, categories); if (!activeCategory) { activeCategory = findActiveCategory('all', categories); @@ -782,27 +823,37 @@ export class TileViewPage extends React.Component {
{activeCategory.label}
- (this.filterByKeywordInput = ref)} - placeholder="Filter by keyword..." - bsClass="pf-c-form-control" - value={activeFilters.keyword.value} - onChange={(e) => this.onKeywordChange(e.target.value)} - /> +
+ (this.filterByKeywordInput = ref)} + placeholder="Filter by keyword..." + bsClass="pf-c-form-control" + value={activeFilters.keyword.value} + onChange={(e) => this.onKeywordChange(e.target.value)} + /> + {groupItems && ( + this.onGroupChange(e)} + titlePrefix="Group By" + title={groupBy} + /> + )} +
{activeCategory.numItems} items
+ {activeCategory.numItems > 0 && ( - - {_.map(activeCategory.items, (item) => ( - {renderTile(item)} - ))} - +
+ {groupItems && groupBy !== groupByTypes.None + ? this.renderGroupedItems(activeCategory.items, groupBy, renderTile, groupItems) + : this.renderItems(activeCategory.items, renderTile)} +
)} {activeCategory.numItems === 0 && this.renderEmptyState()} @@ -825,6 +876,8 @@ TileViewPage.propTypes = { renderTile: PropTypes.func.isRequired, emptyStateTitle: PropTypes.string, emptyStateInfo: PropTypes.string, + groupItems: PropTypes.func, + groupByTypes: PropTypes.object, }; TileViewPage.defaultProps = {