Last active
March 26, 2025 14:16
-
-
Save tomastrajan/6433fbf973adfed0c40ad49ecac6a543 to your computer and use it in GitHub Desktop.
Angular Crud Resource POC
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { HttpClient } from '@angular/common/http'; | |
import { inject, DestroyRef, signal, computed } from '@angular/core'; | |
import { takeUntilDestroyed, rxResource } from '@angular/core/rxjs-interop'; | |
import { | |
Subject, | |
concatMap, | |
tap, | |
Observable, | |
mergeMap, | |
switchMap, | |
exhaustMap, | |
catchError, | |
map, | |
} from 'rxjs'; | |
export function crudResource<T, ID>( | |
url: string, | |
options?: CrudResourceOptions<T, ID>, | |
) { | |
const http = inject(HttpClient); | |
const strategy = options?.strategy ?? 'pessimistic'; | |
const loadingCreate = signal(false); | |
const loadingUpdate = signal(false); | |
const loadingRemove = signal(false); | |
const errorCreate = signal(''); | |
const errorUpdate = signal(''); | |
const errorRemove = signal(''); | |
const resource = rxResource({ | |
request: () => '' as ID, | |
loader: () => { | |
return http.get<T[]>(url); | |
}, | |
}); | |
const create = streamify<[T]>((stream) => | |
stream.pipe( | |
tap(([item]) => { | |
loadingCreate.set(true); | |
if ( | |
(options?.create?.strategy ?? strategy) === crudResource.OPTIMISTIC | |
) { | |
resource.update((prev) => [...(prev ?? []), item]); | |
} | |
}), | |
behaviorToOperator(options?.create?.behavior)(([item]) => | |
http.post(url, item).pipe( | |
catchError((err) => { | |
errorCreate.set(err); | |
if ( | |
(options?.create?.strategy ?? strategy) === | |
crudResource.OPTIMISTIC | |
) { | |
resource.update((prev) => | |
prev?.filter((prevItem) => prevItem !== item), | |
); | |
} | |
return [undefined]; | |
}), | |
), | |
), | |
tap(() => { | |
if ( | |
(options?.create?.strategy ?? strategy) !== crudResource.OPTIMISTIC | |
) { | |
resource.reload(); | |
} | |
loadingCreate.set(false); | |
}), | |
), | |
); | |
const update = streamify<[ID, T]>((stream) => | |
stream.pipe( | |
tap(([id, item]) => { | |
loadingUpdate.set(true); | |
if ( | |
(options?.update?.strategy ?? strategy) === crudResource.OPTIMISTIC | |
) { | |
resource.update((prev) => | |
prev?.map((prevItem) => { | |
const prevItemId = | |
options?.idSelector?.(prevItem) ?? | |
(prevItem as unknown as { id: string }).id; | |
return prevItemId === id ? { ...prevItem, ...item } : prevItem; | |
}), | |
); | |
} | |
}), | |
behaviorToOperator(options?.update?.behavior)(([id, item]) => | |
http.put(`${url}/${id}`, item).pipe( | |
catchError((err) => { | |
errorUpdate.set(err); | |
if ( | |
(options?.update?.strategy ?? strategy) === | |
crudResource.OPTIMISTIC | |
) { | |
resource.update((prev) => | |
prev?.map((prevItem) => { | |
const prevItemId = | |
options?.idSelector?.(prevItem) ?? | |
(prevItem as unknown as { id: string }).id; | |
return prevItemId === id ? item : prevItem; | |
}), | |
); | |
} | |
return [undefined]; | |
}), | |
), | |
), | |
tap(() => { | |
if ( | |
(options?.update?.strategy ?? strategy) !== crudResource.OPTIMISTIC | |
) { | |
resource.reload(); | |
} | |
loadingUpdate.set(false); | |
}), | |
), | |
); | |
const remove = streamify<[ID]>((stream) => | |
stream.pipe( | |
tap(() => loadingRemove.set(true)), | |
map(([id]) => { | |
const removedItem = resource.value()?.find((item) => { | |
if (options?.idSelector) { | |
return options.idSelector(item) === id; | |
} else { | |
return (item as unknown as { id: ID }).id === id; | |
} | |
}); | |
if ( | |
(options?.remove?.strategy ?? strategy) === crudResource.OPTIMISTIC && | |
removedItem | |
) { | |
resource.update((prev) => | |
prev?.filter((prevItem) => prevItem !== removedItem), | |
); | |
} | |
return { id, removedItem }; | |
}), | |
behaviorToOperator(options?.remove?.behavior)(({ id, removedItem }) => | |
http.delete(`${url}/${id}`).pipe( | |
catchError((err) => { | |
errorUpdate.set(err); | |
if ((options?.remove?.strategy ?? strategy) && removedItem) { | |
resource.update((prev) => [...(prev ?? []), removedItem]); | |
} | |
return [undefined]; | |
}), | |
), | |
), | |
tap(() => { | |
if ( | |
(options?.remove?.strategy ?? strategy) !== crudResource.OPTIMISTIC | |
) { | |
resource.reload(); | |
} | |
loadingRemove.set(false); | |
}), | |
), | |
); | |
const loading = computed( | |
() => | |
!loadingInitial() && | |
(resource.isLoading() || | |
loadingCreate() || | |
loadingUpdate() || | |
loadingRemove()), | |
); | |
const loadingInitial = computed( | |
() => !resource.value() && resource.isLoading(), | |
); | |
return { | |
loadingInitial, | |
loading, | |
loadingCreate, | |
loadingUpdate, | |
loadingRemove, | |
errorCreate, | |
errorUpdate, | |
errorRemove, | |
value: resource.value, | |
create, | |
update, | |
remove, | |
}; | |
} | |
function behaviorToOperator( | |
behavior: crudResource.Behavior = crudResource.CONCAT, | |
) { | |
switch (behavior) { | |
case crudResource.CONCAT: | |
return concatMap; | |
case crudResource.MERGE: | |
return mergeMap; | |
case crudResource.SWITCH: | |
return switchMap; | |
case crudResource.EXHAUST: | |
return exhaustMap; | |
} | |
} | |
function streamify<T extends unknown[]>( | |
impl: (stream: Observable<T>) => Observable<unknown>, | |
) { | |
const destroyRef = inject(DestroyRef); | |
const subject = new Subject<T>(); | |
impl(subject).pipe(takeUntilDestroyed(destroyRef)).subscribe(); | |
return (...args: T) => subject.next(args); | |
} | |
// eslint-disable-next-line @typescript-eslint/no-namespace | |
export namespace crudResource { | |
export const CONCAT = 1; | |
export const MERGE = 2; | |
export const SWITCH = 3; | |
export const EXHAUST = 4; | |
export type Behavior = | |
| typeof CONCAT | |
| typeof MERGE | |
| typeof SWITCH | |
| typeof EXHAUST; | |
export const OPTIMISTIC = 'optimistic'; | |
export const PESSIMISTIC = 'pessimistic'; | |
export type Strategy = typeof OPTIMISTIC | typeof PESSIMISTIC; | |
} | |
export interface CrudResourceOptions<T, ID> { | |
idSelector?: (item: T) => ID; | |
strategy?: crudResource.Strategy; | |
create?: { | |
behavior: crudResource.Behavior; | |
strategy?: crudResource.Strategy; | |
}; | |
update?: { | |
behavior: crudResource.Behavior; | |
strategy?: crudResource.Strategy; | |
}; | |
remove?: { | |
behavior: crudResource.Behavior; | |
strategy?: crudResource.Strategy; | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment