feat: implement dependency deletion modal for content tab (#6410)

* feat: implement dependency deletion modal for content tab

* fix: handle bulk delete state

* fix: lint
This commit is contained in:
Calum H.
2026-06-16 19:36:30 +01:00
committed by GitHub
parent b5f7406998
commit 46a7cf490d
11 changed files with 921 additions and 50 deletions
+59 -1
View File
@@ -99,7 +99,7 @@ import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache.js'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import {
@@ -451,6 +451,63 @@ async function removeMod(mod: ContentItem) {
}
}
function isBreakingDependency(dependency: Labrinth.Versions.v2.Dependency) {
return dependency.dependency_type === 'required' || dependency.dependency_type === 'embedded'
}
function dependencyTargetsItem(dependency: Labrinth.Versions.v2.Dependency, item: ContentItem) {
return (
(!!dependency.project_id && dependency.project_id === item.project?.id) ||
('version_id' in dependency &&
!!dependency.version_id &&
dependency.version_id === item.version?.id)
)
}
async function getDeleteDependencyWarning(items: ContentItem[]) {
if (props.isServerInstance) return null
const deletingIds = new Set(items.map(getContentItemId))
const remainingItems = projects.value.filter((item) => !deletingIds.has(getContentItemId(item)))
const versionIds = [
...new Set(remainingItems.map((item) => item.version?.id).filter((id): id is string => !!id)),
]
if (versionIds.length === 0) return null
const versions = (await get_version_many(versionIds).catch((err) => {
handleError(err as Error)
return null
})) as Labrinth.Versions.v2.Version[] | null
if (!versions) return null
const versionsById = new Map(versions.map((version) => [version.id, version]))
const dependents = remainingItems
.map((candidate) => {
const version = candidate.version?.id ? versionsById.get(candidate.version.id) : null
if (!version) return null
const dependencies = items.filter((item) => {
if (!item.project?.id && !item.version?.id) return false
return version.dependencies?.some(
(dependency) =>
isBreakingDependency(dependency) && dependencyTargetsItem(dependency, item),
)
})
return dependencies.length > 0 ? { item: candidate, dependencies } : null
})
.filter(
(dependent): dependent is { item: ContentItem; dependencies: ContentItem[] } =>
dependent !== null,
)
return dependents.length > 0 ? { items, dependents } : null
}
async function updateProject(mod: ContentItem) {
if (!mod.file_path) return
const operation = beginContentOperation(mod)
@@ -1081,6 +1138,7 @@ provideContentManager({
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
uploadFiles: handleUploadFiles,
@@ -61,6 +61,7 @@ interface Props {
showCheckbox?: boolean
hideDelete?: boolean
hideActions?: boolean
inline?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -82,12 +83,14 @@ const props = withDefaults(defineProps<Props>(), {
showCheckbox: false,
hideDelete: false,
hideActions: false,
inline: false,
})
const selected = defineModel<boolean>('selected')
const emit = defineEmits<{
'update:enabled': [value: boolean]
select: [value: boolean, event?: MouseEvent]
delete: [event: MouseEvent]
update: []
switchVersion: []
@@ -124,8 +127,10 @@ const deleteHovered = ref(false)
<template>
<div
role="row"
class="flex h-[74px] items-center justify-between gap-4 px-3"
class="flex items-center justify-between"
:class="{
'h-[74px] gap-4 px-3': !inline,
'gap-3': inline,
'opacity-50 grayscale': disabled && !installing,
'opacity-50': installing,
}"
@@ -141,7 +146,7 @@ const deleteHovered = ref(false)
:model-value="selected ?? false"
:aria-label="formatMessage(messages.selectProject, { project: project.title })"
class="shrink-0"
@update:model-value="selected = $event"
@update:model-value="(value, event) => emit('select', value, event)"
/>
<div
@@ -150,7 +155,7 @@ const deleteHovered = ref(false)
>
<div
v-tooltip="installing ? formatMessage(commonMessages.installingLabel) : undefined"
class="relative shrink-0"
class="relative flex shrink-0 items-center"
>
<Avatar
:src="project.icon_url"
@@ -180,6 +185,7 @@ const deleteHovered = ref(false)
>
{{ project.title }}
</AutoLink>
<slot name="title-badges" />
<Tooltip
v-if="isClientOnly"
theme="dismissable-prompt"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { computed, getCurrentInstance, onMounted, onUnmounted, ref, toRef } from 'vue'
import { computed, getCurrentInstance, ref, toRef } from 'vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import { useVIntl } from '#ui/composables/i18n'
@@ -119,26 +119,14 @@ function toggleSelectAll() {
}
const lastSelectedIndex = ref<number | null>(null)
const shiftHeld = ref(false)
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Shift') shiftHeld.value = true
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === 'Shift') shiftHeld.value = false
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
})
function toggleItemSelection(itemId: string, selected: boolean, index?: number) {
if (selected && shiftHeld.value && lastSelectedIndex.value !== null && index !== undefined) {
function toggleItemSelection(
itemId: string,
selected: boolean,
index?: number,
event?: MouseEvent,
) {
if (selected && event?.shiftKey && lastSelectedIndex.value !== null && index !== undefined) {
const start = Math.min(lastSelectedIndex.value, index)
const end = Math.max(lastSelectedIndex.value, index)
const rangeIds = props.items.slice(start, end + 1).map((item) => item.id)
@@ -297,8 +285,9 @@ function handleSort(column: ContentCardTableSortColumn) {
'border-0 border-t border-solid border-surface-4',
visibleRange.start + idx === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@update:selected="
(val) => toggleItemSelection(item.id, val ?? false, visibleRange.start + idx)
@select="
(val, event) =>
toggleItemSelection(item.id, val ?? false, visibleRange.start + idx, event)
"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
@@ -355,7 +344,7 @@ function handleSort(column: ContentCardTableSortColumn) {
'border-0 border-t border-solid border-surface-4',
index === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@update:selected="(val) => toggleItemSelection(item.id, val ?? false, index)"
@select="(val, event) => toggleItemSelection(item.id, val ?? false, index, event)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
@update="emit('update', item.id)"
@@ -6,15 +6,12 @@
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
})
"
:fade="props.variant === 'server' ? 'warning' : 'danger'"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition
:type="props.variant === 'server' ? 'warning' : 'critical'"
:header="formatMessage(messages.admonitionHeader)"
>
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<InlineBackupCreator
@@ -32,7 +29,7 @@
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled :color="props.variant === 'server' ? 'orange' : 'red'">
<ButtonStyled color="orange">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@@ -0,0 +1,371 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
fade="danger"
max-width="560px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
{{
visibleItems.length === 1
? formatMessage(messages.singleAdmonitionBody, {
project:
visibleItems[0]?.project.title ?? formatMessage(commonMessages.unknownLabel),
context: contextLabel,
})
: formatMessage(messages.bulkAdmonitionBody, { context: contextLabel })
}}
</Admonition>
<div v-if="visibleItems.length > 0" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.deletingLabel) }}</span>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDeletingTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="deletingListRef"
class="flex flex-col gap-2 overflow-y-auto max-h-[212px]"
@scroll="checkDeletingScrollState"
>
<div v-for="item in visibleItems" :key="item.id" :class="modalContentCardClasses">
<ContentCardItem
:project="item.project"
:project-link="item.projectLink"
:version="item.version"
:version-link="item.versionLink"
:owner="item.owner"
hide-actions
inline
/>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDeletingBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
<div v-if="visibleDependents.length > 0" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.affectedDependentsLabel, { count: visibleDependents.length })
}}</span>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDependentTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="dependentListRef"
class="flex max-h-[212px] flex-col gap-2 overflow-y-auto"
@scroll="checkDependentScrollState"
>
<div
v-for="dependent in visibleDependents"
:key="dependent.item.id"
:class="modalContentCardClasses"
>
<ContentCardItem
:project="dependent.item.project"
:project-link="dependent.item.projectLink"
:version="dependent.item.version"
:version-link="dependent.item.versionLink"
:owner="dependent.item.owner"
hide-actions
inline
>
<template #title-badges>
<span class="flex min-w-0 flex-wrap items-center gap-1">
<span
v-for="dependency in dependent.dependencies"
:key="dependency.id"
:title="dependency.project.title"
>
<span class="truncate text-xs text-secondary mr-0.5"
>({{ dependency.project.title }})</span
>
</span>
</span>
</template>
</ContentCardItem>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDependentBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.whatHappensLabel)
}}</span>
<ul class="m-0 list-disc pl-6 text-primary">
<li class="leading-6 marker:text-secondary">
{{ formatMessage(messages.effectDependentContent) }}
</li>
<li class="leading-6 marker:text-secondary">
{{ formatMessage(messages.effectInstance, { context: contextLabel }) }}
</li>
</ul>
<Checkbox
v-model="disableDependentsAfterDeleting"
:label="formatMessage(messages.disableDependentsLabel)"
label-class="font-medium text-primary"
class="mt-1"
/>
</div>
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupName"
hide-shift-click-hint
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border !border-surface-5" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="confirm"
>
<TrashIcon aria-hidden="true" />
{{ deleteButtonLabel }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { TrashIcon, XIcon } from '@modrinth/assets'
import { computed, nextTick, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useScrollIndicator } from '#ui/composables/scroll-indicator'
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
import type { ContentCardTableItem } from '../../types'
import ContentCardItem from '../ContentCardItem.vue'
import InlineBackupCreator from './InlineBackupCreator.vue'
export interface ContentDependencyWarningDependent {
item: ContentCardTableItem
dependencies: ContentCardTableItem[]
}
const props = withDefaults(
defineProps<{
items?: ContentCardTableItem[]
dependents?: ContentDependencyWarningDependent[]
itemType: string
variant?: 'instance' | 'server'
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>(),
{
items: () => [],
dependents: () => [],
variant: 'instance',
backupTip: undefined,
actionDisabled: false,
actionDisabledTooltip: undefined,
},
)
const emit = defineEmits<{
(e: 'delete', disableDependentsAfterDeleting: boolean): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'content.dependency-warning.header',
defaultMessage: 'Dependency warning',
},
admonitionHeader: {
id: 'content.dependency-warning.admonition-header',
defaultMessage: 'This content is required by other content',
},
singleAdmonitionBody: {
id: 'content.dependency-warning.single-admonition-body',
defaultMessage:
'{project} is installed as a dependency. Deleting it may break your {context} or stop dependent content from loading correctly.',
},
bulkAdmonitionBody: {
id: 'content.dependency-warning.bulk-admonition-body',
defaultMessage:
'Some selected projects are installed as dependencies. Deleting them may break your {context} or stop dependent content from loading correctly.',
},
deletingLabel: {
id: 'content.dependency-warning.deleting-label',
defaultMessage: 'Deleting',
},
affectedDependentsLabel: {
id: 'content.dependency-warning.affected-dependents-label',
defaultMessage: 'Affected {count, plural, one {project} other {projects}}',
},
whatHappensLabel: {
id: 'content.dependency-warning.what-happens-label',
defaultMessage: 'What happens?',
},
effectDependentContent: {
id: 'content.dependency-warning.effect-dependent-content',
defaultMessage: 'Dependent content may fail to load or may disable itself',
},
effectInstance: {
id: 'content.dependency-warning.effect-instance',
defaultMessage: 'Your {context} may crash, refuse to start, or behave unexpectedly',
},
deleteAnywayButton: {
id: 'content.dependency-warning.delete-anyway-button',
defaultMessage: 'Delete anyway',
},
deleteManyAnywayButton: {
id: 'content.dependency-warning.delete-many-anyway-button',
defaultMessage: 'Delete {count, number} {itemType} anyway',
},
disableDependentsLabel: {
id: 'content.dependency-warning.disable-dependents-label',
defaultMessage: 'Disable dependents after deleting',
},
instanceContext: {
id: 'content.dependency-warning.context.instance',
defaultMessage: 'instance',
},
serverContext: {
id: 'content.dependency-warning.context.server',
defaultMessage: 'server',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const deletingListRef = ref<HTMLElement | null>(null)
const dependentListRef = ref<HTMLElement | null>(null)
const visibleItems = ref<ContentCardTableItem[]>(props.items)
const visibleDependents = ref<ContentDependencyWarningDependent[]>(props.dependents)
const visibleItemType = ref(props.itemType)
const disableDependentsAfterDeleting = ref(false)
const buttonsDisabled = ref(false)
const modalContentCardClasses = 'rounded-xl border border-solid border-surface-5 p-4 !bg-surface-2'
const {
showTopFade: showDeletingTopFade,
showBottomFade: showDeletingBottomFade,
checkScrollState: checkDeletingScrollState,
forceCheck: forceCheckDeletingScroll,
} = useScrollIndicator(deletingListRef)
const {
showTopFade: showDependentTopFade,
showBottomFade: showDependentBottomFade,
checkScrollState: checkDependentScrollState,
forceCheck: forceCheckDependentScroll,
} = useScrollIndicator(dependentListRef)
const contextLabel = computed(() =>
formatMessage(props.variant === 'server' ? messages.serverContext : messages.instanceContext),
)
const backupName = computed(() =>
props.backupTip ? `Before deletion (${props.backupTip})` : 'Before deletion',
)
const deleteButtonLabel = computed(() => {
if (visibleItems.value.length <= 1) return formatMessage(messages.deleteAnywayButton)
return formatMessage(messages.deleteManyAnywayButton, {
count: visibleItems.value.length,
itemType: formatContentTypeSentence(
formatMessage,
visibleItemType.value,
visibleItems.value.length,
),
})
})
async function show() {
await nextTick()
visibleItems.value = props.items
visibleDependents.value = props.dependents
visibleItemType.value = props.itemType
disableDependentsAfterDeleting.value = false
buttonsDisabled.value = false
modal.value?.show()
await nextTick()
forceCheckDeletingScroll()
forceCheckDependentScroll()
}
function hide() {
modal.value?.hide()
}
function confirm() {
if (props.actionDisabled || buttonsDisabled.value) return
modal.value?.hide()
emit('delete', disableDependentsAfterDeleting.value)
}
defineExpose({
show,
hide,
})
</script>
@@ -8,6 +8,7 @@ export { default as ConfirmModpackUpdateModal } from './components/modals/Confir
export { default as ConfirmReinstallModal } from './components/modals/ConfirmReinstallModal.vue'
export { default as ConfirmRepairModal } from './components/modals/ConfirmRepairModal.vue'
export { default as ConfirmUnlinkModal } from './components/modals/ConfirmUnlinkModal.vue'
export { default as ContentDependencyWarningModal } from './components/modals/ContentDependencyWarningModal.vue'
export type {
ContentInstallInstance,
ContentInstallProjectInfo,
@@ -34,6 +34,7 @@ import ContentSelectionBar from './components/ContentSelectionBar.vue'
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
import ContentDependencyWarningModal from './components/modals/ContentDependencyWarningModal.vue'
import {
getClientWarningType,
isClientOnlyEnvironment,
@@ -319,21 +320,73 @@ const hasOutdatedProjects = computed(() => {
// Deletion
const pendingDeletionItems = ref<ContentItem[]>([])
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
const contentDependencyWarningModal = ref<InstanceType<typeof ContentDependencyWarningModal>>()
const pendingDependencyWarningItems = ref<ContentCardTableItem[]>([])
const pendingDependencyWarningDependents = ref<
Array<{
item: ContentCardTableItem
dependencies: ContentCardTableItem[]
}>
>([])
const pendingDependencyWarningDisableTargets = ref<ContentItem[]>([])
function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
pendingDeletionItems.value = [item]
if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
}
function mapToDisplayItem(item: ContentItem) {
return {
...ctx.mapToTableItem(item),
id: getItemId(item),
}
}
function showBulkDeleteModal(event?: MouseEvent) {
pendingDeletionItems.value = [...selectedItems.value]
async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
if (items.length === 0) return
pendingDeletionItems.value = items
pendingDependencyWarningItems.value = []
pendingDependencyWarningDependents.value = []
pendingDependencyWarningDisableTargets.value = []
const deletingIds = new Set(items.map(getItemId))
const warning = ctx.getDeleteDependencyWarning
? await Promise.resolve()
.then(() => ctx.getDeleteDependencyWarning!(items))
.catch(() => null)
: null
if (warning) {
const remainingDependents = warning.dependents.filter(
(dependent) => !deletingIds.has(getItemId(dependent.item)),
)
if (remainingDependents.length === 0) {
showDeletionConfirmation(event)
return
}
const relevantDependencyIds = new Set(
remainingDependents.flatMap((dependent) => dependent.dependencies.map(getItemId)),
)
const warningItems = items.filter((item) => relevantDependencyIds.has(getItemId(item)))
if (warningItems.length === 0) {
showDeletionConfirmation(event)
return
}
pendingDependencyWarningItems.value = warningItems.map(mapToDisplayItem)
pendingDependencyWarningDependents.value = remainingDependents.map((dependent) => ({
item: mapToDisplayItem(dependent.item),
dependencies: dependent.dependencies
.filter((dependency) => relevantDependencyIds.has(getItemId(dependency)))
.map(mapToDisplayItem),
}))
pendingDependencyWarningDisableTargets.value = remainingDependents.map(
(dependent) => dependent.item,
)
contentDependencyWarningModal.value?.show()
return
}
showDeletionConfirmation(event)
}
function showDeletionConfirmation(event?: MouseEvent) {
if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
@@ -341,6 +394,51 @@ function showBulkDeleteModal(event?: MouseEvent) {
}
}
async function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
await promptDeleteItems([item], event)
}
}
async function showBulkDeleteModal(event?: MouseEvent) {
await promptDeleteItems([...selectedItems.value], event)
}
async function confirmDependencyWarningDelete(disableDependentsAfterDeleting: boolean) {
if (disableDependentsAfterDeleting) {
pendingDependencyWarningDisableTargets.value =
pendingDependencyWarningDisableTargets.value.filter((item) => item.enabled)
} else {
pendingDependencyWarningDisableTargets.value = []
}
pendingDependencyWarningItems.value = []
pendingDependencyWarningDependents.value = []
await confirmDelete()
}
async function disablePendingDependencyWarningDependents() {
const items = pendingDependencyWarningDisableTargets.value.filter((item) => item.enabled)
pendingDependencyWarningDisableTargets.value = []
if (items.length === 0) return
if (ctx.bulkDisableItems) {
await ctx.bulkDisableItems(items)
return
}
for (const item of items) {
const id = getItemId(item)
markChanging(id)
try {
await ctx.toggleEnabled(item)
} finally {
unmarkChanging(id)
}
}
}
async function confirmDelete() {
if (ctx.isBusy.value) return
const itemsToDelete = [...pendingDeletionItems.value]
@@ -353,6 +451,7 @@ async function confirmDelete() {
bulkWaiting.value = true
try {
await ctx.bulkDeleteItems(itemsToDelete)
await disablePendingDependencyWarningDependents()
} finally {
clearSelection()
isBulkOperating.value = false
@@ -369,6 +468,7 @@ async function confirmDelete() {
try {
await ctx.deleteItem(item)
removeFromSelection(id)
await disablePendingDependencyWarningDependents()
} finally {
unmarkChanging(id)
}
@@ -384,6 +484,7 @@ async function confirmDelete() {
},
{ onComplete: clearSelection },
)
await disablePendingDependencyWarningDependents()
}
async function handleToggleEnabledById(id: string, _value: boolean) {
@@ -879,6 +980,17 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDelete"
/>
<ContentDependencyWarningModal
ref="contentDependencyWarningModal"
:items="pendingDependencyWarningItems"
:dependents="pendingDependencyWarningDependents"
:item-type="ctx.contentTypeLabel.value"
:variant="ctx.deletionContext ?? 'instance'"
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDependencyWarningDelete"
/>
<ConfirmBulkUpdateModal
v-if="hasBulkUpdateSupport"
ref="confirmBulkUpdateModal"
@@ -25,6 +25,14 @@ export interface ContentModpackData {
disabledText?: string
}
export interface ContentDependencyWarning {
items: ContentItem[]
dependents: Array<{
item: ContentItem
dependencies: ContentItem[]
}>
}
export interface ContentManagerContext {
// Data
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
@@ -55,6 +63,9 @@ export interface ContentManagerContext {
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
bulkEnableItems?: (items: ContentItem[]) => Promise<void>
bulkDisableItems?: (items: ContentItem[]) => Promise<void>
getDeleteDependencyWarning?: (
items: ContentItem[],
) => ContentDependencyWarning | null | Promise<ContentDependencyWarning | null>
// Update support (optional per-platform)
hasUpdateSupport: boolean
+42
View File
@@ -377,6 +377,48 @@
"content.confirm-unlink.unlink-button": {
"defaultMessage": "Unlink"
},
"content.dependency-warning.admonition-header": {
"defaultMessage": "This content is required by other content"
},
"content.dependency-warning.affected-dependents-label": {
"defaultMessage": "Affected {count, plural, one {project} other {projects}}"
},
"content.dependency-warning.bulk-admonition-body": {
"defaultMessage": "Some selected projects are installed as dependencies. Deleting them may break your {context} or stop dependent content from loading correctly."
},
"content.dependency-warning.context.instance": {
"defaultMessage": "instance"
},
"content.dependency-warning.context.server": {
"defaultMessage": "server"
},
"content.dependency-warning.delete-anyway-button": {
"defaultMessage": "Delete anyway"
},
"content.dependency-warning.delete-many-anyway-button": {
"defaultMessage": "Delete {count, number} {itemType} anyway"
},
"content.dependency-warning.deleting-label": {
"defaultMessage": "Deleting"
},
"content.dependency-warning.disable-dependents-label": {
"defaultMessage": "Disable dependents after deleting"
},
"content.dependency-warning.effect-dependent-content": {
"defaultMessage": "Dependent content may fail to load or may disable itself"
},
"content.dependency-warning.effect-instance": {
"defaultMessage": "Your {context} may crash, refuse to start, or behave unexpectedly"
},
"content.dependency-warning.header": {
"defaultMessage": "Dependency warning"
},
"content.dependency-warning.single-admonition-body": {
"defaultMessage": "{project} is installed as a dependency. Deleting it may break your {context} or stop dependent content from loading correctly."
},
"content.dependency-warning.what-happens-label": {
"defaultMessage": "What happens?"
},
"content.diff-modal.added-count": {
"defaultMessage": "{count} added"
},
@@ -0,0 +1,289 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ContentDependencyWarningModal from '../../layouts/shared/content-tab/components/modals/ContentDependencyWarningModal.vue'
import type { ContentCardTableItem } from '../../layouts/shared/content-tab/types'
const fabricApiItem: ContentCardTableItem = {
id: 'fabric-api',
project: {
id: 'P7dR8mSH',
slug: 'fabric-api',
title: 'Fabric API',
icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
},
projectLink: '/project/fabric-api',
version: {
id: 'Lwa1Q6e4',
version_number: '0.141.3+1.21.6',
file_name: 'fabric-api-0.141.3+1.21.6.jar',
},
versionLink: '/project/fabric-api/version/Lwa1Q6e4',
owner: {
id: 'fabricmc',
name: 'FabricMC',
avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
type: 'organization',
link: '/organization/fabricmc',
},
enabled: true,
}
const sodiumItem: ContentCardTableItem = {
id: 'sodium',
project: {
id: 'AANobbMI',
slug: 'sodium',
title: 'Sodium',
icon_url:
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
},
projectLink: '/project/sodium',
version: {
id: 'sodium-version',
version_number: 'mc1.21.6-0.6.13-fabric',
file_name: 'sodium-fabric-0.6.13+mc1.21.6.jar',
},
versionLink: '/project/sodium/version/sodium-version',
owner: {
id: 'jellysquid3',
name: 'jellysquid3',
type: 'user',
link: '/user/jellysquid3',
},
enabled: true,
}
const irisItem: ContentCardTableItem = {
id: 'iris',
project: {
id: 'YL57xq9U',
slug: 'iris',
title: 'Iris Shaders',
icon_url: 'https://cdn.modrinth.com/data/YL57xq9U/icon.png',
},
projectLink: '/project/iris',
version: {
id: 'iris-version',
version_number: '1.8.12+1.21.6-fabric',
file_name: 'iris-fabric-1.8.12+mc1.21.6.jar',
},
versionLink: '/project/iris/version/iris-version',
owner: {
id: 'coderbot',
name: 'coderbot',
type: 'user',
link: '/user/coderbot',
},
enabled: true,
}
const lithiumItem: ContentCardTableItem = {
id: 'lithium',
project: {
id: 'gvQqBUqZ',
slug: 'lithium',
title: 'Lithium',
icon_url:
'https://cdn.modrinth.com/data/gvQqBUqZ/d6a1873d52b7d1c82b9a8d9b1889c9c1a29ae92d_96.webp',
},
projectLink: '/project/lithium',
version: {
id: 'lithium-version',
version_number: 'mc1.21.6-0.16.2-fabric',
file_name: 'lithium-fabric-0.16.2+mc1.21.6.jar',
},
versionLink: '/project/lithium/version/lithium-version',
owner: {
id: 'caffeinemc',
name: 'CaffeineMC',
type: 'organization',
link: '/organization/caffeinemc',
},
enabled: true,
}
const continuityItem: ContentCardTableItem = {
id: 'continuity',
project: {
id: '1IjD5062',
slug: 'continuity',
title: 'Continuity',
icon_url: 'https://cdn.modrinth.com/data/1IjD5062/icon.png',
},
projectLink: '/project/continuity',
version: {
id: 'continuity-version',
version_number: '3.0.1-beta.2+1.21.6',
file_name: 'continuity-3.0.1-beta.2+1.21.6.jar',
},
versionLink: '/project/continuity/version/continuity-version',
owner: {
id: 'pepper-bell',
name: 'Pepper_Bell',
type: 'user',
link: '/user/pepper-bell',
},
enabled: true,
}
const capeProviderItem: ContentCardTableItem = {
id: 'cape-provider',
project: {
id: 'cape-provider',
slug: 'cape-provider',
title: 'Cape Provider',
icon_url: null,
},
projectLink: '/project/cape-provider',
version: {
id: 'cape-provider-version',
version_number: '5.4.2',
file_name: 'cape-provider-5.4.2.jar',
},
versionLink: '/project/cape-provider/version/cape-provider-version',
owner: {
id: 'litetex',
name: 'litetex',
type: 'user',
link: '/user/litetex',
},
enabled: true,
}
const meta = {
title: 'Instances/ContentDependencyWarningModal',
component: ContentDependencyWarningModal,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof ContentDependencyWarningModal>
export default meta
type Story = StoryObj<typeof meta>
export const InstanceDependency: Story = {
render: () => ({
components: { ButtonStyled, ContentDependencyWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
const deleted = ref(false)
function handleDelete() {
deleted.value = true
}
return {
modalRef,
deleted,
handleDelete,
fabricApiItem,
sodiumItem,
irisItem,
continuityItem,
lithiumItem,
capeProviderItem,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="orange">
<button @click="modalRef?.show()">Delete dependency</button>
</ButtonStyled>
<p v-if="deleted" class="m-0 text-sm text-secondary">Dependency deletion confirmed</p>
<ContentDependencyWarningModal
ref="modalRef"
:items="[fabricApiItem]"
item-type="project"
:dependents="[
{ item: sodiumItem, dependencies: [fabricApiItem] },
{ item: irisItem, dependencies: [fabricApiItem] },
{ item: continuityItem, dependencies: [fabricApiItem] },
{ item: lithiumItem, dependencies: [fabricApiItem] },
{ item: capeProviderItem, dependencies: [fabricApiItem] },
]"
@delete="handleDelete"
/>
</div>
`,
}),
}
export const ServerDependency: Story = {
render: () => ({
components: { ButtonStyled, ContentDependencyWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
const deleted = ref(false)
function handleDelete() {
deleted.value = true
}
return {
modalRef,
deleted,
handleDelete,
lithiumItem,
sodiumItem,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="orange">
<button @click="modalRef?.show()">Delete server dependency</button>
</ButtonStyled>
<p v-if="deleted" class="m-0 text-sm text-secondary">Server dependency deletion confirmed</p>
<ContentDependencyWarningModal
ref="modalRef"
:items="[lithiumItem]"
item-type="project"
:dependents="[{ item: sodiumItem, dependencies: [lithiumItem] }]"
variant="server"
@delete="handleDelete"
/>
</div>
`,
}),
}
export const BulkDependencies: Story = {
render: () => ({
components: { ButtonStyled, ContentDependencyWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
const deleted = ref(false)
function handleDelete() {
deleted.value = true
}
return {
modalRef,
deleted,
handleDelete,
fabricApiItem,
lithiumItem,
sodiumItem,
irisItem,
continuityItem,
capeProviderItem,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="orange">
<button @click="modalRef?.show()">Delete selected dependencies</button>
</ButtonStyled>
<p v-if="deleted" class="m-0 text-sm text-secondary">Bulk dependency deletion confirmed</p>
<ContentDependencyWarningModal
ref="modalRef"
:items="[fabricApiItem, lithiumItem]"
item-type="project"
:dependents="[
{ item: sodiumItem, dependencies: [fabricApiItem, lithiumItem] },
{ item: irisItem, dependencies: [fabricApiItem] },
{ item: continuityItem, dependencies: [fabricApiItem] },
{ item: capeProviderItem, dependencies: [lithiumItem] },
]"
@delete="handleDelete"
/>
</div>
`,
}),
}
+1 -6
View File
@@ -2,12 +2,7 @@ import preset from '@modrinth/tooling-config/tailwind/tailwind-preset.ts'
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/components/**/*.{js,vue,ts}',
'./src/pages/**/*.{js,vue,ts}',
'./src/stories/**/*.{js,vue,ts,mdx}',
'./.storybook/**/*.{ts,js}',
],
content: ['./src/**/*.{js,vue,ts,mdx}', './.storybook/**/*.{ts,js}'],
presets: [preset],
}