Merge pull request #8949 from aakhter/SPAP-40-review-hardening

fix(sync): suppress the "Project updated" snack on conflict-review flips
This commit is contained in:
Johannes Millan 2026-07-13 10:31:06 +02:00 committed by GitHub
commit 79e6a48ba6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 77 additions and 2 deletions

View file

@ -31,7 +31,9 @@ export const addProjects = createAction(
export const updateProject = createAction(
'[Project] Update Project',
(projectProps: { project: Update<Project> }) => ({
// isSkipSnack: suppress the unconditional "Project updated" snack — used by
// flows that surface their own outcome (e.g. the conflict-review flip).
(projectProps: { project: Update<Project>; isSkipSnack?: boolean }) => ({
...projectProps,
meta: {
isPersistent: true,

View file

@ -187,6 +187,35 @@ describe('ProjectEffects', () => {
});
});
describe('snackUpdateBaseSettings$', () => {
it('shows the "Project updated" snack for a normal updateProject', fakeAsync(() => {
effects.snackUpdateBaseSettings$.subscribe();
actions$.next(
updateProject({
project: { id: 'project-1', changes: { title: 'New title' } },
}),
);
tick(100);
expect(snackServiceSpy.open).toHaveBeenCalled();
}));
it('suppresses the snack when isSkipSnack is set (conflict-review flip)', fakeAsync(() => {
effects.snackUpdateBaseSettings$.subscribe();
actions$.next(
updateProject({
project: { id: 'project-1', changes: { title: 'Flipped title' } },
isSkipSnack: true,
}),
);
tick(100);
expect(snackServiceSpy.open).not.toHaveBeenCalled();
}));
});
describe('moveAllProjectToTodayListWhenBacklogIsDisabled$', () => {
it('should dispatch moveAllProjectBacklogTasksToRegularList when backlog is disabled', (done) => {
effects.moveAllProjectToTodayListWhenBacklogIsDisabled$.subscribe((action: any) => {

View file

@ -107,7 +107,8 @@ export class ProjectEffects {
snackUpdateBaseSettings$: Observable<unknown> = createEffect(
() =>
this._actions$.pipe(
ofType(updateProject.type),
ofType(updateProject),
filter((a) => !a.isSkipSnack),
tap(() => {
this._snackService.open({
type: 'SUCCESS',

View file

@ -10,6 +10,9 @@ import { SnackService } from '../../core/snack/snack.service';
import { EntityType } from '../core/operation.types';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { selectTaskById } from '../../features/tasks/store/task.selectors';
import { selectProjectById } from '../../features/project/store/project.selectors';
import { updateProject } from '../../features/project/store/project.actions';
import { Project } from '../../features/project/project.model';
import { Task } from '../../features/tasks/task.model';
const makeEntry = (over: Partial<ConflictJournalEntry> = {}): ConflictJournalEntry => ({
@ -75,6 +78,13 @@ describe('SyncConflictUiService', () => {
dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
});
afterEach(() => {
// overrideSelector mutates the SHARED selector references (selectTaskById /
// selectProjectById) — without a reset the overrides leak into other spec
// files in the same karma bundle (e.g. project.service.spec).
store.resetSelectors();
});
it('keep() marks the entry kept', async () => {
const entry = makeEntry();
await journal.record(entry);
@ -382,6 +392,36 @@ describe('SyncConflictUiService', () => {
expect(Object.prototype.hasOwnProperty.call(changes, 'notes')).toBe(false);
});
it('flip() on a PROJECT entry suppresses the "Project updated" snack (isSkipSnack)', async () => {
// The flip already reports its own outcome; without isSkipSnack the
// unconditional snackUpdateBaseSettings$ effect would ALSO pop
// "Project updated" on top of it.
const entry = makeEntry({
id: 'p1',
entityType: 'PROJECT' as EntityType,
entityId: 'project-1',
});
await journal.record(entry);
store.overrideSelector(selectProjectById, {
id: 'project-1',
title: 'Remote title',
} as Project);
store.refreshState();
const result = await service.flip(entry);
expect(result).toBe('applied');
const dispatched = dispatchSpy.calls.mostRecent().args[0] as ReturnType<
typeof updateProject
>;
expect(dispatched.type).toBe(updateProject.type);
expect(dispatched.project).toEqual({
id: 'project-1',
changes: { title: 'Local title' },
});
expect((dispatched as { isSkipSnack?: boolean }).isSkipSnack).toBe(true);
});
it('canFlip() is false for delete-lost and delete-wins entries', () => {
expect(service.canFlip(makeEntry({ reason: 'delete-lost', fieldDiffs: [] }))).toBe(
false,

View file

@ -280,6 +280,9 @@ export class SyncConflictUiService {
id: entityId,
changes: changes as Partial<Project>,
} as Update<Project>,
// The flip flow reports its own outcome — without this the
// unconditional "Project updated" snack pops on top of it.
isSkipSnack: true,
});
case 'NOTE':
return updateNote({