azure-devops-ui
Version:
React components for building web UI in Azure DevOps
498 lines (497 loc) • 30.1 kB
JavaScript
import { __assign, __extends, __spreadArray } from "tslib";
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';
var FilterCalloutWidth = 320;
var FilterItemPadding = 48;
var Filter = /** @class */ (function (_super) {
__extends(Filter, _super);
function Filter(props) {
var _this = _super.call(this, props) || this;
_this.dropdown = React.createRef();
_this.dropdownCallout = React.createRef();
_this.filterText = new ObservableValue("");
_this.timerManagement = new TimerManagement();
_this.collapse = function () {
if (_this.dropdown.current) {
_this.dropdown.current.collapse();
}
};
_this.expand = function () {
if (_this.dropdown.current) {
_this.dropdown.current.expand();
}
};
_this.onDoneClick = function () {
var filterStore = _this.props.filterStore;
if (filterStore.usesApplyMode()) {
filterStore.applyChanges();
}
_this.collapse();
};
_this.onApplyClick = function () {
var filterStore = _this.props.filterStore;
if (filterStore.usesApplyMode()) {
filterStore.applyChanges();
}
_this.clearActiveFilter();
};
_this.onExpandClick = function () {
Utils_Accessibility.announce(_this.props.title || Resources.FilterTitle);
};
_this.renderBeforeContent = function () {
return (React.createElement(Observer, { activeFilter: _this.activeFilter, filterText: _this.filterText, bestHitItem: _this.props.bestHitItem, userFilteredItems: _this.props.userFilteredItems }, function (props) {
return props.activeFilter
? props.activeFilter.renderBeforeContent
? props.activeFilter.renderBeforeContent(_this.clearActiveFilter)
: null
: props.filterText
? _this.renderFilteredView()
: _this.renderFilterItems();
}));
};
_this.renderFilteredView = function () {
var 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.apply(items, getListBoxItemsValue(_this.props.userFilteredItems));
}
else {
_this.props.filterItems.forEach(function (filterItem) {
var filteredItems = filterItems(getListBoxItemsValue(filterItem.items), _this.filterText.value || "").filteredItems;
// Remove all headers and dividers and selected items from results
var selectedItems = _this.getSelectedFilterItems(filterItem);
filteredItems = filteredItems.filter(function (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.apply(items, filteredItems.map(function (item) { return (__assign(__assign({}, item), { groupId: filterItem.id })); }));
}
});
// Add keyword results last
if (_this.props.filterItems.some(function (filterItem) { return filterItem.id === "keyword-item"; })) {
items.push.apply(items, getKeywordSearchResults(_this.filterText.value));
}
}
var numberOfItems = items.filter(function (item) { return 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 = function (selection, items) {
return _this.props.showFilterOnText !== false && _this.filtered() ? Resources.FilterOn : Resources.Filter;
};
_this.renderFilterItems = function () {
var _a = _this.props, filterItems = _a.filterItems, filterStore = _a.filterStore;
return (React.createElement(FocusZoneContext.Consumer, null, function (zoneContext) {
return filterItems.map(function (filterItem, index) {
var selectedItems = _this.getSelectedFilterItems(filterItem);
var selectedCount = selectedItems.length;
var itemState = filterStore.getFilterItemState(filterItem.filterItemKey);
var defaultItemState = filterStore.getDefaultState()[filterItem.filterItemKey];
var 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-".concat(filterItem.id), "data-focuszone": zoneContext.focuszoneId, tabIndex: -1, onClick: function () { return _this.onFilterItemSelected(filterItem); }, onKeyDown: function (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 = function (event, item) {
if (item.groupId) {
var filterStore = _this.props.filterStore;
var group = _this.props.filterItems.find(function (f) { return f.id === item.groupId; });
if (group) {
var key = group.filterItemKey;
var itemState = filterStore.getFilterItemState(key);
var 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: __spreadArray(__spreadArray([], itemState.value, true), [newValue], false) });
}
else {
filterStore.setFilterItemState(key, { value: [newValue] });
}
}
}
_this.filterText.value = "";
if (_this.dropdownCallout.current) {
_this.dropdownCallout.current.focus();
}
};
_this.onFilterChanged = function (changedState) {
var filterState = _this.props.filterStore.getState();
var newSelection = new DropdownMultiSelection();
var items = getListBoxItemsValue(_this.wrappedItems || _this.props.items);
var _loop_1 = function (key) {
var itemState = filterState[key];
if (itemState && itemState.value) {
if (key === "keyword") {
var index = items.findIndex(function (item) { return item.id === itemState.value; });
if (index > -1) {
newSelection.select(index, 1, true);
}
}
else {
var _loop_2 = function (i) {
var index = items.findIndex(function (item) { return item.id === itemState.value[i] || item.data === itemState.value[i]; });
if (index > -1) {
newSelection.select(index, 1, true);
}
};
for (var i = 0; i < itemState.value.length; i++) {
_loop_2(i);
}
}
}
};
for (var key in filterState) {
_loop_1(key);
}
var selectionDifference = compareSelectionRanges(_this.selection.value, newSelection.value);
if (selectionDifference.length) {
_this.selection.value = newSelection.value;
}
};
_this.onSelectionChanged = function (values) {
var items = getListBoxItemsValue(_this.wrappedItems || _this.props.items);
if (_this.props.filterStore && _this.activeFilter.value) {
var activeFilterSelection_1 = new DropdownMultiSelection();
var startingIndex_1 = 0;
for (var i = 0; _this.props.filterItems[i].id !== _this.activeFilter.value.id; i++) {
startingIndex_1 += _this.props.filterItems[i].items.length;
}
values.forEach(function (value) {
for (var i = value.beginIndex; i <= value.endIndex; i++) {
if (i >= startingIndex_1 && i < startingIndex_1 + _this.activeFilter.value.items.length) {
activeFilterSelection_1.select(i, 1, true);
}
}
});
if (_this.activeFilter.value.filterItemKey === "keyword" && activeFilterSelection_1.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_1.value, items, _this.props.filterStore, _this.activeFilter.value.filterItemKey);
}
return true;
};
_this.onResetClick = function () {
if (_this.dropdownCallout.current) {
_this.dropdownCallout.current.focus();
}
_this.props.filterStore.reset();
};
_this.onResetFilterItemClick = function (key) {
if (_this.dropdownCallout.current) {
_this.dropdownCallout.current.focus();
}
_this.props.filterStore.resetFilterItemState(key);
};
_this.onFilterItemSelected = function (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 = function (props) {
return function (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 = function (filter) {
if (filter) {
var filterIndex = _this.props.filterItems.indexOf(filter);
var itemCount = 0;
for (var i = 0; i < filterIndex; i++) {
itemCount += _this.props.filterItems[i].items.length;
}
return itemCount;
}
return -1;
};
_this.getSelectedFilterItems = function (filter) {
var selectedItems = [];
var items = getListBoxItemsValue(_this.wrappedItems || _this.props.items);
var startingIndex = _this.getFilterStartingIndex(filter);
for (var i = startingIndex; i < startingIndex + filter.items.length; i++) {
if (indexWithinRanges(i, _this.selection.value)) {
selectedItems.push(items[i]);
}
}
return selectedItems;
};
_this.clearFilterSelection = function () {
if (_this.activeFilter.value) {
_this.props.filterStore.setFilterItemState(_this.activeFilter.value.filterItemKey, { value: null });
_this.activeFilter.value = null;
}
};
_this.clearActiveFilter = function (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(function () {
if (_this.activeFilterReturnElementId) {
var 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 = function () {
var filterState = _this.props.filterStore.getAppliedState();
for (var key in filterState) {
if (filterState[key].value && (!Array.isArray(filterState[key].value) || filterState[key].value.length > 0)) {
return true;
}
}
return false;
};
_this.announceWithDebouncing = function (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);
return _this;
}
Filter.prototype.focus = function () {
if (this.dropdown.current) {
this.dropdown.current.focus();
}
};
Filter.prototype.componentDidMount = function () {
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);
};
Filter.prototype.componentWillUnmount = function () {
this.props.filterStore && this.props.filterStore.unsubscribe(this.onFilterChanged, FILTER_CHANGE_EVENT);
};
Filter.prototype.render = function () {
var _this = this;
var _a = this.props, filterStore = _a.filterStore, showActiveFilterResetButton = _a.showActiveFilterResetButton, showFilterOnText = _a.showFilterOnText;
var filterOn = showFilterOnText !== false && this.filtered();
return (React.createElement(UncheckedObserver, { activeFilter: this.activeFilter, filter: filterStore },
React.createElement(SelectionObserver, { selection: this.selection, onSelectionChanged: this.onSelectionChanged }, function () {
var activeFilter = _this.activeFilter.value;
var actions = [];
var activeFilterSelectionCount = 0;
var resetAction = {
className: "bolt-filter-reset-button",
text: !activeFilter ? Resources.ResetAll : Resources.Reset,
subtle: false,
onClick: !activeFilter ? _this.onResetClick : function () { return _this.onResetFilterItemClick(activeFilter.filterItemKey); },
id: "filter-reset-button"
};
if (activeFilter) {
var 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, function (screenSizeProps) {
var 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: function () { return _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: function (props) { return (React.createElement(DropdownExpandableButton, __assign({}, props, { iconProps: { iconName: "Filter" }, hideDropdownIcon: true, renderSelectedItems: _this.renderSelectedItems }))); }, renderCallout: function (props) { return (React.createElement(DropdownCalloutComponent, __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: function (event) {
if (event &&
!event.defaultPrevented &&
event.which === KeyCode.enter &&
_this.props.filterItems.some(function (filterItem) { return 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: function () { return _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
};
return Filter;
}(React.Component));
export { Filter };
export function getKeywordFilterItem(filter, throttle, items) {
if (items === void 0) { items = []; }
var filterItemKey = "keyword";
var timerManagement = new TimerManagement();
var updateFilterState = function (newValue) {
filter.setFilterItemState(filterItemKey, { value: newValue });
if (!filter.usesApplyMode()) {
filter.applyChanges();
}
};
var throttledUpdateFilterState = throttle
? timerManagement.debounce(updateFilterState, throttle, { leading: false, trailing: true })
: updateFilterState;
return {
items: items,
renderBeforeContent: function (onEditingComplete) {
var value = new ObservableValue("");
var filterState = filter.getFilterItemState(filterItemKey);
value.value = filterState && filterState.value ? filterState.value : "";
return (React.createElement(Observer, { filterExpression: {
observableValue: filter,
filter: function () {
var filterState = filter.getFilterItemState(filterItemKey);
value.value = filterState && filterState.value ? filterState.value : "";
}
} }, function () {
return (React.createElement(TextField, { ariaLabel: Resources.Keyword, placeholder: Resources.SearchKeyword, autoFocus: true, className: "bolt-filter-keyword-item", value: value, onChange: function (e, newValue) {
value.value = newValue;
throttledUpdateFilterState(newValue);
}, onKeyDown: function (event) {
if (event.which === KeyCode.enter) {
onEditingComplete();
event.preventDefault();
}
} }));
}));
},
renderSelectedItems: function () {
var filterState = filter.getFilterItemState(filterItemKey);
return filterState && filterState.value ? React.createElement("span", null, "\"".concat(filterState.value, "\"")) : null;
},
enforceSingleSelect: true,
id: "keyword-item",
filterItemKey: filterItemKey,
name: Resources.Keyword,
showFilterBox: false
};
}
export function getKeywordSearchResults(filterText) {
var 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) {
var hasIcons = selectedItems.some(function (selectedItem) { return !!selectedItem.iconProps; });
return (React.createElement(React.Fragment, null, selectedItems.map(function (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, ", ")));
})));
}