From 46a7cf490d5b3f0ce1abe4ed00f24cc45462253b Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Tue, 16 Jun 2026 19:36:30 +0100 Subject: [PATCH] feat: implement dependency deletion modal for content tab (#6410) * feat: implement dependency deletion modal for content tab * fix: handle bulk delete state * fix: lint --- apps/app-frontend/src/pages/instance/Mods.vue | 60 ++- .../components/ContentCardItem.vue | 12 +- .../components/ContentCardTable.vue | 35 +- .../modals/ConfirmDeletionModal.vue | 9 +- .../modals/ContentDependencyWarningModal.vue | 371 ++++++++++++++++++ .../src/layouts/shared/content-tab/index.ts | 1 + .../src/layouts/shared/content-tab/layout.vue | 134 ++++++- .../content-tab/providers/content-manager.ts | 11 + packages/ui/src/locales/en-US/index.json | 42 ++ .../ContentDependencyWarningModal.stories.ts | 289 ++++++++++++++ packages/ui/tailwind.config.ts | 7 +- 11 files changed, 921 insertions(+), 50 deletions(-) create mode 100644 packages/ui/src/layouts/shared/content-tab/components/modals/ContentDependencyWarningModal.vue create mode 100644 packages/ui/src/stories/instances/ContentDependencyWarningModal.stories.ts diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index a323f939c..bed41c11e 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -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, diff --git a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue index 3a600cc76..27c824007 100644 --- a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue +++ b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue @@ -61,6 +61,7 @@ interface Props { showCheckbox?: boolean hideDelete?: boolean hideActions?: boolean + inline?: boolean } const props = withDefaults(defineProps(), { @@ -82,12 +83,14 @@ const props = withDefaults(defineProps(), { showCheckbox: false, hideDelete: false, hideActions: false, + inline: false, }) const selected = defineModel('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) + + diff --git a/packages/ui/src/layouts/shared/content-tab/index.ts b/packages/ui/src/layouts/shared/content-tab/index.ts index d697ae3e9..7c7144a78 100644 --- a/packages/ui/src/layouts/shared/content-tab/index.ts +++ b/packages/ui/src/layouts/shared/content-tab/index.ts @@ -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, diff --git a/packages/ui/src/layouts/shared/content-tab/layout.vue b/packages/ui/src/layouts/shared/content-tab/layout.vue index 021af8534..de8479e94 100644 --- a/packages/ui/src/layouts/shared/content-tab/layout.vue +++ b/packages/ui/src/layouts/shared/content-tab/layout.vue @@ -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([]) const confirmDeletionModal = ref>() +const contentDependencyWarningModal = ref>() +const pendingDependencyWarningItems = ref([]) +const pendingDependencyWarningDependents = ref< + Array<{ + item: ContentCardTableItem + dependencies: ContentCardTableItem[] + }> +>([]) +const pendingDependencyWarningDisableTargets = ref([]) -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>() :action-disabled-tooltip="ctx.busyMessage?.value ?? undefined" @delete="confirmDelete" /> + +} + export interface ContentManagerContext { // Data items: Ref | ComputedRef @@ -55,6 +63,9 @@ export interface ContentManagerContext { bulkDeleteItems?: (items: ContentItem[]) => Promise bulkEnableItems?: (items: ContentItem[]) => Promise bulkDisableItems?: (items: ContentItem[]) => Promise + getDeleteDependencyWarning?: ( + items: ContentItem[], + ) => ContentDependencyWarning | null | Promise // Update support (optional per-platform) hasUpdateSupport: boolean diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 6b1a12e3a..c6141ef74 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -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" }, diff --git a/packages/ui/src/stories/instances/ContentDependencyWarningModal.stories.ts b/packages/ui/src/stories/instances/ContentDependencyWarningModal.stories.ts new file mode 100644 index 000000000..fcb644d62 --- /dev/null +++ b/packages/ui/src/stories/instances/ContentDependencyWarningModal.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +export const InstanceDependency: Story = { + render: () => ({ + components: { ButtonStyled, ContentDependencyWarningModal }, + setup() { + const modalRef = ref | null>(null) + const deleted = ref(false) + function handleDelete() { + deleted.value = true + } + return { + modalRef, + deleted, + handleDelete, + fabricApiItem, + sodiumItem, + irisItem, + continuityItem, + lithiumItem, + capeProviderItem, + } + }, + template: /* html */ ` +
+ + + +

Dependency deletion confirmed

+ +
+ `, + }), +} + +export const ServerDependency: Story = { + render: () => ({ + components: { ButtonStyled, ContentDependencyWarningModal }, + setup() { + const modalRef = ref | null>(null) + const deleted = ref(false) + function handleDelete() { + deleted.value = true + } + return { + modalRef, + deleted, + handleDelete, + lithiumItem, + sodiumItem, + } + }, + template: /* html */ ` +
+ + + +

Server dependency deletion confirmed

+ +
+ `, + }), +} + +export const BulkDependencies: Story = { + render: () => ({ + components: { ButtonStyled, ContentDependencyWarningModal }, + setup() { + const modalRef = ref | null>(null) + const deleted = ref(false) + function handleDelete() { + deleted.value = true + } + return { + modalRef, + deleted, + handleDelete, + fabricApiItem, + lithiumItem, + sodiumItem, + irisItem, + continuityItem, + capeProviderItem, + } + }, + template: /* html */ ` +
+ + + +

Bulk dependency deletion confirmed

+ +
+ `, + }), +} diff --git a/packages/ui/tailwind.config.ts b/packages/ui/tailwind.config.ts index 9c6506154..afc5f1e9f 100644 --- a/packages/ui/tailwind.config.ts +++ b/packages/ui/tailwind.config.ts @@ -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], }