refactor: project saving logic (#5225)

* fix: project data saving not visually shown immediately

* feat: useSavable improvements

* feat: migrate where possible to useSavable

* fix: gitignore

* feat: use es-toolkit
This commit is contained in:
Calum H.
2026-01-28 16:46:14 +00:00
committed by GitHub
parent e57c15b3ce
commit 400c571fe6
15 changed files with 699 additions and 507 deletions

View File

@@ -1,6 +1,14 @@
<script setup>
import { SaveIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { Avatar, Button, ConfirmModal, FileInput, injectNotificationManager } from '@modrinth/ui'
import { TrashIcon, UploadIcon } from '@modrinth/assets'
import {
Avatar,
Button,
ConfirmModal,
FileInput,
injectNotificationManager,
UnsavedChangesPopup,
useSavable,
} from '@modrinth/ui'
import { injectOrganizationContext } from '~/providers/organization-context.ts'
@@ -14,32 +22,49 @@ const {
patchOrganization,
} = injectOrganizationContext()
// Icon state (separate from useSavable, like collection page)
const icon = ref(null)
const deletedIcon = ref(false)
const previewImage = ref(null)
const name = ref(organization.value.name)
const slug = ref(organization.value.slug)
const {
saved,
current,
saving,
hasChanges: hasFieldChanges,
reset: resetFields,
} = useSavable(
() => ({
name: organization.value.name,
slug: organization.value.slug,
summary: organization.value.description,
}),
async ({ name, slug, summary }) => {
await patchOrganization({
...(name !== undefined && { name }),
...(slug !== undefined && { slug }),
...(summary !== undefined && { description: summary }),
})
},
)
const summary = ref(organization.value.description)
// Combined state for UnsavedChangesPopup
const originalState = computed(() => ({
...saved.value,
iconChanged: false,
}))
const patchData = computed(() => {
const data = {}
if (name.value !== organization.value.name) {
data.name = name.value
}
if (slug.value !== organization.value.slug) {
data.slug = slug.value
}
if (summary.value !== organization.value.description) {
data.description = summary.value
}
return data
})
const modifiedState = computed(() => ({
...current.value,
iconChanged: !!(deletedIcon.value || icon.value),
}))
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
})
const reset = () => {
resetFields()
icon.value = null
deletedIcon.value = false
previewImage.value = null
}
const markIconForDeletion = () => {
deletedIcon.value = true
@@ -61,11 +86,16 @@ const showPreviewImage = (files) => {
const orgId = useRouteId()
const onSaveChanges = useClientTry(async () => {
// Only PATCH organization details if there are actual field changes
const hasOrgFieldChanges = Object.keys(patchData.value).length > 0
if (hasOrgFieldChanges) {
await patchOrganization(patchData.value)
const save = async () => {
// Save field changes via useSavable
if (hasFieldChanges.value) {
await patchOrganization({
...(current.value.name !== organization.value.name && { name: current.value.name }),
...(current.value.slug !== organization.value.slug && { slug: current.value.slug }),
...(current.value.summary !== organization.value.description && {
description: current.value.summary,
}),
})
}
// Handle icon deletion / upload separately
@@ -85,7 +115,7 @@ const onSaveChanges = useClientTry(async () => {
text: 'Your organization has been updated.',
type: 'success',
})
})
}
const onDeleteOrganization = useClientTry(async () => {
await useBaseFetch(`organization/${orgId}`, {
@@ -159,7 +189,7 @@ const onDeleteOrganization = useClientTry(async () => {
</label>
<input
id="project-name"
v-model="name"
v-model="current.name"
maxlength="2048"
type="text"
:disabled="!hasPermission"
@@ -172,7 +202,7 @@ const onDeleteOrganization = useClientTry(async () => {
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<input
id="project-slug"
v-model="slug"
v-model="current.slug"
type="text"
maxlength="64"
autocomplete="off"
@@ -186,17 +216,11 @@ const onDeleteOrganization = useClientTry(async () => {
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
v-model="summary"
v-model="current.summary"
maxlength="256"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<Button color="primary" :disabled="!hasChanges" @click="onSaveChanges">
<SaveIcon />
Save changes
</Button>
</div>
</div>
<div class="universal-card">
<div class="label">
@@ -213,6 +237,13 @@ const onDeleteOrganization = useClientTry(async () => {
Delete organization
</Button>
</div>
<UnsavedChangesPopup
:original="originalState"
:modified="modifiedState"
:saving="saving"
@reset="reset"
@save="save"
/>
</div>
</template>