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
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] }) // 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] })
}
// 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', projectId] }) await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== 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 }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] }) // Cancel outgoing refetches for both slug-based and ID-based cache keys
const previousProject = queryClient.getQueryData(['project', 'v2', projectId]) await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.cancelQueries({ queryKey: ['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 }) => {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', 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] })
}
}, },
}) })
@@ -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,205 +318,199 @@ 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,
const deleteIndex = ref(-1)
const editIndex = ref(-1)
const editTitle = ref('')
const editDescription = ref('')
const editFeatured = ref(false)
const editOrder = ref(null)
const editFile = ref(null)
const previewImage = ref(null)
const shouldPreventActions = ref(false)
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
const nextImage = () => {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= project.value.gallery.length) {
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()
if (editFile.value instanceof Blob) {
reader.readAsDataURL(editFile.value)
reader.onload = (event) => {
previewImage.value = event.target.result
} }
}, }
data() { }
return {
expandedGalleryItem: null,
expandedGalleryIndex: 0,
zoomedIn: false,
deleteIndex: -1, const createGalleryItem = async () => {
shouldPreventActions.value = true
startLoading()
editIndex: -1, try {
editTitle: '', let url = `project/${project.value.id}/gallery?ext=${
editDescription: '', editFile.value
editFeatured: false, ? editFile.value.type.split('/')[editFile.value.type.split('/').length - 1]
editOrder: null, : null
editFile: null, }&featured=${editFeatured.value}`
previewImage: null,
shouldPreventActions: false, if (editTitle.value) {
url += `&title=${encodeURIComponent(editTitle.value)}`
} }
}, if (editDescription.value) {
computed: { url += `&description=${encodeURIComponent(editDescription.value)}`
acceptFileTypes() { }
return 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp' if (editOrder.value) {
}, url += `&ordering=${editOrder.value}`
},
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)) await useBaseFetch(url, {
}, method: 'POST',
methods: { body: editFile.value,
nextImage() { })
this.expandedGalleryIndex++ await refreshProject()
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() modal_edit_item.value.hide()
this.$refs.modal_edit_item.show() } catch (err) {
}, addNotification({
showPreviewImage() { title: 'An error occurred',
const reader = new FileReader() text: err.data ? err.data.description : err,
if (this.editFile instanceof Blob) { type: 'error',
reader.readAsDataURL(this.editFile) })
reader.onload = (event) => { }
this.previewImage = event.target.result
}
}
},
async createGalleryItem() {
this.shouldPreventActions = true
startLoading()
try { stopLoading()
let url = `project/${this.project.id}/gallery?ext=${ shouldPreventActions.value = false
this.editFile }
? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1]
: null
}&featured=${this.editFeatured}`
if (this.editTitle) { const editGalleryItem = async () => {
url += `&title=${encodeURIComponent(this.editTitle)}` shouldPreventActions.value = true
} startLoading()
if (this.editDescription) { try {
url += `&description=${encodeURIComponent(this.editDescription)}` let url = `project/${project.value.id}/gallery?url=${encodeURIComponent(
} project.value.gallery[editIndex.value].url,
if (this.editOrder) { )}&featured=${editFeatured.value}`
url += `&ordering=${this.editOrder}`
}
await useBaseFetch(url, { if (editTitle.value) {
method: 'POST', url += `&title=${encodeURIComponent(editTitle.value)}`
body: this.editFile, }
}) if (editDescription.value) {
await this.refreshProject() url += `&description=${encodeURIComponent(editDescription.value)}`
}
if (editOrder.value) {
url += `&ordering=${editOrder.value}`
}
this.$refs.modal_edit_item.hide() await useBaseFetch(url, {
} catch (err) { method: 'PATCH',
this.addNotification({ })
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading() await refreshProject()
this.shouldPreventActions = false modal_edit_item.value.hide()
}, } catch (err) {
async editGalleryItem() { addNotification({
this.shouldPreventActions = true title: 'An error occurred',
startLoading() text: err.data ? err.data.description : err,
try { type: 'error',
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent( })
this.project.gallery[this.editIndex].url, }
)}&featured=${this.editFeatured}`
if (this.editTitle) { stopLoading()
url += `&title=${encodeURIComponent(this.editTitle)}` shouldPreventActions.value = false
} }
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await useBaseFetch(url, { const deleteGalleryImage = async () => {
method: 'PATCH', startLoading()
})
await this.refreshProject() try {
this.$refs.modal_edit_item.hide() await useBaseFetch(
} catch (err) { `project/${project.value.id}/gallery?url=${encodeURIComponent(
this.addNotification({ project.value.gallery[deleteIndex.value].url,
title: 'An error occurred', )}`,
text: err.data ? err.data.description : err, {
type: 'error', method: 'DELETE',
}) },
} )
stopLoading() await refreshProject()
this.shouldPreventActions = false } catch (err) {
}, addNotification({
async deleteGalleryImage() { title: 'An error occurred',
startLoading() text: err.data ? err.data.description : err,
type: 'error',
})
}
try { stopLoading()
await useBaseFetch( }
`project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.deleteIndex].url,
)}`,
{
method: 'DELETE',
},
)
await this.refreshProject() const handleKeydown = (e) => {
} catch (err) { if (expandedGalleryItem.value) {
this.addNotification({ e.preventDefault()
title: 'An error occurred', if (e.key === 'Escape') {
text: err.data ? err.data.description : err, expandedGalleryItem.value = null
type: 'error', } else if (e.key === 'ArrowLeft') {
}) e.stopPropagation()
} previousImage()
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
nextImage()
}
}
}
stopLoading() onMounted(() => {
}, document.addEventListener('keydown', handleKeydown)
}, })
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
}) })
</script> </script>