diff --git a/frontend/src/components/settings/PostMaintenanceCard.vue b/frontend/src/components/settings/PostMaintenanceCard.vue index 6a0c153..4fd9bf6 100644 --- a/frontend/src/components/settings/PostMaintenanceCard.vue +++ b/frontend/src/components/settings/PostMaintenanceCard.vue @@ -42,8 +42,8 @@ :loading="committing" @click="onCommit" >Delete {{ preview.count }} bare post(s) - - Deleted {{ deleted }} ✓ + + Deleted {{ bareResult.deleted ?? 0 }} ✓ @@ -88,78 +88,47 @@ :loading="merging" @click="onMergeDupes" >Merge {{ dupPreview.posts_to_merge }} duplicate(s) - - Merged {{ merged }} ✓ + + Merged {{ dupResult.merged ?? 0 }} ✓ diff --git a/frontend/src/components/settings/TagMaintenanceCard.vue b/frontend/src/components/settings/TagMaintenanceCard.vue index 0091a9e..a29ec89 100644 --- a/frontend/src/components/settings/TagMaintenanceCard.vue +++ b/frontend/src/components/settings/TagMaintenanceCard.vue @@ -186,11 +186,11 @@ Standardize {{ normPreview.total_changes }} tag group(s) - + Queued ✓ — runs in the background (you can leave this page). It processes in chunks, so re-run “Preview” later to confirm it's all done. @@ -199,106 +199,53 @@ diff --git a/frontend/src/composables/usePreviewCommit.js b/frontend/src/composables/usePreviewCommit.js new file mode 100644 index 0000000..9460cdf --- /dev/null +++ b/frontend/src/composables/usePreviewCommit.js @@ -0,0 +1,52 @@ +import { ref } from 'vue' + +// Canonical sync preview→commit flow for a maintenance tile: the operator +// previews (dry-run) → sees the projection → commits (apply), where the apply +// returns its result inline (no long Celery task). Owns the shared lifecycle — +// the two loading flags, the preview payload, and the apply result — so each +// tile stops hand-rolling its own `preview`/`loading`/`committing` refs + +// onPreview/onCommit handlers (this was duplicated across 6 maintenance flows; +// DRY pass #753). +// +// The tile supplies the `preview`/`commit` thunks (which call the right store +// action with dryRun true/false) and, optionally, `emptyPreview`: the shape to +// collapse the preview to after a successful apply — the apply uses the SAME +// backend predicate as the preview, so afterward there's nothing left. Pass a +// function to derive it from the apply result; omit it entirely for a commit +// that dispatches a background task and should leave the preview in place. +// +// Errors surface via the store's own lastError (shown in the tile's alert), so +// they're intentionally NOT captured here. For an apply that runs as a LONG +// Celery task the operator can navigate away from, use useMaintenanceTask. +export function usePreviewCommit ({ preview, commit, emptyPreview = null }) { + const previewData = ref(null) + const previewing = ref(false) + const committing = ref(false) + const result = ref(null) + + async function runPreview () { + previewing.value = true + result.value = null // clear any prior apply badge when re-previewing + try { + previewData.value = await preview() + } finally { + previewing.value = false + } + } + + async function runCommit () { + committing.value = true + try { + result.value = await commit() + if (emptyPreview !== null) { + previewData.value = typeof emptyPreview === 'function' + ? emptyPreview(result.value) + : emptyPreview + } + } finally { + committing.value = false + } + } + + return { previewData, previewing, committing, result, runPreview, runCommit } +} diff --git a/frontend/test/usePreviewCommit.spec.js b/frontend/test/usePreviewCommit.spec.js new file mode 100644 index 0000000..4459c72 --- /dev/null +++ b/frontend/test/usePreviewCommit.spec.js @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { usePreviewCommit } from '../src/composables/usePreviewCommit.js' + +// Canonical sync preview→commit flow for maintenance tiles (DRY pattern sweep, +// #753) — the primitive TagMaintenanceCard + PostMaintenanceCard's 6 flows route +// through. + +describe('usePreviewCommit', () => { + it('runPreview loads the projection and toggles previewing', async () => { + const pc = usePreviewCommit({ + preview: async () => ({ count: 3 }), + commit: async () => ({ deleted: 3 }), + }) + expect(pc.previewData.value).toBe(null) + const p = pc.runPreview() + expect(pc.previewing.value).toBe(true) + await p + expect(pc.previewing.value).toBe(false) + expect(pc.previewData.value).toEqual({ count: 3 }) + }) + + it('runCommit stores the result and collapses to a static emptyPreview', async () => { + const pc = usePreviewCommit({ + preview: async () => ({ count: 3 }), + commit: async () => ({ deleted: 3 }), + emptyPreview: { count: 0, sample_names: [] }, + }) + await pc.runPreview() + await pc.runCommit() + expect(pc.result.value).toEqual({ deleted: 3 }) + expect(pc.previewData.value).toEqual({ count: 0, sample_names: [] }) + }) + + it('emptyPreview as a function derives the collapsed shape from the result', async () => { + const pc = usePreviewCommit({ + preview: async () => ({ count: 2 }), + commit: async () => ({ sample_names: ['a', 'b'] }), + emptyPreview: (r) => ({ count: 0, sample_names: r.sample_names }), + }) + await pc.runCommit() + expect(pc.previewData.value).toEqual({ count: 0, sample_names: ['a', 'b'] }) + }) + + it('without emptyPreview the projection stays (dispatch-on-commit variant)', async () => { + const pc = usePreviewCommit({ + preview: async () => ({ total_changes: 5 }), + commit: async () => ({ status: 'queued' }), + }) + await pc.runPreview() + await pc.runCommit() + expect(pc.result.value).toEqual({ status: 'queued' }) + expect(pc.previewData.value).toEqual({ total_changes: 5 }) // unchanged + }) + + it('runPreview clears a prior apply result (re-preview hides the badge)', async () => { + const pc = usePreviewCommit({ + preview: async () => ({ count: 1 }), + commit: async () => ({ deleted: 1 }), + emptyPreview: { count: 0 }, + }) + await pc.runCommit() + expect(pc.result.value).toEqual({ deleted: 1 }) + await pc.runPreview() + expect(pc.result.value).toBe(null) + }) +})