diff --git a/package-lock.json b/package-lock.json index 194e77c32..bcc9c0f6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.22.0-alpha-11", + "version": "1.22.0-alpha-12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.22.0-alpha-11", + "version": "1.22.0-alpha-12", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index b36090676..09462f1d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.22.0-alpha-11", + "version": "1.22.0-alpha-12", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", 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/CICDHistory/ConflictedResourcesTable.tsx b/src/Shared/Components/CICDHistory/ConflictedResourcesTable.tsx index 238293eae..9e24fc76f 100644 --- a/src/Shared/Components/CICDHistory/ConflictedResourcesTable.tsx +++ b/src/Shared/Components/CICDHistory/ConflictedResourcesTable.tsx @@ -10,7 +10,6 @@ import './ConflictedResourcesTable.scss' const Wrapper = ({ children }: TableViewWrapperProps) => (
{children}
) -const filter = () => true const ConflictedResourcesTable = ({ resourceConflictDetails }: ConflictedResourcesTableProps) => { const rows: RowsType = useMemo( @@ -42,7 +41,7 @@ const ConflictedResourcesTable = ({ resourceConflictDetails }: ConflictedResourc }} filtersVariant={FiltersTypeEnum.STATE} ViewWrapper={Wrapper} - filter={filter} + filter={null} /> ) } 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..007ab9084 100644 --- a/src/Shared/Components/Table/InternalTable.tsx +++ b/src/Shared/Components/Table/InternalTable.tsx @@ -56,6 +56,9 @@ const InternalTable = < rowActionOnHoverConfig, pageSizeOptions, clearFilters: userGivenUrlClearFilters, + rowStartIconConfig, + onRowClick, + areFiltersApplied: userProvidedAreFiltersApplied, }: InternalTableProps) => { const { sortBy, @@ -124,6 +127,7 @@ const InternalTable = < rows, filter, filterData, + additionalProps, visibleColumns.find(({ field }) => field === sortBy)?.comparator, ) @@ -138,10 +142,14 @@ const InternalTable = < // useAsync hook for 'rows' scenario const [_areRowsLoading, rowsResult, rowsError, reloadRows] = useAsync( handleFiltering, - [searchKey, sortBy, sortOrder, rows, JSON.stringify(otherFilters), visibleColumns], + [searchKey, filter, sortBy, sortOrder, rows, JSON.stringify(otherFilters), visibleColumns], !!rows, ) + // NOTE: passing getRows to queryKey won't trigger a refetch + // since it is a function + const lastUpdatedGetRowsInstance = useMemo(() => new Date().toISOString(), [getRows]) + // useAsync hook for 'getRows' scenario const { isFetching: _areGetRowsLoading, @@ -158,7 +166,7 @@ const InternalTable = < searchKey, sortBy, sortOrder, - getRows, + lastUpdatedGetRowsInstance, offset, pageSize, JSON.stringify(otherFilters), @@ -188,7 +196,8 @@ const InternalTable = < } if (!areFilteredRowsLoading && !filteredRows?.length && !loading) { - return filtersVariant !== FiltersTypeEnum.NONE && areFiltersApplied ? ( + return filtersVariant !== FiltersTypeEnum.NONE && + (userProvidedAreFiltersApplied !== undefined ? userProvidedAreFiltersApplied : areFiltersApplied) ? ( ) diff --git a/src/Shared/Components/Table/TableContent.tsx b/src/Shared/Components/Table/TableContent.tsx index f7239c885..59c755fe6 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,23 @@ 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, + SignalEnum, + SignalsType, + TableContentProps, +} from './types' import useTableWithKeyboardShortcuts from './useTableWithKeyboardShortcuts' import { getStickyColumnConfig, scrollToShowActiveElementIfNeeded } from './utils' @@ -53,14 +65,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 +113,51 @@ 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 || rowStartIconConfig + ? `${ACTION_GUTTER_SIZE}px ${gridTemplateColumnsWithoutExpandButton}` + : gridTemplateColumnsWithoutExpandButton + useEffect(() => { const scrollEventHandler = () => { setShowBorderRightOnStickyElements(rowsContainerRef.current?.scrollLeft > 0) @@ -113,18 +175,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 = @@ -140,7 +190,7 @@ const TableContent = < useEffectAfterMount(() => { setActiveRowIndex(0) - }, [offset, visibleRows]) + }, [offset]) useEffect(() => { setIdentifiers?.( @@ -155,7 +205,74 @@ const TableContent = < handleSorting(newSortBy) } + useEffect(() => { + if (!isAnyRowExpandable) { + return () => {} + } + + const getExpandCollapseRowHandler = + (state: boolean) => + ({ detail: { activeRowData } }) => { + if ((activeRowData as RowType).expandableRows) { + setExpandState((prev) => ({ + ...prev, + [activeRowData.id]: state, + })) + } + } + + const handleExpandRow = getExpandCollapseRowHandler(true) + const handleCollapseRow = getExpandCollapseRowHandler(false) + + const signals = EVENT_TARGET as SignalsType + + signals.addEventListener(SignalEnum.EXPAND_ROW, handleExpandRow) + signals.addEventListener(SignalEnum.COLLAPSE_ROW, handleCollapseRow) + + return () => { + signals.removeEventListener(SignalEnum.EXPAND_ROW, handleExpandRow) + signals.removeEventListener(SignalEnum.COLLAPSE_ROW, handleCollapseRow) + } + }, [isAnyRowExpandable]) + + useEffect(() => { + if (!onRowClick) { + return () => {} + } + + const handleEnterPress = ({ detail: { activeRowData } }) => { + onRowClick(activeRowData, activeRowData.id.startsWith('expanded-row-' satisfies ExpandedRowPrefixType)) + } + + const signals = EVENT_TARGET as SignalsType + + signals.addEventListener(SignalEnum.ENTER_PRESSED, handleEnterPress) + + return () => { + signals.removeEventListener(SignalEnum.ENTER_PRESSED, handleEnterPress) + } + }, [onRowClick]) + + 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()) && @@ -180,14 +297,16 @@ const TableContent = < return Object.values(bulkSelectionState) } + const showIconOrExpandActionGutter = isBulkSelectionConfigured || !!rowStartIconConfig || isAnyRowExpandable + const renderRows = () => { - if (loading) { + if (loading && !visibleColumns.length) { return SHIMMER_DUMMY_ARRAY.map((shimmerRowLabel) => (
- {isBulkSelectionConfigured ?
: null} + {showIconOrExpandActionGutter ?
: null} {SHIMMER_DUMMY_ARRAY.map((shimmerCellLabel) => (
))} @@ -195,7 +314,7 @@ const TableContent = < )) } - if (areFilteredRowsLoading) { + if (areFilteredRowsLoading || (loading && visibleColumns.length)) { return SHIMMER_DUMMY_ARRAY.map((shimmerRowLabel) => (
+ {showIconOrExpandActionGutter ? ( +
+
+
+ ) : null} {visibleColumns.map(({ label }) => (
@@ -216,15 +340,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 +478,9 @@ const TableContent = < row={row} filterData={filterData as any} isRowActive={isRowActive} + isExpandedRow={isExpandedRow} + isRowInExpandState={expandState[row.id]} + expandRowCallback={toggleExpandRow} {...additionalProps} /> ) : ( @@ -295,7 +494,7 @@ const TableContent = < ) })} - {RowOnHoverComponent && ( + {!isExpandedRow && RowOnHoverComponent && (
@@ -307,6 +506,8 @@ const TableContent = < }) } + const expandAllBtnStickyConfig = getStickyColumnConfig(gridTemplateColumns, 0) + return (
- {loading ? ( + {loading && !visibleColumns.length ? (
- {isBulkSelectionConfigured ?
: null} + {showIconOrExpandActionGutter ?
: null} {SHIMMER_DUMMY_ARRAY.map((label) => (
))} @@ -337,6 +538,32 @@ const TableContent = < gridTemplateColumns, }} > + {isAnyRowExpandable ? ( +
+
+ ) : null} + + {!isAnyRowExpandable && rowStartIconConfig &&
} + {visibleColumns.map( ( { @@ -346,6 +573,7 @@ const TableContent = < size, showTippyOnTruncate, horizontallySticky: isStickyColumn, + infoTooltipText, }, index, ) => { @@ -353,7 +581,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 +612,7 @@ const TableContent = < return (
@@ -392,6 +625,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..16bfa0e00 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-line::before { + left: -24px; + width: 24px; + } } &--scrolled { @@ -69,6 +76,10 @@ outline: none; } + &--expanded-row:has(+ .generic-table__row--expanded-row) { + border-bottom: 0px; + } + &:hover, &:hover > *, &--active, @@ -94,6 +105,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 +143,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..a45d2b7f8 100644 --- a/src/Shared/Components/Table/types.ts +++ b/src/Shared/Components/Table/types.ts @@ -26,12 +26,15 @@ 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' export interface UseFiltersReturnType extends UseStateFiltersReturnType {} export enum SignalEnum { + COLLAPSE_ROW = 'collapse-row', + EXPAND_ROW = 'expand-row', ENTER_PRESSED = 'enter-pressed', DELETE_PRESSED = 'delete-pressed', ESCAPE_PRESSED = 'escape-pressed', @@ -87,13 +90,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 +130,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< @@ -222,7 +239,11 @@ export type ViewWrapperProps< : {}) > -type FilterConfig = { +type FilterConfig< + FilterVariant extends FiltersTypeEnum, + RowData extends unknown, + AdditionalProps extends Record, +> = { filtersVariant: FilterVariant /** * Props for useUrlFilters/useStateFilters hooks @@ -236,12 +257,14 @@ type FilterConfig, filterData: UseFiltersReturnType) => boolean + : (row: RowType, filterData: UseFiltersReturnType, additionalProps: AdditionalProps) => boolean clearFilters?: FilterVariant extends FiltersTypeEnum.URL ? () => void : FilterVariant extends FiltersTypeEnum.STATE ? never : never + + areFiltersApplied?: FilterVariant extends FiltersTypeEnum.NONE ? never : boolean } export type InternalTableProps< @@ -307,6 +330,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 } & ( | { /** @@ -337,7 +368,7 @@ export type InternalTableProps< pageSizeOptions?: never } ) & - FilterConfig + FilterConfig export type UseResizableTableConfigWrapperProps< RowData extends unknown, @@ -390,6 +421,9 @@ export type TableProps< | 'ViewWrapper' | 'pageSizeOptions' | 'clearFilters' + | 'rowStartIconConfig' + | 'onRowClick' + | 'areFiltersApplied' > export type BulkActionStateType = string | null @@ -440,6 +474,8 @@ export interface TableContentProps< | 'rowActionOnHoverConfig' | 'pageSizeOptions' | 'getRows' + | 'rowStartIconConfig' + | 'onRowClick' >, RowsResultType { areFilteredRowsLoading: boolean diff --git a/src/Shared/Components/Table/useTableWithKeyboardShortcuts.ts b/src/Shared/Components/Table/useTableWithKeyboardShortcuts.ts index a7a67a14b..cb51044c9 100644 --- a/src/Shared/Components/Table/useTableWithKeyboardShortcuts.ts +++ b/src/Shared/Components/Table/useTableWithKeyboardShortcuts.ts @@ -101,6 +101,20 @@ const useTableWithKeyboardShortcuts = < ) useEffect(() => { + registerShortcut({ + keys: ['ArrowLeft'], + callback: () => { + dispatchEvent(SignalEnum.COLLAPSE_ROW) + }, + }) + + registerShortcut({ + keys: ['ArrowRight'], + callback: () => { + dispatchEvent(SignalEnum.EXPAND_ROW) + }, + }) + registerShortcut({ keys: ['ArrowDown'], callback: () => { @@ -142,6 +156,8 @@ const useTableWithKeyboardShortcuts = < unregisterShortcut(['Enter']) unregisterShortcut(['Backspace']) unregisterShortcut(['.']) + unregisterShortcut(['ArrowLeft']) + unregisterShortcut(['ArrowRight']) } }, [getMoveFocusToNextRowHandler, getMoveFocusToPreviousRowHandler, dispatchEvent]) diff --git a/src/Shared/Components/Table/utils.ts b/src/Shared/Components/Table/utils.ts index 8e9ff9580..5d0f4b46a 100644 --- a/src/Shared/Components/Table/utils.ts +++ b/src/Shared/Components/Table/utils.ts @@ -42,11 +42,12 @@ export const searchAndSortRows = < rows: TableProps['rows'], filter: TableProps['filter'], filterData: UseFiltersReturnType, + additionalProps: AdditionalProps, comparator?: Column['comparator'], ): Awaited['getRows']>> => { const { sortBy, sortOrder } = filterData ?? {} - const filteredRows = filter ? rows.filter((row) => filter(row, filterData)) : rows + const filteredRows = filter ? rows.filter((row) => filter(row, filterData, additionalProps)) : rows return { rows: