Refactors upload panels for reusable bulk/batch logic

Introduces composable utilities and shared components for bulk operations and validation across upload panels, reducing duplication and improving maintainability. Moves extension, dbkey, and upload settings header UI into reusable components, centralizes item validation and upload readiness checks, and cleans up code logic. Enhances consistency and simplifies future enhancements to upload features.
This commit is contained in:
davelopez
2026-01-26 10:04:35 +01:00
parent b3d80e9a45
commit f49fb4c486
10 changed files with 814 additions and 291 deletions
@@ -58,7 +58,7 @@ interface PasteItem {
id: number;
name: string;
content: string;
ext: string;
extension: string;
dbkey: string;
spaceToTab: boolean;
toPosixLines: boolean;
@@ -72,7 +72,7 @@ const pasteItems = ref<PasteItem[]>([
id: nextId++,
name: "Pasted Dataset 1",
content: "",
ext: configOptions.value?.defaultExtension || "auto",
extension: configOptions.value?.defaultExtension || "auto",
dbkey: configOptions.value?.defaultDbKey || "?",
spaceToTab: false,
toPosixLines: false,
@@ -101,7 +101,7 @@ function addPasteItem() {
id: newId,
name: `Pasted Dataset ${pasteItems.value.length + 1}`,
content: "",
ext: configOptions.value?.defaultExtension || "auto",
extension: configOptions.value?.defaultExtension || "auto",
dbkey: configOptions.value?.defaultDbKey || "?",
spaceToTab: false,
toPosixLines: false,
@@ -180,7 +180,7 @@ const bulkExtensionWarning = computed(() => {
function setAllExtensions(extension: string | null) {
if (extension) {
pasteItems.value.forEach((item) => {
item.ext = extension;
item.extension = extension;
});
}
}
@@ -285,10 +285,10 @@ function startUpload() {
const uploads = validItems.map((item) => ({
uploadMode: "paste-content" as const,
name: item.name,
size: item.content.length,
size: new Blob([item.content]).size,
targetHistoryId: props.targetHistoryId,
dbkey: item.dbkey,
extension: item.ext,
extension: item.extension,
spaceToTab: item.spaceToTab,
toPosixLines: item.toPosixLines,
deferred: false,
@@ -313,7 +313,7 @@ function startUpload() {
id: newId,
name: "Pasted Dataset 1",
content: "",
ext: configOptions.value?.defaultExtension || "auto",
extension: configOptions.value?.defaultExtension || "auto",
dbkey: configOptions.value?.defaultDbKey || "?",
spaceToTab: false,
toPosixLines: false,
@@ -445,7 +445,7 @@ defineExpose<UploadMethodComponent>({ startUpload });
<template v-slot:cell(extension)="{ item }">
<div class="d-flex align-items-center">
<BFormSelect
v-model="item.ext"
v-model="item.extension"
v-b-tooltip.hover.noninteractive
size="sm"
title="File format (auto-detect recommended)"
@@ -455,11 +455,11 @@ defineExpose<UploadMethodComponent>({ startUpload });
</option>
</BFormSelect>
<FontAwesomeIcon
v-if="getExtensionWarning(item.ext)"
v-if="getExtensionWarning(item.extension)"
v-b-tooltip.hover.noninteractive
class="text-warning ml-1 flex-shrink-0"
:icon="faExclamationTriangle"
:title="getExtensionWarning(item.ext)" />
:title="getExtensionWarning(item.extension)" />
</div>
</template>
@@ -4,15 +4,21 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BFormCheckbox, BFormInput, BFormSelect, BTable } from "bootstrap-vue";
import { computed, nextTick, ref, watch } from "vue";
import { findExtension } from "@/components/Upload/utils";
import { useBulkUploadOperations } from "@/composables/upload/bulkUploadOperations";
import { useUploadItemValidation } from "@/composables/upload/uploadItemValidation";
import { useUploadReadyState } from "@/composables/upload/uploadReadyState";
import { useUploadConfigurations } from "@/composables/uploadConfigurations";
import type { CollectionConfig } from "@/composables/uploadQueue";
import { useUploadQueue } from "@/composables/uploadQueue";
import { extractNameFromUrl, isValidUrl, validateUrl } from "@/utils/url";
import type { UploadMethodComponent, UploadMethodConfig } from "../types";
import type { CollectionCreationState } from "../types/collectionCreation";
import CollectionCreationConfig from "../CollectionCreationConfig.vue";
import UploadTableBulkDbKeyHeader from "../shared/UploadTableBulkDbKeyHeader.vue";
import UploadTableBulkExtensionHeader from "../shared/UploadTableBulkExtensionHeader.vue";
import UploadTableOptionsHeader from "../shared/UploadTableOptionsHeader.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
interface Props {
@@ -51,7 +57,7 @@ interface UrlItem {
id: number;
url: string;
name: string;
ext: string;
extension: string;
dbkey: string;
spaceToTab: boolean;
toPosixLines: boolean;
@@ -68,81 +74,27 @@ const placeholder = "https://example.org/data1.txt\nhttps://example.org/data2.tx
const hasItems = computed(() => urlItems.value.length > 0);
const isReadyToUpload = computed(() => {
if (!hasItems.value) {
return false;
}
// Check if any URL is invalid
const hasInvalidUrls = urlItems.value.some((item) => isValidUrl(item.url) === false);
if (hasInvalidUrls) {
return false;
}
// If collection creation is active, it must be valid to proceed
if (collectionState.value.validation.isActive && !collectionState.value.validation.isValid) {
return false;
}
return true;
});
const { isNameValid, restoreOriginalName: restoreOriginalNameBase } = useUploadItemValidation();
const bulk = useBulkUploadOperations(urlItems, effectiveExtensions);
// Additional validation for URL items
const hasInvalidUrls = computed(() => urlItems.value.some((item) => !isValidUrl(item.url)));
const { isReadyToUpload } = useUploadReadyState(
hasItems,
collectionState,
computed(() => !hasInvalidUrls.value),
);
watch(isReadyToUpload, (ready) => emit("ready", ready), { immediate: true });
function extractNameFromUrl(url: string): string {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const segments = pathname.split("/").filter((s) => s.length > 0);
if (segments.length > 0) {
const lastSegment = segments[segments.length - 1];
if (lastSegment) {
// Decode URI component to handle encoded characters
return decodeURIComponent(lastSegment);
}
}
} catch {
// If URL parsing fails, fall through to return the full URL
}
return url;
}
function validateUrl(url: string): { isValid: boolean; message: string | null } {
if (!url.trim()) {
return { isValid: false, message: "URL is required" };
}
try {
const urlObj = new URL(url);
if (!urlObj.host) {
return { isValid: false, message: "URL must include a valid hostname" };
}
const hasContent = urlObj.pathname !== "/" || urlObj.search || urlObj.hash;
if (!hasContent) {
return { isValid: false, message: "URL should point to a specific file or resource" };
}
return { isValid: true, message: null };
} catch {
return { isValid: false, message: "URL format is invalid or missing protocol" };
}
}
function isValidUrl(url: string): boolean {
return validateUrl(url).isValid;
}
function getUrlValidationMessage(url: string): string {
return validateUrl(url).message || url;
}
function isNameValid(name: string): boolean | null {
return name.trim().length > 0 ? null : false;
}
function restoreOriginalName(item: UrlItem) {
if (!item.name.trim()) {
item.name = extractNameFromUrl(item.url);
}
restoreOriginalNameBase(item, extractNameFromUrl(item.url));
}
function addUrlsFromText() {
@@ -163,7 +115,7 @@ function addUrlsFromText() {
id: nextId++,
url,
name: extractNameFromUrl(url),
ext: defaultExtension,
extension: defaultExtension,
dbkey: defaultDbKey,
spaceToTab: false,
toPosixLines: false,
@@ -197,80 +149,6 @@ function handleCollectionStateChange(state: CollectionCreationState) {
collectionState.value = state;
}
function getExtensionWarning(extensionId: string): string | null {
const ext = findExtension(effectiveExtensions.value, extensionId);
return ext?.upload_warning || null;
}
// Bulk operations
const bulkExtension = ref<string | null>(null);
const bulkDbKey = ref<string | null>(null);
const bulkExtensionWarning = computed(() => {
if (!bulkExtension.value) {
return null;
}
const ext = findExtension(effectiveExtensions.value, bulkExtension.value);
return ext?.upload_warning || null;
});
function setAllExtensions(extension: string | null) {
if (extension) {
urlItems.value.forEach((item) => {
item.ext = extension;
});
}
}
function setAllDbKeys(dbKey: string | null) {
if (dbKey) {
urlItems.value.forEach((item) => {
item.dbkey = dbKey;
});
}
}
const allSpaceToTab = computed(() => urlItems.value.length > 0 && urlItems.value.every((f) => f.spaceToTab));
const allToPosixLines = computed(() => urlItems.value.length > 0 && urlItems.value.every((f) => f.toPosixLines));
const allDeferred = computed(() => urlItems.value.length > 0 && urlItems.value.every((f) => f.deferred));
const spaceToTabIndeterminate = computed(
() =>
urlItems.value.length > 0 &&
urlItems.value.some((f) => f.spaceToTab) &&
!urlItems.value.every((f) => f.spaceToTab),
);
const toPosixLinesIndeterminate = computed(
() =>
urlItems.value.length > 0 &&
urlItems.value.some((f) => f.toPosixLines) &&
!urlItems.value.every((f) => f.toPosixLines),
);
const deferredIndeterminate = computed(
() =>
urlItems.value.length > 0 && urlItems.value.some((f) => f.deferred) && !urlItems.value.every((f) => f.deferred),
);
function toggleAllSpaceToTab() {
const newValue = !allSpaceToTab.value;
urlItems.value.forEach((f) => (f.spaceToTab = newValue));
}
function toggleAllToPosixLines() {
const newValue = !allToPosixLines.value;
urlItems.value.forEach((f) => (f.toPosixLines = newValue));
}
function toggleAllDeferred() {
const newValue = !allDeferred.value;
urlItems.value.forEach((f) => (f.deferred = newValue));
}
// Table configuration
const tableFields = [
{
key: "name",
@@ -329,7 +207,7 @@ function startUpload() {
size: 0,
targetHistoryId: props.targetHistoryId,
dbkey: item.dbkey,
extension: item.ext,
extension: item.extension,
spaceToTab: item.spaceToTab,
toPosixLines: item.toPosixLines,
deferred: item.deferred,
@@ -420,33 +298,19 @@ defineExpose<UploadMethodComponent>({ startUpload });
<!-- Extension column with bulk operations -->
<template v-slot:head(extension)>
<div class="column-header-vertical">
<span class="column-title">Type</span>
<BFormSelect
v-model="bulkExtension"
v-b-tooltip.hover.noninteractive
size="sm"
title="Set file format for all URLs"
:disabled="!configurationsReady"
@change="setAllExtensions">
<option :value="null">Set all...</option>
<option v-for="(ext, extIndex) in effectiveExtensions" :key="extIndex" :value="ext.id">
{{ ext.text }}
</option>
</BFormSelect>
<FontAwesomeIcon
v-if="bulkExtensionWarning"
v-b-tooltip.hover.noninteractive
class="text-warning warning-icon"
:icon="faExclamationTriangle"
:title="bulkExtensionWarning" />
</div>
<UploadTableBulkExtensionHeader
:model-value="bulk.bulkExtension.value"
:extensions="effectiveExtensions"
:warning="bulk.bulkExtensionWarning.value"
:disabled="!configurationsReady"
tooltip="Set file format for all URLs"
@update:model-value="bulk.setAllExtensions" />
</template>
<template v-slot:cell(extension)="{ item }">
<div class="d-flex align-items-center">
<BFormSelect
v-model="item.ext"
v-model="item.extension"
v-b-tooltip.hover.noninteractive
size="sm"
title="File format (auto-detect recommended)"
@@ -456,31 +320,22 @@ defineExpose<UploadMethodComponent>({ startUpload });
</option>
</BFormSelect>
<FontAwesomeIcon
v-if="getExtensionWarning(item.ext)"
v-if="bulk.getExtensionWarning(item.extension)"
v-b-tooltip.hover.noninteractive
class="text-warning ml-1 flex-shrink-0"
:icon="faExclamationTriangle"
:title="getExtensionWarning(item.ext)" />
:title="bulk.getExtensionWarning(item.extension)" />
</div>
</template>
<!-- DbKey column with bulk operations -->
<template v-slot:head(dbKey)>
<div class="column-header-vertical">
<span class="column-title">Reference</span>
<BFormSelect
v-model="bulkDbKey"
v-b-tooltip.hover.noninteractive
size="sm"
title="Set database key for all URLs"
:disabled="!configurationsReady"
@change="setAllDbKeys">
<option :value="null">Set all...</option>
<option v-for="(dbKey, dbKeyIndex) in listDbKeys" :key="dbKeyIndex" :value="dbKey.id">
{{ dbKey.text }}
</option>
</BFormSelect>
</div>
<UploadTableBulkDbKeyHeader
:model-value="bulk.bulkDbKey.value"
:db-keys="listDbKeys"
:disabled="!configurationsReady"
tooltip="Set database key for all URLs"
@update:model-value="bulk.setAllDbKeys" />
</template>
<template v-slot:cell(dbKey)="{ item }">
@@ -498,40 +353,17 @@ defineExpose<UploadMethodComponent>({ startUpload });
<!-- Options column with bulk checkboxes -->
<template v-slot:head(options)>
<div class="options-header">
<span class="options-title">Upload Settings</span>
<div class="d-flex align-items-center">
<BFormCheckbox
v-b-tooltip.hover.noninteractive
:checked="allSpaceToTab"
:indeterminate="spaceToTabIndeterminate"
size="sm"
class="mr-2"
title="Toggle all: Convert spaces to tab characters"
@change="toggleAllSpaceToTab">
<span class="small">Spaces→Tabs</span>
</BFormCheckbox>
<BFormCheckbox
v-b-tooltip.hover.noninteractive
:checked="allToPosixLines"
:indeterminate="toPosixLinesIndeterminate"
size="sm"
class="mr-2"
title="Toggle all: Convert line endings to POSIX standard"
@change="toggleAllToPosixLines">
<span class="small">POSIX</span>
</BFormCheckbox>
<BFormCheckbox
v-b-tooltip.hover.noninteractive
:checked="allDeferred"
:indeterminate="deferredIndeterminate"
size="sm"
title="Toggle all: Galaxy will store a reference and fetch data only when needed by a tool"
@change="toggleAllDeferred">
<span class="small">Deferred</span>
</BFormCheckbox>
</div>
</div>
<UploadTableOptionsHeader
:all-space-to-tab="bulk.allSpaceToTab.value"
:space-to-tab-indeterminate="bulk.spaceToTabIndeterminate.value"
:all-to-posix-lines="bulk.allToPosixLines.value"
:to-posix-lines-indeterminate="bulk.toPosixLinesIndeterminate.value"
:show-deferred="true"
:all-deferred="bulk.allDeferred.value"
:deferred-indeterminate="bulk.deferredIndeterminate.value"
@toggle-space-to-tab="bulk.toggleAllSpaceToTab"
@toggle-to-posix-lines="bulk.toggleAllToPosixLines"
@toggle-deferred="bulk.toggleAllDeferred" />
</template>
<template v-slot:cell(options)="{ item }">
@@ -607,6 +439,7 @@ defineExpose<UploadMethodComponent>({ startUpload });
<style scoped lang="scss">
@import "@/style/scss/theme/blue.scss";
@import "../shared/upload-table-shared.scss";
.paste-links-upload {
height: 100%;
@@ -640,31 +473,14 @@ defineExpose<UploadMethodComponent>({ startUpload });
}
.url-list-header {
flex-shrink: 0;
border-bottom: 1px solid $border-color;
padding-bottom: 0.5rem;
@include upload-list-header;
}
.url-table-container {
flex: 1;
min-height: 0;
overflow: auto;
:deep(.table) {
table-layout: auto;
min-width: 100%;
}
@include upload-table-container;
:deep(.url-table-header) {
position: sticky;
top: 0;
background-color: $white;
z-index: 100;
th {
vertical-align: middle;
white-space: nowrap;
}
@include upload-table-header;
}
:deep(.url-name-cell) {
@@ -681,51 +497,9 @@ defineExpose<UploadMethodComponent>({ startUpload });
font-size: 0.85rem;
}
}
.column-header-vertical {
display: flex;
flex-direction: column;
align-items: stretch;
position: relative;
.column-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.warning-icon {
position: absolute;
right: 0;
top: 0;
}
}
.options-header {
.options-title {
display: block;
font-weight: 600;
margin-bottom: 0.25rem;
}
}
.remove-btn {
width: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
background-color: rgba($brand-danger, 0.1);
}
}
}
.url-list-actions {
flex-shrink: 0;
display: flex;
gap: 0.5rem;
justify-content: flex-start;
padding-top: 1rem;
border-top: 1px solid $border-color;
@include upload-list-actions;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { BFormSelect } from "bootstrap-vue";
interface DbKey {
id: string;
text: string;
}
interface Props {
/** Selected bulk dbKey value (v-model) */
modelValue: string;
/** Available database keys to display in dropdown */
dbKeys: DbKey[];
/** Whether the dropdown should be disabled */
disabled?: boolean;
/** Tooltip text for the bulk selector */
tooltip?: string;
}
withDefaults(defineProps<Props>(), {
disabled: false,
tooltip: "Set database key for all items",
});
const emit = defineEmits<{
(e: "update:model-value", value: string): void;
}>();
function handleInput(value: string) {
emit("update:model-value", value);
}
</script>
<template>
<div class="column-header-vertical">
<span class="column-title">Reference</span>
<BFormSelect
v-b-tooltip.hover.noninteractive
:value="modelValue"
size="sm"
:title="tooltip"
:disabled="disabled"
@input="handleInput">
<option value="">Set all...</option>
<option v-for="(dbKey, dbKeyIndex) in dbKeys" :key="dbKeyIndex" :value="dbKey.id">
{{ dbKey.text }}
</option>
</BFormSelect>
</div>
</template>
<style scoped lang="scss">
@import "../shared/upload-table-shared.scss";
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BFormSelect } from "bootstrap-vue";
import type { ExtensionDetails } from "@/composables/upload/bulkUploadOperations";
interface Props {
/** Selected bulk extension value (v-model) */
modelValue: string;
/** Available extensions to display in dropdown */
extensions: ExtensionDetails[];
/** Warning message for current bulk selection */
warning?: string;
/** Whether the dropdown should be disabled */
disabled?: boolean;
/** Tooltip text for the bulk selector */
tooltip?: string;
}
withDefaults(defineProps<Props>(), {
disabled: false,
tooltip: "Set file format for all items",
warning: undefined,
});
const emit = defineEmits<{
(e: "update:model-value", value: string): void;
}>();
function handleInput(value: string) {
emit("update:model-value", value);
}
</script>
<template>
<div class="column-header-vertical">
<span class="column-title">Type</span>
<BFormSelect
v-b-tooltip.hover.noninteractive
:value="modelValue"
size="sm"
:title="tooltip"
:disabled="disabled"
@input="handleInput">
<option value="">Set all...</option>
<option v-for="(ext, extIndex) in extensions" :key="extIndex" :value="ext.id">
{{ ext.text }}
</option>
</BFormSelect>
<FontAwesomeIcon
v-if="warning"
v-b-tooltip.hover.noninteractive
class="text-warning warning-icon"
:icon="faExclamationTriangle"
:title="warning" />
</div>
</template>
<style scoped lang="scss">
@import "../shared/upload-table-shared.scss";
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { BFormCheckbox } from "bootstrap-vue";
interface Props {
/** All items have spaceToTab enabled */
allSpaceToTab: boolean;
/** Some but not all items have spaceToTab enabled */
spaceToTabIndeterminate: boolean;
/** All items have toPosixLines enabled */
allToPosixLines: boolean;
/** Some but not all items have toPosixLines enabled */
toPosixLinesIndeterminate: boolean;
/** Whether to show the deferred checkbox (for URL uploads) */
showDeferred?: boolean;
/** All items have deferred enabled */
allDeferred?: boolean;
/** Some but not all items have deferred enabled */
deferredIndeterminate?: boolean;
}
withDefaults(defineProps<Props>(), {
showDeferred: false,
allDeferred: false,
deferredIndeterminate: false,
});
const emit = defineEmits<{
(e: "toggle-space-to-tab"): void;
(e: "toggle-to-posix-lines"): void;
(e: "toggle-deferred"): void;
}>();
function handleToggleSpaceToTab() {
emit("toggle-space-to-tab");
}
function handleToggleToPosixLines() {
emit("toggle-to-posix-lines");
}
function handleToggleDeferred() {
emit("toggle-deferred");
}
</script>
<template>
<div class="options-header">
<span class="options-title">Upload Settings</span>
<div class="d-flex align-items-center">
<BFormCheckbox
v-b-tooltip.hover.noninteractive
:checked="allSpaceToTab"
:indeterminate="spaceToTabIndeterminate"
size="sm"
class="mr-2"
title="Toggle all: Convert spaces to tab characters"
@change="handleToggleSpaceToTab">
<span class="small">Spaces→Tabs</span>
</BFormCheckbox>
<BFormCheckbox
v-b-tooltip.hover.noninteractive
:checked="allToPosixLines"
:indeterminate="toPosixLinesIndeterminate"
size="sm"
class="mr-2"
title="Toggle all: Convert line endings to POSIX standard"
@change="handleToggleToPosixLines">
<span class="small">POSIX</span>
</BFormCheckbox>
<BFormCheckbox
v-if="showDeferred"
v-b-tooltip.hover.noninteractive
:checked="allDeferred"
:indeterminate="deferredIndeterminate"
size="sm"
title="Toggle all: Galaxy will store a reference and fetch data only when needed by a tool"
@change="handleToggleDeferred">
<span class="small">Deferred</span>
</BFormCheckbox>
</div>
</div>
</template>
<style scoped lang="scss">
@import "../shared/upload-table-shared.scss";
</style>
@@ -0,0 +1,88 @@
@import "@/style/scss/theme/blue.scss";
/**
* Shared styles for upload table components.
* Used by LocalFileUpload, PasteContentUpload, and PasteLinksUpload.
*/
// Table container with scrolling - use as mixin
@mixin upload-table-container {
flex: 1;
min-height: 0;
overflow: auto;
:deep(.table) {
table-layout: auto;
min-width: 100%;
}
}
// Sticky header for table - use as mixin
@mixin upload-table-header {
position: sticky;
top: 0;
background-color: $white;
z-index: 100;
th {
vertical-align: middle;
white-space: nowrap;
}
}
// Column header with vertical layout (title + dropdown)
.column-header-vertical {
display: flex;
flex-direction: column;
align-items: stretch;
position: relative;
.column-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.warning-icon {
position: absolute;
right: 0;
top: 0;
}
}
// Options column header
.options-header {
.options-title {
display: block;
font-weight: 600;
margin-bottom: 0.25rem;
}
}
// Remove button in actions column
.remove-btn {
width: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
background-color: rgba($brand-danger, 0.1);
}
}
// Actions at the bottom of the list - use as mixin
@mixin upload-list-actions {
flex-shrink: 0;
display: flex;
gap: 0.5rem;
justify-content: flex-start;
padding-top: 1rem;
border-top: 1px solid $border-color;
}
// List header - use as mixin
@mixin upload-list-header {
flex-shrink: 0;
border-bottom: 1px solid $border-color;
padding-bottom: 0.5rem;
}
@@ -0,0 +1,257 @@
import { computed, type ComputedRef, type Ref, ref } from "vue";
import { findExtension } from "@/components/Upload/utils";
/**
* Base interface for upload items that support bulk operations.
* Upload items must have these properties to work with bulk operations.
*/
export interface BulkUploadItem {
extension?: string;
dbkey?: string;
spaceToTab?: boolean;
toPosixLines?: boolean;
deferred?: boolean;
}
/**
* Extension details interface matching the structure from uploadConfigurations
*/
export interface ExtensionDetails {
id: string;
text: string;
upload_warning?: string | null;
}
/**
* Return type for the useBulkUploadOperations composable
*/
export interface BulkUploadOperations {
// Bulk selectors
bulkExtension: Ref<string>;
bulkDbKey: Ref<string>;
// "All" computed states
allSpaceToTab: ComputedRef<boolean>;
allToPosixLines: ComputedRef<boolean>;
allDeferred: ComputedRef<boolean>;
// Indeterminate states for checkboxes
spaceToTabIndeterminate: ComputedRef<boolean>;
toPosixLinesIndeterminate: ComputedRef<boolean>;
deferredIndeterminate: ComputedRef<boolean>;
// Extension warning functions
getExtensionWarning: (extensionId: string) => string | undefined;
bulkExtensionWarning: ComputedRef<string | undefined>;
// Bulk setter functions
setAllExtensions: (extension: string) => void;
setAllDbKeys: (dbKey: string) => void;
// Toggle functions for checkboxes
toggleAllSpaceToTab: () => void;
toggleAllToPosixLines: () => void;
toggleAllDeferred: () => void;
}
/**
* Composable for managing bulk operations on upload items.
* Provides functionality for bulk setting extensions, database keys, toggling
* upload settings (spaceToTab, toPosixLines, deferred), and extension warnings.
*
* @example
* ```typescript
* const items = ref<FileItem[]>([...]);
* const { effectiveExtensions } = useUploadConfigurations();
* const bulk = useBulkUploadOperations(items, effectiveExtensions);
*
* // Use in template
* <BFormSelect v-model="bulk.bulkExtension.value" @change="bulk.setAllExtensions">
* <BFormCheckbox :checked="bulk.allSpaceToTab.value" @change="bulk.toggleAllSpaceToTab">
* <span v-if="bulk.bulkExtensionWarning">{{ bulk.bulkExtensionWarning }}</span>
* ```
*
* @param items - Reactive reference to array of upload items
* @param effectiveExtensions - Reactive reference to available extensions for warnings
* @returns Object containing bulk operation state and functions
*/
export function useBulkUploadOperations<T extends BulkUploadItem>(
items: Ref<T[]>,
effectiveExtensions: Ref<ExtensionDetails[]>,
): BulkUploadOperations {
// Bulk selectors for header dropdowns
const bulkExtension = ref<string>("");
const bulkDbKey = ref<string>("");
/**
* Computed property that returns true if all items have spaceToTab enabled.
*/
const allSpaceToTab = computed(() => {
return items.value.length > 0 && items.value.every((item) => item.spaceToTab === true);
});
/**
* Computed property that returns true if all items have toPosixLines enabled.
*/
const allToPosixLines = computed(() => {
return items.value.length > 0 && items.value.every((item) => item.toPosixLines === true);
});
/**
* Computed property that returns true if all items have deferred enabled.
* Only relevant when supportDeferred option is true.
*/
const allDeferred = computed(() => {
return items.value.length > 0 && items.value.every((item) => item.deferred === true);
});
/**
* Computed property for checkbox indeterminate state (some but not all checked).
*/
const spaceToTabIndeterminate = computed(() => {
return (
items.value.length > 0 &&
items.value.some((item) => item.spaceToTab === true) &&
!items.value.every((item) => item.spaceToTab === true)
);
});
/**
* Computed property for checkbox indeterminate state (some but not all checked).
*/
const toPosixLinesIndeterminate = computed(() => {
return (
items.value.length > 0 &&
items.value.some((item) => item.toPosixLines === true) &&
!items.value.every((item) => item.toPosixLines === true)
);
});
/**
* Computed property for checkbox indeterminate state (some but not all checked).
*/
const deferredIndeterminate = computed(() => {
return (
items.value.length > 0 &&
items.value.some((item) => item.deferred === true) &&
!items.value.every((item) => item.deferred === true)
);
});
/**
* Sets the extension/type for all items in the list.
* @param extension - The extension ID to set, or empty string to skip
*/
function setAllExtensions(extension: string) {
bulkExtension.value = extension;
if (!extension || extension === "") {
return;
}
items.value.forEach((item) => {
if ("extension" in item) {
item.extension = extension;
}
});
}
/**
* Sets the database key for all items in the list.
* @param dbKey - The database key to set, or empty string to skip
*/
function setAllDbKeys(dbKey: string) {
bulkDbKey.value = dbKey;
if (!dbKey || dbKey === "") {
return;
}
items.value.forEach((item) => {
if ("dbkey" in item) {
item.dbkey = dbKey;
}
});
}
/**
* Toggles the spaceToTab setting for all items.
* If all are currently enabled, disables all; otherwise enables all.
*/
function toggleAllSpaceToTab() {
const newValue = !allSpaceToTab.value;
items.value.forEach((item) => {
if ("spaceToTab" in item) {
item.spaceToTab = newValue;
}
});
}
/**
* Toggles the toPosixLines setting for all items.
* If all are currently enabled, disables all; otherwise enables all.
*/
function toggleAllToPosixLines() {
const newValue = !allToPosixLines.value;
items.value.forEach((item) => {
if ("toPosixLines" in item) {
item.toPosixLines = newValue;
}
});
}
/**
* Toggles the deferred setting for all items.
* If all are currently enabled, disables all; otherwise enables all.
* Only relevant when supportDeferred option is true.
*/
function toggleAllDeferred() {
const newValue = !allDeferred.value;
items.value.forEach((item) => {
if ("deferred" in item) {
item.deferred = newValue;
}
});
}
/**
* Gets the upload warning for a specific extension ID.
*
* @param extensionId - The extension ID to look up
* @returns The warning message or undefined if no warning exists
*/
function getExtensionWarning(extensionId: string): string | undefined {
const ext = findExtension(effectiveExtensions.value, extensionId);
return ext?.upload_warning || undefined;
}
/**
* Computed property for the bulk extension warning.
* Updates when bulkExtension changes.
*/
const bulkExtensionWarning = computed(() => {
if (!bulkExtension.value || bulkExtension.value === "") {
return undefined;
}
return getExtensionWarning(bulkExtension.value);
});
return {
bulkExtension,
bulkDbKey,
allSpaceToTab,
allToPosixLines,
allDeferred,
spaceToTabIndeterminate,
toPosixLinesIndeterminate,
deferredIndeterminate,
getExtensionWarning,
bulkExtensionWarning,
setAllExtensions,
setAllDbKeys,
toggleAllSpaceToTab,
toggleAllToPosixLines,
toggleAllDeferred,
};
}
@@ -0,0 +1,42 @@
/**
* Composable for validating upload item properties.
* Provides validation functions for item names and name restoration logic.
*
* @example
* ```typescript
* const { isNameValid, restoreOriginalName } = useUploadItemValidation();
*
* // In template
* <BFormInput :state="isNameValid(item.name)" @blur="restoreOriginalName(item, 'default.txt')" />
* ```
*/
export function useUploadItemValidation() {
/**
* Validates that a name is non-empty after trimming whitespace.
* Returns null for valid names (Bootstrap-Vue convention for valid state).
*
* @param name - The name to validate
* @returns null if valid (non-empty), false if invalid (empty)
*/
function isNameValid(name: string): boolean | null {
return name.trim().length > 0 ? null : false;
}
/**
* Restores an item's name to a default value if it's empty.
* Useful for blur handlers to ensure items always have valid names.
*
* @param item - The item with a name property to restore
* @param defaultName - The default name to use if current name is empty
*/
function restoreOriginalName<T extends { name: string }>(item: T, defaultName: string): void {
if (!item.name.trim()) {
item.name = defaultName;
}
}
return {
isNameValid,
restoreOriginalName,
};
}
@@ -0,0 +1,63 @@
import { computed, type ComputedRef, type Ref } from "vue";
import type { CollectionCreationState } from "@/components/Panels/Upload/types/collectionCreation";
/**
* Composable for determining if upload items are ready to be uploaded.
* Consolidates validation logic across multiple upload methods.
*
* @example
* ```typescript
* const hasItems = computed(() => items.value.length > 0);
* const hasInvalidItems = computed(() => items.value.some(item => !isValid(item)));
*
* const { isReadyToUpload } = useUploadReadyState(
* hasItems,
* collectionState,
* hasInvalidItems
* );
*
* // Use in template or watch
* watch(isReadyToUpload, (ready) => emit("ready", ready));
* ```
*
* @param hasItems - Computed/ref indicating if any items exist
* @param collectionState - Reactive reference to collection creation state
* @param additionalValidation - Optional computed/ref for method-specific validation (e.g., URL validation)
* @returns Object with isReadyToUpload computed property
*/
export function useUploadReadyState(
hasItems: ComputedRef<boolean> | Ref<boolean>,
collectionState: Ref<CollectionCreationState>,
additionalValidation?: ComputedRef<boolean> | Ref<boolean>,
) {
/**
* Computed property that determines if upload can proceed.
* Checks:
* 1. Items exist
* 2. Additional validation passes (if provided)
* 3. Collection validation passes (if collection creation is active)
*/
const isReadyToUpload = computed(() => {
// Must have items to upload
if (!hasItems.value) {
return false;
}
// Check additional validation if provided
if (additionalValidation !== undefined && !additionalValidation.value) {
return false;
}
// If collection creation is active, it must be valid
if (collectionState.value.validation.isActive && !collectionState.value.validation.isValid) {
return false;
}
return true;
});
return {
isReadyToUpload,
};
}
+97
View File
@@ -65,3 +65,100 @@ export function addSearchParams(url: string, params: Record<string, string>) {
const searchString = searchParams.toString();
return searchString ? `${url}${placeholder}${searchString}` : url;
}
/**
* URL validation result interface
*/
export interface UrlValidationResult {
isValid: boolean;
message: string | null;
}
/**
* Validates a URL string and returns detailed validation results.
* Checks for required fields, valid hostname, and meaningful content.
*
* @param url - The URL string to validate
* @returns Validation result with isValid flag and error message
*
* @example
* ```typescript
* const result = validateUrl("https://example.org/data.txt");
* if (!result.isValid) {
* console.error(result.message);
* }
* ```
*/
export function validateUrl(url: string): UrlValidationResult {
if (!url.trim()) {
return { isValid: false, message: "URL is required" };
}
try {
const urlObj = new URL(url);
if (!urlObj.host) {
return { isValid: false, message: "URL must include a valid hostname" };
}
const hasContent = urlObj.pathname !== "/" || urlObj.search || urlObj.hash;
if (!hasContent) {
return { isValid: false, message: "URL should point to a specific file or resource" };
}
return { isValid: true, message: null };
} catch {
return { isValid: false, message: "URL format is invalid or missing protocol" };
}
}
/**
* Simplified URL validation that returns only a boolean.
*
* @param url - The URL string to validate
* @returns true if URL is valid, false otherwise
*
* @example
* ```typescript
* if (isValidUrl("https://example.org/data.txt")) {
* // Process valid URL
* }
* ```
*/
export function isValidUrl(url: string): boolean {
return validateUrl(url).isValid;
}
/**
* Extracts a meaningful filename from a URL.
* Attempts to get the last path segment and decode it.
* Falls back to the full URL if extraction fails.
*
* @param url - The URL to extract the name from
* @returns The extracted filename or the full URL as fallback
*
* @example
* ```typescript
* extractNameFromUrl("https://example.org/path/to/data.txt") // returns "data.txt"
* extractNameFromUrl("https://example.org/file%20name.txt") // returns "file name.txt"
* extractNameFromUrl("https://example.org/") // returns "https://example.org/"
* ```
*/
export function extractNameFromUrl(url: string): string {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const segments = pathname.split("/").filter((s) => s.length > 0);
if (segments.length > 0) {
const lastSegment = segments[segments.length - 1];
if (lastSegment) {
// Decode URI component to handle encoded characters
return decodeURIComponent(lastSegment);
}
}
} catch {
// If URL parsing fails, fall through to return the full URL
}
return url;
}