azure-devops-ui
Version:
React components for building web UI in Azure DevOps
569 lines (568 loc) • 22.5 kB
JavaScript
import * as React from "react";
/**
* An Observable implementation that will track a set of subscribers and supports
* notifications when the underlying system changes.
*/
export class Observable {
constructor() {
this.observers = {};
this.subscriberCount = 0;
}
/**
* notify is used to send the event to all subscribers that have signed up for this events
* action. This means they have subscribed directly to this action, or to all actions.
* If the caller requested the event be persisted the event will be fired in order to new
* subscribers as well when they subscribe.
*
* @param value - The object that represents the event data.
*
* @param action - The action that happened on this observable to produce the event.
*
* @param persistEvent - Optional value that determines if all future subscribers will
* recieve the event as well.
*/
notify(value, action, persistEvent) {
const executeObserverAction = (observer, value, action) => {
try {
observer(value, action);
}
catch (ex) {
console.warn(ex);
if (ex && typeof ErrorEvent === "function") {
window.dispatchEvent(new ErrorEvent("error", {
error: ex,
filename: "Observable.ts",
message: ex.message
}));
}
}
};
// NOTE: We need to make a copy of the observers since they may change during notification.
if (this.observers[action]) {
const observers = this.observers[action].slice();
for (let observerIndex = 0; observerIndex < observers.length; observerIndex++) {
executeObserverAction(observers[observerIndex], value, action);
}
}
if (this.observers[""]) {
const observers = this.observers[""].slice();
for (let observerIndex = 0; observerIndex < observers.length; observerIndex++) {
executeObserverAction(observers[observerIndex], value, action);
}
}
// If the caller wants this event sent to all subscribers, even future ones, track it.
if (persistEvent) {
if (!this.events) {
this.events = [];
}
this.events.push({ action: action, value: value });
}
}
subscribe(observer, action) {
action = action || "";
if (!this.observers[action]) {
this.observers[action] = [];
}
this.observers[action].push(observer);
this.subscriberCount++;
// Fire the callback for any events that were persisted when they were sent.
if (this.events) {
for (const event of this.events) {
if (!action || event.action === action) {
observer(event.value, event.action);
}
}
}
return observer;
}
unsubscribe(observer, action) {
action = action || "";
if (this.observers[action]) {
const observerIndex = this.observers[action].indexOf(observer);
if (observerIndex >= 0) {
this.observers[action].splice(observerIndex, 1);
this.subscriberCount--;
}
}
}
}
export var ObservableLike;
(function (ObservableLike) {
/**
* Check whether the specified object is an observable or not.
*
* @param observableLike Object to perform observable check.
*/
function isObservable(observableLike) {
return observableLike && typeof observableLike.subscribe === "function";
}
ObservableLike.isObservable = isObservable;
function getValue(observableLike) {
if (isObservable(observableLike)) {
return observableLike.value;
}
return observableLike;
}
ObservableLike.getValue = getValue;
/**
* Subscribes to the specified object if it is an observable.
*
* @param observableLike Object to subscribe its value change if applicable.
* @param observer Delegate to be executed when the underlying data changes.
* @param action Optional argument that allows the consumer to supply a action
* with the delegate. If the action is supplied only those actions are delievered,
* while all actions are delivered is no action is supplied.
* @returns observer
*/
function subscribe(observableLike, observer, action) {
if (isObservable(observableLike)) {
return observableLike.subscribe(observer, action);
}
return () => { };
}
ObservableLike.subscribe = subscribe;
/**
* Unsubscribes from the specified object if it is an observable.
*
* @param observableLike Object to subscribe its value change if applicable.
* @param observer Delegate to be executed when the underlying data changes.
* @param action Optional argument that allows the consumer to supply a action
* with the delegate. If the action is supplied only those actions are delievered,
* while all actions are delivered is no action is supplied.
*/
function unsubscribe(observableLike, observer, action) {
if (isObservable(observableLike)) {
observableLike.unsubscribe(observer, action);
}
}
ObservableLike.unsubscribe = unsubscribe;
})(ObservableLike || (ObservableLike = {}));
export class ObservableValue extends Observable {
constructor(value) {
super();
this.v = value;
}
get value() {
return this.v;
}
set value(value) {
this.v = value;
this.notify(this.v, "set");
}
}
/**
* An ObservableObject can be used to key a named collection of properties
* and offer an observable endpoint.
*/
export class ObservableObject extends Observable {
constructor() {
super(...arguments);
this.objects = {};
}
add(objectName, objectDefinition) {
if (!this.objects.hasOwnProperty(objectName)) {
this.objects[objectName] = objectDefinition;
this.notify({ key: objectName, value: objectDefinition }, "add");
}
}
get(objectName) {
return this.objects[objectName];
}
set(objectName, objectDefinition) {
if (this.objects.hasOwnProperty(objectName)) {
this.objects[objectName] = objectDefinition;
this.notify({ key: objectName, value: objectDefinition }, "replace");
}
else {
this.add(objectName, objectDefinition);
}
}
keys() {
return Object.keys(this.objects);
}
}
/**
* EventTypes:
* change - { changedItems, index }
* push - {addedItems, index }
* pop - { index, removedItems}
* removeAll - {index, removedItems }
* splice - { addedItems, index, removedItems }
*/
export class ObservableArray extends Observable {
constructor(items = []) {
super();
this.internalItems = items || [];
}
change(start, ...items) {
this.internalItems.splice(start, items.length, ...items);
this.notify({ index: start, changedItems: items }, "change");
return items.length;
}
changeOrderedBatch(batch) {
const changedItems = [];
for (const el of batch) {
if (el.items !== undefined && el.items.length) {
this.internalItems.splice(el.start, el.items.length, ...el.items);
changedItems.push(...el.items);
}
}
this.notify({ index: this.getMinItemIndexByBatch(batch), changedItems: changedItems }, "change");
return batch.reduce((acc, val) => (acc += val.items ? val.items.length : 0), 0);
}
get length() {
return this.internalItems.length;
}
push(...items) {
if (items.length) {
const index = this.internalItems.length;
this.internalItems.push(...items);
this.notify({ addedItems: items, index }, "push");
}
return items.length;
}
pop() {
const item = this.internalItems.pop();
if (item !== undefined) {
this.notify({ index: this.internalItems.length, removedItems: [item] }, "pop");
}
return item;
}
removeAll(filter) {
const removedItems = [];
const remainingItems = [];
for (const item of this.internalItems) {
if (!filter || filter(item)) {
removedItems.push(item);
}
else {
remainingItems.push(item);
}
}
if (removedItems.length > 0) {
this.internalItems.splice(0, this.internalItems.length);
for (const item of remainingItems) {
this.internalItems.push(item);
}
this.notify({ index: 0, removedItems: removedItems }, "removeAll");
}
return removedItems;
}
splice(start, deleteCount, ...itemsToAdd) {
const removedItems = this.internalItems.splice(start, deleteCount, ...itemsToAdd);
this.notify({ addedItems: itemsToAdd, index: start, removedItems: removedItems }, "splice");
return removedItems;
}
spliceOrderedBatch(batch) {
let added = [];
let removed = [];
for (const el of batch) {
let removedItems;
if (el.itemsToAdd !== undefined && el.itemsToAdd.length) {
removedItems = this.internalItems.splice(el.start, el.deleteCount, ...el.itemsToAdd);
added.push(...el.itemsToAdd);
}
else {
removedItems = this.internalItems.splice(el.start, el.deleteCount);
}
removed.push(...removedItems);
}
this.notify({ addedItems: added, index: this.getMinItemIndexByBatch(batch), removedItems: removed }, "splice");
return removed;
}
get value() {
return this.internalItems;
}
set value(items) {
// Preserve the original array, but avoid the "..." arguments issue with splice/push
let removedItems;
if (items === this.internalItems) {
// Special case for someone passing us the same internal array that we are already using
// We don't need to modify the internalItems. The "removedItems" in the event is
// not going to be accurate in the case that someone modified this internal array
// outside of the observable -- we won't know the prior state in that case.
removedItems = this.internalItems;
}
else {
// Clear out the existing items
removedItems = this.internalItems.slice();
this.internalItems.length = 0;
// Add all new items
if (items.length) {
for (const item of items) {
this.internalItems.push(item);
}
}
}
this.notify({ addedItems: items, index: 0, removedItems: removedItems }, "splice");
}
getMinItemIndexByBatch(batch) {
const itemChangesStartedAt = batch.reduce((minObject, currentObject) => {
if (currentObject.start < minObject.start) {
return currentObject;
}
return minObject;
});
return itemChangesStartedAt.start;
}
}
/**
* An Observable Collection takes an array of arrays or observable arrays
* and flattens out the items into a single readonly observable array
* (with all the underlying array values aggregated together).
*
* This handles subscribing to any underlying observable arrays and
* updating the aggregate array as appropriate (and notifying subscribers)
*/
export class ObservableCollection extends Observable {
constructor() {
super(...arguments);
this.collections = [];
this.items = [];
}
get length() {
if (!this.subscriberCount) {
this.recalculateItems();
}
return this.items.length;
}
get value() {
if (!this.subscriberCount) {
this.recalculateItems();
}
return this.items;
}
/**
* Adds an additional collection of items to the end of the array
*
* @param collection Array of items or an observable array of items
* @params transformItems Delegate to process each item that is pulled from the given collection
*/
push(collection, transformItems) {
let collectionEntry;
let pushedItems;
if (ObservableLike.isObservable(collection)) {
const observable = collection;
const subscriber = this.getSubscriber(this.collections.length, transformItems);
collectionEntry = { observable, subscriber, transformItems, items: [] };
pushedItems = observable.value;
if (this.subscriberCount) {
ObservableLike.subscribe(collectionEntry.observable, subscriber);
}
}
else if (collection.length) {
pushedItems = collection;
collectionEntry = { items: this.transformItems(pushedItems, transformItems) };
}
if (collectionEntry) {
this.collections.push(collectionEntry);
if (this.subscriberCount && pushedItems.length) {
const newItems = this.transformItems(pushedItems, transformItems);
for (const newItem of newItems) {
this.items.push(newItem);
}
this.notify({ addedItems: newItems, index: this.items.length - newItems.length }, "push");
}
}
}
subscribe(observer, action) {
const subscription = super.subscribe(observer, action);
if (this.subscriberCount === 1) {
this.recalculateItems();
for (const collection of this.collections) {
if (collection.subscriber) {
collection.observable.subscribe(collection.subscriber);
}
}
}
return subscription;
}
unsubscribe(observer, action) {
super.unsubscribe(observer, action);
if (this.subscriberCount === 0) {
for (const collection of this.collections) {
if (collection.subscriber) {
collection.observable.unsubscribe(collection.subscriber);
}
}
}
}
/**
* Recalculate items. This is necessary while we work without subscribers, as we're not listening to changes in observable inner collections.
* Once the first subscriber joins, items collection will be in sync real-time.
*/
recalculateItems() {
this.items.length = 0;
for (const collection of this.collections) {
if (collection.observable) {
collection.items = this.transformItems(collection.observable.value, collection.transformItems);
}
for (const item of collection.items) {
this.items.push(item);
}
}
}
transformItems(inputItems, transformInput) {
let transformedItems;
if (!inputItems) {
transformedItems = [];
}
else if (transformInput) {
transformedItems = [];
for (const inputItem of inputItems) {
const transformedItem = transformInput(inputItem);
if (transformedItem !== undefined) {
transformedItems.push(transformedItem);
}
}
}
else {
transformedItems = inputItems;
}
return transformedItems;
}
getSubscriber(collectionIndex, transformInput) {
return (args) => {
// Find the index in our aggregate array
let index = args.index;
for (let i = 0; i < collectionIndex; i++) {
index += this.collections[i].items.length;
}
if (args.changedItems) {
// Handle change event
const changedItems = this.transformItems(args.changedItems, transformInput);
this.items.splice(index, args.changedItems.length, ...changedItems);
this.notify({ changedItems, index }, "change");
}
else {
// Handle splice, push, pop events
const removedItems = this.transformItems(args.removedItems, transformInput);
const addedItems = this.transformItems(args.addedItems, transformInput);
// We would normally just call splice here with 3 arguments, but splice takes a "..." argument for added items
// which passes array elements on the stack and is therefore limited (to 32K/64K on some browsers)
// Remove the removedItems first
this.items.splice(index, removedItems.length);
// Slice-off any remaining items past where we want to insert
const endItems = this.items.splice(index);
// Push the addedItems followed by the endItems that we just removed
for (const item of addedItems) {
this.items.push(item);
}
for (const item of endItems) {
this.items.push(item);
}
this.notify({ removedItems, addedItems, index }, "splice");
}
};
}
}
export class ReadyableObservableArray extends ObservableArray {
constructor(items = [], ready = false) {
super(items);
this.ready = new ObservableValue(ready);
}
}
/// <summary>
/// An observable variable which lets consumers know when its initial items have been populated and it is ready to use.
/// </summary>
export class ReadyableObservableValue extends ObservableValue {
constructor(item, ready = false) {
super(item);
this.ready = new ObservableValue(ready);
}
}
/**
* React Hooks extension that allows the consumer to track Observables with a useState like
* hooks API.
*
* @param initialState Initial value for the state, or a function that will resolve the value
* the when the value is initialized.
*/
export function useObservable(initialState) {
const [underlyingState] = React.useState(initialState);
const [observable] = React.useState(() => new ObservableValue(underlyingState));
const updateState = (updatedState) => {
if (typeof updatedState === "function") {
observable.value = updatedState(observable.value);
}
else {
observable.value = updatedState;
}
};
return [observable, updateState];
}
/**
* React Hooks extension that allows the consmer to track ObservableArrays with a useState like
* hooks API.
*
* @param initialState Initial value for the state, or a function that will resolve the value
* the when the value is initialized.
*/
export function useObservableArray(initialState) {
const [underlyingState] = React.useState(initialState);
const reactState = React.useState(new ObservableArray(underlyingState));
const updateState = (updatedState) => {
if (typeof updatedState === "function") {
reactState[0].value = updatedState(reactState[0].value);
}
else {
reactState[0].value = updatedState;
}
};
return [reactState[0], updateState];
}
/**
* React Hooks extension that provides a constant reference to an ObservableValue which will update
* based on another observable.
*
* @remarks
* The subscription will be safely unsubscribed any time:
* - The source observable points to a new object
* - The callback dependencies array changes
* - The component is unmounted
*
* @param sourceObservable
* @param getDerivedValue
* @param callbackDependencies
*/
export function useDerivedObservable(sourceObservable, getDerivedValue, callbackDependencies) {
const initialValue = getDerivedValue(sourceObservable.value);
const [observable, setValue] = useObservable(initialValue);
const getDerivedValueCallback = React.useCallback(getDerivedValue, callbackDependencies);
// Update the observable's value when the source observable changes its value
useSubscription(sourceObservable, (newValue) => {
const derivedValue = getDerivedValueCallback(newValue);
setValue(derivedValue);
}, callbackDependencies);
return observable;
}
export function useSubscription(sourceObservable, callbackFn, callbackDependencies = []) {
const isFirstRenderFinished = React.useRef(false);
const callback = React.useCallback(callbackFn, callbackDependencies);
// Call the callback when the source observable points to a new object, but not on the first render with the first observable
React.useEffect(() => {
if (!isFirstRenderFinished.current) {
isFirstRenderFinished.current = true;
return;
}
callback(sourceObservable.value);
}, [sourceObservable]);
// Call the callback when the source observable changes its value
React.useEffect(() => {
const doCallback = () => callback(sourceObservable.value);
sourceObservable.subscribe(doCallback);
return () => sourceObservable.unsubscribe(doCallback);
}, [sourceObservable, callback]);
}
export function useDebouncedSubscription(sourceObservable, debounceMs, callbackFn, callbackDependencies = []) {
const timeoutRef = React.useRef(null);
useSubscription(sourceObservable, (value) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackFn(value);
timeoutRef.current = null;
}, debounceMs);
}, [debounceMs, ...callbackDependencies]);
}