diff --git a/.changeset/dark-signs-enjoy.md b/.changeset/dark-signs-enjoy.md new file mode 100644 index 00000000000..77af0799da9 --- /dev/null +++ b/.changeset/dark-signs-enjoy.md @@ -0,0 +1,16 @@ +--- +"@hashicorp/design-system-components": minor +--- + + +`FilterBar` - Added new Filter Bar component + + + +`AdvancedTable` - Added support for filtering within the table with new `actions` named block and `FilterBar` contextual component + + + +`AdvancedTable` - Added argument `isEmpty` and named block `emptyState` for setting an empty state for the table + + diff --git a/packages/components/package.json b/packages/components/package.json index 6081e4dacc7..db08082ee95 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -235,6 +235,17 @@ "./components/hds/dropdown/toggle/button.js": "./dist/_app_/components/hds/dropdown/toggle/button.js", "./components/hds/dropdown/toggle/chevron.js": "./dist/_app_/components/hds/dropdown/toggle/chevron.js", "./components/hds/dropdown/toggle/icon.js": "./dist/_app_/components/hds/dropdown/toggle/icon.js", + "./components/hds/filter-bar/dropdown.js": "./dist/_app_/components/hds/filter-bar/dropdown.js", + "./components/hds/filter-bar/filter-group/checkbox.js": "./dist/_app_/components/hds/filter-bar/filter-group/checkbox.js", + "./components/hds/filter-bar/filter-group/date.js": "./dist/_app_/components/hds/filter-bar/filter-group/date.js", + "./components/hds/filter-bar/filter-group/generic.js": "./dist/_app_/components/hds/filter-bar/filter-group/generic.js", + "./components/hds/filter-bar/filter-group.js": "./dist/_app_/components/hds/filter-bar/filter-group.js", + "./components/hds/filter-bar/filter-group/numerical.js": "./dist/_app_/components/hds/filter-bar/filter-group/numerical.js", + "./components/hds/filter-bar/filter-group/radio.js": "./dist/_app_/components/hds/filter-bar/filter-group/radio.js", + "./components/hds/filter-bar.js": "./dist/_app_/components/hds/filter-bar.js", + "./components/hds/filter-bar/tabs.js": "./dist/_app_/components/hds/filter-bar/tabs.js", + "./components/hds/filter-bar/tabs/panel.js": "./dist/_app_/components/hds/filter-bar/tabs/panel.js", + "./components/hds/filter-bar/tabs/tab.js": "./dist/_app_/components/hds/filter-bar/tabs/tab.js", "./components/hds/flyout.js": "./dist/_app_/components/hds/flyout.js", "./components/hds/form/character-count.js": "./dist/_app_/components/hds/form/character-count.js", "./components/hds/form/checkbox/base.js": "./dist/_app_/components/hds/form/checkbox/base.js", diff --git a/packages/components/src/components.ts b/packages/components/src/components.ts index fb1b5c4d9b8..e896eef62bb 100644 --- a/packages/components/src/components.ts +++ b/packages/components/src/components.ts @@ -130,6 +130,20 @@ export * from './components/hds/dropdown/list-item/types.ts'; export * from './components/hds/dropdown/toggle/types.ts'; export * from './components/hds/dropdown/types.ts'; +// FilterBar +export { default as HdsFilterBar } from './components/hds/filter-bar/index.ts'; +export { default as HdsFilterBarDropdown } from './components/hds/filter-bar/dropdown.ts'; +export { default as HdsFilterBarFilterGroup } from './components/hds/filter-bar/filter-group/index.ts'; +export { default as HdsFilterBarFilterGroupCheckbox } from './components/hds/filter-bar/filter-group/checkbox.ts'; +export { default as HdsFilterBarFilterGroupDate } from './components/hds/filter-bar/filter-group/date.ts'; +export { default as HdsFilterBarFilterGroupGeneric } from './components/hds/filter-bar/filter-group/generic.ts'; +export { default as HdsFilterBarFilterGroupRadio } from './components/hds/filter-bar/filter-group/radio.ts'; +export { default as HdsFilterBarFilterGroupNumerical } from './components/hds/filter-bar/filter-group/numerical.ts'; +export { default as HdsFilterBarTabs } from './components/hds/filter-bar/tabs/index.ts'; +export { default as HdsFilterBarTabsPanel } from './components/hds/filter-bar/tabs/panel.ts'; +export { default as HdsFilterBarTabsTab } from './components/hds/filter-bar/tabs/tab.ts'; +export * from './components/hds/filter-bar/types.ts'; + // Flyout export { default as HdsFlyout } from './components/hds/flyout/index.ts'; export * from './components/hds/flyout/types.ts'; diff --git a/packages/components/src/components/hds/advanced-table/index.hbs b/packages/components/src/components/hds/advanced-table/index.hbs index 45eb3875f77..3a8de93b50e 100644 --- a/packages/components/src/components/hds/advanced-table/index.hbs +++ b/packages/components/src/components/hds/advanced-table/index.hbs @@ -3,202 +3,236 @@ SPDX-License-Identifier: MPL-2.0 }} -
- {{! Caption }} -
- {{@caption}} - {{this.sortedMessageText}} - {{this.reorderedMessageText}} -
- - {{! Grid }} +
+ {{#if (has-block "actions")}} +
+ {{yield (hash FilterBar=(component "hds/filter-bar")) to="actions"}} +
+ {{/if}}
- {{! Header }} + {{! Caption }} +
+ {{@caption}} + {{this.sortedMessageText}} + {{this.reorderedMessageText}} +
+ + {{! Grid }}
- - {{#each this._tableModel.orderedColumns as |column|}} - {{#if column.isSortable}} - - {{column.label}} - - {{else}} - - {{column.label}} - - {{/if}} - {{/each}} - + + {{#each this._tableModel.orderedColumns as |column|}} + {{#if column.isSortable}} + + {{column.label}} + + {{else}} + + {{column.label}} + + {{/if}} + {{/each}} + - {{#if this.showScrollIndicatorTop}} -
- {{/if}} -
+ {{#if this.showScrollIndicatorTop}} +
+ {{/if}} +
- {{! Body }} -
- {{! ---------------------------------------------------------------------------------------- - IMPORTANT: we loop on the `model` array and for each record - we yield the Tr/Td/Th elements _and_ the record itself as `data` - this means the consumer will *have to* use the `data` key to access it in their template - -------------------------------------------------------------------------------------------- }} - {{#each this._tableModel.sortedRows key=this.identityKey as |record index|}} - {{#if this._tableModel.hasRowsWithChildren}} - - {{yield - (hash - Tr=(component - "hds/advanced-table/tr" - isLastRow=(eq this._tableModel.lastVisibleRow.id T.data.id) - isParentRow=T.isExpandable - depth=T.depth - displayRow=T.shouldDisplayChildRows - data=T.data - ) - Th=(component - "hds/advanced-table/th" - depth=T.depth - isExpandable=T.isExpandable - isExpanded=T.isExpanded - newLabel=T.id - parentId=T.parentId - scope="row" - onClickToggle=T.onClickToggle + {{! Body }} + {{#unless this.isEmpty}} +
+ {{! ---------------------------------------------------------------------------------------- + IMPORTANT: we loop on the `model` array and for each record + we yield the Tr/Td/Th elements _and_ the record itself as `data` + this means the consumer will *have to* use the `data` key to access it in their template + -------------------------------------------------------------------------------------------- }} + {{#each this._tableModel.sortedRows key=this.identityKey as |record index|}} + {{#if this._tableModel.hasRowsWithChildren}} + + {{yield + (hash + Tr=(component + "hds/advanced-table/tr" + isLastRow=(eq this._tableModel.lastVisibleRow.id T.data.id) + isParentRow=T.isExpandable + depth=T.depth + displayRow=T.shouldDisplayChildRows + data=T.data + ) + Th=(component + "hds/advanced-table/th" + depth=T.depth + isExpandable=T.isExpandable + isExpanded=T.isExpanded + newLabel=T.id + parentId=T.parentId + scope="row" + onClickToggle=T.onClickToggle + ) + Td=(component "hds/advanced-table/td" align=@align) + data=T.data + isOpen=T.isExpanded + rowIndex=T.rowIndex + ) + to="body" + }} + + {{else}} + {{yield + (hash + Tr=(component + "hds/advanced-table/tr" + selectionScope="row" + isLastRow=(eq this._tableModel.lastVisibleRow.id record.id) + isSelectable=this.isSelectable + onSelectionChange=this.onSelectionRowChange + didInsert=this.didInsertRowCheckbox + willDestroy=this.willDestroyRowCheckbox + selectionAriaLabelSuffix=@selectionAriaLabelSuffix + hasStickyColumn=this.hasStickyFirstColumn + isStickyColumnPinned=this.isStickyColumnPinned + data=record + ) + Th=(component + "hds/advanced-table/th" + scope="row" + isStickyColumn=this.hasStickyFirstColumn + isStickyColumnPinned=this.isStickyColumnPinned + ) + Td=(component "hds/advanced-table/td" align=@align) + data=record + rowIndex=index ) - Td=(component "hds/advanced-table/td" align=@align) - data=T.data - isOpen=T.isExpanded - rowIndex=T.rowIndex - ) - to="body" - }} - - {{else}} - {{yield - (hash - Tr=(component - "hds/advanced-table/tr" - selectionScope="row" - isLastRow=(eq this._tableModel.lastVisibleRow.id record.id) - isSelectable=this.isSelectable - onSelectionChange=this.onSelectionRowChange - didInsert=this.didInsertRowCheckbox - willDestroy=this.willDestroyRowCheckbox - selectionAriaLabelSuffix=@selectionAriaLabelSuffix - hasStickyColumn=this.hasStickyFirstColumn - isStickyColumnPinned=this.isStickyColumnPinned - data=record - ) - Th=(component - "hds/advanced-table/th" - scope="row" - isStickyColumn=this.hasStickyFirstColumn - isStickyColumnPinned=this.isStickyColumnPinned - ) - Td=(component "hds/advanced-table/td" align=@align) - data=record - rowIndex=index - ) - to="body" - }} - {{/if}} - {{/each}} + to="body" + }} + {{/if}} + {{/each}} +
+ {{/unless}}
-
- {{#if this.showScrollIndicatorLeft}} -
- {{/if}} + {{#if this.showScrollIndicatorLeft}} +
+ {{/if}} - {{#if this.showScrollIndicatorRight}} -
- {{/if}} + {{#if this.showScrollIndicatorRight}} +
+ {{/if}} - {{#if this.showScrollIndicatorBottom}} -
- {{/if}} + {{#unless this.isEmpty}} + {{#if this.showScrollIndicatorBottom}} +
+ {{/if}} + {{/unless}} + + {{#if this.isEmpty}} +
+
+ {{#if (has-block "emptyState")}} + {{yield to="emptyState"}} + {{else}} + + {{hds-t + "hds.components.advanced-table.empty-state.title" + default="No data available" + }} + + {{hds-t + "hds.components.advanced-table.empty-state.description" + default="There is currently no data to display in the table." + }} + + + {{/if}} +
+
+ {{/if}} +
\ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/index.ts b/packages/components/src/components/hds/advanced-table/index.ts index a5bfae87784..61d49bf72a2 100644 --- a/packages/components/src/components/hds/advanced-table/index.ts +++ b/packages/components/src/components/hds/advanced-table/index.ts @@ -14,6 +14,7 @@ import HdsAdvancedTableTableModel from './models/table.ts'; import type Owner from '@ember/owner'; import type { WithBoundArgs } from '@glint/template'; +import type { ComponentLike } from '@glint/template'; import { HdsAdvancedTableDensityValues, HdsAdvancedTableVerticalAlignmentValues, @@ -30,6 +31,7 @@ import type { HdsAdvancedTableExpandState, HdsAdvancedTableColumnReorderCallback, } from './types.ts'; +import type { HdsFilterBarSignature } from '../filter-bar/index.ts'; import type HdsAdvancedTableColumnType from './models/column.ts'; import type { HdsFormCheckboxBaseSignature } from '../form/checkbox/base.ts'; import type HdsAdvancedTableTd from './td.ts'; @@ -149,6 +151,7 @@ export interface HdsAdvancedTableSignature { hasStickyFirstColumn?: boolean; childrenKey?: string; maxHeight?: string; + isEmpty?: boolean; onColumnReorder?: HdsAdvancedTableColumnReorderCallback; onColumnResize?: (columnKey: string, newWidth?: string) => void; onSelectionChange?: ( @@ -157,6 +160,11 @@ export interface HdsAdvancedTableSignature { onSort?: (sortBy: string, sortOrder: HdsAdvancedTableThSortOrder) => void; }; Blocks: { + actions?: [ + { + FilterBar?: ComponentLike; + }, + ]; body?: [ { Td?: WithBoundArgs; @@ -192,6 +200,7 @@ export interface HdsAdvancedTableSignature { isOpen?: HdsAdvancedTableExpandState; }, ]; + emptyState?: []; }; Element: HTMLDivElement; } @@ -259,6 +268,14 @@ export default class HdsAdvancedTable extends Component + + + + {{yield + (hash + FilterGroup=(component + "hds/filter-bar/filter-group" tab=T.Tab panel=T.Panel onChange=this.onFilter filters=this.internalFilters + ) + close=D.close + ) + }} + + + + + {{#unless this.isLiveFilter}} + + {{/unless}} + + + + \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/dropdown.ts b/packages/components/src/components/hds/filter-bar/dropdown.ts new file mode 100644 index 00000000000..3a0b53a958f --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/dropdown.ts @@ -0,0 +1,141 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { modifier } from 'ember-modifier'; +import type Owner from '@ember/owner'; +import type { WithBoundArgs } from '@glint/template'; + +import HdsFilterBarFilterGroup from './filter-group/index.ts'; +import type { HdsFilterBarFilters, HdsFilterBarFilter } from './types.ts'; + +import type { HdsDropdownSignature } from '../dropdown/index.ts'; + +export interface HdsFilterBarDropdownSignature { + Args: { + filters: HdsFilterBarFilters; + isLiveFilter: boolean; + onFilter?: (filters: HdsFilterBarFilters) => void; + }; + Blocks: { + default: [ + { + FilterGroup?: WithBoundArgs< + typeof HdsFilterBarFilterGroup, + 'tab' | 'panel' | 'filters' | 'onChange' + >; + close: HdsDropdownSignature['Blocks']['default'][0]['close']; + }, + ]; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarDropdown extends Component { + @tracked internalFilters: HdsFilterBarFilters = {}; + + constructor(owner: Owner, args: HdsFilterBarDropdownSignature['Args']) { + super(owner, args); + + const { filters } = this.args; + + if (filters) { + this.internalFilters = { ...filters }; + } + } + + private _syncFilters = modifier( + (_element, [_filters]: [HdsFilterBarFilters | undefined]) => { + if (_filters) { + this.internalFilters = _filters; + } + } + ); + + get isLiveFilter(): boolean { + return this.args.isLiveFilter || false; + } + + @action + onFilter(key: string, keyFilter?: HdsFilterBarFilter): void { + this.internalFilters = this._updateFilter(key, keyFilter); + + if (this.isLiveFilter) { + this._applyFilters(); + } + } + + @action + onApply(closeDropdown?: () => void): void { + this._applyFilters(closeDropdown); + } + + @action + onClear(closeDropdown?: () => void): void { + const { onFilter } = this.args; + this.internalFilters = {}; + + if (onFilter && typeof onFilter === 'function') { + onFilter(this.internalFilters); + } + + if (closeDropdown && typeof closeDropdown === 'function') { + closeDropdown(); + } + } + + private _copyFilters = ( + filters: HdsFilterBarFilters + ): HdsFilterBarFilters => { + const newFilters = {} as HdsFilterBarFilters; + + Object.keys(filters).forEach((k) => { + newFilters[k] = JSON.parse( + JSON.stringify(filters[k]) + ) as HdsFilterBarFilter; + }); + + return newFilters; + }; + + private _updateFilter( + key: string, + keyFilter?: HdsFilterBarFilter + ): HdsFilterBarFilters { + const newFilters = this._copyFilters(this.internalFilters); + if ( + keyFilter === undefined || + (Array.isArray(keyFilter) && keyFilter.length === 0) + ) { + delete newFilters[key]; + } else { + Object.assign(newFilters, { [key]: keyFilter }); + } + + return { ...newFilters }; + } + + private _applyFilters = (closeDropdown?: () => void): void => { + const { onFilter } = this.args; + if (onFilter && typeof onFilter === 'function') { + onFilter(this.internalFilters); + } + + if (closeDropdown && typeof closeDropdown === 'function') { + closeDropdown(); + } + }; + + private _onClose = (): void => { + const { filters } = this.args; + if (filters) { + this.internalFilters = { ...filters }; + } else { + this.internalFilters = {}; + } + }; +} diff --git a/packages/components/src/components/hds/filter-bar/filter-group/checkbox.hbs b/packages/components/src/components/hds/filter-bar/filter-group/checkbox.hbs new file mode 100644 index 00000000000..b0c9522019d --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/checkbox.hbs @@ -0,0 +1,9 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +
  • + + {{@label}} + +
  • \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/filter-group/checkbox.ts b/packages/components/src/components/hds/filter-bar/filter-group/checkbox.ts new file mode 100644 index 00000000000..057b8ae8cbf --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/checkbox.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +import type { HdsFilterBarFilter } from '../types.ts'; + +export interface HdsFilterBarFilterGroupCheckboxSignature { + Args: { + value?: string; + label?: string; + keyFilter: HdsFilterBarFilter | undefined; + onChange?: (event: Event, label?: string) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarFilterGroupCheckbox extends Component { + @action + onChange(event: Event): void { + const { onChange, label } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(event, label); + } + } + + get isChecked(): boolean { + const { keyFilter, value } = this.args; + if (keyFilter && Array.isArray(keyFilter.data)) { + return keyFilter.data.some((filter) => filter.value === value); + } + return false; + } +} diff --git a/packages/components/src/components/hds/filter-bar/filter-group/date.hbs b/packages/components/src/components/hds/filter-bar/filter-group/date.hbs new file mode 100644 index 00000000000..e561490034f --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/date.hbs @@ -0,0 +1,65 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +
    + + + {{this.selectorLabelText}} + + + {{#each this._selectorValues as |selectorValue|}} + + {{/each}} + + + {{#if (eq this._selector "between")}} + + + + + {{else}} + + {{/if}} + +
    + +
    +
    \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/filter-group/date.ts b/packages/components/src/components/hds/filter-bar/filter-group/date.ts new file mode 100644 index 00000000000..46ad1ddc586 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/date.ts @@ -0,0 +1,200 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import type Owner from '@ember/owner'; +import { guidFor } from '@ember/object/internals'; +import { service } from '@ember/service'; + +import type HdsIntlService from '../../../../services/hds-intl'; +import type { HdsFormTextInputTypes } from '../../form/text-input/types.ts'; + +import type { + HdsFilterBarFilter, + HdsFilterBarDateFilterSelector, + HdsFilterBarDateFilterValue, +} from '../types.ts'; +import { HdsFilterBarDateFilterSelectorValues } from '../types.ts'; + +export const DATE_SELECTORS: HdsFilterBarDateFilterSelector[] = Object.values( + HdsFilterBarDateFilterSelectorValues +); + +export const DATE_SELECTORS_TEXT: Record< + HdsFilterBarDateFilterSelector, + string +> = { + [HdsFilterBarDateFilterSelectorValues.before]: 'before', + [HdsFilterBarDateFilterSelectorValues.exactly]: 'exactly', + [HdsFilterBarDateFilterSelectorValues.after]: 'after', + [HdsFilterBarDateFilterSelectorValues.between]: 'between', +}; + +export const DATE_SELECTORS_INPUT_TEXT: Record< + HdsFilterBarDateFilterSelector, + string +> = { + [HdsFilterBarDateFilterSelectorValues.before]: 'Before', + [HdsFilterBarDateFilterSelectorValues.exactly]: 'Exactly', + [HdsFilterBarDateFilterSelectorValues.after]: 'After', + [HdsFilterBarDateFilterSelectorValues.between]: 'Between', +}; + +export interface HdsFilterBarFilterGroupDateSignature { + Args: { + keyFilter: HdsFilterBarFilter | undefined; + type?: 'date' | 'time' | 'datetime'; + onChange?: ( + selector?: HdsFilterBarDateFilterSelector, + value?: HdsFilterBarDateFilterValue + ) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarFilterGroupDate extends Component { + @service hdsIntl!: HdsIntlService; + + @tracked private _selector: HdsFilterBarDateFilterSelector | undefined; + @tracked private _value: string | undefined; + @tracked private _betweenValueStart: string | undefined; + @tracked private _betweenValueEnd: string | undefined; + + private _selectorValues = DATE_SELECTORS; + private _selectorInputId = 'selector-input-' + guidFor(this); + private _valueInputId = 'value-input-' + guidFor(this); + private _betweenValueStartInputId = + 'between-value-start-input-' + guidFor(this); + private _betweenValueEndInputId = 'between-value-end-input-' + guidFor(this); + + constructor( + owner: Owner, + args: HdsFilterBarFilterGroupDateSignature['Args'] + ) { + super(owner, args); + + const { keyFilter } = this.args; + if ( + keyFilter && + (keyFilter.type === 'date' || + keyFilter.type === 'time' || + keyFilter.type === 'datetime') + ) { + const data = keyFilter.data; + this._selector = data.selector; + if (data.selector === 'between') { + if ( + data.value && + typeof data.value === 'object' && + 'start' in data.value && + 'end' in data.value + ) { + this._betweenValueStart = data.value.start; + this._betweenValueEnd = data.value.end; + } + } else { + this._value = data.value as string; + } + } + } + + get type(): 'date' | 'time' | 'datetime' { + return this.args.type || 'date'; + } + + get inputType(): HdsFormTextInputTypes { + if (this.type === 'datetime') { + return 'datetime-local'; + } + return this.type; + } + + get selectorLabelText(): string { + return this.hdsIntl.t(`hds.components.filter-bar.date.${this.type}.label`, { + default: 'Date is', + }); + } + + @action + onSelectorChange(event: Event): void { + const select = event.target as HTMLSelectElement; + this._selector = select.value as HdsFilterBarDateFilterSelector; + if (this._selector === 'between') { + this._value = undefined; + } else { + this._betweenValueStart = undefined; + this._betweenValueEnd = undefined; + } + this._onChange(); + } + + @action + onValueChange(event: Event): void { + const input = event.target as HTMLInputElement; + this._value = input.value; + this._onChange(); + } + + @action + onBetweenValueStartChange(event: Event): void { + const input = event.target as HTMLInputElement; + this._betweenValueStart = input.value; + this._onChange(); + } + + @action + onBetweenValueEndChange(event: Event): void { + const input = event.target as HTMLInputElement; + this._betweenValueEnd = input.value; + this._onChange(); + } + + @action + onClear(): void { + this._resetInputValues(); + this._onChange(); + } + + private _onChange(): void { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + if ( + this._selector === 'between' && + this._betweenValueStart !== undefined && + this._betweenValueEnd !== undefined + ) { + onChange(this._selector, { + start: this._betweenValueStart, + end: this._betweenValueEnd, + }); + } else { + onChange(this._selector, this._value); + } + } + } + + private _getSelectorText = ( + selector: HdsFilterBarDateFilterSelector + ): string => { + return this.hdsIntl.t( + `hds.components.filter-bar.date.selector-input.${selector}`, + { + default: DATE_SELECTORS_INPUT_TEXT[selector], + } + ); + }; + + private _resetInputValues = (): void => { + this._selector = undefined; + this._value = undefined; + this._betweenValueStart = undefined; + this._betweenValueEnd = undefined; + }; +} diff --git a/packages/components/src/components/hds/filter-bar/filter-group/generic.hbs b/packages/components/src/components/hds/filter-bar/filter-group/generic.hbs new file mode 100644 index 00000000000..e3f9ebbd4dc --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/generic.hbs @@ -0,0 +1,18 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +
    + + {{yield (hash updateFilter=this.updateFilter)}} + +
    + +
    +
    \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/filter-group/generic.ts b/packages/components/src/components/hds/filter-bar/filter-group/generic.ts new file mode 100644 index 00000000000..69d53b1b30d --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/generic.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +import type { + HdsFilterBarFilter, + HdsFilterBarGenericFilter, +} from '../types.ts'; + +export interface HdsFilterBarFilterGroupGenericSignature { + Args: { + keyFilter: HdsFilterBarFilter | undefined; + onChange?: (filter?: HdsFilterBarGenericFilter) => void; + }; + Blocks: { + default: [ + { + updateFilter: (filter: HdsFilterBarGenericFilter) => void; + }, + ]; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarFilterGroupGeneric extends Component { + @action + updateFilter(filter: HdsFilterBarGenericFilter): void { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(filter); + } + } + + @action + onClear(): void { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(); + } + } +} diff --git a/packages/components/src/components/hds/filter-bar/filter-group/index.hbs b/packages/components/src/components/hds/filter-bar/filter-group/index.hbs new file mode 100644 index 00000000000..646ca893518 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/index.hbs @@ -0,0 +1,66 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +{{#let @tab as |Tab|}} + + {{@text}} + +{{/let}} +{{#let @panel as |Panel|}} + + {{#if @searchEnabled}} + + {{/if}} + {{#if (eq @type "numerical")}} + + {{else if (eq @type "date")}} + + {{else if (eq @type "datetime")}} + + {{else if (eq @type "time")}} + + {{else if (eq @type "generic")}} + {{yield + (hash + Generic=(component + "hds/filter-bar/filter-group/generic" keyFilter=this.keyFilter onChange=this.onGenericChange + ) + ) + }} + {{else}} +
    +
    + +
    +
      + {{yield + (hash + Checkbox=(component + "hds/filter-bar/filter-group/checkbox" keyFilter=this.keyFilter onChange=this.onSelectionChange + ) + Radio=(component + "hds/filter-bar/filter-group/radio" keyFilter=this.keyFilter onChange=this.onSelectionChange + ) + ) + }} +
    +
    + {{/if}} +
    +{{/let}} \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/filter-group/index.ts b/packages/components/src/components/hds/filter-bar/filter-group/index.ts new file mode 100644 index 00000000000..89dc866e417 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/index.ts @@ -0,0 +1,283 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { modifier } from 'ember-modifier'; +import type { WithBoundArgs } from '@glint/template'; + +import HdsFilterBarTabsTab from '../tabs/tab.ts'; +import HdsFilterBarTabsPanel from '../tabs/panel.ts'; +import type { HdsTabsPanelSignature } from '../../tabs/panel.ts'; + +import HdsFilterBarFilterGroupGeneric from './generic.ts'; +import HdsFilterBarFilterGroupCheckbox from './checkbox.ts'; +import HdsFilterBarFilterGroupRadio from './radio.ts'; + +import type { + HdsFilterBarFilter, + HdsFilterBarFilters, + HdsFilterBarFilterType, + HdsFilterBarData, + HdsFilterBarGenericFilter, + HdsFilterBarGenericFilterData, + HdsFilterBarNumericalFilterData, + HdsFilterBarNumericalFilterSelector, + HdsFilterBarNumericalFilterValue, + HdsFilterBarDateFilterData, + HdsFilterBarDateFilterSelector, + HdsFilterBarDateFilterValue, +} from '../types.ts'; + +export interface HdsFilterBarFilterGroupSignature { + Args: { + tab?: WithBoundArgs; + panel?: WithBoundArgs; + key: string; + text: string; + type?: HdsFilterBarFilterType; + filters: HdsFilterBarFilters; + searchEnabled?: boolean; + onChange: (key: string, keyFilter?: HdsFilterBarFilter) => void; + }; + Blocks: { + default: [ + { + Generic?: WithBoundArgs< + typeof HdsFilterBarFilterGroupGeneric, + 'keyFilter' + >; + Checkbox?: WithBoundArgs< + typeof HdsFilterBarFilterGroupCheckbox, + 'keyFilter' | 'onChange' + >; + Radio?: WithBoundArgs< + typeof HdsFilterBarFilterGroupRadio, + 'keyFilter' | 'onChange' + >; + }, + ]; + }; + Element: HdsTabsPanelSignature['Element']; +} + +export default class HdsFilterBarFilterGroup extends Component { + @tracked internalFilters: HdsFilterBarData | undefined = []; + + private _panelElement!: HdsTabsPanelSignature['Element']; + + private _setUpFilterPanel = modifier( + (element: HdsTabsPanelSignature['Element']) => { + this._panelElement = element; + + if (this.keyFilter) { + this.internalFilters = JSON.parse( + JSON.stringify(this.keyFilter.data) + ) as HdsFilterBarData; + } + } + ); + + get type(): HdsFilterBarFilterType { + const { type } = this.args; + + if (!type) { + return 'multi-select'; + } + return type; + } + + get keyFilter(): HdsFilterBarFilter | undefined { + const { filters, key } = this.args; + + if (!filters) { + return undefined; + } + return filters[key]; + } + + get numFilters(): number { + const { filters, key } = this.args; + if (filters && key in filters) { + const keyFilters = filters[key]?.data; + if (Array.isArray(keyFilters)) { + return keyFilters.length; + } else if (keyFilters) { + return 1; + } + } + return 0; + } + + get formattedFilters(): HdsFilterBarFilter | undefined { + if ( + this.internalFilters === undefined || + (Array.isArray(this.internalFilters) && this.internalFilters.length === 0) + ) { + return undefined; + } + return { + type: this.type, + text: this.args.text, + data: this.internalFilters, + } as HdsFilterBarFilter; + } + + @action + onSelectionChange(event: Event, label?: string): void { + const addFilter = (value: unknown): void => { + const newFilter = { + value: value, + label: label, + } as HdsFilterBarGenericFilterData; + if (this.type === 'single-select') { + this.internalFilters = newFilter; + } else { + if (Array.isArray(this.internalFilters)) { + this.internalFilters.push(newFilter); + } else { + this.internalFilters = [newFilter]; + } + } + }; + + const removeFilter = (value: string): void => { + if (this.type === 'single-select') { + this.internalFilters = undefined; + } else { + if (Array.isArray(this.internalFilters)) { + const newFilter = [] as HdsFilterBarGenericFilterData[]; + this.internalFilters.forEach((filter) => { + if (filter.value != value) { + newFilter.push(filter); + } + }); + this.internalFilters = newFilter; + } else { + this.internalFilters = []; + } + } + }; + + const input = event.target as HTMLInputElement; + + if (input.checked) { + addFilter(input.value); + } else { + removeFilter(input.value); + } + + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.args.key, this.formattedFilters); + } + } + + @action + onNumericalChange( + selector?: HdsFilterBarNumericalFilterSelector, + value?: HdsFilterBarNumericalFilterValue + ): void { + const addFilter = (): HdsFilterBarData => { + const newFilter = { + selector: selector, + value: value, + } as HdsFilterBarNumericalFilterData; + return newFilter; + }; + + if (selector && value) { + this.internalFilters = addFilter(); + } else { + this.internalFilters = undefined; + } + + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.args.key, this.formattedFilters); + } + } + + @action + onDateChange( + selector?: HdsFilterBarDateFilterSelector, + value?: HdsFilterBarDateFilterValue + ): void { + const addFilter = (): HdsFilterBarData => { + const newFilter = { + selector: selector, + value: value, + } as HdsFilterBarDateFilterData; + return newFilter; + }; + + if (selector && value) { + this.internalFilters = addFilter(); + } else { + this.internalFilters = undefined; + } + + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.args.key, this.formattedFilters); + } + } + + @action + onGenericChange(filter?: HdsFilterBarGenericFilter): void { + if (filter) { + this.internalFilters = filter.data; + filter.text = this.args.text; + } else { + this.internalFilters = undefined; + } + + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.args.key, filter); + } + } + + @action + onClear(): void { + this.internalFilters = undefined; + + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.args.key, this.formattedFilters); + } + } + + get classNames(): string { + const classes = ['hds-filter-bar__filter-group']; + + classes.push(`hds-filter-bar__dropdown--type-${this.type}`); + + return classes.join(' '); + } + + private onSearch = (event: Event) => { + const listItems = this._panelElement.querySelectorAll( + '.hds-filter-bar__filter-group__selection-option' + ); + const input = event.target as HTMLInputElement; + listItems.forEach((item) => { + if (item.textContent) { + const text = item.textContent.toLowerCase(); + const searchText = input.value.toLowerCase(); + if (text.includes(searchText)) { + item.classList.remove( + 'hds-filter-bar__filter-group__selection-option--hidden' + ); + } else { + item.classList.add( + 'hds-filter-bar__filter-group__selection-option--hidden' + ); + } + } + }); + }; +} diff --git a/packages/components/src/components/hds/filter-bar/filter-group/numerical.hbs b/packages/components/src/components/hds/filter-bar/filter-group/numerical.hbs new file mode 100644 index 00000000000..8f80af37cb0 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/numerical.hbs @@ -0,0 +1,70 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +
    + + + + {{hds-t "hds.components.filter-bar.filter-group.numerical.label" default="Number is"}} + + + + {{#each this._selectorValues as |selectorValue|}} + + {{/each}} + + + {{#if (eq this._selector "between")}} + + + + + {{else}} + + {{/if}} + +
    + +
    +
    \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/filter-group/numerical.ts b/packages/components/src/components/hds/filter-bar/filter-group/numerical.ts new file mode 100644 index 00000000000..e31d50b7236 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/numerical.ts @@ -0,0 +1,191 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import type Owner from '@ember/owner'; +import { guidFor } from '@ember/object/internals'; +import { service } from '@ember/service'; + +import type HdsIntlService from '../../../../services/hds-intl'; +import type { + HdsFilterBarFilter, + HdsFilterBarNumericalFilterSelector, + HdsFilterBarNumericalFilterValue, +} from '../types.ts'; +import { HdsFilterBarNumericalFilterSelectorValues } from '../types.ts'; + +export const NUMERICAL_SELECTORS: HdsFilterBarNumericalFilterSelector[] = + Object.values(HdsFilterBarNumericalFilterSelectorValues); + +export const NUMERICAL_SELECTORS_TEXT: Record< + HdsFilterBarNumericalFilterSelector, + string +> = { + [HdsFilterBarNumericalFilterSelectorValues.lessThan]: '<', + [HdsFilterBarNumericalFilterSelectorValues.lessThanOrEqualTo]: '≤', + [HdsFilterBarNumericalFilterSelectorValues.equalTo]: '=', + [HdsFilterBarNumericalFilterSelectorValues.greaterThanOrEqualTo]: '≥', + [HdsFilterBarNumericalFilterSelectorValues.greaterThan]: '>', + [HdsFilterBarNumericalFilterSelectorValues.between]: 'between', +}; + +export const NUMERICAL_SELECTORS_INPUT_TEXT: Record< + HdsFilterBarNumericalFilterSelector, + string +> = { + [HdsFilterBarNumericalFilterSelectorValues.lessThan]: 'Less than (<)', + [HdsFilterBarNumericalFilterSelectorValues.lessThanOrEqualTo]: + 'Less than or equal to (≤)', + [HdsFilterBarNumericalFilterSelectorValues.equalTo]: 'Equal to (=)', + [HdsFilterBarNumericalFilterSelectorValues.greaterThanOrEqualTo]: + 'Greater than or equal to (≥)', + [HdsFilterBarNumericalFilterSelectorValues.greaterThan]: 'Greater than (>)', + [HdsFilterBarNumericalFilterSelectorValues.between]: 'Between', +}; + +export interface HdsFilterBarFilterGroupNumericalSignature { + Args: { + keyFilter: HdsFilterBarFilter | undefined; + onChange?: ( + selector?: HdsFilterBarNumericalFilterSelector, + value?: HdsFilterBarNumericalFilterValue + ) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarFilterGroupNumerical extends Component { + @service hdsIntl!: HdsIntlService; + + @tracked private _selector: HdsFilterBarNumericalFilterSelector | undefined; + @tracked private _value: number | undefined; + @tracked private _betweenValueStart: number | undefined; + @tracked private _betweenValueEnd: number | undefined; + + private _selectorValues = NUMERICAL_SELECTORS; + private _selectorInputId = 'selector-input-' + guidFor(this); + private _valueInputId = 'value-input-' + guidFor(this); + private _betweenValueStartInputId = + 'between-value-start-input-' + guidFor(this); + private _betweenValueEndInputId = 'between-value-end-input-' + guidFor(this); + + constructor( + owner: Owner, + args: HdsFilterBarFilterGroupNumericalSignature['Args'] + ) { + super(owner, args); + + const { keyFilter } = this.args; + if (keyFilter && keyFilter.type === 'numerical') { + const data = keyFilter.data; + this._selector = data?.selector; + if (data.selector === 'between') { + if (data.value && typeof data.value === 'object') { + this._betweenValueStart = Number(data.value.start); + this._betweenValueEnd = Number(data.value.end); + } + } else { + this._value = Number(data.value); + } + } + } + + get stringValue(): string | undefined { + return this._value !== undefined ? this._value.toString() : undefined; + } + + get stringBetweenValueStart(): string | undefined { + return this._betweenValueStart !== undefined + ? this._betweenValueStart.toString() + : undefined; + } + + get stringBetweenValueEnd(): string | undefined { + return this._betweenValueEnd !== undefined + ? this._betweenValueEnd.toString() + : undefined; + } + + @action + onSelectorChange(event: Event): void { + const select = event.target as HTMLSelectElement; + this._selector = select.value as HdsFilterBarNumericalFilterSelector; + if (this._selector === 'between') { + this._value = undefined; + } else { + this._betweenValueStart = undefined; + this._betweenValueEnd = undefined; + } + this._onChange(); + } + + @action + onValueChange(event: Event): void { + const input = event.target as HTMLInputElement; + this._value = parseFloat(input.value); + this._onChange(); + } + + @action + onBetweenValueStartChange(event: Event): void { + const input = event.target as HTMLInputElement; + this._betweenValueStart = parseFloat(input.value); + this._onChange(); + } + + @action + onBetweenValueEndChange(event: Event): void { + const input = event.target as HTMLInputElement; + this._betweenValueEnd = parseFloat(input.value); + this._onChange(); + } + + @action + onClear(): void { + this._resetInputValues(); + this._onChange(); + } + + private _onChange(): void { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + if ( + this._selector === 'between' && + this._betweenValueStart !== undefined && + this._betweenValueEnd !== undefined + ) { + onChange(this._selector, { + start: this._betweenValueStart, + end: this._betweenValueEnd, + }); + } else { + onChange(this._selector, this._value); + } + } + } + + private _getSelectorText = ( + selector: HdsFilterBarNumericalFilterSelector + ): string => { + return this.hdsIntl.t( + `hds.components.filter-bar.filter-group.numerical.selector-input.${selector}`, + { + default: NUMERICAL_SELECTORS_INPUT_TEXT[selector], + } + ); + }; + + private _resetInputValues = (): void => { + this._selector = undefined; + this._value = undefined; + this._betweenValueStart = undefined; + this._betweenValueEnd = undefined; + }; +} diff --git a/packages/components/src/components/hds/filter-bar/filter-group/radio.hbs b/packages/components/src/components/hds/filter-bar/filter-group/radio.hbs new file mode 100644 index 00000000000..319efa5cdb3 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/radio.hbs @@ -0,0 +1,9 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +
  • + + {{@label}} + +
  • \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/filter-group/radio.ts b/packages/components/src/components/hds/filter-bar/filter-group/radio.ts new file mode 100644 index 00000000000..00c7bc3106d --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filter-group/radio.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +import type { HdsFilterBarFilter } from '../types.ts'; + +export interface HdsFilterBarFilterGroupRadioSignature { + Args: { + value?: string; + label?: string; + keyFilter: HdsFilterBarFilter | undefined; + onChange?: (event: Event, label?: string) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarFilterGroupRadio extends Component { + @action + onChange(event: Event): void { + const { onChange, label } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(event, label); + } + } + + get isChecked(): boolean { + const { keyFilter, value } = this.args; + if ( + keyFilter && + keyFilter.type === 'single-select' && + value && + 'value' in keyFilter.data + ) { + return keyFilter.data.value === value; + } + return false; + } +} diff --git a/packages/components/src/components/hds/filter-bar/index.hbs b/packages/components/src/components/hds/filter-bar/index.hbs new file mode 100644 index 00000000000..7bcf45daeba --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/index.hbs @@ -0,0 +1,98 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +
    + + + {{yield + (hash + Dropdown=(component + "hds/filter-bar/dropdown" filters=@filters isLiveFilter=@isLiveFilter onFilter=this.onFilter + ) + ) + }} + {{#if @hasSearch}} + + {{/if}} + + {{yield (hash ActionsGeneric=(component "hds/yield"))}} + {{yield (hash ActionsDropdown=(component "hds/dropdown"))}} + + + {{#if this._isExpanded}} +
    + {{#if this.hasActiveFilters}} + {{#each-in @filters as |key filter|}} + {{#if filter.data}} + {{#if (eq filter.type "single-select")}} + + {{else if (eq filter.type "numerical")}} + + {{else if (or (eq filter.type "date") (eq filter.type "datetime") (eq filter.type "time"))}} + + {{else if (eq filter.type "search")}} + + {{else if (eq filter.type "generic")}} + + {{else if (eq filter.type "multi-select")}} + {{#each (this._getMultiSelectFilterData filter.data) as |item|}} + + {{/each}} + {{/if}} + {{/if}} + {{/each-in}} + + {{else}} + + {{hds-t "hds.components.filter-bar.no-filters-applied" default="No filters applied"}} + + {{/if}} +
    + {{/if}} +
    \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/index.ts b/packages/components/src/components/hds/filter-bar/index.ts new file mode 100644 index 00000000000..6a3fce8c535 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/index.ts @@ -0,0 +1,302 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { service } from '@ember/service'; +import { isArray } from '@ember/array'; +import type { WithBoundArgs } from '@glint/template'; + +import type HdsIntlService from '../../../services/hds-intl'; +import type { + HdsFilterBarFilters, + HdsFilterBarFilter, + HdsFilterBarFilterType, + HdsFilterBarData, + HdsFilterBarGenericFilterData, +} from './types.ts'; +import HdsDropdown from '../dropdown/index.ts'; +import HdsYield from '../yield/index.ts'; +import HdsFilterBarDropdown from './dropdown.ts'; + +import { NUMERICAL_SELECTORS_TEXT } from './filter-group/numerical.ts'; +import { DATE_SELECTORS_TEXT } from './filter-group/date.ts'; + +export interface HdsFilterBarSignature { + Args: { + filters: HdsFilterBarFilters; + isLiveFilter?: boolean; + hasSearch?: boolean; + onFilter?: (filters: HdsFilterBarFilters) => void; + }; + Blocks: { + default?: [ + { + ActionsDropdown?: WithBoundArgs; + ActionsGeneric?: WithBoundArgs; + Dropdown?: WithBoundArgs< + typeof HdsFilterBarDropdown, + 'filters' | 'isLiveFilter' | 'onFilter' + >; + }, + ]; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBar extends Component { + @service hdsIntl!: HdsIntlService; + + @tracked _isExpanded: boolean = this.hasActiveFilters; + + get searchValue(): string { + const { filters } = this.args; + if (filters['search']) { + return this._getFilterValueText(filters['search']); + } + return ''; + } + + get hasActiveFilters(): boolean { + return Object.keys(this.args.filters).length > 0; + } + + @action + onFilter(filters: HdsFilterBarFilters): void { + const { onFilter } = this.args; + if (onFilter && typeof onFilter === 'function') { + onFilter(filters); + + if (Object.keys(filters).length > 0) { + this._isExpanded = true; + } else { + this._isExpanded = false; + } + } + } + + @action + clearFilters(): void { + const { onFilter } = this.args; + if (onFilter && typeof onFilter === 'function') { + onFilter({}); + this._isExpanded = false; + } + } + + @action + onSearch(event: Event): void { + const { filters } = this.args; + const input = event.target as HTMLInputElement; + const value = input?.value; + + const newFilters = this._copyFilters(filters); + + if (value.length > 0) { + newFilters['search'] = { + type: 'search', + text: 'Search', + data: { value }, + }; + } else { + delete newFilters['search']; + } + + this.onFilter({ ...newFilters }); + } + + @action + toggleExpand(): void { + this._isExpanded = !this._isExpanded; + } + + private _copyFilters = ( + filters: HdsFilterBarFilters + ): HdsFilterBarFilters => { + const newFilters = {} as HdsFilterBarFilters; + + Object.keys(filters).forEach((k) => { + newFilters[k] = JSON.parse( + JSON.stringify(filters[k]) + ) as HdsFilterBarFilter; + }); + + return newFilters; + }; + + private _onFilterDismiss = (key: string, filterValue?: unknown): void => { + const { filters } = this.args; + if (filters && filters[key]) { + const keyFilter: HdsFilterBarFilter = filters[key]; + const newFilters = this._copyFilters(filters); + + if (keyFilter.type === 'multi-select' && isArray(keyFilter.data)) { + const newKeyfilter = keyFilter.data?.filter( + (item) => item.value !== filterValue + ); + if (newKeyfilter.length === 0) { + delete newFilters[key]; + } else { + newFilters[key] = { + type: 'multi-select', + text: keyFilter.text, + data: newKeyfilter, + }; + } + } else { + delete newFilters[key]; + } + + this.onFilter({ ...newFilters }); + } + }; + + private _filterData = ( + data: HdsFilterBarData + ): HdsFilterBarGenericFilterData => { + const result = { + value: '', + } as HdsFilterBarGenericFilterData; + if ('value' in data) { + result.value = data.value; + } + if ('label' in data) { + result.label = data.label; + } + return result; + }; + + private _getFilterValueText = (filter: HdsFilterBarFilter): string => { + const result = this._filterData(filter.data); + const resultLabel = result?.label as string; + const resultValue = result?.value as string; + return resultLabel ?? resultValue; + }; + + private _getFilterKeyText = ( + key: string, + data: HdsFilterBarFilter + ): string => { + if (data.text) { + return data.text; + } else { + return key; + } + }; + + private _getNumericalFilterValueText = ( + filter: HdsFilterBarFilter + ): string => { + const data = filter.data; + + if (filter.type === 'numerical' && 'selector' in data && 'value' in data) { + const selector = data.selector as keyof typeof NUMERICAL_SELECTORS_TEXT; + if ( + selector === 'between' && + typeof data.value === 'object' && + data.value !== null + ) { + const separatorText = this.hdsIntl.t( + 'hds.components.filter-bar.filter-text.numerical-filter.separator', + { + default: 'and', + } + ); + return `${NUMERICAL_SELECTORS_TEXT[selector]} ${data.value.start} ${separatorText} ${data.value.end}`; + } else if (typeof data.value !== 'object') { + return `${NUMERICAL_SELECTORS_TEXT[selector]} ${data.value}`; + } + return ''; + } else { + return ''; + } + }; + + private _getDateFilterValueText = (filter: HdsFilterBarFilter): string => { + const data = filter.data; + + if ( + (filter.type === 'date' || + filter.type === 'datetime' || + filter.type === 'time') && + 'selector' in data && + 'value' in data + ) { + const selector = data.selector as keyof typeof DATE_SELECTORS_TEXT; + if ( + selector === 'between' && + typeof data.value === 'object' && + data.value !== null + ) { + const separatorText = this.hdsIntl.t( + 'hds.components.filter-bar.filter-text.date-filter.separator', + { + default: 'and', + } + ); + const startDateText = this._formatDateFilterText( + data.value.start as string, + filter.type + ); + const endDateText = this._formatDateFilterText( + data.value.end as string, + filter.type + ); + return `${DATE_SELECTORS_TEXT[selector]} ${startDateText} ${separatorText} ${endDateText}`; + } else if (data.value !== null && typeof data.value !== 'object') { + const dateText = this._formatDateFilterText( + data.value as string, + filter.type + ); + return `${DATE_SELECTORS_TEXT[selector]} ${dateText}`; + } + return ''; + } else { + return ''; + } + }; + + private _formatDateFilterText = ( + dateString: string, + filterType: HdsFilterBarFilterType + ): string => { + let date; + if (filterType === 'time') { + date = new Date(`1970-01-01T${dateString}`); + } else { + date = new Date(dateString); + } + + let options = {}; + if (filterType === 'date') { + options = { dateStyle: 'short' }; + } else if (filterType === 'time') { + options = { timeStyle: 'short' }; + } else { + options = { dateStyle: 'short', timeStyle: 'short' }; + } + + const newDate = new Intl.DateTimeFormat(undefined, options); + return newDate.format(date); + }; + + private _getGenericFilterValueText = (filter: HdsFilterBarFilter): string => { + if ('dismissTagText' in filter) { + return filter.dismissTagText ?? ''; + } else { + return ''; + } + }; + + private _getMultiSelectFilterData = ( + data: HdsFilterBarData + ): { value: unknown; label?: string }[] => { + if (isArray(data)) { + return data.map((item) => this._filterData(item)); + } + return []; + }; +} diff --git a/packages/components/src/components/hds/filter-bar/tabs/index.hbs b/packages/components/src/components/hds/filter-bar/tabs/index.hbs new file mode 100644 index 00000000000..4b58e4dd652 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/tabs/index.hbs @@ -0,0 +1,34 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +
    +
      + {{yield + (hash + Tab=(component + "hds/filter-bar/tabs/tab" + selectedTabIndex=this._selectedTabIndex + tabIds=this._tabIds + panelIds=this._panelIds + didInsertNode=this.didInsertTab + willDestroyNode=this.willDestroyTab + onClick=this.onClick + onKeyUp=this.onKeyUp + ) + ) + }} +
    + {{yield + (hash + Panel=(component + "hds/filter-bar/tabs/panel" + selectedTabIndex=this._selectedTabIndex + tabIds=this._tabIds + panelIds=this._panelIds + didInsertNode=this.didInsertPanel + willDestroyNode=this.willDestroyPanel + ) + ) + }} +
    \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/tabs/index.ts b/packages/components/src/components/hds/filter-bar/tabs/index.ts new file mode 100644 index 00000000000..158c4da34f7 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/tabs/index.ts @@ -0,0 +1,185 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { schedule } from '@ember/runloop'; +import { modifier } from 'ember-modifier'; +import type { WithBoundArgs } from '@glint/template'; +import HdsFilterBarTabsTabComponent from './tab.ts'; +import HdsFilterBarTabsPanelComponent from './panel.ts'; + +const TAB_ELEMENT_SELECTOR = '.hds-filter-bar__tabs__tab__button'; +const PANEL_ELEMENT_SELECTOR = '.hds-filter-bar__tabs__panel'; + +export interface HdsFilterBarTabsSignature { + Args: { + selectedTabIndex?: number; + ariaLabel: string; + onClickTab?: (event: MouseEvent, tabIndex: number) => void; + }; + Blocks: { + default: [ + { + Tab?: WithBoundArgs< + typeof HdsFilterBarTabsTabComponent, + | 'selectedTabIndex' + | 'tabIds' + | 'panelIds' + | 'didInsertNode' + | 'willDestroyNode' + | 'onClick' + | 'onKeyUp' + >; + Panel?: WithBoundArgs< + typeof HdsFilterBarTabsPanelComponent, + | 'selectedTabIndex' + | 'tabIds' + | 'panelIds' + | 'didInsertNode' + | 'willDestroyNode' + >; + }, + ]; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarTabs extends Component { + @tracked private _tabIds: string[] = []; + @tracked private _tabNodes: HTMLElement[] = []; + @tracked private _panelNodes: HTMLElement[] = []; + @tracked private _panelIds: string[] = []; + @tracked private _selectedTabIndex: number = 0; + + private _element!: HTMLDivElement; + + private _setUpFilterBarTabs = modifier((element: HTMLDivElement) => { + const { selectedTabIndex } = this.args; + + if (selectedTabIndex) { + this._selectedTabIndex = selectedTabIndex; + } + + this._element = element; + + return () => {}; + }); + + @action + didInsertTab(): void { + // eslint-disable-next-line ember/no-runloop + schedule('afterRender', (): void => { + this._updateTabs(); + }); + } + + @action + willDestroyTab(element: HTMLElement): void { + // eslint-disable-next-line ember/no-runloop + schedule('afterRender', (): void => { + this._tabNodes = this._tabNodes.filter( + (node): boolean => node.id !== element.id + ); + this._tabIds = this._tabIds.filter( + (tabId): boolean => tabId !== element.id + ); + }); + } + + @action + didInsertPanel(): void { + // eslint-disable-next-line ember/no-runloop + schedule('afterRender', (): void => { + this._updatePanels(); + }); + } + + @action + willDestroyPanel(element: HTMLElement): void { + // eslint-disable-next-line ember/no-runloop + schedule('afterRender', (): void => { + this._panelNodes = this._panelNodes.filter( + (node): boolean => node.id !== element.id + ); + this._panelIds = this._panelIds.filter( + (panelId): boolean => panelId !== element.id + ); + }); + } + + @action + onClick(event: MouseEvent, tabIndex: number): void { + this._selectedTabIndex = tabIndex; + + // invoke the callback function if it's provided as argument + if (typeof this.args.onClickTab === 'function') { + this.args.onClickTab(event, tabIndex); + } + } + + @action + onKeyUp(event: KeyboardEvent, tabIndex: number): void { + const leftArrow = 'ArrowLeft'; + const rightArrow = 'ArrowRight'; + const upArrow = 'ArrowUp'; + const downArrow = 'ArrowDown'; + const enterKey = 'Enter'; + const spaceKey = ' '; + + if (event.key === rightArrow || event.key === downArrow) { + const nextTabIndex = (tabIndex + 1) % this._tabIds.length; + this._focusTab(nextTabIndex, event); + } else if (event.key === leftArrow || event.key === upArrow) { + const prevTabIndex = + (tabIndex + this._tabIds.length - 1) % this._tabIds.length; + this._focusTab(prevTabIndex, event); + } else if (event.key === enterKey || event.key === spaceKey) { + this._selectedTabIndex = tabIndex; + } + // scroll selected tab into view (it may be out of view when activated using a keyboard with `prev/next`) + const parentNode = this._tabNodes[this._selectedTabIndex]?.parentNode; + if (parentNode instanceof HTMLElement) { + parentNode.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + } + } + + // Focus tab for keyboard & mouse navigation: + private _focusTab(tabIndex: number, event: KeyboardEvent): void { + event.preventDefault(); + this._tabNodes[tabIndex]?.focus(); + } + + // Update the tab arrays based on how they are ordered in the DOM + private _updateTabs(): void { + const tabs = this._element.querySelectorAll(TAB_ELEMENT_SELECTOR); + let newTabIds: string[] = []; + let newTabNodes: HTMLElement[] = []; + tabs.forEach((tab) => { + newTabIds = [...newTabIds, tab.id]; + newTabNodes = [...newTabNodes, tab as HTMLElement]; + }); + this._tabIds = newTabIds; + this._tabNodes = newTabNodes; + } + + // Update the panel arrays based on how they are ordered in the DOM + private _updatePanels(): void { + const panels = this._element.querySelectorAll(PANEL_ELEMENT_SELECTOR); + let newPanelIds: string[] = []; + let newPanelNodes: HTMLElement[] = []; + panels.forEach((panel) => { + newPanelIds = [...newPanelIds, panel.id]; + newPanelNodes = [...newPanelNodes, panel as HTMLElement]; + }); + this._panelIds = newPanelIds; + this._panelNodes = newPanelNodes; + } +} diff --git a/packages/components/src/components/hds/filter-bar/tabs/panel.hbs b/packages/components/src/components/hds/filter-bar/tabs/panel.hbs new file mode 100644 index 00000000000..80f302512b8 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/tabs/panel.hbs @@ -0,0 +1,17 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/tabs/panel.ts b/packages/components/src/components/hds/filter-bar/tabs/panel.ts new file mode 100644 index 00000000000..276bd9f33dd --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/tabs/panel.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { guidFor } from '@ember/object/internals'; +import { action } from '@ember/object'; +import { modifier } from 'ember-modifier'; + +export interface HdsFilterBarTabsPanelSignature { + Args: { + selectedTabIndex?: number; + tabIds?: string[]; + panelIds?: string[]; + didInsertNode?: () => void; + willDestroyNode?: (element: HTMLElement) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLElement; +} + +export default class HdsFilterBarTabsPanel extends Component { + private _panelId = 'panel-' + guidFor(this); + private _elementId?: string; + + private _setUpPanel = modifier( + ( + element: HTMLElement, + [insertCallbackFunction, destroyCallbackFunction] + ) => { + if (typeof insertCallbackFunction === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + insertCallbackFunction(element); + } + + return () => { + if (typeof destroyCallbackFunction === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + destroyCallbackFunction(element); + } + }; + } + ); + + get nodeIndex(): number | undefined { + return this.args.panelIds?.indexOf(this._panelId); + } + + get coupledTabId(): string | undefined { + return this.nodeIndex !== undefined + ? this.args.tabIds?.[this.nodeIndex] + : undefined; + } + + get isVisible(): boolean { + return this.nodeIndex === this.args.selectedTabIndex; + } + + @action + didInsertNode(element: HTMLElement): void { + const { didInsertNode } = this.args; + + if (typeof didInsertNode === 'function') { + this._elementId = element.id; + didInsertNode(); + } + } + + @action + willDestroyNode(element: HTMLElement): void { + const { willDestroyNode } = this.args; + + if (typeof willDestroyNode === 'function') { + willDestroyNode(element); + } + } +} diff --git a/packages/components/src/components/hds/filter-bar/tabs/tab.hbs b/packages/components/src/components/hds/filter-bar/tabs/tab.hbs new file mode 100644 index 00000000000..8872757795a --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/tabs/tab.hbs @@ -0,0 +1,30 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +{{! template-lint-disable require-context-role no-invalid-role }} +{{! template-lint-disable require-presentational-children }} + +{{! template-lint-enable require-presentational-children }} +{{! template-lint-enable require-context-role no-invalid-role }} \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/tabs/tab.ts b/packages/components/src/components/hds/filter-bar/tabs/tab.ts new file mode 100644 index 00000000000..80d2234eecb --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/tabs/tab.ts @@ -0,0 +1,116 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { guidFor } from '@ember/object/internals'; +import { action } from '@ember/object'; +import { modifier } from 'ember-modifier'; + +export interface HdsFilterBarTabsTabSignature { + Args: { + selectedTabIndex?: number; + tabIds?: string[]; + panelIds?: string[]; + numFilters?: number; + didInsertNode?: () => void; + willDestroyNode?: (element: HTMLButtonElement) => void; + onClick?: (event: MouseEvent, nodeIndex: number) => void; + onKeyUp?: (event: KeyboardEvent, nodeIndex: number) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLElement; +} + +export default class HdsFilterBarTabsTab extends Component { + private _tabId = 'tab-' + guidFor(this); + private _elementId?: string; + + private _setUpTab = modifier( + ( + element: HTMLElement, + [insertCallbackFunction, destroyCallbackFunction] + ) => { + if (typeof insertCallbackFunction === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + insertCallbackFunction(element); + } + + return () => { + if (typeof destroyCallbackFunction === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + destroyCallbackFunction(element); + } + }; + } + ); + + get nodeIndex(): number | undefined { + return this.args.tabIds?.indexOf(this._tabId); + } + + get coupledPanelId(): string | undefined { + return this.nodeIndex !== undefined + ? this.args.panelIds?.[this.nodeIndex] + : undefined; + } + + get isSelected(): boolean { + return ( + this.nodeIndex !== undefined && + this.nodeIndex === this.args.selectedTabIndex + ); + } + + @action + didInsertNode(element: HTMLButtonElement): void { + const { didInsertNode } = this.args; + + if (typeof didInsertNode === 'function') { + this._elementId = element.id; + didInsertNode(); + } + } + + @action + willDestroyNode(element: HTMLButtonElement): void { + const { willDestroyNode } = this.args; + + if (typeof willDestroyNode === 'function') { + willDestroyNode(element); + } + } + + @action + onClick(event: MouseEvent): false | undefined { + const { onClick } = this.args; + + if (this.nodeIndex !== undefined && typeof onClick === 'function') { + onClick(event, this.nodeIndex); + } else { + return false; + } + } + + @action + onKeyUp(event: KeyboardEvent): void { + const { onKeyUp } = this.args; + + if (this.nodeIndex !== undefined && typeof onKeyUp === 'function') { + onKeyUp(event, this.nodeIndex); + } + } + + get classNames(): string { + const classes = ['hds-filter-bar__tabs__tab']; + + if (this.isSelected) { + classes.push(`hds-filter-bar__tabs__tab--is-selected`); + } + + return classes.join(' '); + } +} diff --git a/packages/components/src/components/hds/filter-bar/types.ts b/packages/components/src/components/hds/filter-bar/types.ts new file mode 100644 index 00000000000..af230923a81 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/types.ts @@ -0,0 +1,117 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +export enum HdsFilterBarFilterTypeValues { + multiSelect = 'multi-select', + singleSelect = 'single-select', + numerical = 'numerical', + date = 'date', + time = 'time', + datetime = 'datetime', + generic = 'generic', + search = 'search', +} + +export type HdsFilterBarFilterType = `${HdsFilterBarFilterTypeValues}`; + +export interface HdsFilterBarGenericFilterData { + value: unknown; + label?: string; +} + +export enum HdsFilterBarNumericalFilterSelectorValues { + lessThan = 'less-than', + lessThanOrEqualTo = 'less-than-or-equal-to', + equalTo = 'equal-to', + greaterThanOrEqualTo = 'greater-than-or-equal-to', + greaterThan = 'greater-than', + between = 'between', +} + +export type HdsFilterBarNumericalFilterSelector = + `${HdsFilterBarNumericalFilterSelectorValues}`; + +export type HdsFilterBarNumericalFilterValue = + | number + | { start?: number; end?: number }; + +export interface HdsFilterBarNumericalFilterData { + selector: HdsFilterBarNumericalFilterSelector; + value: HdsFilterBarNumericalFilterValue; +} + +export enum HdsFilterBarDateFilterSelectorValues { + before = 'before', + exactly = 'exactly', + after = 'after', + between = 'between', +} + +export type HdsFilterBarDateFilterSelector = + `${HdsFilterBarDateFilterSelectorValues}`; + +export type HdsFilterBarDateFilterValue = + | string + | { start?: string; end?: string }; + +export interface HdsFilterBarDateFilterData { + selector: HdsFilterBarDateFilterSelector; + value: HdsFilterBarDateFilterValue; +} + +export type HdsFilterBarData = + | HdsFilterBarGenericFilterData[] + | HdsFilterBarGenericFilterData + | HdsFilterBarNumericalFilterData + | HdsFilterBarDateFilterData; + +export interface HdsFilterBarMultiSelectFilter { + type: 'multi-select'; + text?: string; + data: HdsFilterBarGenericFilterData[]; +} + +export interface HdsFilterBarSingleSelectFilter { + type: 'single-select'; + text?: string; + data: HdsFilterBarGenericFilterData; +} + +export interface HdsFilterBarNumericalFilter { + type: 'numerical'; + text?: string; + data: HdsFilterBarNumericalFilterData; +} + +export interface HdsFilterBarDateFilter { + type: 'date' | 'time' | 'datetime'; + text?: string; + data: HdsFilterBarDateFilterData; +} + +export interface HdsFilterBarGenericFilter { + type: 'generic'; + text?: string; + dismissTagText?: string; + data: HdsFilterBarGenericFilterData | HdsFilterBarGenericFilterData[]; +} + +export interface HdsFilterBarSearchFilter { + type: 'search'; + text?: string; + data: HdsFilterBarGenericFilterData; +} + +export type HdsFilterBarFilter = + | HdsFilterBarMultiSelectFilter + | HdsFilterBarSingleSelectFilter + | HdsFilterBarNumericalFilter + | HdsFilterBarDateFilter + | HdsFilterBarGenericFilter + | HdsFilterBarSearchFilter; + +export interface HdsFilterBarFilters { + [name: string]: HdsFilterBarFilter; +} diff --git a/packages/components/src/styles/@hashicorp/design-system-components.scss b/packages/components/src/styles/@hashicorp/design-system-components.scss index ae3fb7b1912..fdc971e96eb 100644 --- a/packages/components/src/styles/@hashicorp/design-system-components.scss +++ b/packages/components/src/styles/@hashicorp/design-system-components.scss @@ -36,6 +36,7 @@ @use "../components/disclosure-primitive"; @use "../components/dismiss-button"; @use "../components/dropdown"; +@use "../components/filter-bar"; @use "../components/flyout"; @use "../components/form"; // multiple components @use "../components/icon"; diff --git a/packages/components/src/styles/components/advanced-table.scss b/packages/components/src/styles/components/advanced-table.scss index 75936fadddd..fdd3679bcc3 100644 --- a/packages/components/src/styles/components/advanced-table.scss +++ b/packages/components/src/styles/components/advanced-table.scss @@ -415,7 +415,8 @@ $hds-advanced-table-drag-preview-background-color: rgba(204, 227, 254, 30%); } } -.hds-advanced-table__th-context-menu .hds-dropdown-toggle-icon { +.hds-advanced-table__th-context-menu .hds-dropdown-toggle-icon, +.hds-advanced-table__th-filter-menu .hds-dropdown-toggle-icon { width: $hds-advanced-table-button-size; height: $hds-advanced-table-button-size; margin: -2px 0; @@ -504,6 +505,25 @@ $hds-advanced-table-drag-preview-background-color: rgba(204, 227, 254, 30%); align-self: flex-start; } +.hds-advanced-table__th-filter-menu--active { + position: relative; + + &::before { + position: absolute; + top: -4px; + right: -4px; + width: 6px; + height: 6px; + background-color: var(--token-color-foreground-action); + border-radius: 50%; + content: ""; + } +} + +.hds-advanced-table__clear-filters-button { + margin-bottom: 16px; +} + // ---------------------------------------------------------------- // TABLE BODY @@ -745,3 +765,49 @@ $hds-advanced-table-drag-preview-background-color: rgba(204, 227, 254, 30%); border-radius: var(--token-border-radius-medium); box-shadow: var(--token-elevation-mid-box-shadow); } + +// ---------------------------------------------------------------- + +// FILTER BAR +.hds-advanced-table__actions .hds-filter-bar { + border-bottom: none; + border-radius: $hds-advanced-table-border-radius $hds-advanced-table-border-radius 0 0; +} + +.hds-advanced-table__actions + .hds-advanced-table__container { + border-top-left-radius: 0; + border-top-right-radius: 0; + + .hds-advanced-table__thead .hds-advanced-table__tr:first-of-type .hds-advanced-table__th { + &:first-child { + border-top-left-radius: 0; + } + + &:last-child { + border-top-right-radius: 0; + } + } +} + +/// ---------------------------------------------------------------- + +// EMPTY STATE +.hds-advanced-table__empty-state { + display: flex; + align-items: center; + justify-content: center; + height: 400px; + background-color: var(--token-color-surface-primary); + border: 1px solid var(--token-color-border-primary); + border-bottom-right-radius: $hds-advanced-table-border-radius; + border-bottom-left-radius: $hds-advanced-table-border-radius; +} + +.hds-advanced-table__empty-state__content { + max-width: 450px; +} + +.hds-advanced-table:not(:has(+ .hds-advanced-table__empty-state)) { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} diff --git a/packages/components/src/styles/components/filter-bar.scss b/packages/components/src/styles/components/filter-bar.scss new file mode 100644 index 00000000000..df66864fcdf --- /dev/null +++ b/packages/components/src/styles/components/filter-bar.scss @@ -0,0 +1,164 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// +// FILTER BAR +// + +.hds-filter-bar { + display: grid; + gap: 8px; + padding: 8px; + background-color: var(--token-color-surface-faint); + border: 1px solid var(--token-color-border-primary); + border-radius: var(--token-border-radius-medium); +} + +.hds-filter-bar__applied-filters-list { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: end; + padding-top: 8px; + border-top: 1px solid var(--token-color-border-primary); +} + +.hds-filter-bar__actions__right { + margin-left: auto; +} + +.hds-filter-bar__search { + --token-form-control-padding: 3px; +} + +// FILTER BAR > DROPDOWN + +.hds-filter-bar__dropdown .hds-dropdown__list, +.hds-filter-bar__dropdown .hds-dropdown-list-item { + padding: 0; +} + +// FILTER BAR > TABS + +.hds-filter-bar__tabs { + display: flex; +} + +.hds-filter-bar__tabs__list { + display: flex; + flex-direction: column; + gap: 4px; + width: 50%; + padding: 8px; + list-style: none; + background-color: var(--token-color-surface-faint); + border-right: 1px solid var(--token-color-border-primary); + border-top-left-radius: var(--token-border-radius-medium); +} + +.hds-filter-bar__tabs__tab__button { + display: flex; + gap: 16px; + align-items: center; + width: 100%; + padding: 8px 12px; + color: var(--token-color-foreground-primary); + text-align: left; + background-color: transparent; + border: none; + border-radius: var(--token-border-radius-small); + + &.mock-hover, + &:hover { + background-color: var(--token-color-surface-interactive-hover); + cursor: pointer; + } + + &.mock-active, + &:active { + background-color: var(--token-color-surface-interactive-active); + } +} + +.hds-filter-bar__tabs__tab__text { + width: 100%; +} + +.hds-filter-bar__tabs__tab--is-selected .hds-filter-bar__tabs__tab__button { + color: var(--token-color-foreground-action); + background-color: var(--token-color-surface-strong); + + &.mock-hover, + &:hover { + background-color: var(--token-color-palette-neutral-200); + } + + &.mock-active, + &:active { + background-color: var(--token-color-palette-neutral-300); + } +} + +.hds-filter-bar__tabs__panel:not([hidden]) { + display: flex; + flex-direction: column; + gap: 8px; + width: 50%; + padding: 16px 0; + background-color: var(--token-color-surface-primary); + border-top-right-radius: var(--token-border-radius-medium); +} + +// FILTER BAR > FILTER GROUP + +.hds-filter-bar__filter-group .hds-form-field--layout-flag { + padding: 8px 12px; +} + +.hds-filter-bar__filter-group__search { + padding: 0 16px 16px 16px; + border-bottom: 1px solid var(--token-color-border-primary); +} + +.hds-filter-bar__filter-group__list { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; + overscroll-behavior: contain; +} + +.hds-filter-bar__filter-group__fields { + padding: 0 16px; + + .hds-filter-bar__filter-group__field, + .hds-filter-bar__filter-group__field[type="date"], + .hds-filter-bar__filter-group__field[type="time"] { + width: 100%; + } +} + +.hds-filter-bar__filter-group__numerical, +.hds-filter-bar__filter-group__date { + display: flex; + flex-direction: column; + gap: 8px; +} + +.hds-filter-bar__filter-group__selection-option { + display: block; + padding: 8px 16px; + + &--hidden { + display: none; + } +} + +.hds-filter-bar__filter-group__clear { + padding: 4px; +} diff --git a/packages/components/src/template-registry.ts b/packages/components/src/template-registry.ts index 4dee43a90a6..5569b8cc52d 100644 --- a/packages/components/src/template-registry.ts +++ b/packages/components/src/template-registry.ts @@ -97,6 +97,18 @@ import type HdsDropdownListItemTitleComponent from './components/hds/dropdown/li import type HdsDropdownToggleButtonComponent from './components/hds/dropdown/toggle/button'; import type HdsDropdownToggleChevronComponent from './components/hds/dropdown/toggle/chevron'; import type HdsDropdownToggleIconComponent from './components/hds/dropdown/toggle/icon'; + +import type HdsFilterBarComponent from './components/hds/filter-bar'; +import type HdsFilterBarDropdownComponent from './components/hds/filter-bar/dropdown'; +import type HdsFilterBarFilterGroupComponent from './components/hds/filter-bar/filter-group/index.ts'; +import type HdsFilterBarFilterGroupCheckboxComponent from './components/hds/filter-bar/filter-group/checkbox'; +import type HdsFilterBarFilterGroupDateComponent from './components/hds/filter-bar/filter-group/date'; +import type HdsFilterBarFilterGroupGenericComponent from './components/hds/filter-bar/filter-group/generic'; +import type HdsFilterBarFilterGroupRadioComponent from './components/hds/filter-bar/filter-group/radio'; +import type HdsFilterBarFilterGroupNumericalComponent from './components/hds/filter-bar/filter-group/numerical'; +import type HdsFilterBarTabsComponent from './components/hds/filter-bar/tabs'; +import type HdsFilterBarTabsPanelComponent from './components/hds/filter-bar/tabs/panel'; +import type HdsFilterBarTabsTabComponent from './components/hds/filter-bar/tabs/tab'; import type HdsFlyoutComponent from './components/hds/flyout'; import type HdsFormComponent from './components/hds/form'; @@ -554,6 +566,30 @@ export default interface HdsComponentsRegistry { 'Hds::Dropdown::Toggle::Icon': typeof HdsDropdownToggleIconComponent; 'hds/dropdown/toggle/icon': typeof HdsDropdownToggleIconComponent; + // Filter Bar + 'Hds::FilterBar': typeof HdsFilterBarComponent; + 'hds/filter-bar': typeof HdsFilterBarComponent; + 'Hds::FilterBar::Dropdown': typeof HdsFilterBarDropdownComponent; + 'hds/filter-bar/dropdown': typeof HdsFilterBarDropdownComponent; + 'Hds::FilterBar::FilterGroup': typeof HdsFilterBarFilterGroupComponent; + 'hds/filter-bar/filter-group': typeof HdsFilterBarFilterGroupComponent; + 'Hds::FilterBar::FilterGroup::Checkbox': typeof HdsFilterBarFilterGroupCheckboxComponent; + 'hds/filter-bar/filter-group/checkbox': typeof HdsFilterBarFilterGroupCheckboxComponent; + 'Hds::FilterBar::FilterGroup::Date': typeof HdsFilterBarFilterGroupDateComponent; + 'hds/filter-bar/filter-group/date': typeof HdsFilterBarFilterGroupDateComponent; + 'Hds::FilterBar::FilterGroup::Generic': typeof HdsFilterBarFilterGroupGenericComponent; + 'hds/filter-bar/filter-group/generic': typeof HdsFilterBarFilterGroupGenericComponent; + 'Hds::FilterBar::FilterGroup::Radio': typeof HdsFilterBarFilterGroupRadioComponent; + 'hds/filter-bar/filter-group/radio': typeof HdsFilterBarFilterGroupRadioComponent; + 'Hds::FilterBar::FilterGroup::Numerical': typeof HdsFilterBarFilterGroupNumericalComponent; + 'hds/filter-bar/filter-group/numerical': typeof HdsFilterBarFilterGroupNumericalComponent; + 'Hds::FilterBar::Tabs': typeof HdsFilterBarTabsComponent; + 'hds/filter-bar/tabs': typeof HdsFilterBarTabsComponent; + 'Hds::FilterBar::Tabs::Panel': typeof HdsFilterBarTabsPanelComponent; + 'hds/filter-bar/tabs/panel': typeof HdsFilterBarTabsPanelComponent; + 'Hds::FilterBar::Tabs::Tab': typeof HdsFilterBarTabsTabComponent; + 'hds/filter-bar/tabs/tab': typeof HdsFilterBarTabsTabComponent; + // Flyout 'Hds::Flyout': typeof HdsFlyoutComponent; 'hds/flyout': typeof HdsFlyoutComponent; diff --git a/packages/components/translations/hds/components/advanced-table/en-us.yaml b/packages/components/translations/hds/components/advanced-table/en-us.yaml index b6074f30b8b..7e5d5ac4348 100644 --- a/packages/components/translations/hds/components/advanced-table/en-us.yaml +++ b/packages/components/translations/hds/components/advanced-table/en-us.yaml @@ -1 +1,4 @@ reordered-message: Moved {columnLabel} column to position {newPosition} +empty-state: + title: No data available + description: There is currently no data to display in the table. diff --git a/packages/components/translations/hds/components/filter-bar/date/en-us.yaml b/packages/components/translations/hds/components/filter-bar/date/en-us.yaml new file mode 100644 index 00000000000..832a7cf3e6f --- /dev/null +++ b/packages/components/translations/hds/components/filter-bar/date/en-us.yaml @@ -0,0 +1,18 @@ +date: + label: Date is +datetime: + label: Datetime is +time: + label: Time is +selector-input: + default-value: Pick a selector + before: Before + exactly: Exactly + after: After + between: Between +value-input: + placeholder: Enter a date +between-value-inputs: + start-placeholder: Start + end-placeholder: End +clear: Clear filter diff --git a/packages/components/translations/hds/components/filter-bar/dropdown/en-us.yaml b/packages/components/translations/hds/components/filter-bar/dropdown/en-us.yaml new file mode 100644 index 00000000000..21cdddde856 --- /dev/null +++ b/packages/components/translations/hds/components/filter-bar/dropdown/en-us.yaml @@ -0,0 +1,9 @@ +apply: Apply filters +clear: Clear all filters +expand-collapse-button: + expand: Expand filters + collapse: Collapse filters +selected-filters: Filters selected +tabs: + aria-label: Filter bar tabs +toggle-button: Filters diff --git a/packages/components/translations/hds/components/filter-bar/en-us.yaml b/packages/components/translations/hds/components/filter-bar/en-us.yaml new file mode 100644 index 00000000000..bde0ef6ae74 --- /dev/null +++ b/packages/components/translations/hds/components/filter-bar/en-us.yaml @@ -0,0 +1,9 @@ +search: + aria-label: Search filters + placeholder: Search +filter-text: + numerical-filter: + separator: and + date-filter: + separator: and +no-filters-applied: No filters applied diff --git a/packages/components/translations/hds/components/filter-bar/filter-group/en-us.yaml b/packages/components/translations/hds/components/filter-bar/filter-group/en-us.yaml new file mode 100644 index 00000000000..06a65bc3b91 --- /dev/null +++ b/packages/components/translations/hds/components/filter-bar/filter-group/en-us.yaml @@ -0,0 +1 @@ +clear: Clear selection diff --git a/packages/components/translations/hds/components/filter-bar/filter-group/numerical/en-us.yaml b/packages/components/translations/hds/components/filter-bar/filter-group/numerical/en-us.yaml new file mode 100644 index 00000000000..9e1eb4f1217 --- /dev/null +++ b/packages/components/translations/hds/components/filter-bar/filter-group/numerical/en-us.yaml @@ -0,0 +1,15 @@ +label: Number is +selector-input: + default-value: Pick a selector + less-than: Less than (<) + less-than-or-equal-to: Less than or equal to (≤) + equal-to: Equal to (=) + greater-than-or-equal-to: Greater than or equal to (≥) + greater-than: Greater than (>) + between: Between +value-input: + placeholder: Enter a value +between-value-inputs: + start-placeholder: Start + end-placeholder: End +clear: Clear filter diff --git a/packages/components/translations/hds/components/filter-bar/filter-options/en-us.yaml b/packages/components/translations/hds/components/filter-bar/filter-options/en-us.yaml new file mode 100644 index 00000000000..e94d2868354 --- /dev/null +++ b/packages/components/translations/hds/components/filter-bar/filter-options/en-us.yaml @@ -0,0 +1 @@ +search-input-placeholder: "Search" diff --git a/showcase/app/components/mock/app/main/generic-advanced-table.gts b/showcase/app/components/mock/app/main/generic-advanced-table.gts index c238ae7606e..68af63946b9 100644 --- a/showcase/app/components/mock/app/main/generic-advanced-table.gts +++ b/showcase/app/components/mock/app/main/generic-advanced-table.gts @@ -4,111 +4,43 @@ */ import Component from '@glimmer/component'; import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import { deepTracked } from 'ember-deep-tracked'; -import { get } from '@ember/helper'; +import { get, fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import style from 'ember-style-modifier/modifiers/style'; + +import ShwPlaceholder from 'showcase/components/shw/placeholder'; // HDS components import { HdsAdvancedTable, + HdsButton, + HdsFilterBar, + HdsLayoutFlex, HdsLinkInline, HdsBadge, HdsBadgeColorValues, + HdsFormToggleField, + HdsTextBody, + HdsTextDisplay, type HdsAdvancedTableOnSelectionChangeSignature, + type HdsFilterBarNumericalFilter, + type HdsFilterBarDateFilter, + type HdsFilterBarSingleSelectFilter, + type HdsFilterBarMultiSelectFilter, + type HdsFilterBarSearchFilter, + type HdsFilterBarFilter, + type HdsFilterBarGenericFilter, } from '@hashicorp/design-system-components/components'; import type { HdsAdvancedTableSignature } from '@hashicorp/design-system-components/components/hds/advanced-table/index'; +import type { HdsFilterBarSignature } from '@hashicorp/design-system-components/components/hds/filter-bar/index'; export interface MockAppMainGenericAdvancedTableSignature { Element: HTMLDivElement; } -const SAMPLE_COLUMNS = [ - { - isSortable: true, - label: 'Name', - key: 'name', - width: 'max-content', - }, - { - label: 'Project name', - key: 'project-name', - isSortable: true, - width: 'max-content', - }, - { - label: 'Current run ID', - key: 'current-run-id', - isSortable: true, - width: 'max-content', - }, - { - label: 'Run status', - key: 'run-status', - isSortable: true, - width: 'max-content', - }, - { - label: 'Current run applied', - key: 'current-run-applied', - isSortable: true, - width: 'max-content', - }, - { - label: 'VCS repo', - key: 'vcs-repo', - isSortable: true, - width: 'max-content', - }, - { - label: 'Module count', - key: 'module-count', - isSortable: true, - width: 'max-content', - }, - { - label: 'Modules', - key: 'modules', - isSortable: true, - width: 'max-content', - }, - { - label: 'Provider count', - key: 'provider-count', - isSortable: true, - width: 'max-content', - }, - { - label: 'Providers', - key: 'providers', - isSortable: true, - width: 'max-content', - }, - { - label: 'Terraform version', - key: 'terraform-version', - isSortable: true, - width: 'max-content', - }, - { - label: 'State terraform version', - key: 'state-terraform-version', - isSortable: true, - width: 'max-content', - }, - { - label: 'Created', - key: 'created', - isSortable: true, - width: 'max-content', - }, - { - label: 'Updated', - key: 'updated', - isSortable: true, - width: 'max-content', - }, -]; - const SAMPLE_MODEL = [ { name: 'zoguve-guw-mannaz', @@ -117,6 +49,7 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 09:10:14 am', + 'creation-time': '09:13:13', 'vcs-repo': 'example/a))!hzfpKcBl0', 'module-count': 46, modules: 'wad-bedzeaje-rogmejca', @@ -134,6 +67,7 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 09:09:14 am', + 'creation-time': '22:22:45', 'vcs-repo': 'example/tp7Xe!mDHlI[70ZO1', 'module-count': 152, modules: 'wad-bedzeaje-rogmejca', @@ -151,7 +85,8 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 09:08:14 am', - 'vcs-repo': 'example/sClKKTBbyCIzf@d8NxH2', + 'creation-time': '11:05:33', + 'vcs-repo': 'example/a))!hzfpKcBl0', 'module-count': 31, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 42, @@ -168,7 +103,8 @@ const SAMPLE_MODEL = [ 'run-status': 'planned', 'run-status-color': HdsBadgeColorValues.Warning, 'current-run-applied': 'Mar 06, 2025 09:07:14 am', - 'vcs-repo': 'example/y0^(Nm*63', + 'creation-time': '20:44:21', + 'vcs-repo': 'example/a))!hzfpKcBl0', 'module-count': 58, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 140, @@ -185,7 +121,8 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 09:06:14 am', - 'vcs-repo': 'example/ljPWe[4', + 'creation-time': '07:59:59', + 'vcs-repo': 'example/a))!hzfpKcBl0', 'module-count': 32, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 50, @@ -202,7 +139,8 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 09:05:14 am', - 'vcs-repo': 'example/E*fcS4mn@BoDgZu0O5', + 'creation-time': '20:30:00', + 'vcs-repo': 'example/a))!hzfpKcBl0', 'module-count': 94, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 113, @@ -219,6 +157,7 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 09:04:14 am', + 'creation-time': '10:15:30', 'vcs-repo': 'example/&j[RmmtjpQX6', 'module-count': 117, modules: 'wad-bedzeaje-rogmejca', @@ -236,13 +175,14 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 09:03:14 am', - 'vcs-repo': 'example/(DCFjSEKcBuU44J8AB87', + 'creation-time': '09:45:00', + 'vcs-repo': 'example/&j[RmmtjpQX6', 'module-count': 114, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 107, providers: 'susnup-da-zuw', 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'state-terraform-version': '0.16.0', created: 'Feb 27 2025', updated: 'Feb 27 2025', }, @@ -253,13 +193,14 @@ const SAMPLE_MODEL = [ 'run-status': 'planned', 'run-status-color': HdsBadgeColorValues.Warning, 'current-run-applied': 'Mar 06, 2025 09:02:14 am', - 'vcs-repo': 'example/9YURY8', + 'creation-time': '10:30:00', + 'vcs-repo': 'example/&j[RmmtjpQX6', 'module-count': 106, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 185, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'terraform-version': '0.14.5', + 'state-terraform-version': '0.16.0', created: 'Feb 26 2025', updated: 'Feb 26 2025', }, @@ -270,13 +211,14 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 09:01:14 am', - 'vcs-repo': 'example/9YURY8', + 'creation-time': '11:00:00', + 'vcs-repo': 'example/&j[RmmtjpQX6', 'module-count': 124, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 175, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'terraform-version': '0.14.5', + 'state-terraform-version': '0.16.0', created: 'Feb 25 2025', updated: 'Feb 25 2025', }, @@ -287,13 +229,14 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 09:00:14 am', - 'vcs-repo': 'example/d2s3B46I10', + 'creation-time': '10:45:00', + 'vcs-repo': 'example/&j[RmmtjpQX6', 'module-count': 70, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 168, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'terraform-version': '0.14.5', + 'state-terraform-version': '0.16.0', created: 'Feb 24 2025', updated: 'Feb 24 2025', }, @@ -304,13 +247,14 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 09:00:14 am', + 'creation-time': '10:45:00', 'vcs-repo': 'example/d2s3B46I10', 'module-count': 70, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 168, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'terraform-version': '0.14.5', + 'state-terraform-version': '0.16.0', created: 'Feb 23 2025', updated: 'Feb 23 2025', }, @@ -321,13 +265,14 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 08:59:14 am', - 'vcs-repo': 'example/v@C6&hBTou11', + 'creation-time': '09:50:00', + 'vcs-repo': 'example/d2s3B46I10', 'module-count': 106, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 61, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'terraform-version': '0.14.5', + 'state-terraform-version': '0.16.0', created: 'Feb 22 2025', updated: 'Feb 22 2025', }, @@ -338,12 +283,13 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 08:58:14 am', - 'vcs-repo': 'example/@t23^12', + 'creation-time': '10:10:00', + 'vcs-repo': 'example/d2s3B46I10', 'module-count': 14, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 143, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 21 2025', updated: 'Feb 21 2025', @@ -355,12 +301,13 @@ const SAMPLE_MODEL = [ 'run-status': 'planned', 'run-status-color': HdsBadgeColorValues.Warning, 'current-run-applied': 'Mar 06, 2025 08:58:14 am', - 'vcs-repo': 'example/@t23^12', + 'creation-time': '10:20:00', + 'vcs-repo': 'example/d2s3B46I10', 'module-count': 14, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 143, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 20 2025', updated: 'Feb 20 2025', @@ -372,12 +319,13 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 08:57:14 am', + 'creation-time': '09:30:00', 'vcs-repo': 'example/JUha^7zr14', 'module-count': 114, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 98, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 19 2025', updated: 'Feb 19 2025', @@ -389,12 +337,13 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 08:56:14 am', + 'creation-time': '10:05:00', 'vcs-repo': 'example/JUha^7zr14', 'module-count': 99, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 170, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 18 2025', updated: 'Feb 18 2025', @@ -406,12 +355,13 @@ const SAMPLE_MODEL = [ 'run-status': 'planned', 'run-status-color': HdsBadgeColorValues.Warning, 'current-run-applied': 'Mar 06, 2025 08:57:14 am', - 'vcs-repo': 'example/t*vN3@*BxJnG116', + 'creation-time': '09:55:00', + 'vcs-repo': 'example/d2s3B46I10', 'module-count': 139, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 170, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 17 2025', updated: 'Feb 17 2025', @@ -423,12 +373,13 @@ const SAMPLE_MODEL = [ 'run-status': 'planned', 'run-status-color': HdsBadgeColorValues.Warning, 'current-run-applied': 'Mar 06, 2025 08:57:14 am', + 'creation-time': '09:40:00', 'vcs-repo': 'example/8G3C81*u*q*O$17', 'module-count': 107, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 83, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 16 2025', updated: 'Feb 16 2025', @@ -440,12 +391,13 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 08:56:14 am', + 'creation-time': '09:15:00', 'vcs-repo': 'example/gt]5*c!N1*N%I!m)18', 'module-count': 80, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 152, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 15 2025', updated: 'Feb 15 2025', @@ -457,18 +409,141 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 08:57:14 am', + 'creation-time': '10:00:00', 'vcs-repo': 'example/gt]5*c!N1*N%I!m)18', 'module-count': 158, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 11, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 14 2025', updated: 'Feb 14 2025', }, ]; +const SAMPLE_MODEL_VALUES = { + name: Array.from(new Set(SAMPLE_MODEL.map((item) => item['name']))).map( + (value) => ({ value, label: value }), + ), + 'project-name': Array.from( + new Set(SAMPLE_MODEL.map((item) => item['project-name'])), + ).map((value) => ({ value, label: value })), + 'run-status': Array.from( + new Set(SAMPLE_MODEL.map((item) => item['run-status'])), + ).map((value) => ({ value, label: value })), + 'vcs-repo': Array.from( + new Set(SAMPLE_MODEL.map((item) => item['vcs-repo'])), + ).map((value) => ({ value, label: value })), + 'terraform-version': Array.from( + new Set(SAMPLE_MODEL.map((item) => item['terraform-version'])), + ).map((value) => ({ value, label: value })), + 'state-terraform-version': Array.from( + new Set(SAMPLE_MODEL.map((item) => item['state-terraform-version'])), + ).map((value) => ({ value, label: value })), +}; + +const SAMPLE_COLUMNS = [ + { + isSortable: true, + label: 'Name', + key: 'name', + width: 'max-content', + }, + { + label: 'Project name', + key: 'project-name', + isSortable: true, + width: 'max-content', + }, + { + label: 'Current run ID', + key: 'current-run-id', + isSortable: true, + width: 'max-content', + }, + { + label: 'Run status', + key: 'run-status', + isSortable: true, + width: 'max-content', + }, + { + label: 'Current run applied', + key: 'current-run-applied', + isSortable: true, + width: 'max-content', + }, + { + label: 'Creation time', + key: 'creation-time', + isSortable: true, + width: 'max-content', + }, + { + label: 'VCS repo', + key: 'vcs-repo', + isSortable: true, + width: 'max-content', + }, + { + label: 'Module count', + key: 'module-count', + isSortable: true, + width: 'max-content', + }, + { + label: 'Modules', + key: 'modules', + isSortable: true, + width: 'max-content', + }, + { + label: 'Provider count', + key: 'provider-count', + isSortable: true, + width: 'max-content', + }, + { + label: 'Providers', + key: 'providers', + isSortable: true, + width: 'max-content', + }, + { + label: 'Terraform version', + key: 'terraform-version', + isSortable: true, + width: 'max-content', + }, + { + label: 'State terraform version', + key: 'state-terraform-version', + isSortable: true, + width: 'max-content', + }, + { + label: 'Created', + key: 'created', + isSortable: true, + width: 'max-content', + }, + { + label: 'Updated', + key: 'updated', + isSortable: true, + width: 'max-content', + }, +]; + +const CUSTOM_FILTER = { + type: 'generic', + dismissTagText: 'equals example/a))!hzfpKcBl0', + data: { + value: 'example/a))!hzfpKcBl0', + }, +} as HdsFilterBarGenericFilter; + const updateModelWithSelectAllState = ( modelData: HdsAdvancedTableSignature['Args']['model'], selectAllState: boolean, @@ -499,6 +574,9 @@ export default class MockAppMainGenericAdvancedTable extends Component { + console.log('onFilter called with filters: ', filters); + this.filters = filters; + }; + + get demoModelFilteredData() { + const filterItem = (item: Record): boolean => { + if (Object.keys(this.filters).length === 0) return true; + let match = true; + Object.keys(this.filters).forEach((key) => { + const filter = this.filters[key] as HdsFilterBarFilter; + if (filter) { + switch (filter.type) { + case 'date': + case 'datetime': + case 'time': + if (!this.isDateFilterMatch(item[key], filter)) { + match = false; + } + break; + case 'numerical': + if (!this.isNumericalFilterMatch(item[key], filter)) { + match = false; + } + break; + case 'single-select': + if (!this.isSingleSelectFilterMatch(item[key], filter)) { + match = false; + } + break; + case 'search': + if (!this.isSearchFilterMatch(item, filter)) { + match = false; + } + break; + case 'generic': + if (!this.isGenericFilterMatch(item[key], filter)) { + match = false; + } + break; + default: + if (!this.isMultiSelectFilterMatch(item[key], filter)) { + match = false; + } + } + } + }); + return match; + }; + + const filteredData = this.demoModel.filter(filterItem); + return filteredData; + } + + get noFilterData() { + return this.demoModelFilteredData.length === 0; + } + + isNumericalFilterMatch( + itemValue: unknown, + filter: HdsFilterBarNumericalFilter, + ): boolean { + const filterData = filter.data; + const selector = filterData.selector; + const number = Number(itemValue); + + const value = filterData.value; + const valueNumber = Number(value); + + if (isNaN(number)) { + return false; + } else if (!isNaN(valueNumber)) { + switch (selector) { + case 'less-than': + return number < valueNumber; + case 'less-than-or-equal-to': + return number <= valueNumber; + case 'equal-to': + return number === valueNumber; + case 'greater-than-or-equal-to': + return number >= valueNumber; + case 'greater-than': + return number > valueNumber; + default: + return false; + } + } else if (selector === 'between' && typeof value === 'object') { + if (!value.start || !value.end) { + return false; + } + return number >= value.start && number <= value.end; + } + + return false; + } + + isDateFilterMatch( + itemValue: unknown, + filter: HdsFilterBarDateFilter, + ): boolean { + const filterData = filter.data; + const selector = filterData.selector; + const value = filterData.value; + + const date = this.dateFromFilter(String(itemValue), filter.type); + + if (selector === 'between' && typeof value === 'object') { + if (!value.start || !value.end) { + return false; + } + const startDate = this.dateFromFilter(value.start, filter.type); + const endDate = this.dateFromFilter(value.end, filter.type); + if (this.dateIsValid(startDate) && this.dateIsValid(endDate)) { + return ( + date.getTime() >= startDate.getTime() && + date.getTime() <= endDate.getTime() + ); + } else { + return false; + } + } else if (typeof value === 'string') { + const valueDate = this.dateFromFilter(value, filter.type); + if (this.dateIsValid(valueDate)) { + switch (selector) { + case 'before': + return date.getTime() < valueDate.getTime(); + case 'exactly': + return date.getTime() === valueDate.getTime(); + case 'after': + return date.getTime() > valueDate.getTime(); + default: + return false; + } + } + } + + return false; + } + + isSingleSelectFilterMatch( + itemValue: unknown, + filter: HdsFilterBarSingleSelectFilter, + ): boolean { + return itemValue === filter.data.value; + } + + isMultiSelectFilterMatch( + itemValue: unknown, + filter: HdsFilterBarMultiSelectFilter, + ): boolean { + const filterValues = filter.data.map((d) => d.value); + return filterValues.includes(itemValue); + } + + isSearchFilterMatch( + item: Record, + filter: HdsFilterBarSearchFilter, + ): boolean { + let match = false; + Object.keys(item).forEach((key) => { + const itemValue = item[key]; + const filterValue = filter.data.value; + if ( + typeof itemValue === 'string' && + typeof filterValue === 'string' && + itemValue.toLowerCase().includes(filterValue.toLowerCase()) + ) { + match = true; + } + }); + return match; + } + + isGenericFilterMatch( + itemValue: unknown, + filter: HdsFilterBarGenericFilter, + ): boolean { + if (Array.isArray(filter.data)) { + const filterValues = filter.data.map((d) => d.value); + return filterValues.includes(itemValue); + } else { + return itemValue === filter.data.value; + } + } + + dateFromFilter = (dateString: string, filterType: string): Date => { + if (filterType === 'time') { + return new Date(`1970-01-01T${dateString}`); + } + return new Date(dateString); + }; + + dateIsValid = (date?: Date | string): date is Date => + date instanceof Date && !isNaN(+date); + + clearFilters = () => { + this.filters = {}; + }; + + onSeparatedFilterBar = (event: Event) => { + const target = event.target as HTMLInputElement; + this.isSeparatedFilterBar = target.checked; + }; + + onLiveFilterToggle = (event: Event) => { + const target = event.target as HTMLInputElement; + this.isLiveFilter = target.checked; + }; + } diff --git a/showcase/app/components/page-components/filter-bar/index.gts b/showcase/app/components/page-components/filter-bar/index.gts new file mode 100644 index 00000000000..1d226a56507 --- /dev/null +++ b/showcase/app/components/page-components/filter-bar/index.gts @@ -0,0 +1,23 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import type { TemplateOnlyComponent } from '@ember/component/template-only'; +import { pageTitle } from 'ember-page-title'; + +import ShwTextH1 from 'showcase/components/shw/text/h1'; + +import SubSectionBasic from 'showcase/components/page-components/filter-bar/sub-sections/basic'; + +const FilterBarIndex: TemplateOnlyComponent = ; + +export default FilterBarIndex; diff --git a/showcase/app/components/page-components/filter-bar/sub-sections/basic.gts b/showcase/app/components/page-components/filter-bar/sub-sections/basic.gts new file mode 100644 index 00000000000..debaffc9d97 --- /dev/null +++ b/showcase/app/components/page-components/filter-bar/sub-sections/basic.gts @@ -0,0 +1,62 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; + +import ShwDivider from 'showcase/components/shw/divider'; +import ShwPlaceholder from 'showcase/components/shw/placeholder'; +import ShwTextH2 from 'showcase/components/shw/text/h2'; + +import { HdsFilterBar } from '@hashicorp/design-system-components/components'; + +export default class SubSectionBasic extends Component { + private _filters = {}; + + +} diff --git a/showcase/app/router.ts b/showcase/app/router.ts index d83532978cf..ee573d1cb93 100644 --- a/showcase/app/router.ts +++ b/showcase/app/router.ts @@ -50,6 +50,7 @@ Router.map(function () { this.route('code-block'); this.route('code-editor'); this.route('dropdown'); + this.route('filter-bar'); this.route('flyout'); this.route('form', function (): void { this.route('frameless', function (): void { diff --git a/showcase/app/routes/page-components/filter-bar.ts b/showcase/app/routes/page-components/filter-bar.ts new file mode 100644 index 00000000000..9128e10b5a7 --- /dev/null +++ b/showcase/app/routes/page-components/filter-bar.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; + +import type { ModelFrom } from 'showcase/utils/model-from-route'; + +export type PageComponentsFilterBarModel = + ModelFrom; + +export default class PageComponentsFilterBarRoute extends Route {} diff --git a/showcase/app/templates/index.gts b/showcase/app/templates/index.gts index b5369de0bc8..0acf969458f 100644 --- a/showcase/app/templates/index.gts +++ b/showcase/app/templates/index.gts @@ -137,6 +137,11 @@ const Index: TemplateOnlyComponent =