222 lines
7.4 KiB
TypeScript
222 lines
7.4 KiB
TypeScript
import { computed, inject } from '@angular/core';
|
|
import {
|
|
addEntity,
|
|
removeEntities,
|
|
setAllEntities,
|
|
updateEntity,
|
|
withEntities,
|
|
} from '@ngrx/signals/entities';
|
|
import {
|
|
signalStore,
|
|
withComputed,
|
|
withHooks,
|
|
withMethods,
|
|
withState,
|
|
} from '@ngrx/signals';
|
|
import { rxMethod } from '@ngrx/signals/rxjs-interop';
|
|
import {
|
|
setError,
|
|
setLoaded,
|
|
setLoading,
|
|
updateState,
|
|
withCallState,
|
|
withDevtools,
|
|
withLocalStorage,
|
|
withStorageSync,
|
|
} from '@angular-architects/ngrx-toolkit';
|
|
import { EMPTY, pipe } from 'rxjs';
|
|
import { catchError, switchMap, tap } from 'rxjs/operators';
|
|
import { Task, TaskFilter, TaskPriority } from './task.model';
|
|
import { TasksApiService } from './tasks-api.service';
|
|
|
|
interface TasksState {
|
|
filter: TaskFilter;
|
|
draftPriority: TaskPriority;
|
|
searchTerm: string;
|
|
lastSyncedAt: string | null;
|
|
}
|
|
|
|
const initialState: TasksState = {
|
|
filter: 'all',
|
|
draftPriority: 'medium',
|
|
searchTerm: '',
|
|
lastSyncedAt: null,
|
|
};
|
|
|
|
function matchesFilter(task: Task, filter: TaskFilter): boolean {
|
|
switch (filter) {
|
|
case 'active':
|
|
return !task.completed;
|
|
case 'completed':
|
|
return task.completed;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export const TasksStore = signalStore(
|
|
{ providedIn: 'root' },
|
|
withState(initialState),
|
|
withEntities<Task>(),
|
|
withCallState(),
|
|
withDevtools('tasks-store'),
|
|
withStorageSync(
|
|
{
|
|
key: 'ngrx-playground.tasks',
|
|
select: (state) => ({
|
|
ids: state.ids,
|
|
entityMap: state.entityMap,
|
|
filter: state.filter,
|
|
draftPriority: state.draftPriority,
|
|
searchTerm: state.searchTerm,
|
|
lastSyncedAt: state.lastSyncedAt,
|
|
}),
|
|
},
|
|
withLocalStorage(),
|
|
),
|
|
withComputed((store) => ({
|
|
filteredTasks: computed(() => {
|
|
const normalizedSearch = store.searchTerm().trim().toLowerCase();
|
|
|
|
return store.entities().filter((task) => {
|
|
const matchesText = normalizedSearch.length === 0 || task.title.toLowerCase().includes(normalizedSearch);
|
|
|
|
return matchesFilter(task, store.filter()) && matchesText;
|
|
});
|
|
}),
|
|
totalCount: computed(() => store.entities().length),
|
|
activeCount: computed(() => store.entities().filter((task) => !task.completed).length),
|
|
completedCount: computed(() => store.entities().filter((task) => task.completed).length),
|
|
hasCompletedTasks: computed(() => store.entities().some((task) => task.completed)),
|
|
emptyStateMessage: computed(() => {
|
|
if (store.loading()) {
|
|
return 'Loading tasks...';
|
|
}
|
|
|
|
if (store.error()) {
|
|
return 'The task list could not be refreshed.';
|
|
}
|
|
|
|
if (store.entities().length === 0) {
|
|
return 'No tasks saved yet. Create the first one.';
|
|
}
|
|
|
|
return 'No tasks match the current filters.';
|
|
}),
|
|
lastSyncedLabel: computed(() => {
|
|
const value = store.lastSyncedAt();
|
|
return value ? new Date(value).toLocaleTimeString('en-US') : 'Never';
|
|
}),
|
|
})),
|
|
methodsStore(),
|
|
withHooks({
|
|
onInit(store) {
|
|
if (store.entities().length === 0) {
|
|
store.loadTasks();
|
|
}
|
|
},
|
|
}),
|
|
);
|
|
|
|
function methodsStore() {
|
|
return withMethods((store, api = inject(TasksApiService)) => ({
|
|
loadTasks: rxMethod<void>(
|
|
pipe(
|
|
tap(() => updateState(store, 'tasks load started', setLoading())),
|
|
switchMap(() =>
|
|
api.getTasks().pipe(
|
|
tap((tasks) =>
|
|
updateState(
|
|
store,
|
|
'tasks load succeeded',
|
|
setAllEntities(tasks),
|
|
{ lastSyncedAt: new Date().toISOString() },
|
|
setLoaded(),
|
|
),
|
|
),
|
|
catchError((error: unknown) => {
|
|
updateState(store, 'tasks load failed', setError(error));
|
|
return EMPTY;
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
createTask: rxMethod<{ title: string; priority: TaskPriority }>(
|
|
pipe(
|
|
tap(() => updateState(store, 'task create started', setLoading())),
|
|
switchMap(({ title, priority }) =>
|
|
api.createTask(title, priority).pipe(
|
|
tap((task) =>
|
|
updateState(store, 'task create succeeded', addEntity(task), { lastSyncedAt: task.updatedAt }, setLoaded()),
|
|
),
|
|
catchError((error: unknown) => {
|
|
updateState(store, 'task create failed', setError(error));
|
|
return EMPTY;
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
toggleTask: rxMethod<string>(
|
|
pipe(
|
|
tap(() => updateState(store, 'task toggle started', setLoading())),
|
|
switchMap((taskId) =>
|
|
api.toggleTask(taskId).pipe(
|
|
tap((task) =>
|
|
updateState(
|
|
store,
|
|
'task toggle succeeded',
|
|
updateEntity({ id: task.id, changes: task }),
|
|
{ lastSyncedAt: task.updatedAt },
|
|
setLoaded(),
|
|
),
|
|
),
|
|
catchError((error: unknown) => {
|
|
updateState(store, 'task toggle failed', setError(error));
|
|
return EMPTY;
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
archiveCompleted: rxMethod<void>(
|
|
pipe(
|
|
tap(() => updateState(store, 'completed tasks archive started', setLoading())),
|
|
switchMap(() =>
|
|
api.archiveCompleted().pipe(
|
|
tap((archivedIds) =>
|
|
updateState(
|
|
store,
|
|
'completed tasks archive succeeded',
|
|
removeEntities(archivedIds),
|
|
{ lastSyncedAt: new Date().toISOString() },
|
|
setLoaded(),
|
|
),
|
|
),
|
|
catchError((error: unknown) => {
|
|
updateState(store, 'completed tasks archive failed', setError(error));
|
|
return EMPTY;
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
setFilter(filter: TaskFilter): void {
|
|
updateState(store, 'task filter changed', { filter });
|
|
},
|
|
|
|
setDraftPriority(priority: TaskPriority): void {
|
|
updateState(store, 'task priority draft changed', { draftPriority: priority });
|
|
},
|
|
|
|
setSearchTerm(searchTerm: string): void {
|
|
updateState(store, 'task search changed', { searchTerm });
|
|
},
|
|
}));
|
|
}
|