azure-devops-ui
Version:
React components for building web UI in Azure DevOps
481 lines (480 loc) • 28.5 kB
JavaScript
import "../../CommonImports";
import "../../Core/core.css";
import "./Filter.css";
import * as React from "react";
import { ObservableValue } from '../../Core/Observable';
import { TimerManagement } from '../../Core/TimerManagement';
import { announce } from '../../Core/Util/Accessibility';
import { ScreenSize } from '../../Core/Util/Screen';
import { format } from '../../Core/Util/String';
import { Button } from '../../Button';
import { ContentLocation } from '../../Callout';
import { Dropdown, DropdownCalloutComponent, DropdownExpandableButton, filterItems } from '../../Dropdown';
import { FocusZoneContext } from '../../FocusZone';
import { Icon } from '../../Icon';
import { renderListCell } from '../../List';
import { getListBoxItemsValue, ListBox, ListBoxItemType, wrapListBoxItems } from '../../ListBox';
import { Observer, SelectionObserver, UncheckedObserver } from '../../Observer';
import { Pill, PillSize } from '../../Pill';
import * as Resources from '../../Resources.Filter';
import { TextField } from '../../TextField';
import { css, KeyCode } from '../../Util';
import { updateFilterToSelection } from '../../Utilities/DropdownFilter';
import { DropdownMultiSelection } from '../../Utilities/DropdownSelection';
import { FILTER_CHANGE_EVENT } from '../../Utilities/Filter';
import { Location } from '../../Utilities/Position';
import { ScreenSizeObserver } from '../../Utilities/ScreenSize';
import { compareSelectionRanges, indexWithinRanges } from '../../Utilities/Selection';
import * as Utils_Accessibility from '../../Core/Util/Accessibility';
const FilterCalloutWidth = 320;
const FilterItemPadding = 48;
export class Filter extends React.Component {
constructor(props) {
super(props);
this.dropdown = React.createRef();
this.dropdownCallout = React.createRef();
this.filterText = new ObservableValue("");
this.timerManagement = new TimerManagement();
this.collapse = () => {
if (this.dropdown.current) {
this.dropdown.current.collapse();
}
};
this.expand = () => {
if (this.dropdown.current) {
this.dropdown.current.expand();
}
};
this.onDoneClick = () => {
const { filterStore } = this.props;
if (filterStore.usesApplyMode()) {
filterStore.applyChanges();
}
this.collapse();
};
this.onApplyClick = () => {
const { filterStore } = this.props;
if (filterStore.usesApplyMode()) {
filterStore.applyChanges();
}
this.clearActiveFilter();
};
this.onExpandClick = () => {
Utils_Accessibility.announce(this.props.title || Resources.FilterTitle);
};
this.renderBeforeContent = () => {
return (React.createElement(Observer, { activeFilter: this.activeFilter, filterText: this.filterText, bestHitItem: this.props.bestHitItem, userFilteredItems: this.props.userFilteredItems }, (props) => props.activeFilter
? props.activeFilter.renderBeforeContent
? props.activeFilter.renderBeforeContent(this.clearActiveFilter)
: null
: props.filterText
? this.renderFilteredView()
: this.renderFilterItems()));
};
this.renderFilteredView = () => {
const items = [];
if (this.props.bestHitItem && this.props.bestHitItem.value) {
items.push({ id: "best-hit-header", text: Resources.BestHit, type: ListBoxItemType.Header, className: "bolt-filtered-header" });
items.push(this.props.bestHitItem.value);
}
if (this.props.userFilteredItems) {
items.push(...getListBoxItemsValue(this.props.userFilteredItems));
}
else {
this.props.filterItems.forEach(filterItem => {
let filteredItems = filterItems(getListBoxItemsValue(filterItem.items), this.filterText.value || "").filteredItems;
// Remove all headers and dividers and selected items from results
const selectedItems = this.getSelectedFilterItems(filterItem);
filteredItems = filteredItems.filter(item => {
return (item.type !== ListBoxItemType.Header &&
item.type !== ListBoxItemType.Divider &&
selectedItems.indexOf(item) === -1 &&
(!this.props.bestHitItem || this.props.bestHitItem.value !== item));
});
if (filteredItems.length) {
items.push({ id: filterItem.id, text: filterItem.name, type: ListBoxItemType.Header, className: "bolt-filtered-header" });
items.push(...filteredItems.map(item => (Object.assign(Object.assign({}, item), { groupId: filterItem.id }))));
}
});
// Add keyword results last
if (this.props.filterItems.some(filterItem => filterItem.id === "keyword-item")) {
items.push(...getKeywordSearchResults(this.filterText.value));
}
}
const numberOfItems = items.filter(item => item.type !== ListBoxItemType.Header).length;
if (numberOfItems > 0) {
this.announceWithDebouncing(format(Resources.AnnounceFilterResultCount, numberOfItems));
}
else {
this.announceWithDebouncing(Resources.NoFilterResults);
}
return (React.createElement(ListBox, { items: items, onSelect: this.onFilteredItemSelect, onActivate: this.onFilteredItemSelect, excludeTabStop: true, focuszoneProps: null }));
};
this.renderSelectedItems = (selection, items) => {
return this.props.showFilterOnText !== false && this.filtered() ? Resources.FilterOn : Resources.Filter;
};
this.renderFilterItems = () => {
const { filterItems, filterStore } = this.props;
return (React.createElement(FocusZoneContext.Consumer, null, zoneContext => filterItems.map((filterItem, index) => {
const selectedItems = this.getSelectedFilterItems(filterItem);
const selectedCount = selectedItems.length;
const itemState = filterStore.getFilterItemState(filterItem.filterItemKey);
const defaultItemState = filterStore.getDefaultState()[filterItem.filterItemKey];
const isDefault = filterStore.filterItemStatesAreEqual(filterItem.filterItemKey, itemState, defaultItemState);
return (React.createElement("div", { className: "flex-row flex-center bolt-filter-item", key: filterItem.id, id: `bolt-filter-item-${filterItem.id}`, "data-focuszone": zoneContext.focuszoneId, tabIndex: -1, onClick: () => this.onFilterItemSelected(filterItem), onKeyDown: event => {
if (!event.defaultPrevented &&
(event.which === KeyCode.enter || event.which === KeyCode.space || event.which === KeyCode.rightArrow)) {
this.onFilterItemSelected(filterItem, event.currentTarget);
event.preventDefault();
}
} },
React.createElement("div", { className: css("flex-row flex-center flex-grow bolt-filter-label", itemState && itemState.value && itemState.value.length !== 0 && "bolt-filter-label-selected"), style: { width: this.props.width - FilterItemPadding } },
React.createElement("span", { className: css(isDefault && "primary-text", !isDefault && "font-weight-semibold") }, filterItem.name),
selectedCount > 1 && (React.createElement(Pill, { className: "bolt-filter-selection-pill", excludeFocusZone: true, size: PillSize.compact }, selectedCount)),
React.createElement("div", { className: "flex-grow flex-row bolt-filter-selected-item-container" }, filterItem.renderSelectedItems
? filterItem.renderSelectedItems(selectedItems)
: renderSelectedFilterItems(selectedItems))),
React.createElement(Icon, { iconName: "ChevronRight" })));
})));
};
this.onFilteredItemSelect = (event, item) => {
if (item.groupId) {
const { filterStore } = this.props;
const group = this.props.filterItems.find(f => f.id === item.groupId);
if (group) {
const key = group.filterItemKey;
const itemState = filterStore.getFilterItemState(key);
const newValue = item.data !== undefined ? item.data : item.id;
if (key === "keyword") {
filterStore.setFilterItemState(key, { value: item.id });
}
else if (itemState && itemState.value && Array.isArray(itemState.value) && this.selection.multiSelect) {
filterStore.setFilterItemState(item.groupId, { value: [...itemState.value, newValue] });
}
else {
filterStore.setFilterItemState(key, { value: [newValue] });
}
}
}
this.filterText.value = "";
if (this.dropdownCallout.current) {
this.dropdownCallout.current.focus();
}
};
this.onFilterChanged = (changedState) => {
const filterState = this.props.filterStore.getState();
const newSelection = new DropdownMultiSelection();
const items = getListBoxItemsValue(this.wrappedItems || this.props.items);
for (const key in filterState) {
const itemState = filterState[key];
if (itemState && itemState.value) {
if (key === "keyword") {
const index = items.findIndex(item => item.id === itemState.value);
if (index > -1) {
newSelection.select(index, 1, true);
}
}
else {
for (let i = 0; i < itemState.value.length; i++) {
const index = items.findIndex(item => item.id === itemState.value[i] || item.data === itemState.value[i]);
if (index > -1) {
newSelection.select(index, 1, true);
}
}
}
}
}
const selectionDifference = compareSelectionRanges(this.selection.value, newSelection.value);
if (selectionDifference.length) {
this.selection.value = newSelection.value;
}
};
this.onSelectionChanged = (values) => {
const items = getListBoxItemsValue(this.wrappedItems || this.props.items);
if (this.props.filterStore && this.activeFilter.value) {
const activeFilterSelection = new DropdownMultiSelection();
let startingIndex = 0;
for (let i = 0; this.props.filterItems[i].id !== this.activeFilter.value.id; i++) {
startingIndex += this.props.filterItems[i].items.length;
}
values.forEach(value => {
for (let i = value.beginIndex; i <= value.endIndex; i++) {
if (i >= startingIndex && i < startingIndex + this.activeFilter.value.items.length) {
activeFilterSelection.select(i, 1, true);
}
}
});
if (this.activeFilter.value.filterItemKey === "keyword" && activeFilterSelection.value.length === 0) {
// Don't clear keyword filter when selection gets empty.
// This happens when user edits current text and we don't want to reset his editing text to ""
return true;
}
updateFilterToSelection(activeFilterSelection.value, items, this.props.filterStore, this.activeFilter.value.filterItemKey);
}
return true;
};
this.onResetClick = () => {
if (this.dropdownCallout.current) {
this.dropdownCallout.current.focus();
}
this.props.filterStore.reset();
};
this.onResetFilterItemClick = (key) => {
if (this.dropdownCallout.current) {
this.dropdownCallout.current.focus();
}
this.props.filterStore.resetFilterItemState(key);
};
this.onFilterItemSelected = (filterItem, triggerElement) => {
if (this.dropdownCallout.current) {
this.dropdownCallout.current.focus();
}
if (!this.props.activeFilter) {
this.activeFilter.value = filterItem;
announce(format(Resources.FilterSelected, filterItem.name));
this.activeFilterReturnElementId = triggerElement === null || triggerElement === void 0 ? void 0 : triggerElement.id;
}
if (this.props.onActiveFilterChanged) {
this.props.onActiveFilterChanged(filterItem);
}
};
this.getOnFilterTextChanged = (props) => {
return (e, newValue) => {
this.filterText.value = newValue;
if (this.activeFilter.value && props.onFilterTextChanged) {
props.onFilterTextChanged(e, newValue);
this.dropdownOnFilterTextChanged = props.onFilterTextChanged;
}
if (this.props.onFilterTextChanged) {
this.props.onFilterTextChanged(e, newValue);
}
};
};
this.getFilterStartingIndex = (filter) => {
if (filter) {
const filterIndex = this.props.filterItems.indexOf(filter);
let itemCount = 0;
for (let i = 0; i < filterIndex; i++) {
itemCount += this.props.filterItems[i].items.length;
}
return itemCount;
}
return -1;
};
this.getSelectedFilterItems = (filter) => {
const selectedItems = [];
const items = getListBoxItemsValue(this.wrappedItems || this.props.items);
const startingIndex = this.getFilterStartingIndex(filter);
for (let i = startingIndex; i < startingIndex + filter.items.length; i++) {
if (indexWithinRanges(i, this.selection.value)) {
selectedItems.push(items[i]);
}
}
return selectedItems;
};
this.clearFilterSelection = () => {
if (this.activeFilter.value) {
this.props.filterStore.setFilterItemState(this.activeFilter.value.filterItemKey, { value: null });
this.activeFilter.value = null;
}
};
this.clearActiveFilter = (focusOnDropdown) => {
// Focus on base filter dropdown, when filter is closed to avoid losing of keyboard focus for tabbing
if (focusOnDropdown) {
this.focus();
}
else if (this.dropdownCallout.current) {
this.dropdownCallout.current.focus();
// a11y: Focus back the filter item we selected.
// We need to give time to re-render so filter items are visible again.
requestAnimationFrame(() => {
if (this.activeFilterReturnElementId) {
const returnElement = document.getElementById(this.activeFilterReturnElementId);
returnElement === null || returnElement === void 0 ? void 0 : returnElement.focus();
this.activeFilterReturnElementId = undefined;
}
});
}
if (!this.props.activeFilter) {
this.activeFilter.value = null;
}
if (this.props.onActiveFilterChanged) {
this.props.onActiveFilterChanged(null);
}
this.filterText.value = "";
if (this.dropdownOnFilterTextChanged) {
this.dropdownOnFilterTextChanged(null, "");
}
};
this.filtered = () => {
const filterState = this.props.filterStore.getAppliedState();
for (const key in filterState) {
if (filterState[key].value && (!Array.isArray(filterState[key].value) || filterState[key].value.length > 0)) {
return true;
}
}
return false;
};
this.announceWithDebouncing = (message) => {
Utils_Accessibility.announce(message, false, 300);
};
this.state = {};
this.selection = props.selection || new DropdownMultiSelection();
this.wrappedItems = wrapListBoxItems(props.items);
this.activeFilter = props.activeFilter || new ObservableValue(null);
}
focus() {
if (this.dropdown.current) {
this.dropdown.current.focus();
}
}
componentDidMount() {
this.props.filterStore && this.props.filterStore.subscribe(this.onFilterChanged, FILTER_CHANGE_EVENT);
this.onFilterChanged(this.props.filterStore.getState());
this.announceWithDebouncing = this.timerManagement.debounce(this.announceWithDebouncing, 300);
}
componentWillUnmount() {
this.props.filterStore && this.props.filterStore.unsubscribe(this.onFilterChanged, FILTER_CHANGE_EVENT);
}
render() {
const { filterStore, showActiveFilterResetButton, showFilterOnText } = this.props;
const filterOn = showFilterOnText !== false && this.filtered();
return (React.createElement(UncheckedObserver, { activeFilter: this.activeFilter, filter: filterStore },
React.createElement(SelectionObserver, { selection: this.selection, onSelectionChanged: this.onSelectionChanged }, () => {
const activeFilter = this.activeFilter.value;
const actions = [];
let activeFilterSelectionCount = 0;
const resetAction = {
className: "bolt-filter-reset-button",
text: !activeFilter ? Resources.ResetAll : Resources.Reset,
subtle: false,
onClick: !activeFilter ? this.onResetClick : () => this.onResetFilterItemClick(activeFilter.filterItemKey),
id: "filter-reset-button"
};
if (activeFilter) {
const filterItemState = filterStore.getFilterItemState(activeFilter.filterItemKey);
activeFilterSelectionCount = this.getSelectedFilterItems(activeFilter).length;
if (showActiveFilterResetButton) {
if (filterStore.hasChangesToReset()) {
actions.push(resetAction);
}
}
else {
actions.push({
text: Resources.Clear,
disabled: !(filterItemState && filterItemState.value),
subtle: false,
onClick: this.clearFilterSelection,
id: "filter-clear-button"
});
}
if (filterStore.usesApplyMode()) {
actions.push({
className: css(!showActiveFilterResetButton && "bolt-filter-apply-button"),
disabled: !filterStore.hasChangesToApply(),
text: Resources.Apply,
primary: true,
subtle: false,
onClick: this.onApplyClick,
id: "filter-apply-button"
});
}
}
else {
if (filterStore.hasChangesToReset()) {
actions.push(resetAction);
}
if (filterStore.usesApplyMode()) {
actions.push({
disabled: !filterStore.hasChangesToApply(),
text: Resources.Apply,
primary: true,
subtle: false,
onClick: this.onDoneClick,
id: "filter-done-button"
});
}
}
return (React.createElement(ScreenSizeObserver, null, (screenSizeProps) => {
const fullscreen = screenSizeProps.screenSize === ScreenSize.xsmall;
return (React.createElement(Dropdown, { actions: actions, calloutContentClassName: css("bolt-filter-callout", activeFilter && "bolt-active-filter", fullscreen && "absolute-fill"), className: css(this.props.className, "bolt-filter", filterOn && "bolt-filter-on"), dismissOnSelect: false, enforceSingleSelect: activeFilter === null || activeFilter === void 0 ? void 0 : activeFilter.enforceSingleSelect, filterByText: this.props.filterByText, onExpand: this.onExpandClick, onCollapse: () => this.clearActiveFilter(true), placeholder: filterOn ? Resources.FilterOn : Resources.Filter, ref: this.dropdown, items: this.props.items, userFilteredItems: activeFilter ? (this.props.userFilteredItems ? this.props.userFilteredItems : activeFilter.items) : [], renderExpandable: props => (React.createElement(DropdownExpandableButton, Object.assign({}, props, { iconProps: { iconName: "Filter" }, hideDropdownIcon: true, renderSelectedItems: this.renderSelectedItems }))), renderCallout: props => (React.createElement(DropdownCalloutComponent, Object.assign({}, props, { ariaLabel: this.props.title || Resources.FilterTitle, anchorElement: fullscreen ? undefined : props.anchorElement, anchorOrigin: { horizontal: Location.start, vertical: Location.end }, blurDismiss: !fullscreen, containerClassName: "bolt-filter-listbox-container", contentLocation: fullscreen ? ContentLocation.Center : undefined, dropdownOrigin: { horizontal: Location.start, vertical: Location.start }, enforceSingleSelect: activeFilter === null || activeFilter === void 0 ? void 0 : activeFilter.enforceSingleSelect, filterText: this.filterText, ignoreMouseDown: true, key: activeFilter === null || activeFilter === void 0 ? void 0 : activeFilter.id, onFilterTextChanged: this.getOnFilterTextChanged(props), onFilterKeyDown: event => {
if (event &&
!event.defaultPrevented &&
event.which === KeyCode.enter &&
this.props.filterItems.some(filterItem => filterItem.id === "keyword-item") &&
this.filterText.value.length > 0) {
filterStore.setFilterItemState("keyword", { value: this.filterText.value });
this.filterText.value = "";
}
}, showCloseButton: true, title: activeFilter ? (React.createElement("div", { className: "flex-row flex-center bolt-filter-title-container" },
!this.props.hideBackButton && (React.createElement(Button, { ariaLabel: Resources.Back, subtle: true, className: "bolt-dropdown-header-button bolt-filter-back-button", iconProps: { iconName: "Back" }, onClick: () => this.clearActiveFilter(), tabIndex: -1 })),
activeFilter.title || activeFilter.name,
activeFilterSelectionCount > 1 && (React.createElement(Pill, { className: "bolt-filter-selection-pill", size: PillSize.compact }, activeFilterSelectionCount)))) : (this.props.title || Resources.FilterTitle), renderBeforeContent: this.renderBeforeContent, ref: this.dropdownCallout }))), selection: this.selection, showFilterBox: activeFilter ? !(activeFilter.showFilterBox === false) : true, width: fullscreen ? -1 : this.props.width }));
}));
})));
}
}
Filter.defaultProps = {
width: FilterCalloutWidth
};
export function getKeywordFilterItem(filter, throttle, items = []) {
const filterItemKey = "keyword";
const timerManagement = new TimerManagement();
const updateFilterState = (newValue) => {
filter.setFilterItemState(filterItemKey, { value: newValue });
if (!filter.usesApplyMode()) {
filter.applyChanges();
}
};
const throttledUpdateFilterState = throttle
? timerManagement.debounce(updateFilterState, throttle, { leading: false, trailing: true })
: updateFilterState;
return {
items,
renderBeforeContent: (onEditingComplete) => {
const value = new ObservableValue("");
const filterState = filter.getFilterItemState(filterItemKey);
value.value = filterState && filterState.value ? filterState.value : "";
return (React.createElement(Observer, { filterExpression: {
observableValue: filter,
filter: () => {
const filterState = filter.getFilterItemState(filterItemKey);
value.value = filterState && filterState.value ? filterState.value : "";
}
} }, () => {
return (React.createElement(TextField, { ariaLabel: Resources.Keyword, placeholder: Resources.SearchKeyword, autoFocus: true, className: "bolt-filter-keyword-item", value: value, onChange: (e, newValue) => {
value.value = newValue;
throttledUpdateFilterState(newValue);
}, onKeyDown: event => {
if (event.which === KeyCode.enter) {
onEditingComplete();
event.preventDefault();
}
} }));
}));
},
renderSelectedItems: () => {
const filterState = filter.getFilterItemState(filterItemKey);
return filterState && filterState.value ? React.createElement("span", null, `"${filterState.value}"`) : null;
},
enforceSingleSelect: true,
id: "keyword-item",
filterItemKey: filterItemKey,
name: Resources.Keyword,
showFilterBox: false
};
}
export function getKeywordSearchResults(filterText) {
const items = [];
items.push({ id: "keyword-header", text: Resources.Keyword, type: ListBoxItemType.Header, className: "bolt-filtered-header" });
items.push({ id: filterText, text: format(Resources.KeywordSearchResult, filterText), groupId: "keyword-item" });
return items;
}
export function renderSelectedFilterItems(selectedItems) {
const hasIcons = selectedItems.some(selectedItem => !!selectedItem.iconProps);
return (React.createElement(React.Fragment, null, selectedItems.map((selectedItem, index) => {
return (React.createElement("div", { className: css("bolt-filter-selected-item flex-row", !hasIcons && "bolt-filter-selected-text-item"), key: selectedItem.id },
renderListCell(selectedItem),
!hasIcons && index !== selectedItems.length - 1 && React.createElement("span", null, ", ")));
})));
}