refactor(settings): canonical usePreviewCommit for maintenance preview→commit tiles (#753)
Frontend pattern-consistency sweep (note #1026, the last DRY-thread item). TagMaintenanceCard (4 flows) + PostMaintenanceCard (2 flows) each hand-rolled the same sync preview→commit state machine: a previewData/previewing/committing triple + onPreview/onCommit that dry-run-previews, then applies and collapses the projection (the apply shares the backend predicate, so afterward it's empty). Extract usePreviewCommit({preview, commit, emptyPreview}) owning that lifecycle. The 6 flows become declarative: supply the two thunks + the collapse shape. The normalize flow (commit dispatches a self-resuming background task, not a sync apply) omits emptyPreview so the projection stays and a truthy result = queued. Composable returns are aliased to the cards' existing local names, so the templates only change where they read the apply result (the success badges). Long-Celery-task cards (GatedPurge/VideoDedup) keep useMaintenanceTask — a different pattern (navigable-away task lifecycle), deliberately not merged. Exhaustiveness: no card hand-rolls the refs anymore; the only dryRun:false callers are these two cards, both via the composable. Added a vitest spec for the primitive (collapse static + fn, dispatch-variant, re-preview clears result). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,8 +42,8 @@
|
|||||||
:loading="committing"
|
:loading="committing"
|
||||||
@click="onCommit"
|
@click="onCommit"
|
||||||
>Delete {{ preview.count }} bare post(s)</v-btn>
|
>Delete {{ preview.count }} bare post(s)</v-btn>
|
||||||
<span v-if="deleted != null" class="ml-3 text-caption text-success">
|
<span v-if="bareResult" class="ml-3 text-caption text-success">
|
||||||
Deleted {{ deleted }} ✓
|
Deleted {{ bareResult.deleted ?? 0 }} ✓
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</MaintenanceTile>
|
</MaintenanceTile>
|
||||||
@@ -88,78 +88,47 @@
|
|||||||
:loading="merging"
|
:loading="merging"
|
||||||
@click="onMergeDupes"
|
@click="onMergeDupes"
|
||||||
>Merge {{ dupPreview.posts_to_merge }} duplicate(s)</v-btn>
|
>Merge {{ dupPreview.posts_to_merge }} duplicate(s)</v-btn>
|
||||||
<span v-if="merged != null" class="ml-3 text-caption text-success">
|
<span v-if="dupResult" class="ml-3 text-caption text-success">
|
||||||
Merged {{ merged }} ✓
|
Merged {{ dupResult.merged ?? 0 }} ✓
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</MaintenanceTile>
|
</MaintenanceTile>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { useAdminStore } from '../../stores/admin.js'
|
import { useAdminStore } from '../../stores/admin.js'
|
||||||
|
import { usePreviewCommit } from '../../composables/usePreviewCommit.js'
|
||||||
import SampleNameGrid from '../common/SampleNameGrid.vue'
|
import SampleNameGrid from '../common/SampleNameGrid.vue'
|
||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
|
||||||
const store = useAdminStore()
|
const store = useAdminStore()
|
||||||
const preview = ref(null)
|
|
||||||
const loadingPreview = ref(false)
|
|
||||||
const committing = ref(false)
|
|
||||||
const deleted = ref(null)
|
|
||||||
|
|
||||||
const dupPreview = ref(null)
|
// Bare-post prune: preview the count, then delete. After the apply the same
|
||||||
const loadingDupPreview = ref(false)
|
// predicate is empty, so collapse to count 0.
|
||||||
const merging = ref(false)
|
const {
|
||||||
const merged = ref(null)
|
previewData: preview, previewing: loadingPreview, committing,
|
||||||
|
result: bareResult, runPreview: onPreview, runCommit: onCommit,
|
||||||
|
} = usePreviewCommit({
|
||||||
|
preview: () => store.pruneBarePosts({ dryRun: true }),
|
||||||
|
commit: () => store.pruneBarePosts({ dryRun: false }),
|
||||||
|
emptyPreview: { count: 0, sample_names: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Duplicate-post reconcile: same shape, collapse to zero groups after merge.
|
||||||
|
const {
|
||||||
|
previewData: dupPreview, previewing: loadingDupPreview, committing: merging,
|
||||||
|
result: dupResult, runPreview: onPreviewDupes, runCommit: onMergeDupes,
|
||||||
|
} = usePreviewCommit({
|
||||||
|
preview: () => store.reconcileDuplicatePosts({ dryRun: true }),
|
||||||
|
commit: () => store.reconcileDuplicatePosts({ dryRun: false }),
|
||||||
|
emptyPreview: { groups: 0, posts_to_merge: 0, sample: [] },
|
||||||
|
})
|
||||||
|
|
||||||
const dupSampleNames = computed(() =>
|
const dupSampleNames = computed(() =>
|
||||||
(dupPreview.value?.sample ?? []).map(
|
(dupPreview.value?.sample ?? []).map(
|
||||||
(g) => `${g.title} — ${g.rows} rows`,
|
(g) => `${g.title} — ${g.rows} rows`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function onPreview() {
|
|
||||||
loadingPreview.value = true
|
|
||||||
deleted.value = null
|
|
||||||
try {
|
|
||||||
preview.value = await store.pruneBarePosts({ dryRun: true })
|
|
||||||
} finally {
|
|
||||||
loadingPreview.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onCommit() {
|
|
||||||
committing.value = true
|
|
||||||
try {
|
|
||||||
const result = await store.pruneBarePosts({ dryRun: false })
|
|
||||||
deleted.value = result.deleted ?? 0
|
|
||||||
// Reflect the completed sweep — the predicate is identical to the preview,
|
|
||||||
// so after a commit there is nothing left to delete.
|
|
||||||
preview.value = { count: 0, sample_names: [] }
|
|
||||||
} finally {
|
|
||||||
committing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPreviewDupes() {
|
|
||||||
loadingDupPreview.value = true
|
|
||||||
merged.value = null
|
|
||||||
try {
|
|
||||||
dupPreview.value = await store.reconcileDuplicatePosts({ dryRun: true })
|
|
||||||
} finally {
|
|
||||||
loadingDupPreview.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onMergeDupes() {
|
|
||||||
merging.value = true
|
|
||||||
try {
|
|
||||||
const result = await store.reconcileDuplicatePosts({ dryRun: false })
|
|
||||||
merged.value = result.merged ?? 0
|
|
||||||
// Same predicate as the preview — after the merge there are no dup groups left.
|
|
||||||
dupPreview.value = { groups: 0, posts_to_merge: 0, sample: [] }
|
|
||||||
} finally {
|
|
||||||
merging.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -186,11 +186,11 @@
|
|||||||
<v-btn
|
<v-btn
|
||||||
color="error" variant="flat" rounded="pill"
|
color="error" variant="flat" rounded="pill"
|
||||||
prepend-icon="mdi-format-letter-case"
|
prepend-icon="mdi-format-letter-case"
|
||||||
:disabled="!normPreview.total_changes || normResult === 'queued'"
|
:disabled="!normPreview.total_changes || !!normResult"
|
||||||
:loading="normCommitting"
|
:loading="normCommitting"
|
||||||
@click="onNormCommit"
|
@click="onNormCommit"
|
||||||
>Standardize {{ normPreview.total_changes }} tag group(s)</v-btn>
|
>Standardize {{ normPreview.total_changes }} tag group(s)</v-btn>
|
||||||
<span v-if="normResult === 'queued'" class="ml-3 text-caption text-success">
|
<span v-if="normResult" class="ml-3 text-caption text-success">
|
||||||
Queued ✓ — runs in the background (you can leave this page). It
|
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.
|
processes in chunks, so re-run “Preview” later to confirm it's all done.
|
||||||
</span>
|
</span>
|
||||||
@@ -199,106 +199,53 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
import { useAdminStore } from '../../stores/admin.js'
|
import { useAdminStore } from '../../stores/admin.js'
|
||||||
|
import { usePreviewCommit } from '../../composables/usePreviewCommit.js'
|
||||||
import SampleNameGrid from '../common/SampleNameGrid.vue'
|
import SampleNameGrid from '../common/SampleNameGrid.vue'
|
||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
|
||||||
const store = useAdminStore()
|
const store = useAdminStore()
|
||||||
const preview = ref(null)
|
|
||||||
const loadingPreview = ref(false)
|
|
||||||
const committing = ref(false)
|
|
||||||
const kindPreview = ref(null)
|
|
||||||
const loadingKindPreview = ref(false)
|
|
||||||
const kindCommitting = ref(false)
|
|
||||||
const resetPreview = ref(null)
|
|
||||||
const loadingResetPreview = ref(false)
|
|
||||||
const resetCommitting = ref(false)
|
|
||||||
const normPreview = ref(null)
|
|
||||||
const loadingNormPreview = ref(false)
|
|
||||||
const normCommitting = ref(false)
|
|
||||||
const normResult = ref(null)
|
|
||||||
|
|
||||||
async function onPreview() {
|
// Unused-tag prune. Collapse to count 0 but carry the apply's sample_names.
|
||||||
loadingPreview.value = true
|
const {
|
||||||
try {
|
previewData: preview, previewing: loadingPreview, committing,
|
||||||
preview.value = await store.pruneUnusedTags({ dryRun: true })
|
runPreview: onPreview, runCommit: onCommit,
|
||||||
} finally {
|
} = usePreviewCommit({
|
||||||
loadingPreview.value = false
|
preview: () => store.pruneUnusedTags({ dryRun: true }),
|
||||||
}
|
commit: () => store.pruneUnusedTags({ dryRun: false }),
|
||||||
}
|
emptyPreview: (r) => ({ count: 0, sample_names: r.sample_names || [] }),
|
||||||
|
})
|
||||||
|
|
||||||
async function onCommit() {
|
// Legacy migration-tag purge.
|
||||||
committing.value = true
|
const {
|
||||||
try {
|
previewData: kindPreview, previewing: loadingKindPreview,
|
||||||
const result = await store.pruneUnusedTags({ dryRun: false })
|
committing: kindCommitting, runPreview: onKindPreview, runCommit: onKindCommit,
|
||||||
preview.value = { count: 0, sample_names: result.sample_names || [] }
|
} = usePreviewCommit({
|
||||||
} finally {
|
preview: () => store.purgeLegacyTags({ dryRun: true }),
|
||||||
committing.value = false
|
commit: () => store.purgeLegacyTags({ dryRun: false }),
|
||||||
}
|
emptyPreview: { count: 0, by_kind: {}, by_prefix: {}, sample_names: [] },
|
||||||
}
|
})
|
||||||
|
|
||||||
async function onKindPreview() {
|
// Reset content tagging (general + character).
|
||||||
loadingKindPreview.value = true
|
const {
|
||||||
try {
|
previewData: resetPreview, previewing: loadingResetPreview,
|
||||||
kindPreview.value = await store.purgeLegacyTags({ dryRun: true })
|
committing: resetCommitting, runPreview: onResetPreview, runCommit: onResetCommit,
|
||||||
} finally {
|
} = usePreviewCommit({
|
||||||
loadingKindPreview.value = false
|
preview: () => store.resetContentTagging({ dryRun: true }),
|
||||||
}
|
commit: () => store.resetContentTagging({ dryRun: false }),
|
||||||
}
|
emptyPreview: { count: 0, by_kind: {}, applications: 0, sample_names: [] },
|
||||||
|
})
|
||||||
|
|
||||||
async function onKindCommit() {
|
// Standardize casing. The apply DISPATCHES a self-resuming background task (no
|
||||||
kindCommitting.value = true
|
// poll-until-done — that would falsely report complete after the first chunk),
|
||||||
try {
|
// so there's no emptyPreview: leave the projection up; a truthy normResult means
|
||||||
await store.purgeLegacyTags({ dryRun: false })
|
// "queued". The operator re-runs Preview later to confirm it's all done.
|
||||||
kindPreview.value = { count: 0, by_kind: {}, by_prefix: {}, sample_names: [] }
|
const {
|
||||||
} finally {
|
previewData: normPreview, previewing: loadingNormPreview,
|
||||||
kindCommitting.value = false
|
committing: normCommitting, result: normResult,
|
||||||
}
|
runPreview: onNormPreview, runCommit: onNormCommit,
|
||||||
}
|
} = usePreviewCommit({
|
||||||
|
preview: () => store.normalizeTags({ dryRun: true }),
|
||||||
async function onResetPreview() {
|
commit: () => store.normalizeTags({ dryRun: false }),
|
||||||
loadingResetPreview.value = true
|
})
|
||||||
try {
|
|
||||||
resetPreview.value = await store.resetContentTagging({ dryRun: true })
|
|
||||||
} finally {
|
|
||||||
loadingResetPreview.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onResetCommit() {
|
|
||||||
resetCommitting.value = true
|
|
||||||
try {
|
|
||||||
await store.resetContentTagging({ dryRun: false })
|
|
||||||
resetPreview.value = { count: 0, by_kind: {}, applications: 0, sample_names: [] }
|
|
||||||
} finally {
|
|
||||||
resetCommitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onNormPreview() {
|
|
||||||
loadingNormPreview.value = true
|
|
||||||
normResult.value = null
|
|
||||||
try {
|
|
||||||
normPreview.value = await store.normalizeTags({ dryRun: true })
|
|
||||||
} finally {
|
|
||||||
loadingNormPreview.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onNormCommit() {
|
|
||||||
normCommitting.value = true
|
|
||||||
normResult.value = null
|
|
||||||
try {
|
|
||||||
// Fire-and-forget: the task is time-boxed and self-resuming across chunks
|
|
||||||
// (a large back-catalog can't finish in one run), so we DON'T poll-until-
|
|
||||||
// done — that would falsely report "complete" after the first chunk. Just
|
|
||||||
// confirm it's queued; the operator can re-run Preview later to verify.
|
|
||||||
await store.normalizeTags({ dryRun: false })
|
|
||||||
normResult.value = 'queued'
|
|
||||||
} finally {
|
|
||||||
normCommitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user