Files
lucia-frontend/src/views/Upload/UploadPage.vue

479 lines
14 KiB
Vue

<template>
<section
class="h-screen-main w-full px-4 flex flex-col justify-between items-start"
>
<!-- Upload Content -->
<div class="w-full h-[calc(100%_-_64px)]">
<!-- File name -->
<!-- cursor-pointer -->
<div class="h-12 pl-1 border-b border-neutral-300 flex items-center">
<InputText
type="text"
v-model="fileName"
id="fileNameInput"
class="rounded !border-transparent !font-bold !text-base !py-1 min-w-[1px] w-auto"
@input="onInput"
@blur="onBlur"
maxlength="200"
title="Rename"
/>
</div>
<!-- Upload notification -->
<div class="flex justify-start items-center space-x-2 ml-2 py-2">
<span
class="material-symbols-outlined text-neutral-700 cursor-pointer"
v-tooltip.right="tooltipUpload"
>info</span
>
<span class="w-px h-7 bg-neutral-300"></span>
<!-- Upload text -->
<div>
<div v-if="!isDisabled"></div>
<div
v-else
class="flex justify-start items-center space-x-2 duration-700"
>
<p v-if="informData.length !== 0" class="text-primary text-sm">
Need to select
<span v-for="(item, index) in informData" :key="index"
>{{ item.label
}}<span v-if="index !== informData.length - 1">, </span></span
>.
</p>
<div v-if="repeatedData.length !== 0" class="duration-700">
<p v-if="repeatedData.length === 1" class="text-danger text-sm">
{{ repeatedData[0].label }} has been assigned.
</p>
<p v-else class="text-danger text-sm">
<span v-for="(item, index) in repeatedData" :key="index"
>{{ item.label
}}<span v-if="index !== repeatedData.length - 1"
>,
</span></span
>
have been assigned.
</p>
</div>
</div>
</div>
</div>
<!-- Upload table -->
<div
class="overflow-y-auto overflow-x-auto scrollbar max-h-[calc(100%_-_94px)]"
>
<table
class="text-sm border-separate border-spacing-0 h-full overflow-y-auto overflow-x-auto scrollbar"
>
<caption class="hidden">
Upload
</caption>
<thead class="sticky top-0 bg-neutral-10">
<tr class="hidden">
<th></th>
</tr>
<tr>
<td
v-for="(item, index) in uploadDetail?.columns"
:key="index"
class="border border-neutral-500 p-2 truncate max-w-[198px]"
>
{{ item }}
</td>
</tr>
<tr>
<td
v-for="(item, index) in uploadDetail?.columns"
:key="index"
class="px-2 py-1 bg-neutral-300 border border-neutral-500 max-w-[198px]"
>
<Select
v-model="selectedColumns[index]"
:options="columnType"
optionLabel="name"
placeholder=""
class="w-[180px] !border-neutral-500"
:data-type="item"
:inputId="index.toString()"
:inputClass="[selectedColumns[index]?.color, 'text-sm']"
>
<template #option="slotProps">
<div :class="slotProps.option.color" class="text-sm">
<span>{{ slotProps.option.name }}</span>
</div>
</template>
</Select>
</td>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in uploadDetail?.data" :key="index">
<td
v-for="(itemDetail, key) in item"
:key="key"
class="border border-neutral-500 p-2 truncate max-w-[198px]"
>
{{ itemDetail }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Upload button -->
<div class="w-full text-right space-x-4 px-8 py-4">
<button type="button" class="btn btn-sm btn-neutral" @click="cancel">
Cancel
</button>
<button type="button" class="btn btn-sm btn-neutral" @click="reset">
Reset
</button>
<button
type="button"
class="btn btn-sm"
@click="submit"
:disabled="isDisabled"
:class="isDisabled ? 'btn-disable' : 'btn-neutral'"
>
Upload
</button>
</div>
</section>
</template>
<script>
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
// imacat.yang@dsp.im (imacat), 2023/9/23
/**
* @module views/Upload File upload page with column mapping
* dropdowns, file rename input, and validation before final
* upload submission.
*/
import { useFilesStore } from "@/stores/files";
export default {
beforeRouteEnter(to, from, next) {
// An uploadID is required to enter this page
next((vm) => {
const filesStore = useFilesStore();
if (filesStore.uploadId === null) {
vm.$router.push({ name: "Files", replace: true });
vm.$toast.default("Please upload your file.", { position: "bottom" });
}
});
},
};
</script>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useLoadingStore } from "@/stores/loading";
import {
uploadConfirm,
} from "@/module/alertModal.js";
const router = useRouter();
// Stores
const loadingStore = useLoadingStore();
const filesStore = useFilesStore();
const { isLoading } = storeToRefs(loadingStore);
const { uploadDetail, uploadId, uploadFileName } = storeToRefs(filesStore);
// Data
const tooltipUpload = {
value: `1. Case ID: A unique identifier for each case.
2. Activity: A process step executed by either a system (automated) or humans (manual).
3. Activity Instance ID: A unique identifier for a single occurrence of an activity.
4. Timestamp: The time of occurrence of a particular event, such as the start or end of an activity.
5. Status: Activity status, such as Start or Complete.
6. Attribute: A property that can be associated with a case to provide additional information about that case.`,
// Resource is not available yet
// 7. Resource: A resource refers to any entity that is required to carry out a business process. This can include people, equipment, software, or any other type of asset.
class: "!max-w-[400px] !text-[10px] !opacity-80",
autoHide: false,
};
const columnType = [
{
name: "Case ID*",
code: "case_id",
color: "!text-secondary",
value: "",
label: "Case ID",
required: true,
},
{
name: "Timestamp*",
code: "timestamp",
color: "!text-secondary",
value: "",
label: "Timestamp",
required: true,
},
{
name: "Status*",
code: "status",
color: "!text-secondary",
value: "",
label: "Status",
required: true,
},
{
name: "Activity*",
code: "name",
color: "!text-secondary",
value: "",
label: "Activity",
required: true,
},
{
name: "Activity Instance ID*",
code: "instance",
color: "!text-secondary",
value: "",
label: "Activity Instance ID",
required: true,
},
{
name: "Case Attribute",
code: "case_attributes",
color: "!text-primary",
value: "",
label: "Case Attribute",
required: false,
},
// { name: 'Resource', code: '', color: '', value: '', label: 'Resource', required: false }, // Not available yet; may be added in the future
{
name: "Not Assigned",
code: "",
color: "!text-neutral-700",
value: "",
label: "Not Assigned",
required: false,
},
];
const selectedColumns = ref([]);
const informData = ref([]);
const repeatedData = ref([]);
const fileName = ref(uploadFileName.value);
const showEdit = ref(false);
// Computed
const isDisabled = computed(() => {
// 1. Length must match; every column must be assigned
// 2. Must not be null or undefined
const hasValue = !selectedColumns.value.includes(undefined);
const result = !(
selectedColumns.value.length === uploadDetail.value?.columns.length &&
informData.value.length === 0 &&
repeatedData.value.length === 0 &&
hasValue
);
return result;
});
// Watch
watch(
selectedColumns,
(newVal) => {
updateValidationData(newVal);
},
{ deep: true },
);
// Methods
/**
* Adjusts input width and trims whitespace on blur.
* @param {Event} e - The blur event from the file name input.
*/
function onBlur(e) {
const baseWidth = 20;
if (e.target.value === "") {
e.target.value = uploadFileName.value;
const textWidth = getTextWidth(e.target.value, e.target);
e.target.style.width = baseWidth + textWidth + "px";
} else if (e.target.value !== e.target.value.trim()) {
e.target.value = e.target.value.trim();
const textWidth = getTextWidth(e.target.value, e.target);
e.target.style.width = baseWidth + textWidth + "px";
}
}
/**
* Dynamically resizes the input width as the user types.
* @param {Event} e - The input event from the file name input.
*/
function onInput(e) {
const baseWidth = 20;
const textWidth = getTextWidth(e.target.value, e.target);
e.target.style.width = baseWidth + textWidth + "px";
}
/**
* Measures the pixel width of text using a hidden span element.
* @param {string} text - The text to measure.
* @param {HTMLElement} e - The input element for font style reference.
* @returns {number} The text width in pixels.
*/
function getTextWidth(text, e) {
// Replace spaces with non-breaking spaces
const processedText = text.replaceAll(" ", "\u00a0");
const hiddenSpan = document.createElement("span");
hiddenSpan.textContent = processedText;
hiddenSpan.style.font = globalThis.getComputedStyle(e).font;
hiddenSpan.style.visibility = "hidden";
document.body.appendChild(hiddenSpan);
const width = hiddenSpan.getBoundingClientRect().width;
hiddenSpan.remove();
return width;
}
/**
* Updates validation state (missing required and duplicate columns).
* @param {Array} data - The currently selected column type assignments.
*/
function updateValidationData(data) {
const nameOccurrences = {};
const noSortedRepeatedData = []; // Unsorted duplicate selections
const selectedData = []; // Already selected data
informData.value = []; // Not yet selected data
repeatedData.value = []; // Duplicate selections
data.forEach((item) => {
const { name, code } = item;
if (nameOccurrences[name]) {
// 'Not Assigned' and 'Case Attribute' are excluded from validation
if (!code || code === "case_attributes") return;
nameOccurrences[name]++;
// Each duplicate option should only appear once
if (nameOccurrences[name] === 2) {
noSortedRepeatedData.push(item);
}
// Sort according to the dropdown menu order
repeatedData.value = columnType.filter((column) =>
noSortedRepeatedData.includes(column),
);
} else {
nameOccurrences[name] = 1;
selectedData.push(name);
informData.value = columnType.filter((item) =>
item.required ? !selectedData.includes(item.name) : false,
);
}
});
}
/** Resets all column selections to default. */
function reset() {
// Do not add to browser history
selectedColumns.value = [];
}
/** Navigates back to the Files page without uploading. */
function cancel() {
// Do not add to browser history
router.push({ name: "Files", replace: true });
}
/** Submits the column mapping and triggers the second-stage upload. */
async function submit() {
// Post API Data
const fetchData = {
timestamp: "",
case_id: "",
name: "",
instance: "",
status: "",
case_attributes: [],
};
// Assign values
const haveValueData = selectedColumns.value.map((column, i) => {
if (column && uploadDetail.value?.columns?.[i]) {
return {
name: column.name,
code: column.code,
color: column.color,
value: uploadDetail.value.columns[i],
};
}
});
// Get the desired file name to change
uploadFileName.value = fileName.value;
// Set the data for the second-stage upload
haveValueData.forEach((column) => {
if (column !== undefined) {
switch (column.code) {
case "timestamp":
fetchData.timestamp = column.value;
break;
case "case_id":
fetchData.case_id = column.value;
break;
case "name":
fetchData.name = column.value;
break;
case "instance":
fetchData.instance = column.value;
break;
case "status":
fetchData.status = column.value;
break;
case "case_attributes":
fetchData.case_attributes.push(column.value);
break;
default:
break;
}
}
});
uploadConfirm(fetchData);
}
// Mounted
onMounted(async () => {
// Watch only once
const unwatch = watch(
fileName,
(newValue) => {
if (newValue) {
const inputElement = document.getElementById("fileNameInput");
if (!inputElement) return;
const baseWidth = 20;
const textWidth = getTextWidth(fileName.value, inputElement);
inputElement.style.width = baseWidth + textWidth + "px";
}
},
{ immediate: true },
);
showEdit.value = true;
if (uploadId.value) await filesStore.getUploadDetail();
if (uploadDetail.value?.columns) {
selectedColumns.value = Array.from(
{ length: uploadDetail.value.columns.length },
() => columnType[columnType.length - 1],
); // Default to "Not Assigned"
}
unwatch();
isLoading.value = false;
});
onBeforeUnmount(() => {
// Clear uploadID when leaving the page
uploadId.value = null;
uploadFileName.value = null;
});
</script>