@dancrumb/fpish
Version:
FP-friendly classes for Typescript
282 lines (281 loc) • 8.31 kB
JavaScript
import { AsyncDatum } from "./AsyncDatum.js";
import { RemoteDataStatus } from "./RemoteDataStatus.js";
import { Optional } from "./Optional.js";
/**
* This class represents data from a remote source that takes time to load.
*
* This data can be a single value or an array of values. Since there's no way to
* infer the shape of the data, the caller is expected to know and to make the
* appropriate calls
*
*
* @template D A type representing the shape of data that is being requested
* @template E A type representing the shape of errors that can be returned
*/
export class AsyncData {
status = RemoteDataStatus.NotAsked;
#internal;
constructor({ status, data, error, }) {
this.status = status;
this.#internal = new AsyncDatum({
status,
data,
error,
});
}
/**
* Create an instance of this type that indicates that the request for async data
* has not yet been made
*/
static notAsked() {
return new AsyncData({
status: RemoteDataStatus.NotAsked,
});
}
/**
* Create an instance of this type that indicates that a request is in flight but has not
* yet completed
*/
static loading() {
return new AsyncData({
status: RemoteDataStatus.Loading,
});
}
/**
* Create an instance of this type that indicates that some data has been returned by the request.
*
* NB: There is nothing here that asserts that the request is complete. This factory method can
* be called multiple times to indicate loading data in progress.
*
* @param data
*/
static loaded(data) {
return new AsyncData({
status: RemoteDataStatus.Succeeded,
data: Object.freeze(data),
});
}
/**
* Create an instance of this type that indicates that the requests has errored.
* @param error
*/
static errored(error) {
return new AsyncData({
status: RemoteDataStatus.Failed,
error,
});
}
cloneWithNewData(data) {
return new AsyncData({
status: this.status,
data,
});
}
/**
*
* @returns true if this object currently contains retrievable data, i.e. not an error and not an inflight request
*/
containsData() {
return this.#internal.containsData();
}
/**
* Check the status of the data request
*
* @param status
*/
is(status) {
return this.status === status;
}
/**
* Check whether data has been requested
*/
isAsked() {
return !this.is(RemoteDataStatus.NotAsked);
}
/**
* Check whether data is currently loading
*/
isLoading() {
return this.is(RemoteDataStatus.Loading);
}
/**
* Check whether any data has loaded (or that the request has failed)
*/
isLoaded() {
return this.is(RemoteDataStatus.Succeeded) || this.is(RemoteDataStatus.Failed);
}
/**
* Check whether the data errored out
*/
isErrored() {
return this.is(RemoteDataStatus.Failed);
}
/**
* Checks whether the data that was loaded is empty.
*
* This will throw an error if the data is not loaded yet
*
* @throws {NoSuchElementException}
*/
isEmpty() {
const value = this.value();
return value.length === 0 || (value.length === 1 && value[0] === null);
}
/**
*
*/
value() {
try {
return this.#internal.value();
}
catch (e) {
if (this.is(RemoteDataStatus.Failed)) {
throw e;
}
throw new Error("Trying to access AsyncData before it has data", { cause: e });
}
}
loadMore() {
if (this.containsData()) {
return new AsyncData({
status: RemoteDataStatus.Loading,
data: this.#internal.value(),
});
}
return AsyncData.loading();
}
/**
* Get the data as an optional and treat it as an array
*
* This will return any internal data that exists. As a result, it will
* return data after a call to `loadMore`.
*/
getOptional() {
return this.containsData()
? Optional.of(this.#internal.value())
: Optional.empty();
}
/**
* Treats the data as an Optional and returns the internal
* value or the provided value.
*
* @param v
*/
orElse(v) {
return this.getOptional().orElse(v);
}
append(v) {
const currentData = this.containsData() ? this.#internal.value() : [];
return AsyncData.loaded(currentData.concat(v));
}
/**
* Standard response for mapping this AsyncData to a new one
* when no data is loaded
*/
getNonLoadedResult() {
if (this.status === RemoteDataStatus.NotAsked) {
return AsyncData.notAsked();
}
if (this.status === RemoteDataStatus.Loading && !this.containsData()) {
return AsyncData.loading();
}
}
map(callbackfn) {
return (this.getNonLoadedResult() ??
this.cloneWithNewData(this.#internal.value().map(callbackfn)));
}
mapValue(callbackfn) {
return this.map(callbackfn).value();
}
filter(callbackfn) {
return (this.getNonLoadedResult() ??
this.cloneWithNewData(this.#internal.value().filter(callbackfn)));
}
reduce(callbackfn, initialValue) {
return (this.getNonLoadedResult() ??
this.cloneWithNewData([
this.#internal.value().reduce(callbackfn, initialValue),
]));
}
find(predicate, thisArg) {
if (!this.containsData()) {
return undefined;
}
return this.value().find(predicate, thisArg);
}
findIndex(predicate, thisArg) {
return this.value().findIndex(predicate, thisArg);
}
update(index, value) {
const arr = this.value();
if (index >= arr.length) {
throw new RangeError(`Index ${index} is too large`);
}
else if (index < 0) {
throw new RangeError(`Index ${index} is too small`);
}
return this.cloneWithNewData(Object.assign([...arr], { [index]: value }));
}
concat(...items) {
return this.cloneWithNewData(this.value().concat(...items));
}
/**
* Sorts the elements of data, according to `compareFn`
*
* Note that this does not sort the elements in place
*
* @param compareFn The comparison function to be applied to the data elements. See [Array.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) documentation for more detail
*
*
* @throws an error if no response has been received
*/
sort(compareFn) {
return this.value().slice().sort(compareFn);
}
get(index) {
return this.value()[index];
}
remove(index) {
return this.filter((_, i) => i !== index);
}
/**
* This returns `true` if the `predicate` returns `true` for every element of data.
*
* It is synonymous with {@link every}
*
* @throws an error if no response has been received
*/
all(predicate) {
return this.every(predicate);
}
/**
* This returns `true` if the `predicate` returns `true` for every element of data.
*
* It is synonymous with {@link all}
*
* @throws an error if no response has been received
*/
every(predicate) {
return this.value().every(predicate);
}
/**
* This returns `true` if the `predicate` returns `true` for any element of data.
*
* It is synonymous with {@link some}
*
* @throws an error if no response has been received
*/
any(predicate) {
return this.some(predicate);
}
/**
* This returns `true` if the `predicate` returns `true` for any element of data.
*
* It is synonymous with {@link any}
*
* @throws an error if no response has been received
*/
some(predicate) {
return this.value().some(predicate);
}
}