fix: optimistically updating on moderation mutations (#5200)

* fix: moderation optimistically update

* fix: gallery settings
This commit is contained in:
Calum H.
2026-01-25 20:27:45 +00:00
committed by GitHub
parent 2d7e87a4cb
commit c96a303d8a
2 changed files with 216 additions and 200 deletions

View File

@@ -1658,14 +1658,18 @@ const patchProjectMutation = useMutation({
}, },
onMutate: async ({ projectId, data }) => { onMutate: async ({ projectId, data }) => {
// Cancel outgoing refetches // Cancel outgoing refetches for both slug-based and ID-based cache keys
// The query may be keyed by slug (routeProjectId) but we also have the actual UUID (projectId)
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] }) await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
}
// Snapshot previous value // Snapshot previous value from the active query (uses route param as key)
const previousProject = queryClient.getQueryData(['project', 'v2', projectId]) const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
// Optimistic update // Optimistic update on the active query key
queryClient.setQueryData(['project', 'v2', projectId], (old) => { queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
if (!old) return old if (!old) return old
return { ...old, ...data } return { ...old, ...data }
}) })
@@ -1673,10 +1677,10 @@ const patchProjectMutation = useMutation({
return { previousProject } return { previousProject }
}, },
onError: (err, { projectId }, context) => { onError: (err, _variables, context) => {
// Rollback on error // Rollback on error using the active query key
if (context?.previousProject) { if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', projectId], context.previousProject) queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
} }
addNotification({ addNotification({
title: formatMessage(commonMessages.errorNotificationTitle), title: formatMessage(commonMessages.errorNotificationTitle),
@@ -1687,8 +1691,11 @@ const patchProjectMutation = useMutation({
}, },
onSettled: async (_data, _error, { projectId }) => { onSettled: async (_data, _error, { projectId }) => {
// Always refetch to ensure consistency // Invalidate both slug-based and ID-based cache keys to ensure consistency
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] }) await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] })
}
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] }) await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] })
}, },
}) })
@@ -1703,11 +1710,17 @@ const patchStatusMutation = useMutation({
}, },
onMutate: async ({ projectId, status }) => { onMutate: async ({ projectId, status }) => {
// Cancel outgoing refetches for both slug-based and ID-based cache keys
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] }) await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
const previousProject = queryClient.getQueryData(['project', 'v2', projectId]) }
// Optimistic update // Snapshot previous value from the active query (uses route param as key)
queryClient.setQueryData(['project', 'v2', projectId], (old) => { const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
// Optimistic update on the active query key
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
if (!old) return old if (!old) return old
return { ...old, status } return { ...old, status }
}) })
@@ -1715,9 +1728,10 @@ const patchStatusMutation = useMutation({
return { previousProject } return { previousProject }
}, },
onError: (err, { projectId }, context) => { onError: (err, _variables, context) => {
// Rollback on error using the active query key
if (context?.previousProject) { if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', projectId], context.previousProject) queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
} }
addNotification({ addNotification({
title: formatMessage(commonMessages.errorNotificationTitle), title: formatMessage(commonMessages.errorNotificationTitle),
@@ -1727,7 +1741,11 @@ const patchStatusMutation = useMutation({
}, },
onSettled: async (_data, _error, { projectId }) => { onSettled: async (_data, _error, { projectId }) => {
// Invalidate both slug-based and ID-based cache keys to ensure consistency
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] }) await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] })
}
}, },
}) })
@@ -1902,6 +1920,9 @@ watch(downloadModal, (modal) => {
}) })
async function setProcessing() { async function setProcessing() {
// Guard against multiple submissions while mutation is pending
if (patchStatusMutation.isPending.value) return
startLoading() startLoading()
patchStatusMutation.mutate( patchStatusMutation.mutate(
{ projectId: project.value.id, status: 'processing' }, { projectId: project.value.id, status: 'processing' },

View File

@@ -95,7 +95,7 @@
Unfeature image Unfeature image
</button> </button>
<div class="button-group"> <div class="button-group">
<button class="iconified-button" @click="$refs.modal_edit_item.hide()"> <button class="iconified-button" @click="modal_edit_item.hide()">
<XIcon aria-hidden="true" /> <XIcon aria-hidden="true" />
Cancel Cancel
</button> </button>
@@ -250,7 +250,7 @@
editDescription = item.description editDescription = item.description
editFeatured = item.featured editFeatured = item.featured
editOrder = item.ordering editOrder = item.ordering
$refs.modal_edit_item.show() modal_edit_item.show()
} }
" "
> >
@@ -262,7 +262,7 @@
@click=" @click="
() => { () => {
deleteIndex = index deleteIndex = index
$refs.modal_confirm.show() modal_confirm.show()
} }
" "
> >
@@ -306,7 +306,8 @@ import {
import { isPermission } from '~/utils/permissions.ts' import { isPermission } from '~/utils/permissions.ts'
const { projectV2: project, currentMember } = injectProjectPageContext() const { addNotification } = injectNotificationManager()
const { projectV2: project, currentMember, refreshProject } = injectProjectPageContext()
const title = `${project.value.title} - Gallery` const title = `${project.value.title} - Gallery`
const description = `View ${project.value.gallery?.length ?? 0} images of ${project.value.title} on Modrinth.` const description = `View ${project.value.gallery?.length ?? 0} images of ${project.value.title} on Modrinth.`
@@ -317,136 +318,107 @@ useSeoMeta({
ogTitle: title, ogTitle: title,
ogDescription: description, ogDescription: description,
}) })
</script>
<script> const modal_edit_item = ref(null)
export default defineNuxtComponent({ const modal_confirm = ref(null)
setup() {
const { addNotification } = injectNotificationManager()
const { projectV2: project, refreshProject } = injectProjectPageContext()
return { const expandedGalleryItem = ref(null)
addNotification, const expandedGalleryIndex = ref(0)
project, const zoomedIn = ref(false)
refreshProject,
}
},
data() {
return {
expandedGalleryItem: null,
expandedGalleryIndex: 0,
zoomedIn: false,
deleteIndex: -1, const deleteIndex = ref(-1)
editIndex: -1, const editIndex = ref(-1)
editTitle: '', const editTitle = ref('')
editDescription: '', const editDescription = ref('')
editFeatured: false, const editFeatured = ref(false)
editOrder: null, const editOrder = ref(null)
editFile: null, const editFile = ref(null)
previewImage: null, const previewImage = ref(null)
shouldPreventActions: false, const shouldPreventActions = ref(false)
}
},
computed: {
acceptFileTypes() {
return 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
},
},
mounted() {
this._keyListener = function (e) {
if (this.expandedGalleryItem) {
e.preventDefault()
if (e.key === 'Escape') {
this.expandedGalleryItem = null
} else if (e.key === 'ArrowLeft') {
e.stopPropagation()
this.previousImage()
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
this.nextImage()
}
}
}
document.addEventListener('keydown', this._keyListener.bind(this)) const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
},
methods: {
nextImage() {
this.expandedGalleryIndex++
if (this.expandedGalleryIndex >= this.project.gallery.length) {
this.expandedGalleryIndex = 0
}
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
},
previousImage() {
this.expandedGalleryIndex--
if (this.expandedGalleryIndex < 0) {
this.expandedGalleryIndex = this.project.gallery.length - 1
}
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
},
expandImage(item, index) {
this.expandedGalleryItem = item
this.expandedGalleryIndex = index
this.zoomedIn = false
},
resetEdit() {
this.editIndex = -1
this.editTitle = ''
this.editDescription = ''
this.editFeatured = false
this.editOrder = null
this.editFile = null
this.previewImage = null
},
handleFiles(files) {
this.resetEdit()
this.editFile = files[0]
this.showPreviewImage() const nextImage = () => {
this.$refs.modal_edit_item.show() expandedGalleryIndex.value++
}, if (expandedGalleryIndex.value >= project.value.gallery.length) {
showPreviewImage() { expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
}
const previousImage = () => {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = project.value.gallery.length - 1
}
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
}
const expandImage = (item, index) => {
expandedGalleryItem.value = item
expandedGalleryIndex.value = index
zoomedIn.value = false
}
const resetEdit = () => {
editIndex.value = -1
editTitle.value = ''
editDescription.value = ''
editFeatured.value = false
editOrder.value = null
editFile.value = null
previewImage.value = null
}
const handleFiles = (files) => {
resetEdit()
editFile.value = files[0]
showPreviewImage()
modal_edit_item.value.show()
}
const showPreviewImage = () => {
const reader = new FileReader() const reader = new FileReader()
if (this.editFile instanceof Blob) { if (editFile.value instanceof Blob) {
reader.readAsDataURL(this.editFile) reader.readAsDataURL(editFile.value)
reader.onload = (event) => { reader.onload = (event) => {
this.previewImage = event.target.result previewImage.value = event.target.result
} }
} }
}, }
async createGalleryItem() {
this.shouldPreventActions = true const createGalleryItem = async () => {
shouldPreventActions.value = true
startLoading() startLoading()
try { try {
let url = `project/${this.project.id}/gallery?ext=${ let url = `project/${project.value.id}/gallery?ext=${
this.editFile editFile.value
? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1] ? editFile.value.type.split('/')[editFile.value.type.split('/').length - 1]
: null : null
}&featured=${this.editFeatured}` }&featured=${editFeatured.value}`
if (this.editTitle) { if (editTitle.value) {
url += `&title=${encodeURIComponent(this.editTitle)}` url += `&title=${encodeURIComponent(editTitle.value)}`
} }
if (this.editDescription) { if (editDescription.value) {
url += `&description=${encodeURIComponent(this.editDescription)}` url += `&description=${encodeURIComponent(editDescription.value)}`
} }
if (this.editOrder) { if (editOrder.value) {
url += `&ordering=${this.editOrder}` url += `&ordering=${editOrder.value}`
} }
await useBaseFetch(url, { await useBaseFetch(url, {
method: 'POST', method: 'POST',
body: this.editFile, body: editFile.value,
}) })
await this.refreshProject() await refreshProject()
this.$refs.modal_edit_item.hide() modal_edit_item.value.hide()
} catch (err) { } catch (err) {
this.addNotification({ addNotification({
title: 'An error occurred', title: 'An error occurred',
text: err.data ? err.data.description : err, text: err.data ? err.data.description : err,
type: 'error', type: 'error',
@@ -454,34 +426,35 @@ export default defineNuxtComponent({
} }
stopLoading() stopLoading()
this.shouldPreventActions = false shouldPreventActions.value = false
}, }
async editGalleryItem() {
this.shouldPreventActions = true const editGalleryItem = async () => {
shouldPreventActions.value = true
startLoading() startLoading()
try { try {
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent( let url = `project/${project.value.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.editIndex].url, project.value.gallery[editIndex.value].url,
)}&featured=${this.editFeatured}` )}&featured=${editFeatured.value}`
if (this.editTitle) { if (editTitle.value) {
url += `&title=${encodeURIComponent(this.editTitle)}` url += `&title=${encodeURIComponent(editTitle.value)}`
} }
if (this.editDescription) { if (editDescription.value) {
url += `&description=${encodeURIComponent(this.editDescription)}` url += `&description=${encodeURIComponent(editDescription.value)}`
} }
if (this.editOrder) { if (editOrder.value) {
url += `&ordering=${this.editOrder}` url += `&ordering=${editOrder.value}`
} }
await useBaseFetch(url, { await useBaseFetch(url, {
method: 'PATCH', method: 'PATCH',
}) })
await this.refreshProject() await refreshProject()
this.$refs.modal_edit_item.hide() modal_edit_item.value.hide()
} catch (err) { } catch (err) {
this.addNotification({ addNotification({
title: 'An error occurred', title: 'An error occurred',
text: err.data ? err.data.description : err, text: err.data ? err.data.description : err,
type: 'error', type: 'error',
@@ -489,24 +462,25 @@ export default defineNuxtComponent({
} }
stopLoading() stopLoading()
this.shouldPreventActions = false shouldPreventActions.value = false
}, }
async deleteGalleryImage() {
const deleteGalleryImage = async () => {
startLoading() startLoading()
try { try {
await useBaseFetch( await useBaseFetch(
`project/${this.project.id}/gallery?url=${encodeURIComponent( `project/${project.value.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.deleteIndex].url, project.value.gallery[deleteIndex.value].url,
)}`, )}`,
{ {
method: 'DELETE', method: 'DELETE',
}, },
) )
await this.refreshProject() await refreshProject()
} catch (err) { } catch (err) {
this.addNotification({ addNotification({
title: 'An error occurred', title: 'An error occurred',
text: err.data ? err.data.description : err, text: err.data ? err.data.description : err,
type: 'error', type: 'error',
@@ -514,8 +488,29 @@ export default defineNuxtComponent({
} }
stopLoading() stopLoading()
}, }
},
const handleKeydown = (e) => {
if (expandedGalleryItem.value) {
e.preventDefault()
if (e.key === 'Escape') {
expandedGalleryItem.value = null
} else if (e.key === 'ArrowLeft') {
e.stopPropagation()
previousImage()
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
nextImage()
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
}) })
</script> </script>