diff --git a/src/Assets/IconV2/ic-ses.svg b/src/Assets/IconV2/ic-ses.svg new file mode 100644 index 000000000..11b2727e7 --- /dev/null +++ b/src/Assets/IconV2/ic-ses.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Assets/IconV2/ic-slack.svg b/src/Assets/IconV2/ic-slack.svg new file mode 100644 index 000000000..fc9268e74 --- /dev/null +++ b/src/Assets/IconV2/ic-slack.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/Assets/IconV2/ic-smtp.svg b/src/Assets/IconV2/ic-smtp.svg new file mode 100644 index 000000000..e0be557be --- /dev/null +++ b/src/Assets/IconV2/ic-smtp.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/Assets/IconV2/ic-webhook-config.svg b/src/Assets/IconV2/ic-webhook-config.svg new file mode 100644 index 000000000..a3ca9e44c --- /dev/null +++ b/src/Assets/IconV2/ic-webhook-config.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Common/SortableTableHeaderCell/SortableTableHeaderCell.tsx b/src/Common/SortableTableHeaderCell/SortableTableHeaderCell.tsx index ebfd7c2ef..049b4059d 100644 --- a/src/Common/SortableTableHeaderCell/SortableTableHeaderCell.tsx +++ b/src/Common/SortableTableHeaderCell/SortableTableHeaderCell.tsx @@ -19,6 +19,7 @@ import Draggable, { DraggableProps } from 'react-draggable' import { ReactComponent as SortIcon } from '@Icons/ic-arrow-up-down.svg' import { ReactComponent as SortArrowDown } from '@Icons/ic-sort-arrow-down.svg' import { Tooltip } from '@Common/Tooltip' +import { Icon } from '@Shared/Components/Icon' import { SortingOrder } from '../Constants' import { noop } from '../Helper' @@ -68,6 +69,7 @@ const SortableTableHeaderCell = ({ id, handleResize, isResizable, + infoTooltipText, }: SortableTableHeaderCellProps) => { const isCellResizable = !!(isResizable && id && handleResize) @@ -107,7 +109,18 @@ const SortableTableHeaderCell = ({ data-testid={title} > - {title} +
+ {title} + + {infoTooltipText && ( + + )} +
{renderSortIcon()} diff --git a/src/Common/SortableTableHeaderCell/types.ts b/src/Common/SortableTableHeaderCell/types.ts index 15f3f058f..346c80a3f 100644 --- a/src/Common/SortableTableHeaderCell/types.ts +++ b/src/Common/SortableTableHeaderCell/types.ts @@ -26,6 +26,10 @@ export type SortableTableHeaderCellProps = { * @default false */ showTippyOnTruncate?: boolean + /** + * If provided, shown in a tooltip on info-icon-outline beside the label + */ + infoTooltipText?: string } & ( | { /** diff --git a/src/Shared/Components/Icon/Icon.tsx b/src/Shared/Components/Icon/Icon.tsx index f9517d50e..f6fcc69da 100644 --- a/src/Shared/Components/Icon/Icon.tsx +++ b/src/Shared/Components/Icon/Icon.tsx @@ -252,9 +252,12 @@ import { ReactComponent as ICSecurityPolicy } from '@IconsV2/ic-security-policy. import { ReactComponent as ICSecurityScan } from '@IconsV2/ic-security-scan.svg' import { ReactComponent as ICSecurityVulnerability } from '@IconsV2/ic-security-vulnerability.svg' import { ReactComponent as ICSelected } from '@IconsV2/ic-selected.svg' +import { ReactComponent as ICSes } from '@IconsV2/ic-ses.svg' import { ReactComponent as ICShapes } from '@IconsV2/ic-shapes.svg' import { ReactComponent as ICShieldCheck } from '@IconsV2/ic-shield-check.svg' +import { ReactComponent as ICSlack } from '@IconsV2/ic-slack.svg' import { ReactComponent as ICSlidersVertical } from '@IconsV2/ic-sliders-vertical.svg' +import { ReactComponent as ICSmtp } from '@IconsV2/ic-smtp.svg' import { ReactComponent as ICSoftwareReleaseManagement } from '@IconsV2/ic-software-release-management.svg' import { ReactComponent as ICSortAscending } from '@IconsV2/ic-sort-ascending.svg' import { ReactComponent as ICSortDescending } from '@IconsV2/ic-sort-descending.svg' @@ -308,6 +311,7 @@ import { ReactComponent as ICWarning } from '@IconsV2/ic-warning.svg' import { ReactComponent as ICWarningFill } from '@IconsV2/ic-warning-fill.svg' import { ReactComponent as ICWarningStroke } from '@IconsV2/ic-warning-stroke.svg' import { ReactComponent as ICWebhook } from '@IconsV2/ic-webhook.svg' +import { ReactComponent as ICWebhookConfig } from '@IconsV2/ic-webhook-config.svg' import { ReactComponent as ICWifiSlash } from '@IconsV2/ic-wifi-slash.svg' import { ReactComponent as ICWorldGlobe } from '@IconsV2/ic-world-globe.svg' @@ -568,9 +572,12 @@ export const iconMap = { 'ic-security-scan': ICSecurityScan, 'ic-security-vulnerability': ICSecurityVulnerability, 'ic-selected': ICSelected, + 'ic-ses': ICSes, 'ic-shapes': ICShapes, 'ic-shield-check': ICShieldCheck, + 'ic-slack': ICSlack, 'ic-sliders-vertical': ICSlidersVertical, + 'ic-smtp': ICSmtp, 'ic-software-release-management': ICSoftwareReleaseManagement, 'ic-sort-ascending': ICSortAscending, 'ic-sort-descending': ICSortDescending, @@ -623,6 +630,7 @@ export const iconMap = { 'ic-warning-fill': ICWarningFill, 'ic-warning-stroke': ICWarningStroke, 'ic-warning': ICWarning, + 'ic-webhook-config': ICWebhookConfig, 'ic-webhook': ICWebhook, 'ic-wifi-slash': ICWifiSlash, 'ic-world-globe': ICWorldGlobe, diff --git a/src/Shared/Components/Table/InternalTable.tsx b/src/Shared/Components/Table/InternalTable.tsx index d179c91a4..99a7045d3 100644 --- a/src/Shared/Components/Table/InternalTable.tsx +++ b/src/Shared/Components/Table/InternalTable.tsx @@ -56,6 +56,8 @@ const InternalTable = < rowActionOnHoverConfig, pageSizeOptions, clearFilters: userGivenUrlClearFilters, + rowStartIconConfig, + onRowClick, }: InternalTableProps) => { const { sortBy, @@ -158,7 +160,8 @@ const InternalTable = < searchKey, sortBy, sortOrder, - getRows, + // !TODO: functions in queryKey cannot trigger refetch + // getRows, offset, pageSize, JSON.stringify(otherFilters), @@ -219,6 +222,8 @@ const InternalTable = < stylesConfig={stylesConfig} getRows={getRows} totalRows={totalRows} + rowStartIconConfig={rowStartIconConfig} + onRowClick={onRowClick} /> ) diff --git a/src/Shared/Components/Table/TableContent.tsx b/src/Shared/Components/Table/TableContent.tsx index f7239c885..a4e779f41 100644 --- a/src/Shared/Components/Table/TableContent.tsx +++ b/src/Shared/Components/Table/TableContent.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useMemo, useRef, useState } from 'react' +import { MouseEvent, useEffect, useMemo, useRef, useState } from 'react' import { Checkbox } from '@Common/Checkbox' import { DEFAULT_BASE_PAGE_SIZE } from '@Common/Constants' @@ -22,11 +22,22 @@ import { useEffectAfterMount } from '@Common/Helper' import { Pagination } from '@Common/Pagination' import { SortableTableHeaderCell } from '@Common/SortableTableHeaderCell' import { CHECKBOX_VALUE } from '@Common/Types' +import { Button, ButtonStyleType, ButtonVariantType } from '@Shared/Components/Button' +import { Icon } from '@Shared/Components/Icon' +import { ComponentSizeType } from '@Shared/constants' import { BulkSelection } from '../BulkSelection' import BulkSelectionActionWidget from './BulkSelectionActionWidget' -import { BULK_ACTION_GUTTER_LABEL, EVENT_TARGET, SHIMMER_DUMMY_ARRAY } from './constants' -import { BulkActionStateType, FiltersTypeEnum, PaginationEnum, SignalsType, TableContentProps } from './types' +import { ACTION_GUTTER_SIZE, BULK_ACTION_GUTTER_LABEL, EVENT_TARGET, SHIMMER_DUMMY_ARRAY } from './constants' +import { + BulkActionStateType, + ExpandedRowPrefixType, + FiltersTypeEnum, + PaginationEnum, + RowType, + SignalsType, + TableContentProps, +} from './types' import useTableWithKeyboardShortcuts from './useTableWithKeyboardShortcuts' import { getStickyColumnConfig, scrollToShowActiveElementIfNeeded } from './utils' @@ -53,14 +64,23 @@ const TableContent = < areFilteredRowsLoading, getRows, totalRows, + rowStartIconConfig, + onRowClick, }: TableContentProps) => { const rowsContainerRef = useRef(null) const parentRef = useRef(null) const bulkSelectionButtonRef = useRef(null) const headerRef = useRef(null) + const skipFocusActiveRowRef = useRef(false) const [bulkActionState, setBulkActionState] = useState(null) const [showBorderRightOnStickyElements, setShowBorderRightOnStickyElements] = useState(false) + const [expandState, _setExpandState] = useState>({}) + + const setExpandState: typeof _setExpandState = (value) => { + skipFocusActiveRowRef.current = true + _setExpandState(value) + } const { width: rowOnHoverComponentWidth, Component: RowOnHoverComponent } = rowActionOnHoverConfig || {} @@ -92,10 +112,50 @@ const TableContent = < .join(' '), } = resizableConfig ?? {} - const gridTemplateColumns = rowOnHoverComponentWidth + const { visibleRows, areAllRowsExpanded, isAnyRowExpandable } = useMemo(() => { + const normalizedFilteredRows = filteredRows ?? [] + + const paginatedRows = + paginationVariant !== PaginationEnum.PAGINATED || + (paginationVariant === PaginationEnum.PAGINATED && getRows) + ? normalizedFilteredRows + : normalizedFilteredRows.slice(offset, offset + pageSize) + + const _isAnyRowExpandable = paginatedRows.some((row) => !!row.expandableRows) + + const _areAllRowsExpanded = + _isAnyRowExpandable && + paginatedRows.reduce((acc, row) => { + if (row.expandableRows) { + return acc && !!expandState[row.id] + } + + return acc + }, true) + + const paginatedRowsWithExpandedRows = paginatedRows.flatMap((row) => { + if (row.expandableRows && expandState[row.id]) { + return [row, ...row.expandableRows] + } + + return [row] + }) + + return { + visibleRows: paginatedRowsWithExpandedRows, + areAllRowsExpanded: _areAllRowsExpanded, + isAnyRowExpandable: _isAnyRowExpandable, + } + }, [paginationVariant, offset, pageSize, filteredRows, expandState]) + + const gridTemplateColumnsWithoutExpandButton = rowOnHoverComponentWidth ? `${initialGridTemplateColumns} ${typeof rowOnHoverComponentWidth === 'number' ? `minmax(${rowOnHoverComponentWidth}px, 1fr)` : rowOnHoverComponentWidth}` : initialGridTemplateColumns + const gridTemplateColumns = isAnyRowExpandable + ? `${ACTION_GUTTER_SIZE}px ${gridTemplateColumnsWithoutExpandButton}` + : gridTemplateColumnsWithoutExpandButton + useEffect(() => { const scrollEventHandler = () => { setShowBorderRightOnStickyElements(rowsContainerRef.current?.scrollLeft > 0) @@ -113,18 +173,6 @@ const TableContent = < const bulkSelectionCount = isBulkSelectionApplied ? totalRows : (getSelectedIdentifiersCount?.() ?? 0) - const visibleRows = useMemo(() => { - const normalizedFilteredRows = filteredRows ?? [] - - const paginatedRows = - paginationVariant !== PaginationEnum.PAGINATED || - (paginationVariant === PaginationEnum.PAGINATED && getRows) - ? normalizedFilteredRows - : normalizedFilteredRows.slice(offset, offset + pageSize) - - return paginatedRows - }, [paginationVariant, offset, pageSize, filteredRows]) - const isBEPagination = !!getRows const showPagination = @@ -155,7 +203,26 @@ const TableContent = < handleSorting(newSortBy) } + const toggleExpandAll = (e: MouseEvent) => { + e.stopPropagation() + + setExpandState( + visibleRows.reduce((acc, row) => { + if ((row as RowType).expandableRows) { + acc[row.id] = !areAllRowsExpanded + } + + return acc + }, {}), + ) + } + const focusActiveRow = (node: HTMLDivElement) => { + if (skipFocusActiveRowRef.current) { + skipFocusActiveRowRef.current = false + return + } + if ( node && !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName.toUpperCase()) && @@ -216,15 +283,37 @@ const TableContent = < return visibleRows.map((row, visibleRowIndex) => { const isRowActive = activeRowIndex === visibleRowIndex const isRowBulkSelected = !!bulkSelectionState[row.id] || isBulkSelectionApplied + const isExpandedRow = row.id.startsWith('expanded-row-' satisfies ExpandedRowPrefixType) + + const handleChangeActiveRowIndex = (e: MouseEvent) => { + e.stopPropagation() - const handleChangeActiveRowIndex = () => { setActiveRowIndex(visibleRowIndex) + + onRowClick?.(row, isExpandedRow) } const handleToggleBulkSelectionForRow = () => { handleToggleBulkSelectionOnRow(row) } + const toggleExpandRow = (e: MouseEvent) => { + e.stopPropagation() + + if ((row as RowType).expandableRows) { + setExpandState({ + ...expandState, + [row.id]: !expandState[row.id], + }) + } + } + + const hasBulkOrExpandAction = + (isAnyRowExpandable && !isExpandedRow && !!(row as RowType).expandableRows) || + !!bulkSelectionReturnValue + + const expandBtnOrRowStartIconGutterStickyConfig = getStickyColumnConfig(gridTemplateColumns, 0) + return (
+ {rowStartIconConfig && !isExpandedRow && ( +
+ +
+ )} + + {!isExpandedRow && !!(row as RowType).expandableRows ? ( +
+
+ ) : null} + + {/* empty div needed for alignment; therefore hide if rowStartIconConfig (only applies to parent rows) is present */} + {isAnyRowExpandable && + (isExpandedRow || (!(row as RowType).expandableRows && !rowStartIconConfig)) && ( +
+ )} + {visibleColumns.map(({ field, horizontallySticky: isStickyColumn, CellComponent }, index) => { const isBulkActionGutter = field === BULK_ACTION_GUTTER_LABEL const horizontallySticky = isStickyColumn || isBulkActionGutter const { className: stickyClassName = '', left: stickyLeftValue = '' } = horizontallySticky - ? getStickyColumnConfig(gridTemplateColumns, index) + ? getStickyColumnConfig( + gridTemplateColumns, + index + (isAnyRowExpandable || rowStartIconConfig ? 1 : 0), + ) : {} - if (isBulkActionGutter) { + if (isBulkActionGutter && !isExpandedRow) { return (
@@ -282,6 +421,9 @@ const TableContent = < row={row} filterData={filterData as any} isRowActive={isRowActive} + isExpandedRow={isExpandedRow} + isRowInExpandState={expandState[row.id]} + expandRowCallback={toggleExpandRow} {...additionalProps} /> ) : ( @@ -295,7 +437,7 @@ const TableContent = < ) })} - {RowOnHoverComponent && ( + {!isExpandedRow && RowOnHoverComponent && (
@@ -307,6 +449,8 @@ const TableContent = < }) } + const expandAllBtnStickyConfig = getStickyColumnConfig(gridTemplateColumns, 0) + return (
+ {isAnyRowExpandable ? ( +
+
+ ) : null} + {visibleColumns.map( ( { @@ -346,6 +514,7 @@ const TableContent = < size, showTippyOnTruncate, horizontallySticky: isStickyColumn, + infoTooltipText, }, index, ) => { @@ -353,7 +522,12 @@ const TableContent = < const isBulkActionGutter = field === BULK_ACTION_GUTTER_LABEL const horizontallySticky = isStickyColumn || isBulkActionGutter const { className: stickyClassName = '', left: stickyLeftValue = '' } = - horizontallySticky ? getStickyColumnConfig(gridTemplateColumns, index) : {} + horizontallySticky + ? getStickyColumnConfig( + gridTemplateColumns, + index + (isAnyRowExpandable ? 1 : 0), + ) + : {} if (field === BULK_ACTION_GUTTER_LABEL) { return ( @@ -379,7 +553,7 @@ const TableContent = < return (
@@ -392,6 +566,7 @@ const TableContent = < triggerSorting={getTriggerSortingHandler(field)} showTippyOnTruncate={showTippyOnTruncate} disabled={areFilteredRowsLoading} + infoTooltipText={infoTooltipText} {...(isResizable ? { isResizable, handleResize, id: label } : { isResizable: false })} diff --git a/src/Shared/Components/Table/constants.ts b/src/Shared/Components/Table/constants.ts index 1b7ed8b0f..63005f738 100644 --- a/src/Shared/Components/Table/constants.ts +++ b/src/Shared/Components/Table/constants.ts @@ -29,3 +29,5 @@ export const DRAG_SELECTOR_IDENTIFIER = 'table-drag-selector' export const SHIMMER_DUMMY_ARRAY = [1, 2, 3] export const NO_ROWS_OR_GET_ROWS_ERROR = new Error('Neither rows nor getRows function provided') + +export const ACTION_GUTTER_SIZE = 24 diff --git a/src/Shared/Components/Table/styles.scss b/src/Shared/Components/Table/styles.scss index ed2fb7c1e..ecc29cb3f 100644 --- a/src/Shared/Components/Table/styles.scss +++ b/src/Shared/Components/Table/styles.scss @@ -40,6 +40,13 @@ left: -20px; width: 20px; } + + &.expand-row-btn::before, + &.row-start-icon::before, + &.expanded-tree-libe::before { + left: -24px; + width: 24px; + } } &--scrolled { @@ -94,6 +101,33 @@ display: inherit; } } + + &.with-start-icon-and-bulk-or-expand-action { + .bulk-action-checkbox { + display: none; + } + + .expand-row-btn { + display: none; + } + + &:hover, + &.generic-table__row--active, + &.generic-table__row--bulk-selected, + &.generic-table__row--is-expanded { + .row-start-icon { + display: none; + } + + .bulk-action-checkbox { + display: flex; + } + + .expand-row-btn { + display: flex; + } + } + } } .sortable-table-header__resize-btn:hover, @@ -105,4 +139,14 @@ transform: scaleY(var(--resize-btn-scale-multiplier)); } } + + .expanded-tree-line::after { + content: ''; + width: 1px; + height: 100%; + background: var(--N200); + left: calc(50% - 1px); // offset to left by width for perfect centering + top: 0; + position: absolute; + } } diff --git a/src/Shared/Components/Table/types.ts b/src/Shared/Components/Table/types.ts index 0d729b82a..ddf7f27ea 100644 --- a/src/Shared/Components/Table/types.ts +++ b/src/Shared/Components/Table/types.ts @@ -26,6 +26,7 @@ import { import { GenericEmptyStateType } from '@Common/index' import { PageSizeOption } from '@Common/Pagination/types' import { SortableTableHeaderCellProps, useResizableTableConfig } from '@Common/SortableTableHeaderCell' +import { IconsProps } from '@Shared/Components/Icon' import { useBulkSelection, UseBulkSelectionProps } from '../BulkSelection' @@ -87,13 +88,23 @@ type BaseColumnType = { size: SizeType horizontallySticky?: boolean -} +} & Pick -export type RowType = { +type CommonRowType = { id: string data: Data } +export type ExpandedRowPrefixType = 'expanded-row-' + +export type ExpandedRowType = CommonRowType & { + id: `${ExpandedRowPrefixType}${string}` +} + +export type RowType = CommonRowType & { + expandableRows?: Array> +} + export type RowsType = RowType[] export enum FiltersTypeEnum { @@ -117,6 +128,10 @@ export type CellComponentProps< ? UseFiltersReturnType : UseUrlFiltersReturnType isRowActive: boolean + isExpandedRow: boolean + isRowInExpandState: boolean + // NOTE: no action if the row is not expandable + expandRowCallback: (e: MouseEvent) => void } export type RowActionsOnHoverComponentProps< @@ -307,6 +322,14 @@ export type InternalTableProps< handleToggleBulkSelectionOnRow: (row: RowType) => void ViewWrapper?: FunctionComponent> + + /** + * An icon as the first element of the row, that hides actions like expand or bulk select icons + * until user hovers over the row or the row has focus from keyboard navigation + */ + rowStartIconConfig?: Omit + + onRowClick?: (row: RowType, isExpandedRow: boolean) => void } & ( | { /** @@ -390,6 +413,8 @@ export type TableProps< | 'ViewWrapper' | 'pageSizeOptions' | 'clearFilters' + | 'rowStartIconConfig' + | 'onRowClick' > export type BulkActionStateType = string | null @@ -440,6 +465,8 @@ export interface TableContentProps< | 'rowActionOnHoverConfig' | 'pageSizeOptions' | 'getRows' + | 'rowStartIconConfig' + | 'onRowClick' >, RowsResultType { areFilteredRowsLoading: boolean