mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
refactor(web): extract attachment upload into a composable (#1034)
Move the image/video attachment state, the file-picker / paste / drag-drop handlers, the upload machinery, the preview lightbox, and the paste-listener + object-URL cleanup lifecycle out of Composer into useAttachmentUpload. The composer keeps handleSubmit / handleSteer (which read the attachments to build the payload) and the hasUpload toolbar flag; it consumes the returned refs and handlers directly. The destructured names match the originals so the template bindings are unchanged. handleSubmit / handleSteer now call the composable's clearAfterSubmit() to revoke object URLs and drop the list. Composer.vue: 1937 -> 1787 lines. Adds unit tests for useAttachmentUpload. No behavior change.
This commit is contained in:
parent
2bfd6860e4
commit
603a7679de
4 changed files with 357 additions and 174 deletions
5
.changeset/web-extract-attachment-upload.md
Normal file
5
.changeset/web-extract-attachment-upload.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Extract the composer's image/video attachment handling into a reusable composable.
|
||||
|
|
@ -13,27 +13,7 @@ import { useInputHistory } from '../composables/useInputHistory';
|
|||
import { useSlashMenu } from '../composables/useSlashMenu';
|
||||
import { useMentionMenu } from '../composables/useMentionMenu';
|
||||
import { useComposerDraft } from '../composables/useComposerDraft';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attachment state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Attachment {
|
||||
/** Unique local id (used as :key) */
|
||||
localId: string;
|
||||
/** File name */
|
||||
name: string;
|
||||
/** image or video — drives the chip preview and the content-block type. */
|
||||
kind: 'image' | 'video';
|
||||
/** Object URL for the thumbnail preview */
|
||||
previewUrl: string;
|
||||
/** True while uploading */
|
||||
uploading: boolean;
|
||||
/** Resolved daemon file id (set after upload completes) */
|
||||
fileId?: string;
|
||||
/** True if upload failed */
|
||||
error?: boolean;
|
||||
}
|
||||
import { useAttachmentUpload } from '../composables/useAttachmentUpload';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props & emits
|
||||
|
|
@ -165,160 +145,36 @@ function handleInput(): void {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attachments
|
||||
// Attachments — see useAttachmentUpload. The composer keeps handleSubmit /
|
||||
// handleSteer (which read the attachments to build the payload) and the
|
||||
// `hasUpload` toolbar flag.
|
||||
// ---------------------------------------------------------------------------
|
||||
const {
|
||||
attachments,
|
||||
previewAttachment,
|
||||
fileInputRef,
|
||||
isDragOver,
|
||||
removeAttachment,
|
||||
openAttachmentPreview,
|
||||
closeAttachmentPreview,
|
||||
openFilePicker,
|
||||
handleFileInputChange,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearAfterSubmit,
|
||||
} = useAttachmentUpload({ uploadImage: () => props.uploadImage });
|
||||
|
||||
const attachments = ref<Attachment[]>([]);
|
||||
const previewAttachment = ref<Attachment | null>(null);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const isDragOver = ref(false);
|
||||
|
||||
let localIdCounter = 0;
|
||||
function nextLocalId(): string {
|
||||
return `att_${++localIdCounter}`;
|
||||
}
|
||||
|
||||
function revokeAttachment(att: Attachment): void {
|
||||
try { URL.revokeObjectURL(att.previewUrl); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function mediaKind(mime: string): 'image' | 'video' | null {
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function addFiles(files: File[]): Promise<void> {
|
||||
if (!props.uploadImage) return;
|
||||
const media = files
|
||||
.map((file) => ({ file, kind: mediaKind(file.type) }))
|
||||
.filter((m): m is { file: File; kind: 'image' | 'video' } => m.kind !== null);
|
||||
if (media.length === 0) return;
|
||||
|
||||
for (const { file, kind } of media) {
|
||||
const localId = nextLocalId();
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
const att: Attachment = { localId, name: file.name, kind, previewUrl, uploading: true };
|
||||
attachments.value = [...attachments.value, att];
|
||||
|
||||
// Upload in background; update the attachment when done
|
||||
props.uploadImage(file, file.name).then((result) => {
|
||||
attachments.value = attachments.value.map((a) =>
|
||||
a.localId === localId
|
||||
? { ...a, uploading: false, fileId: result?.fileId, error: result === null }
|
||||
: a,
|
||||
);
|
||||
}).catch(() => {
|
||||
attachments.value = attachments.value.map((a) =>
|
||||
a.localId === localId ? { ...a, uploading: false, error: true } : a,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(localId: string): void {
|
||||
const att = attachments.value.find((a) => a.localId === localId);
|
||||
if (previewAttachment.value?.localId === localId) previewAttachment.value = null;
|
||||
if (att) revokeAttachment(att);
|
||||
attachments.value = attachments.value.filter((a) => a.localId !== localId);
|
||||
}
|
||||
|
||||
function openAttachmentPreview(att: Attachment): void {
|
||||
previewAttachment.value = att;
|
||||
}
|
||||
|
||||
function closeAttachmentPreview(): void {
|
||||
previewAttachment.value = null;
|
||||
}
|
||||
|
||||
function openFilePicker(): void {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
function handleFileInputChange(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
void addFiles(files);
|
||||
// Reset so re-selecting the same file fires change again
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// Global document-level paste handler — captures Ctrl+V anywhere the composer is mounted.
|
||||
function handleDocumentPaste(e: ClipboardEvent): void {
|
||||
if (!props.uploadImage) return;
|
||||
|
||||
const cd = e.clipboardData;
|
||||
if (!cd) return;
|
||||
|
||||
// Collect image files from both .items and .files to cover all browsers/OS.
|
||||
const files: File[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
const addBlob = (blob: File | Blob, name: string): void => {
|
||||
const key = `${blob.size}:${blob.type}:${name}`;
|
||||
if (seenKeys.has(key)) return;
|
||||
seenKeys.add(key);
|
||||
const ext = blob.type.split('/')[1] ?? 'png';
|
||||
const safeName = name.includes('.') ? name : `paste-${Date.now()}.${ext}`;
|
||||
files.push(blob instanceof File ? blob : new File([blob], safeName, { type: blob.type }));
|
||||
};
|
||||
|
||||
// From DataTransferItemList
|
||||
for (const item of Array.from(cd.items)) {
|
||||
if (item.kind === 'file' && mediaKind(item.type)) {
|
||||
const blob = item.getAsFile();
|
||||
if (blob) addBlob(blob, blob.name || `paste-${Date.now()}.${item.type.split('/')[1] ?? 'png'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// From FileList (some browsers/OS put screenshots here directly)
|
||||
for (const file of Array.from(cd.files)) {
|
||||
if (mediaKind(file.type)) {
|
||||
addBlob(file, file.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) return; // No media — let normal text paste proceed unmodified.
|
||||
|
||||
e.preventDefault();
|
||||
void addFiles(files);
|
||||
}
|
||||
|
||||
// Drag-drop handlers
|
||||
function handleDragOver(e: DragEvent): void {
|
||||
if (!props.uploadImage) return;
|
||||
const hasFiles = Array.from(e.dataTransfer?.items ?? []).some((item) => item.kind === 'file');
|
||||
if (!hasFiles) return;
|
||||
e.preventDefault();
|
||||
isDragOver.value = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(): void {
|
||||
isDragOver.value = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent): void {
|
||||
isDragOver.value = false;
|
||||
if (!props.uploadImage) return;
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
void addFiles(files);
|
||||
}
|
||||
// Silence noUnusedLocals: fileInputRef is used as a template ref (ref="fileInputRef").
|
||||
void fileInputRef;
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handleDocumentPaste);
|
||||
// Fit the box to a restored draft on first render.
|
||||
if (text.value) void nextTick(autosize);
|
||||
});
|
||||
|
||||
// Revoke all object URLs and remove global listener on unmount
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', handleDocumentPaste);
|
||||
document.removeEventListener('mousedown', onModesDocClick);
|
||||
for (const att of attachments.value) {
|
||||
revokeAttachment(att);
|
||||
}
|
||||
previewAttachment.value = null;
|
||||
clearCompositionEndTimer();
|
||||
});
|
||||
|
||||
|
|
@ -369,12 +225,9 @@ function handleSubmit(): void {
|
|||
attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })),
|
||||
};
|
||||
|
||||
// Revoke object URLs for submitted attachments
|
||||
// Revoke object URLs and drop the submitted attachments.
|
||||
previewAttachment.value = null;
|
||||
for (const att of attachments.value) {
|
||||
revokeAttachment(att);
|
||||
}
|
||||
attachments.value = [];
|
||||
clearAfterSubmit();
|
||||
|
||||
text.value = '';
|
||||
slashOpen.value = false;
|
||||
|
|
@ -399,10 +252,7 @@ function handleSteer(): void {
|
|||
text: trimmed,
|
||||
attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })),
|
||||
};
|
||||
for (const att of attachments.value) {
|
||||
revokeAttachment(att);
|
||||
}
|
||||
attachments.value = [];
|
||||
clearAfterSubmit();
|
||||
history.push(trimmed);
|
||||
text.value = '';
|
||||
slashOpen.value = false;
|
||||
|
|
|
|||
218
apps/kimi-web/src/composables/useAttachmentUpload.ts
Normal file
218
apps/kimi-web/src/composables/useAttachmentUpload.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// apps/kimi-web/src/composables/useAttachmentUpload.ts
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
export interface Attachment {
|
||||
/** Unique local id (used as :key) */
|
||||
localId: string;
|
||||
/** File name */
|
||||
name: string;
|
||||
/** image or video — drives the chip preview and the content-block type. */
|
||||
kind: 'image' | 'video';
|
||||
/** Object URL for the thumbnail preview */
|
||||
previewUrl: string;
|
||||
/** True while uploading */
|
||||
uploading: boolean;
|
||||
/** Resolved daemon file id (set after upload completes) */
|
||||
fileId?: string;
|
||||
/** True if upload failed */
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
type UploadImage = (
|
||||
file: Blob,
|
||||
name?: string,
|
||||
) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
|
||||
|
||||
export interface AttachmentUploadDeps {
|
||||
/** Upload a blob; resolves to the daemon file id, or null on failure.
|
||||
Getter so a prop change is picked up. Undefined disables attaching. */
|
||||
uploadImage: () => UploadImage | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image/video attachment handling for the composer: file picker, paste, drag &
|
||||
* drop, the upload machinery, the chip strip, and the preview lightbox.
|
||||
*
|
||||
* The composer keeps `handleSubmit`/`handleSteer` (which read the attachments to
|
||||
* build the payload) and the `hasUpload` toolbar flag; this composable owns the
|
||||
* attachment state, all the file-input UI handlers, and the paste listener +
|
||||
* object-URL cleanup lifecycle.
|
||||
*/
|
||||
export function useAttachmentUpload(deps: AttachmentUploadDeps) {
|
||||
const { uploadImage } = deps;
|
||||
|
||||
const attachments = ref<Attachment[]>([]);
|
||||
const previewAttachment = ref<Attachment | null>(null);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const isDragOver = ref(false);
|
||||
|
||||
let localIdCounter = 0;
|
||||
function nextLocalId(): string {
|
||||
return `att_${++localIdCounter}`;
|
||||
}
|
||||
|
||||
function revokeAttachment(att: Attachment): void {
|
||||
try { URL.revokeObjectURL(att.previewUrl); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function mediaKind(mime: string): 'image' | 'video' | null {
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function addFiles(files: File[]): Promise<void> {
|
||||
const upload = uploadImage();
|
||||
if (!upload) return;
|
||||
const media = files
|
||||
.map((file) => ({ file, kind: mediaKind(file.type) }))
|
||||
.filter((m): m is { file: File; kind: 'image' | 'video' } => m.kind !== null);
|
||||
if (media.length === 0) return;
|
||||
|
||||
for (const { file, kind } of media) {
|
||||
const localId = nextLocalId();
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
const att: Attachment = { localId, name: file.name, kind, previewUrl, uploading: true };
|
||||
attachments.value = [...attachments.value, att];
|
||||
|
||||
// Upload in background; update the attachment when done.
|
||||
upload(file, file.name).then((result) => {
|
||||
attachments.value = attachments.value.map((a) =>
|
||||
a.localId === localId
|
||||
? { ...a, uploading: false, fileId: result?.fileId, error: result === null }
|
||||
: a,
|
||||
);
|
||||
}).catch(() => {
|
||||
attachments.value = attachments.value.map((a) =>
|
||||
a.localId === localId ? { ...a, uploading: false, error: true } : a,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(localId: string): void {
|
||||
const att = attachments.value.find((a) => a.localId === localId);
|
||||
if (previewAttachment.value?.localId === localId) previewAttachment.value = null;
|
||||
if (att) revokeAttachment(att);
|
||||
attachments.value = attachments.value.filter((a) => a.localId !== localId);
|
||||
}
|
||||
|
||||
function openAttachmentPreview(att: Attachment): void {
|
||||
previewAttachment.value = att;
|
||||
}
|
||||
|
||||
function closeAttachmentPreview(): void {
|
||||
previewAttachment.value = null;
|
||||
}
|
||||
|
||||
function openFilePicker(): void {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
function handleFileInputChange(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
void addFiles(files);
|
||||
// Reset so re-selecting the same file fires change again.
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// Global document-level paste handler — captures Ctrl+V anywhere the composer is mounted.
|
||||
function handleDocumentPaste(e: ClipboardEvent): void {
|
||||
if (!uploadImage()) return;
|
||||
|
||||
const cd = e.clipboardData;
|
||||
if (!cd) return;
|
||||
|
||||
// Collect image files from both .items and .files to cover all browsers/OS.
|
||||
const files: File[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
const addBlob = (blob: File | Blob, name: string): void => {
|
||||
const key = `${blob.size}:${blob.type}:${name}`;
|
||||
if (seenKeys.has(key)) return;
|
||||
seenKeys.add(key);
|
||||
const ext = blob.type.split('/')[1] ?? 'png';
|
||||
const safeName = name.includes('.') ? name : `paste-${Date.now()}.${ext}`;
|
||||
files.push(blob instanceof File ? blob : new File([blob], safeName, { type: blob.type }));
|
||||
};
|
||||
|
||||
// From DataTransferItemList.
|
||||
for (const item of Array.from(cd.items)) {
|
||||
if (item.kind === 'file' && mediaKind(item.type)) {
|
||||
const blob = item.getAsFile();
|
||||
if (blob) addBlob(blob, blob.name || `paste-${Date.now()}.${item.type.split('/')[1] ?? 'png'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// From FileList (some browsers/OS put screenshots here directly).
|
||||
for (const file of Array.from(cd.files)) {
|
||||
if (mediaKind(file.type)) {
|
||||
addBlob(file, file.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) return; // No media — let normal text paste proceed unmodified.
|
||||
|
||||
e.preventDefault();
|
||||
void addFiles(files);
|
||||
}
|
||||
|
||||
// Drag-drop handlers.
|
||||
function handleDragOver(e: DragEvent): void {
|
||||
if (!uploadImage()) return;
|
||||
const hasFiles = Array.from(e.dataTransfer?.items ?? []).some((item) => item.kind === 'file');
|
||||
if (!hasFiles) return;
|
||||
e.preventDefault();
|
||||
isDragOver.value = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(): void {
|
||||
isDragOver.value = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent): void {
|
||||
isDragOver.value = false;
|
||||
if (!uploadImage()) return;
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
void addFiles(files);
|
||||
}
|
||||
|
||||
/** Revoke every object URL and drop all attachments (called after submit/steer). */
|
||||
function clearAfterSubmit(): void {
|
||||
for (const att of attachments.value) {
|
||||
revokeAttachment(att);
|
||||
}
|
||||
attachments.value = [];
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handleDocumentPaste);
|
||||
});
|
||||
|
||||
// Revoke all object URLs and remove the global listener on unmount.
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', handleDocumentPaste);
|
||||
for (const att of attachments.value) {
|
||||
revokeAttachment(att);
|
||||
}
|
||||
previewAttachment.value = null;
|
||||
});
|
||||
|
||||
return {
|
||||
attachments,
|
||||
previewAttachment,
|
||||
fileInputRef,
|
||||
isDragOver,
|
||||
removeAttachment,
|
||||
openAttachmentPreview,
|
||||
closeAttachmentPreview,
|
||||
openFilePicker,
|
||||
handleFileInputChange,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearAfterSubmit,
|
||||
};
|
||||
}
|
||||
110
apps/kimi-web/test/attachment-upload.test.ts
Normal file
110
apps/kimi-web/test/attachment-upload.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAttachmentUpload, type Attachment } from '../src/composables/useAttachmentUpload';
|
||||
|
||||
// The composable registers its paste listener and cleanup via onMounted /
|
||||
// onUnmounted. Outside a component (unit test) there is no active instance, so
|
||||
// Vue would warn; stub the two hooks since these tests don't exercise the
|
||||
// lifecycle itself.
|
||||
vi.mock('vue', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue')>();
|
||||
return { ...actual, onMounted: vi.fn(), onUnmounted: vi.fn() };
|
||||
});
|
||||
|
||||
type UploadImage = (
|
||||
file: Blob,
|
||||
name?: string,
|
||||
) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
|
||||
|
||||
function setup(uploadImage?: UploadImage) {
|
||||
return useAttachmentUpload({ uploadImage: () => uploadImage });
|
||||
}
|
||||
|
||||
function imageFile(name: string): File {
|
||||
return { name, type: 'image/png' } as unknown as File;
|
||||
}
|
||||
|
||||
function inputEvent(files: File[]): Event {
|
||||
return { target: { files, value: 'x' } } as unknown as Event;
|
||||
}
|
||||
|
||||
describe('useAttachmentUpload', () => {
|
||||
let createObjectURL: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
createObjectURL = vi.fn().mockReturnValue('blob:mock-url');
|
||||
revokeObjectURL = vi.fn();
|
||||
(globalThis.URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL;
|
||||
(globalThis.URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeObjectURL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('adds an uploading attachment via the file input', () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue({ fileId: 'f1', name: 'a.png', mediaType: 'image/png' });
|
||||
const att = setup(uploadImage);
|
||||
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
|
||||
|
||||
expect(att.attachments.value).toHaveLength(1);
|
||||
expect(att.attachments.value[0]).toMatchObject({ name: 'a.png', kind: 'image', uploading: true });
|
||||
expect(createObjectURL).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ignores non-media files', () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
|
||||
const att = setup(uploadImage);
|
||||
att.handleFileInputChange(inputEvent([{ name: 'a.txt', type: 'text/plain' } as unknown as File]));
|
||||
expect(att.attachments.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is a no-op when uploadImage is not provided', () => {
|
||||
const att = setup(undefined);
|
||||
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
|
||||
expect(att.attachments.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('removeAttachment drops the entry and revokes its object URL', () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
|
||||
const att = setup(uploadImage);
|
||||
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
|
||||
const localId = att.attachments.value[0].localId;
|
||||
|
||||
att.removeAttachment(localId);
|
||||
expect(att.attachments.value).toHaveLength(0);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
||||
});
|
||||
|
||||
it('removeAttachment also closes the preview when it shows the removed entry', () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
|
||||
const att = setup(uploadImage);
|
||||
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
|
||||
const added = att.attachments.value[0];
|
||||
att.openAttachmentPreview(added);
|
||||
expect(att.previewAttachment.value).not.toBeNull();
|
||||
|
||||
att.removeAttachment(added.localId);
|
||||
expect(att.previewAttachment.value).toBeNull();
|
||||
});
|
||||
|
||||
it('openAttachmentPreview / closeAttachmentPreview toggle the preview', () => {
|
||||
const att = setup(undefined);
|
||||
const item: Attachment = { localId: 'x', name: 'a.png', kind: 'image', previewUrl: 'blob:x', uploading: false };
|
||||
att.openAttachmentPreview(item);
|
||||
expect(att.previewAttachment.value?.localId).toBe('x');
|
||||
att.closeAttachmentPreview();
|
||||
expect(att.previewAttachment.value).toBeNull();
|
||||
});
|
||||
|
||||
it('clearAfterSubmit revokes every object URL and empties the list', () => {
|
||||
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
|
||||
const att = setup(uploadImage);
|
||||
att.handleFileInputChange(inputEvent([imageFile('a.png'), imageFile('b.png')]));
|
||||
expect(att.attachments.value).toHaveLength(2);
|
||||
|
||||
att.clearAfterSubmit();
|
||||
expect(att.attachments.value).toHaveLength(0);
|
||||
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue