Skip to content

Commit ff8b0fb

Browse files
authored
Merge pull request #56 from boostcampwm-snu-2026-1/fix/55-confirm-arranged-clip-delete
fix: confirm deletion for clips used in arrangement
2 parents 5890bfd + bdcdb92 commit ff8b0fb

6 files changed

Lines changed: 107 additions & 12 deletions

File tree

PLANS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ M1 should include:
2929

3030
Use this section as the current execution order for agent work. Feature document numbering describes the planned product sequence, but actual issue order may change as dependencies, review feedback, or implementation risks become clearer.
3131

32-
1. #48 Variable hybrid clip length -> `docs/features/19-variable-hybrid-clip-length.md`
32+
1. #55 Confirm before deleting clips used in arrangement
3333
2. #49 Adjustable arrangement length -> `docs/features/20-adjustable-arrangement-length.md`
3434
3. #50 Arrangement WAV export -> `docs/features/21-arrangement-wav-export.md`
3535

docs/data-model.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ export interface ArrangementState {
210210

211211
The first arrangement placement feature should create, move, select, and delete `ClipInstance` objects without mutating the source `Clip`. Deleting a placed clip from the arrangement removes only that instance. It does not delete the sidebar clip.
212212

213+
Deleting a source clip from the sidebar removes that clip and all of its arrangement `ClipInstance` placements. The UI should require confirmation when the source clip has musical events, is an imported audio clip, or has one or more arrangement placements.
214+
213215
The first implementation keeps arrangement tracks, clip instances, and loop range in app-level state. The data is still serializable and should map directly into future `Project.tracks`, `Project.clipInstances`, and arrangement transport fields when export/import is implemented.
214216

215217
Default instance lengths:

src/app/App.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
createEmptyHybridClip,
3636
deleteClipInstance,
3737
deleteNoteEvent,
38+
getClipDeleteConfirmationMessage,
3839
getHybridClipBarCount,
3940
getHybridClipLengthTicks,
4041
getPitchedInstrument,
@@ -1062,18 +1063,15 @@ export function App() {
10621063
return;
10631064
}
10641065

1065-
const hasClipData = isAudioClip(clip)
1066-
? true
1067-
: clip.drumEvents.length > 0 || clip.noteEvents.length > 0;
1066+
const arrangementInstanceCount = clipInstancesRef.current.filter(
1067+
(instance) => instance.clipId === clipId,
1068+
).length;
1069+
const confirmationMessage = getClipDeleteConfirmationMessage({
1070+
arrangementInstanceCount,
1071+
clip,
1072+
});
10681073

1069-
if (
1070-
hasClipData &&
1071-
!window.confirm(
1072-
isAudioClip(clip)
1073-
? `Delete imported audio clip ${clip.name}?`
1074-
: `Delete ${clip.name} and its musical events?`,
1075-
)
1076-
) {
1074+
if (confirmationMessage && !window.confirm(confirmationMessage)) {
10771075
return;
10781076
}
10791077

src/model/audio-clip.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export interface ImportedAudioClipDraft {
3535
sampleMeta: SampleMeta;
3636
}
3737

38+
export interface ClipDeleteConfirmationOptions {
39+
arrangementInstanceCount?: number;
40+
clip: Clip;
41+
}
42+
3843
const WAV_MIME_TYPES = new Set([
3944
"audio/wav",
4045
"audio/wave",
@@ -52,6 +57,39 @@ export function isHybridClip<TClip extends { kind?: string }>(
5257
return clip.kind === "hybrid";
5358
}
5459

60+
export function getClipDeleteConfirmationMessage({
61+
arrangementInstanceCount = 0,
62+
clip,
63+
}: ClipDeleteConfirmationOptions): string | null {
64+
const hasArrangementInstances = arrangementInstanceCount > 0;
65+
const arrangementMessage = hasArrangementInstances
66+
? `${arrangementInstanceCount} arrangement ${arrangementInstanceCount === 1 ? "placement" : "placements"} will also be removed.`
67+
: "";
68+
69+
if (isAudioClip(clip)) {
70+
return joinConfirmationParts([
71+
`Delete imported audio clip ${clip.name}?`,
72+
arrangementMessage,
73+
]);
74+
}
75+
76+
const hasMusicalEvents =
77+
clip.drumEvents.length > 0 || clip.noteEvents.length > 0;
78+
79+
if (!hasMusicalEvents && !hasArrangementInstances) {
80+
return null;
81+
}
82+
83+
if (hasMusicalEvents) {
84+
return joinConfirmationParts([
85+
`Delete ${clip.name} and its musical events?`,
86+
arrangementMessage,
87+
]);
88+
}
89+
90+
return `Delete ${clip.name}? ${arrangementMessage}`;
91+
}
92+
5593
export function validateImportedWavFile(file: ImportedAudioFileLike): void {
5694
const hasWavExtension = /\.wav$/i.test(file.name);
5795
const hasWavMimeType = file.type ? WAV_MIME_TYPES.has(file.type) : false;
@@ -163,3 +201,7 @@ function createUniqueId(baseId: string, existingIds: readonly string[]): string
163201

164202
return candidate;
165203
}
204+
205+
function joinConfirmationParts(parts: readonly string[]): string {
206+
return parts.filter(Boolean).join(" ");
207+
}

src/model/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export {
6363
createImportedAudioClipDraft,
6464
createImportedAudioDisplayName,
6565
createImportedAudioIds,
66+
getClipDeleteConfirmationMessage,
6667
isAudioClip,
6768
isHybridClip,
6869
validateImportedWavFile,
@@ -110,6 +111,7 @@ export type {
110111
export type {
111112
AudioClip,
112113
Clip,
114+
ClipDeleteConfirmationOptions,
113115
ImportedAudioClipDraft,
114116
ImportedAudioFileLike,
115117
SampleMeta,

tests/unit/model/audio-clip.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { describe, expect, it } from "vitest";
22

33
import {
4+
createEmptyHybridClip,
45
createImportedAudioClipDraft,
56
createImportedAudioDisplayName,
67
createImportedAudioIds,
8+
getClipDeleteConfirmationMessage,
9+
toggleDrumStep,
710
validateImportedWavFile,
811
} from "../../../src/model";
912

@@ -81,4 +84,52 @@ describe("audio clip model", () => {
8184
},
8285
});
8386
});
87+
88+
it("does not require confirmation for empty unused hybrid clips", () => {
89+
expect(
90+
getClipDeleteConfirmationMessage({
91+
clip: createEmptyHybridClip({ id: "clip-1", name: "Clip 1" }),
92+
}),
93+
).toBeNull();
94+
});
95+
96+
it("requires confirmation for hybrid clips with musical events", () => {
97+
const clip = toggleDrumStep({
98+
clip: createEmptyHybridClip({ id: "clip-1", name: "Clip 1" }),
99+
laneId: "kick",
100+
stepIndex: 0,
101+
});
102+
103+
expect(getClipDeleteConfirmationMessage({ clip })).toBe(
104+
"Delete Clip 1 and its musical events?",
105+
);
106+
});
107+
108+
it("requires confirmation for hybrid clips used in the arrangement", () => {
109+
expect(
110+
getClipDeleteConfirmationMessage({
111+
arrangementInstanceCount: 2,
112+
clip: createEmptyHybridClip({ id: "clip-1", name: "Clip 1" }),
113+
}),
114+
).toBe("Delete Clip 1? 2 arrangement placements will also be removed.");
115+
});
116+
117+
it("includes arrangement removal in imported audio clip confirmation", () => {
118+
const { clip } = createImportedAudioClipDraft({
119+
clipId: "audio-clip-loop",
120+
durationSeconds: 2.5,
121+
fileName: "Loop.wav",
122+
mimeType: "audio/wav",
123+
sampleId: "imported-audio-loop",
124+
});
125+
126+
expect(
127+
getClipDeleteConfirmationMessage({
128+
arrangementInstanceCount: 1,
129+
clip,
130+
}),
131+
).toBe(
132+
"Delete imported audio clip Loop? 1 arrangement placement will also be removed.",
133+
);
134+
});
84135
});

0 commit comments

Comments
 (0)