@angular-experts/resource
Version:
### The missing create, update, delete (CUD) support for Angular resource
261 lines (255 loc) • 10.2 kB
JavaScript
import { HttpClient } from '@angular/common/http';
import { inject, DestroyRef, signal, linkedSignal, computed } from '@angular/core';
import { takeUntilDestroyed, rxResource } from '@angular/core/rxjs-interop';
import { Subject, exhaustMap, switchMap, mergeMap, concatMap, map, tap, catchError } from 'rxjs';
const LOG_PREFIX = `[@angular-experts/resource]`;
function streamify(impl) {
const destroyRef = inject(DestroyRef);
const subject = new Subject();
impl(subject).pipe(takeUntilDestroyed(destroyRef)).subscribe();
return (...args) => {
subject.next(args);
};
}
function behaviorToOperator(behavior = 'concat') {
switch (behavior) {
case 'concat':
return concatMap;
case 'merge':
return mergeMap;
case 'switch':
return switchMap;
case 'exhaust':
return exhaustMap;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function restResource(apiEndpoint, options = {}) {
const http = inject(HttpClient);
const strategy = options.strategy ?? 'pessimistic';
const loadingCreate = signal(false);
const loadingUpdate = signal(false);
const loadingRemove = signal(false);
const errorCreate = signal(undefined);
const errorUpdate = signal(undefined);
const errorRemove = signal(undefined);
const resource = rxResource({
params: () => options.params?.() ?? '',
stream: ({ params }) => {
const fullUrl = `${apiEndpoint}${params}`;
verbose('Read (Angular resource)', { params, fullUrl });
return http.get(fullUrl);
},
});
const values = linkedSignal({
source: () => {
try {
return resource.value();
}
catch {
return undefined;
}
},
computation: (source, previous) => {
if (!source && previous?.value) {
return previous.value;
}
else {
return source;
}
},
});
const value = computed(() => {
const unwrappedItems = values();
if (unwrappedItems?.length === 1) {
return unwrappedItems[0];
}
else {
return undefined;
}
});
const create = streamify((stream) => stream.pipe(map(([item]) => {
if (options.create?.id?.generator) {
const newId = options.create?.id.generator();
if (options.create?.id?.setter) {
options.create?.id?.setter(newId, item);
}
else {
item.id = newId;
}
}
return [item];
}), tap(([item]) => {
loadingCreate.set(true);
if (isOptimistic('create', options)) {
if (!options.create?.id?.generator &&
!getItemId(item, options)) {
console.warn(LOG_PREFIX, `Optimistic update can only be performed if the provided item has an ID. ID can be added manually or using "options.create.id.generator", Skip...`);
return;
}
resource.update((prev) => [...(prev ?? []), item]);
}
}), behaviorToOperator(options.create?.behavior)(([item]) => http.post(apiEndpoint, item).pipe(tap((createdItem) => {
if (isIncremental('create', options)) {
if (!createdItem) {
console.warn(LOG_PREFIX, `Incremental create request returned no item, this is unexpected, if your API does not return the created item, consider using "optimistic" strategy instead, Skip...`);
}
else {
resource.update((prev) => [...(prev ?? []), createdItem]);
}
}
}), catchError((err) => {
errorCreate.set(err);
if (isOptimistic('create', options)) {
resource.update((prev) => prev?.filter((prevItem) => prevItem !== item));
}
return [undefined];
}))), tap(() => {
reloadIfPessimisticOrHasParams('create', resource, options);
loadingCreate.set(false);
})));
const update = streamify((stream) => stream.pipe(map(([item]) => {
loadingUpdate.set(true);
if (isOptimistic('update', options)) {
const updatedItemId = getItemId(item, options);
const prevVersionOfItem = resource
.value()
?.find((prevItem) => getItemId(prevItem, options) === updatedItemId);
resource.update((prev) => prev?.map((prevItem) => {
return getItemId(prevItem, options) === updatedItemId
? { ...prevItem, ...item }
: prevItem;
}));
return { item, prevVersionOfItem };
}
return { item };
}), behaviorToOperator(options.update?.behavior)(({ item, prevVersionOfItem }) => {
const updatedItemId = getItemId(item, options);
return http
.put(`${apiEndpoint}/${updatedItemId?.toString()}`, item)
.pipe(tap((updatedItem) => {
if (isIncremental('update', options)) {
if (!updatedItem) {
console.warn(LOG_PREFIX, `Incremental update request returned no item, this is unexpected, if your API does not return the updated item, consider using "optimistic" strategy instead, Skip...`);
}
else {
resource.update((prev) => prev?.map((prevItem) => {
return getItemId(prevItem, options) === updatedItemId
? updatedItem
: prevItem;
}));
}
}
}), catchError((err) => {
errorUpdate.set(err);
if ((options.update?.strategy ?? strategy) === 'optimistic') {
resource.update((prev) => prev?.map((prevItem) => {
return getItemId(prevItem, options) === updatedItemId
? prevVersionOfItem
: prevItem;
}));
}
return [undefined];
}));
}), tap(() => {
reloadIfPessimisticOrHasParams('update', resource, options);
loadingUpdate.set(false);
})));
const remove = streamify((stream) => stream.pipe(tap(() => loadingRemove.set(true)), tap(([item]) => {
if (isOptimistic('remove', options)) {
resource.update((prev) => prev?.filter((prevItem) => prevItem !== item));
}
}), behaviorToOperator(options.remove?.behavior)(([item]) => http
.delete(`${apiEndpoint}/${getItemId(item, options)}`)
.pipe(tap((removedItemOrId) => {
if (isIncremental('remove', options)) {
if (removedItemOrId === null || removedItemOrId === undefined) {
console.warn(LOG_PREFIX, `Incremental remove request returned no item or item ID, this is unexpected, if your API does not return the removed item or removed item ID, consider using "optimistic" strategy instead, Skip...`);
}
else {
const removedItemId = typeof removedItemOrId === 'object'
? getItemId(removedItemOrId, options)
: removedItemOrId;
resource.update((prev) => prev?.filter((prevItem) => getItemId(prevItem, options) !== removedItemId));
}
}
}), catchError((err) => {
errorRemove.set(err);
if (isOptimistic('remove', options)) {
resource.update((prev) => [...(prev ?? []), item]);
}
return [undefined];
}))), tap(() => {
reloadIfPessimisticOrHasParams('remove', resource, options);
loadingRemove.set(false);
})));
function getItemId(item, options) {
return options.idSelector?.(item) ?? item.id;
}
function isOptimistic(requestType, options) {
return (options[requestType]?.strategy ?? strategy) === 'optimistic';
}
function isIncremental(requestType, options) {
return (options[requestType]?.strategy ?? strategy) === 'incremental';
}
function reloadIfPessimisticOrHasParams(requestType, resource, options) {
if ((options[requestType]?.strategy ?? options.strategy ?? 'pessimistic') ===
'pessimistic' ||
options.params) {
resource.reload();
}
}
function verbose(...args) {
if (options.verbose) {
console.debug(LOG_PREFIX, ...args);
}
}
const loading = computed(() => !loadingInitial() &&
(resource.isLoading() ||
loadingCreate() ||
loadingUpdate() ||
loadingRemove()));
const loadingInitial = computed(() => !values() && resource.isLoading());
return {
// LOADING
loadingInitial,
loading,
loadingCreate,
loadingUpdate,
loadingRemove,
// ERRORS
errorRead: resource.error,
errorCreate,
errorUpdate,
errorRemove,
// VALUES
/**
* The value of the resource, ONLY if the resource is a single item.
*
* This is useful when building a resource to manage a single entity, eg detail view.
*
* @returns Signal of single item or undefined (also returns undefined if resource has more than one item)
*/
value,
/**
* The values of the resource (eg list of 0 to n items).
*
* @returns Signal of a list of items or undefined (also returns undefined if resource has no items)
*/
values,
hasValue: computed(() => value() !== null && value() !== undefined),
hasValues: computed(() => !!values()?.length),
// METHODS
reload: resource.reload.bind(resource),
create,
update,
remove,
// LIFECYCLE
destroy: resource.destroy.bind(resource),
};
}
/**
* Generated bundle index. Do not edit.
*/
export { LOG_PREFIX, behaviorToOperator, restResource, streamify };
//# sourceMappingURL=angular-experts-resource-src-lib-resource.mjs.map