diff --git a/frontend/package.json b/frontend/package.json index 174f4cf0309c..e98e8378c3b1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,6 +57,8 @@ "dependencies": { "@patternfly/patternfly": "2.4.1", "@patternfly/react-core": "3.2.4", + "@patternfly/react-table": "git+https://git@github.com/priley86/react-table.git", + "@patternfly/react-virtualized-extension": "git+https://git@github.com/priley86/react-virtualized-extension.git", "brace": "0.11.x", "classnames": "2.x", "core-js": "2.x", diff --git a/frontend/public/components/deployment.jsx b/frontend/public/components/deployment.jsx index 80be6fa5d4eb..c8d9decb24ca 100644 --- a/frontend/public/components/deployment.jsx +++ b/frontend/public/components/deployment.jsx @@ -1,6 +1,10 @@ import * as React from 'react'; import * as _ from 'lodash-es'; - +import { + headerCol, + sortable, +} from '@patternfly/react-table'; +import { Link } from 'react-router-dom'; import { DeploymentModel } from '../models'; import { configureUpdateStrategyModal, errorModal } from './modals'; import { Conditions } from './conditions'; @@ -13,6 +17,7 @@ import { ListPage, WorkloadListHeader, WorkloadListRow, + Table, } from './factory'; import { AsyncComponent, @@ -26,6 +31,11 @@ import { StatusIcon, togglePaused, WorkloadPausedAlert, + LabelList, + ResourceKebab, + ResourceLink, + resourcePath, + Selector, } from './utils'; const {ModifyCount, AddStorage, EditEnvironment, common} = Kebab.factory; @@ -117,7 +127,63 @@ const DeploymentsDetailsPage = props => ; const Row = props => ; -const DeploymentsList = props => ; + +const kind = 'Deployment'; + +const DeploymentTableRow = (o, index) => { + return { + id: index, + cells: [ + { + title: , + }, + { + title: , + }, + { + title: , + }, + { + title: + {o.status.replicas || 0} of {o.spec.replicas} pods + , + }, + { + title: , + }, + { + title:
+ +
, + props: { className: 'pf-c-table__action'}, + }, + ] + }; +}; + +const DeploymentTableRows = componentProps => + _.map(componentProps.data, (obj, index) => obj && obj.metadata && DeploymentTableRow(obj, index)); + +const DeploymentTableHeader = props => { + return [ + { title: 'Name', sortField: 'metadata.name', transforms: [sortable], cellTransforms: [headerCol()], props}, + { title: 'Namespace', sortField: 'metadata.namespace', transforms: [sortable], props }, + { title: 'Labels', sortField: 'metadata.labels', transforms: [sortable], props}, + { title: 'Status', sortField: 'metadata.labels', props: {...props, className: 'meta-status'}, + { title: 'Pod Selector', sortField: 'spec.selector', transforms: [sortable], props }, + // todo: add support for table actions api: https://github.com/patternfly/patternfly-react/pull/1441 + // this is for the empty actions/kebab column header + { title: '' }, + ]; +}; + +const DeploymentsList = props => + + {/*
+
+ */} +; + const DeploymentsPage = props => ; export {DeploymentsList, DeploymentsPage, DeploymentsDetailsPage}; diff --git a/frontend/public/components/factory/index.tsx b/frontend/public/components/factory/index.tsx index acb1ae207a8b..e2ab2a67ccb8 100644 --- a/frontend/public/components/factory/index.tsx +++ b/frontend/public/components/factory/index.tsx @@ -2,3 +2,4 @@ export * from './details'; export * from './list-page'; export * from './list'; export * from './modal'; +export * from './table'; \ No newline at end of file diff --git a/frontend/public/components/factory/table.tsx b/frontend/public/components/factory/table.tsx new file mode 100644 index 000000000000..ddcaae32405c --- /dev/null +++ b/frontend/public/components/factory/table.tsx @@ -0,0 +1,437 @@ +/* eslint-disable no-undef */ +import * as _ from 'lodash-es'; +import * as fuzzy from 'fuzzysearch'; +import * as PropTypes from 'prop-types'; +import * as React from 'react'; +import { connect } from 'react-redux'; +import { UIActions } from '../../ui/ui-actions'; +import { ingressValidHosts } from '../ingress'; +import { routeStatus } from '../routes'; +import { secretTypeFilterReducer } from '../secret'; +import { bindingType, roleType } from '../RBAC'; +import { + alertState, + alertStateOrder, + silenceState, + silenceStateOrder, +} from '../../monitoring'; +import { + containerLinuxUpdateOperator, + EmptyBox +} from '../utils'; +import { + getJobTypeAndCompletions, + nodeStatus, + planExternalName, + podPhase, + podPhaseFilterReducer, + podReadiness, + serviceCatalogStatus, + serviceClassDisplayName, + getClusterOperatorStatus, +} from '../../module/k8s'; + +import { + IRowData, + IExtraData, + Table as PfTable, + TableHeader, + TableBody, + SortByDirection, +} from '@patternfly/react-table'; + +import { + VirtualizedBody, + VirtualizedBodyWrapper, + VirtualizedRowWrapper, + WindowScroller +} from '@patternfly/react-virtualized-extension'; + +const fuzzyCaseInsensitive = (a, b) => fuzzy(_.toLower(a), _.toLower(b)); + +// TODO: Having list filters here is undocumented, stringly-typed, and non-obvious. We can change that +const listFilters = { + 'name': (filter, obj) => fuzzyCaseInsensitive(filter, obj.metadata.name), + + 'alert-name': (filter, alert) => fuzzyCaseInsensitive(filter, _.get(alert, 'labels.alertname')), + + 'alert-state': (filter, alert) => filter.selected.has(alertState(alert)), + + 'silence-name': (filter, silence) => fuzzyCaseInsensitive(filter, silence.name), + + 'silence-state': (filter, silence) => filter.selected.has(silenceState(silence)), + + // Filter role by role kind + 'role-kind': (filter, role) => filter.selected.has(roleType(role)), + + // Filter role bindings by role kind + 'role-binding-kind': (filter, binding) => filter.selected.has(bindingType(binding)), + + // Filter role bindings by text match + 'role-binding': (str, {metadata, roleRef, subject}) => { + const isMatch = val => fuzzyCaseInsensitive(str, val); + return [metadata.name, roleRef.name, subject.kind, subject.name].some(isMatch); + }, + + // Filter role bindings by roleRef name + 'role-binding-roleRef': (roleRef, binding) => binding.roleRef.name === roleRef, + + 'selector': (selector, obj) => { + if (!selector || !selector.values || !selector.values.size) { + return true; + } + return selector.values.has(_.get(obj, selector.field)); + }, + + 'pod-status': (phases, pod) => { + if (!phases || !phases.selected || !phases.selected.size) { + return true; + } + + const phase = podPhaseFilterReducer(pod); + return phases.selected.has(phase) || !_.includes(phases.all, phase); + }, + + 'node-status': (statuses, node) => { + if (!statuses || !statuses.selected || !statuses.selected.size) { + return true; + } + + const status = nodeStatus(node); + return statuses.selected.has(status) || !_.includes(statuses.all, status); + }, + + 'clusterserviceversion-resource-kind': (filters, resource) => { + if (!filters || !filters.selected || !filters.selected.size) { + return true; + } + return filters.selected.has(resource.kind); + }, + 'clusterserviceversion-status': (filters, csv) => { + if (!filters || !filters.selected || !filters.selected.size) { + return true; + } + return filters.selected.has(_.get(csv.status, 'reason')) || !_.includes(filters.all, _.get(csv.status, 'reason')); + }, + + 'build-status': (phases, build) => { + if (!phases || !phases.selected || !phases.selected.size) { + return true; + } + + const phase = build.status.phase; + return phases.selected.has(phase) || !_.includes(phases.all, phase); + }, + + 'build-strategy': (strategies, buildConfig) => { + if (!strategies || !strategies.selected || !strategies.selected.size) { + return true; + } + + const strategy = buildConfig.spec.strategy.type; + return strategies.selected.has(strategy) || !_.includes(strategies.all, strategy); + }, + + 'route-status': (statuses, route) => { + if (!statuses || !statuses.selected || !statuses.selected.size) { + return true; + } + + const status = routeStatus(route); + return statuses.selected.has(status) || !_.includes(statuses.all, status); + }, + + 'catalog-status': (statuses, catalog) => { + if (!statuses || !statuses.selected || !statuses.selected.size) { + return true; + } + + const status = serviceCatalogStatus(catalog); + return statuses.selected.has(status) || !_.includes(statuses.all, status); + }, + + 'secret-type': (types, secret) => { + if (!types || !types.selected || !types.selected.size) { + return true; + } + const type = secretTypeFilterReducer(secret); + return types.selected.has(type) || !_.includes(types.all, type); + }, + + 'pvc-status': (phases, pvc) => { + if (!phases || !phases.selected || !phases.selected.size) { + return true; + } + + const phase = pvc.status.phase; + return phases.selected.has(phase) || !_.includes(phases.all, phase); + }, + + // Filter service classes by text match + 'service-class': (str, serviceClass) => { + const displayName = serviceClassDisplayName(serviceClass); + return fuzzyCaseInsensitive(str, displayName); + }, + + 'cluster-operator-status': (statuses, operator) => { + if (!statuses || !statuses.selected || !statuses.selected.size) { + return true; + } + + const status = getClusterOperatorStatus(operator); + return statuses.selected.has(status) || !_.includes(statuses.all, status); + }, +}; + +const getFilteredRows = (_filters, objects) => { + if (_.isEmpty(_filters)) { + return objects; + } + + _.each(_filters, (value, name) => { + const filter = listFilters[name]; + if (_.isFunction(filter)) { + objects = _.filter(objects, o => filter(value, o)); + } + }); + + return objects; +}; + +const filterPropType = (props, propName, componentName) => { + if (!props) { + return; + } + + for (const key of _.keys(props[propName])) { + if (key in listFilters || key === 'loadTest') { + continue; + } + return new Error(`Invalid prop '${propName}' in '${componentName}'. '${key}' is not a valid filter type!`); + } +}; + +const sorts = { + alertStateOrder, + daemonsetNumScheduled: daemonset => _.toInteger(_.get(daemonset, 'status.currentNumberScheduled')), + dataSize: resource => _.size(_.get(resource, 'data')), + ingressValidHosts, + serviceCatalogStatus, + jobCompletions: job => getJobTypeAndCompletions(job).completions, + jobType: job => getJobTypeAndCompletions(job).type, + nodeReadiness: node => { + let readiness = _.get(node, 'status.conditions'); + readiness = _.find(readiness, {type: 'Ready'}); + return _.get(readiness, 'status'); + }, + nodeUpdateStatus: node => _.get(containerLinuxUpdateOperator.getUpdateStatus(node), 'text'), + numReplicas: resource => _.toInteger(_.get(resource, 'status.replicas')), + planExternalName, + podPhase, + podReadiness, + serviceClassDisplayName, + silenceStateOrder, + string: val => JSON.stringify(val), + getClusterOperatorStatus, +}; + +const stateToProps = ({UI}, {data = [], defaultSortField = 'metadata.name', defaultSortFunc = undefined, filters = {}, loaded = false, reduxID = null, reduxIDs = null, staticFilters = [{}]}) => { + const allFilters = staticFilters ? Object.assign({}, filters, ...staticFilters) : filters; + let newData = getFilteredRows(allFilters, data); + + const listId = reduxIDs ? reduxIDs.join(',') : reduxID; + // Only default to 'metadata.name' if no `defaultSortFunc` + const currentSortField = UI.getIn(['listSorts', listId, 'field'], defaultSortFunc ? undefined : defaultSortField); + const currentSortFunc = UI.getIn(['listSorts', listId, 'func'], defaultSortFunc); + const currentSortOrder = UI.getIn(['listSorts', listId, 'orderBy'], SortByDirection.asc); + + if (loaded) { + let sortBy: string | Function = 'metadata.name'; + if (currentSortField) { + // Sort resources by one of their fields as a string + sortBy = resource => sorts.string(_.get(resource, currentSortField, '')); + } else if (currentSortFunc && sorts[currentSortFunc]) { + // Sort resources by a function in the 'sorts' object + sortBy = sorts[currentSortFunc]; + } + + // Always set the secondary sort criteria to ascending by name + newData = _.orderBy(newData, [sortBy, 'metadata.name'], [currentSortOrder, SortByDirection.asc]); + } + + return { + currentSortField, + currentSortFunc, + currentSortOrder, + data: newData, + listId, + }; +}; + + export const Table = connect(stateToProps, {sortList: UIActions.sortList})( + class TableInner extends React.Component { + static propTypes = { + data: PropTypes.array, + EmptyMsg: PropTypes.func, + expand: PropTypes.bool, + fieldSelector: PropTypes.string, + filters: filterPropType, + Header: PropTypes.func.isRequired, + Rows: PropTypes.func.isRequired, + loaded: PropTypes.bool, + loadError: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + mock: PropTypes.bool, + namespace: PropTypes.string, + reduxID: PropTypes.string, + reduxIDs: PropTypes.array, + selector: PropTypes.object, + staticFilters: PropTypes.array, + virtualize: PropTypes.bool, + currentSortField: PropTypes.string, + currentSortFunc: PropTypes.string, + currentSortOrder: PropTypes.any, + defaultSortField: PropTypes.string, + defaultSortFunc: PropTypes.string, + label: PropTypes.string, + listId: PropTypes.string, + sortList: PropTypes.func, + onSelect: PropTypes.func, + }; + _columnShift: number; + + constructor(props){ + super(props); + const componentProps: any = _.pick(props, ['data', 'filters', 'selected', 'match', 'kindObj']); + const columns = props.Header(componentProps); + //sort by first column + this.state = { + columns: columns, + sortBy: {} + }; + this._applySort = this._applySort.bind(this); + this._onSort = this._onSort.bind(this); + this._columnShift = props.onSelect ? 1 : 0; //shift indexes by 1 if select provided + } + + componentDidMount(){ + const {columns} = this.state; + const sp = new URLSearchParams(window.location.search); + const columnIndex = _.findIndex(columns, {title: sp.get('sortBy')}); + + if(columnIndex > -1){ + const sortOrder = sp.get('orderBy') || SortByDirection.asc; + const column = columns[columnIndex]; + this._applySort(column.sortField, sortOrder, column.title); + this.setState({ + sortBy: { + index: columnIndex + this._columnShift, + direction: sortOrder + } + }) + } + } + + _applySort(sortField, direction, columnTitle){ + const {sortList, listId, currentSortFunc} = this.props; + const applySort = _.partial(sortList, listId); + applySort(sortField, currentSortFunc, direction, columnTitle); + } + + _onSort(_event, index, direction){ + const sortColumn = this.state.columns[index - this._columnShift]; + + this._applySort(sortColumn.sortField, direction, sortColumn.title); + + this.setState({ + sortBy: { + index: index, + direction: direction + } + }); + } + + render() { + //todo: handle expand + const {expand, Rows, label, onSelect, selectedResourcesForKind, 'aria-label': ariaLabel, virtualize} = this.props; + const {sortBy, columns} = this.state; + const componentProps: any = _.pick(this.props, ['data', 'filters', 'selected', 'match', 'kindObj']); + const rows = Rows(componentProps, selectedResourcesForKind); + + if(virtualize){ + return rows ? ( + + {({ height, isScrolling, registerChild, onChildScroll, scrollTop }) => ( + + + document.querySelector('#content-scrollable')} + rowKey="id" /> + + )} + + ) : ; + } else { + return rows ? ( + + + + + ) : ; + } + } + }); + + + export type TableInnerProps = { + 'aria-label': string; + currentSortField?: string; + currentSortFunc?: string; + currentSortOrder?: any; + data?: any[]; + defaultSortField?: string; + defaultSortFunc?: string; + EmptyMsg?: React.ComponentType<{}>; + expand?: boolean; + fieldSelector?: string; + filters?: {[name: string]: any}; + Header: React.ComponentType; + label?: string; + listId?: string; + loaded?: boolean; + loadError?: string | Object; + mock?: boolean; + namespace?: string; + reduxID?: string; + reduxIDs?: string[]; + Row: React.ComponentType; + Rows: (...args)=> any; + selector?: Object; + sortList?: (listId: string, field: string, func: any, orderBy: string, column: string) => any; + selectedResourcesForKind?: string[]; + onSelect?: (event: React.MouseEvent, isSelected: boolean, rowIndex: number, rowData: IRowData, extraData: IExtraData) => void; + staticFilters?: any[]; + virtualize?: boolean; +}; + +export type TableInnerState = { + columns?: any[]; + sortBy: object; +} diff --git a/frontend/public/components/utils/kebab.tsx b/frontend/public/components/utils/kebab.tsx index e1f6336dac8e..322e22a7c917 100644 --- a/frontend/public/components/utils/kebab.tsx +++ b/frontend/public/components/utils/kebab.tsx @@ -4,8 +4,8 @@ import * as _ from 'lodash-es'; import * as React from 'react'; import { annotationsModal, configureReplicaCountModal, taintsModal, tolerationsModal, labelsModal, podSelectorModal, deleteModal } from '../modals'; -import { DropdownMixin } from './dropdown'; -import { history, resourceObjPath } from './index'; +import { resourceObjPath } from './index'; +import { OneOf, Dropdown, KebabToggle, DropdownItem, DropdownPosition } from '@patternfly/react-core'; import { referenceForModel, K8sResourceKind, K8sResourceKindReference, K8sKind } from '../../module/k8s'; import { connectToModel } from '../../kinds'; @@ -103,34 +103,69 @@ export const ResourceKebab = connectToModel((props: ResourceKebabProps) => { />; }); -export class Kebab extends DropdownMixin { +export class Kebab extends React.Component { static factory: KebabFactory = kebabFactory; - private onClick = this.onClick_.bind(this); + constructor(props) { + super(props); + this.state = { + isOpen: false + }; + } - onClick_(event, option) { - event.preventDefault(); + onToggle = isOpen => { + this.setState({ + isOpen + }); + }; - if (option.callback) { - option.callback(); - } + onSelect = event => { + this.setState({ + isOpen: !this.state.isOpen + }); + }; - if (option.href) { - history.push(option.href); + render() { + const { isOpen } = this.state; + const { options, isDisabled, position, id } = this.props; + + const items = []; + if(options && options.length){ + options.forEach((option) => { + items.push( + + {option.label} + + ); + }) } - this.hide(); + return ( + } + isOpen={isOpen} + isPlain + dropdownItems={items} + /> + ); } +} - render() { - const {options, isDisabled} = this.props; - - return
- - {(!isDisabled && this.state.active) && } -
; - } +export type KebabProps = { + id?: string; + isDisabled?: boolean; + options: KebabOption[]; + position?: OneOf; +} + +export type KebabState = { + isOpen: boolean; } export type KebabOption = { diff --git a/frontend/public/style/_overrides.scss b/frontend/public/style/_overrides.scss index ecd9714b7418..a650c92fb04a 100644 --- a/frontend/public/style/_overrides.scss +++ b/frontend/public/style/_overrides.scss @@ -470,3 +470,10 @@ tags-input .autocomplete .suggestion-item em { display: none; } } + +// temporary td width's for demo only, responsive pf4 css to follow +@media (min-width: $screen-md-min){ + .pf-c-table .meta-status { + min-width: 100px; + } +} \ No newline at end of file diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6cbb1eb53d55..06efd658d0f0 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -130,6 +130,11 @@ version "3.1.0" resolved "https://registry.yarnpkg.com/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz#497c67a1cef50d1a2459ba60f315e448d2ad87fe" +"@patternfly/patternfly@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@patternfly/patternfly/-/patternfly-2.0.0.tgz#8c701445c7a4fc89a128c1b4a99076d1c37dcbcb" + integrity sha512-Ezh5TDKEO0lFZ5DM+TrOqtsv82LjVXZX5kFLkpM2tZf4dIUCfrhWzGGcQg3Bb/DMU0wkicwuzCu7oqDw3ZoZxg== + "@patternfly/patternfly@2.4.1": version "2.4.1" resolved "https://registry.yarnpkg.com/@patternfly/patternfly/-/patternfly-2.4.1.tgz#2b82e946ec2cdf5a78535ba2b9d514151688dc2d" @@ -148,10 +153,23 @@ exenv "^1.2.2" focus-trap-react "^4.0.1" -"@patternfly/react-icons@^3.7.4": - version "3.7.4" - resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-3.7.4.tgz#e4abe683de7208682dea257355768c4aefd1ff79" - integrity sha512-YgKQt7qloOvGJEt3kRJLlRe6eJMwU0YbfuC2bsTzcmCReI5cSNv0ZyuV9rOIOpB5rRaNPmCm9RjKBCixFjcaYg== +"@patternfly/react-core@^3.2.0": + version "3.2.6" + resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-3.2.6.tgz#8cc2ca0d16084e4437a6c1b25d399a2db6129b2c" + integrity sha512-58GBM8SfhoggQldGArUR3LwvcMe+GaW/uTjB1/a3RTG6HXTfFfs33UXnQnN2IgWZmHOyxpjnWq2yEh5xwJs5HQ== + dependencies: + "@patternfly/react-icons" "^3.7.4" + "@patternfly/react-styles" "^3.0.2" + "@patternfly/react-tokens" "^2.3.3" + "@tippy.js/react" "^1.1.1" + emotion "^9.2.9" + exenv "^1.2.2" + focus-trap-react "^4.0.1" + +"@patternfly/react-icons@^3.7.3", "@patternfly/react-icons@^3.7.4": + version "3.7.5" + resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-3.7.5.tgz#fc84079b8f9c1b283195d306841eec9a786e855a" + integrity sha512-Bejd6GAWfcDgA7YxvIcrohcBPVZUG34E3LWaJSHLUf8XADf33q7UvQ4YQ1eWk477o/GjZA3AX/71Y6op71WdDA== "@patternfly/react-styles@^3.0.2": version "3.0.2" @@ -171,11 +189,32 @@ relative "^3.0.2" resolve-from "^4.0.0" +"@patternfly/react-table@git+https://git@github.com/priley86/react-table.git": + version "2.1.0" + resolved "git+https://git@github.com/priley86/react-table.git#5a8a70634afe5df4c73281060c92bbbd5db0118a" + dependencies: + "@patternfly/patternfly" "2.0.0" + "@patternfly/react-core" "^3.2.0" + "@patternfly/react-icons" "^3.7.3" + "@patternfly/react-styles" "^3.0.2" + exenv "^1.2.2" + reactabular-table "^8.14.0" + "@patternfly/react-tokens@^2.3.3": version "2.3.3" resolved "https://registry.yarnpkg.com/@patternfly/react-tokens/-/react-tokens-2.3.3.tgz#6fea36b284a36d4404b4bd75ddcc3fa5a833654f" integrity sha512-+2SSGvOV1rZr1l6+p2QzORVJhpOKjrHqCBfkp10La7O93+mVLYU0vugTp1elhxeh32IuLCJAPDLrCJPBAfmYKw== +"@patternfly/react-virtualized-extension@git+https://git@github.com/priley86/react-virtualized-extension.git": + version "2.1.0" + resolved "git+https://git@github.com/priley86/react-virtualized-extension.git#f7c06d5333f0f6a1ff3c6c5664afa7f5d1f28b35" + dependencies: + "@patternfly/patternfly" "2.4.1" + "@patternfly/react-icons" "^3.7.3" + "@patternfly/react-styles" "^3.0.2" + exenv "^1.2.2" + reactabular-table "^8.14.0" + "@plotly/d3-sankey@^0.5.1": version "0.5.1" resolved "https://registry.yarnpkg.com/@plotly/d3-sankey/-/d3-sankey-0.5.1.tgz#c2862c71374aba4f097a95a3449c7274a15a22de"