mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(sync): suppress the "Project updated" snack on conflict-review flips
PROJECT flips dispatch updateProject, whose snackUpdateBaseSettings$ effect popped an unconditional "Project updated" snack on top of the flip flow's own outcome reporting. Add an optional isSkipSnack flag to updateProject (mirroring updateTask's isIgnoreShortSyntax precedent) and set it from the flip path. Also adds a MockStore.resetSelectors() afterEach to the conflict-ui spec: overrideSelector mutates the shared selector references, and the new selectProjectById override otherwise leaks into project.service specs in the same karma bundle. SPAP-40 item 1 (from the PR #8874 independent review). Item 2 (clearAll outside the op-log lock) is already fixed on master; item 3 (local-won post-resolution stale baseline) is a documented follow-up and stays in the ticket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8193625c0d
commit
9e924e6e6e
5 changed files with 77 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue