Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 48 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,47 +207,76 @@ src/

### Logging System

The application includes comprehensive logging to visualize the complete event flow through the Flux architecture. Event logging is implemented as a reusable store feature (`withEventLogging`) that can be composed into any store.
The application includes comprehensive logging to visualize the complete event flow through the Flux architecture. Logs are strategically placed at each layer to show the unidirectional data flow.

#### Log Prefixes

```text
[Service - Request] - Outgoing API calls
[Service - Response] - API responses

[Store Event] [event group] [event name] - All events flowing through the store
[Task Store] Response from getTasks - Raw service response (in effects)
[Task Store] Dispatching [event name] - Event being dispatched (in effects)
[Component] Dispatching: [event name] - Event dispatched from UI component
[Event → Reducer] [description] - Event being processed by reducer (synchronous)
[Event → Effect] [event group] [event] - Event reaching effects (asynchronous)
[Effect] [description] - Effect internal operations
[Service - Response] [description] - API/Service responses
```

#### Execution Order

The logs reveal NgRx Signals' internal execution model:

1. **Component dispatches** - User action initiates the flow
2. **Reducers process first** - Synchronous state updates happen immediately
3. **Effects run after** - Asynchronous side effects (logging, API calls) execute
4. **Service responds** - External operations complete
5. **Success events flow** - Results trigger new reducer and effect cycles

The `withEventLogging` feature automatically:

- Logs all events from specified event groups using `Object.values()`
- Detects error events (containing "Failure") and logs with `console.error`
- Runs as an effect, so logs appear after reducers process events
- Requires no maintenance when new events are added

#### Example Event Flow

**Loading tasks:**
**Changing task status (optimistic update):**

```text
1. [Store Event] [Task Page] opened (User action)
2. [Service - Request] Fetching tasks (Effect triggers API)
3. [Service - Response] Tasks fetched (API responds)
4. [Task Store] Dispatching tasksLoadedSuccess (Effect dispatches result)
5. [Store Event] [Task API] tasksLoadedSuccess (Event logged)
1. [Component] Dispatching: taskStatusChanged (User clicks to move task)
{id: '1', status: 'in-progress'}

2. [Event → Reducer] Task status changed (optimistic) (State updated immediately)
{taskId: '1', newStatus: 'in-progress'}

3. [Event → Effect] [Task Page] taskStatusChanged (Event logger effect runs)
{id: '1', status: 'in-progress'}

4. [Service - Response] Task status updated successfully (API confirms change)

5. [Event → Effect] [Task API] taskStatusChangedSuccess (Success event logged)
{id: '1', status: 'in-progress'}
```

**Creating a task:**
**Loading tasks:**

```text
1. [Store Event] [Task Page] taskCreated (User action)
2. [Service - Request] Creating task (Effect triggers API)
3. [Service - Response] Task created (API responds)
4. [Store Event] [Task API] taskCreatedSuccess (Result event)
1. [Component] Dispatching: opened (Page initializes)

2. [Event → Reducer] Page opened - setting isLoading: true (Loading state set)

3. [Event → Effect] [Task Page] opened (Event logger)

4. [Effect] Response from getTasks (Service responds)
{tasks: [...], totalPages: 1}

5. [Effect] Dispatching tasksLoadedSuccess (Effect dispatches result)

6. [Event → Reducer] Tasks loaded successfully (State updated with tasks)
{count: 10, taskIds: ['1', '2', ...]}

7. [Event → Effect] [Task API] tasksLoadedSuccess (Success event logged)
```

This logging pattern makes it easy to trace the complete lifecycle of any user action through the system.
This logging pattern makes it easy to trace the complete lifecycle of any user action through the system and understand the order of execution in the Flux architecture.

## Testing

Expand Down
18 changes: 17 additions & 1 deletion src/app/pages/task-board/task-board.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class TaskBoardComponent {
constructor() {
// Dispatch the 'opened' event when component initializes
// This triggers the effect to load tasks
console.log('[Component] Dispatching: opened');
this.dispatch.opened();
}

Expand All @@ -52,20 +53,35 @@ export class TaskBoardComponent {

// Dispatch event: task.effects.ts handles the API call
// task.reducer.ts updates the store state on success
console.log('[Component] Dispatching: taskCreated', newTask);
this.dispatch.taskCreated(newTask);
this.taskForm.reset();
}

deleteTask(taskId: string) {
if (confirm('Are you sure you want to delete this task?')) {
// Dispatch event: handled by effects and reducer
console.log('[Component] Dispatching: taskDeleted', { taskId });
this.dispatch.taskDeleted(taskId);
}
}

moveTo(taskId: string, targetStatus: TaskStatus) {
// Capture current status before dispatching for potential rollback
const task = this.store.taskEntities().find(t => t.id === taskId);
const previousStatus = task?.status;

// Dispatch event: optimistic update by reducer
// Effects handle API call and revert on failure
this.dispatch.taskStatusChanged({ id: taskId, status: targetStatus });
console.log('[Component] Dispatching: taskStatusChanged', {
id: taskId,
status: targetStatus,
previousStatus,
});
this.dispatch.taskStatusChanged({
id: taskId,
status: targetStatus,
previousStatus: previousStatus!,
});
}
}
25 changes: 18 additions & 7 deletions src/app/services/task.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export class TaskService {
// .set('pageSize', pageSize.toString());
// return this.http.get<{ tasks: Task[]; totalPages: number }>(this.API_URL, { params });

console.log('[Service - Request] Fetching tasks', { page, pageSize });
const start = (page - 1) * pageSize;
const end = start + pageSize;
const tasks = this.MOCK_DATA.slice(start, end);
Expand All @@ -56,7 +55,6 @@ export class TaskService {
// Returns: Task (with id and createdAt)
// return this.http.post<Task>(this.API_URL, task);

console.log('[Service - Request] Creating task', task);
const newTask: Task = {
...task,
id: `${this.MOCK_DATA.length + 1}`,
Expand All @@ -77,7 +75,6 @@ export class TaskService {
// Returns: boolean (success/failure)
// return this.http.delete<boolean>(`${this.API_URL}/${taskId}`);

console.log('[Service - Request] Deleting task', taskId);
const index = this.MOCK_DATA.findIndex(task => task.id === taskId);
if (index > -1) {
this.MOCK_DATA.splice(index, 1);
Expand Down Expand Up @@ -106,10 +103,24 @@ export class TaskService {
// Returns: boolean (success/failure)
// return this.http.patch<boolean>(`${this.API_URL}/${taskId}/status`, { status: newStatus });

console.log('[Service - Request] Updating task status', {
taskId,
newStatus,
});
// Simulate failure for task ID '3' to demonstrate optimistic update rollback
if (taskId === '3') {
return of(false).pipe(
delay(200),
tap(() => {
console.error(
'[Service - Response] Failed to update task status (simulated failure for task 3)'
);
}),
// Convert false to error to trigger catchError in effect
tap(() => {
throw new Error(
'Failed to update task status: Server error (simulated for task 3)'
);
})
);
}

const taskIndex = this.MOCK_DATA.findIndex(task => task.id === taskId);
if (taskIndex > -1) {
this.MOCK_DATA[taskIndex] = {
Expand Down
5 changes: 3 additions & 2 deletions src/app/stores/shared/with-event-logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ export function withEventLogging(eventGroups: EventGroup[]) {
logAllEvents$: events.on(...(allEvents as [any, ...any[]])).pipe(
tap((event: EventWithPayload) => {
const isError = event.type.includes('Failure');

if (isError) {
console.error(`[Store Event] ${event.type}:`, event.payload);
console.error(`[Event → Effect] ${event.type}:`, event.payload);
} else {
console.log(`[Store Event] ${event.type}`, event.payload);
console.log(`[Event → Effect] ${event.type}`, event.payload);
}
})
),
Expand Down
26 changes: 11 additions & 15 deletions src/app/stores/task-store/task.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function withTaskEffects() {
exhaustMap(() =>
taskService.getTasks(1, 10).pipe(
tap(response => {
console.log('[Task Store] Response from getTasks:', response);
console.log('[Effect] Response from getTasks:', response);
}),
catchError((error: { message: string }) =>
of(taskApiEvents.tasksLoadedFailure(error.message))
Expand All @@ -33,7 +33,7 @@ export function withTaskEffects() {
}
// Dispatch success with tasks array
console.log(
'[Task Store] Dispatching tasksLoadedSuccess with:',
'[Effect] Dispatching tasksLoadedSuccess with:',
response.tasks
);
return of(taskApiEvents.tasksLoadedSuccess(response.tasks));
Expand Down Expand Up @@ -75,29 +75,25 @@ export function withTaskEffects() {
// Change task status
changeTaskStatus$: events.on(taskPageEvents.taskStatusChanged).pipe(
exhaustMap(event => {
const taskEntitiesFn = store['taskEntities'] as
| (() => Task[])
| undefined;
const taskEntities = taskEntitiesFn?.() || [];
const task = taskEntities.find(
(t: Task) => t.id === event.payload.id
);
const previousStatus = task?.status;

return taskService
.updateTaskStatus(event.payload.id, event.payload.status)
.pipe(
concatMap(() =>
of(
taskApiEvents.taskStatusChangedSuccess({
id: event.payload.id,
status: event.payload.status,
})
)
),
catchError((error: { message: string }) =>
of(
taskApiEvents.taskStatusChangedFailure({
id: event.payload.id,
previousStatus: previousStatus!,
previousStatus: event.payload.previousStatus,
error: error.message,
})
)
),
concatMap(() =>
of(taskApiEvents.taskStatusChangedSuccess(event.payload))
)
);
})
Expand Down
6 changes: 5 additions & 1 deletion src/app/stores/task-store/task.events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ export const taskPageEvents = eventGroup({
opened: type<void>(),
taskCreated: type<Omit<Task, 'id' | 'createdAt'>>(),
taskDeleted: type<string>(),
taskStatusChanged: type<{ id: string; status: TaskStatus }>(),
taskStatusChanged: type<{
id: string;
status: TaskStatus;
previousStatus: TaskStatus;
}>(),
pageChanged: type<number>(),
},
});
Expand Down
67 changes: 50 additions & 17 deletions src/app/stores/task-store/task.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,57 +13,90 @@ export function withTaskReducer() {
return signalStoreFeature(
withReducer(
// Handle loading states
on(taskPageEvents.opened, () => ({ isLoading: true })),
on(taskPageEvents.opened, () => {
console.log('[Event → Reducer] Page opened - setting isLoading: true');
return { isLoading: true };
}),

// Handle successful task loading
// In NGRX Signals Events, the on() handler receives only the event (not state)
// The event object has a .payload property containing the data
on(taskApiEvents.tasksLoadedSuccess, (event: { payload: Task[] }) => {
console.log('[Event → Reducer] Tasks loaded successfully', {
count: event.payload.length,
taskIds: event.payload.map(t => t.id),
});
return [
setAllEntities(event.payload, { collection: 'task' }),
{ isLoading: false },
];
}),

// Handle failed task loading
on(taskApiEvents.tasksLoadedFailure, (event: { payload: string }) => ({
isLoading: false,
error: event.payload,
})),
on(taskApiEvents.tasksLoadedFailure, (event: { payload: string }) => {
console.log('[Event → Reducer] Tasks load failed', {
error: event.payload,
});
return {
isLoading: false,
error: event.payload,
};
}),

// Handle successful task creation
on(taskApiEvents.taskCreatedSuccess, (event: { payload: Task }) =>
addEntity(event.payload, { collection: 'task' })
),
on(taskApiEvents.taskCreatedSuccess, (event: { payload: Task }) => {
console.log('[Event → Reducer] Task created', {
taskId: event.payload.id,
title: event.payload.title,
});
return addEntity(event.payload, { collection: 'task' });
}),

// Handle successful task deletion
on(taskApiEvents.taskDeletedSuccess, (event: { payload: string }) =>
removeEntity(event.payload, { collection: 'task' })
),
on(taskApiEvents.taskDeletedSuccess, (event: { payload: string }) => {
console.log('[Event → Reducer] Task deleted', {
taskId: event.payload,
});
return removeEntity(event.payload, { collection: 'task' });
}),

// Handle optimistic status update
on(
taskPageEvents.taskStatusChanged,
(event: { payload: { id: string; status: TaskStatus } }) =>
updateEntity(
(event: { payload: { id: string; status: TaskStatus } }) => {
console.log('[Event → Reducer] Task status changed (optimistic)', {
taskId: event.payload.id,
newStatus: event.payload.status,
});
return updateEntity(
{ id: event.payload.id, changes: { status: event.payload.status } },
{ collection: 'task' }
)
);
}
),

// Handle status update failure (revert)
on(
taskApiEvents.taskStatusChangedFailure,
(event: {
payload: { id: string; previousStatus: TaskStatus; error: string };
}) =>
updateEntity(
}) => {
console.log(
'[Event → Reducer] Task status change failed - reverting',
{
taskId: event.payload.id,
revertingTo: event.payload.previousStatus,
error: event.payload.error,
}
);
return updateEntity(
{
id: event.payload.id,
changes: { status: event.payload.previousStatus },
},
{ collection: 'task' }
)
);
}
)
)
);
Expand Down
Loading