UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

569 lines (568 loc) 22.5 kB
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]); }