Update inventory import front-end

This commit is contained in:
Anton 2024-05-28 11:40:57 +02:00
parent 4471ad40dc
commit 748292034b
11 changed files with 513 additions and 677 deletions

View file

@ -1,7 +1,7 @@
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import 'vue3-perfect-scrollbar/dist/vue3-perfect-scrollbar.css';
import ImportRepositoryModal from '../../vue/repositories/modals/import.vue';
import ImportRepositoryModal from '../../vue/repositories/modals/import/container.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp({});

View file

@ -1,113 +0,0 @@
<template>
<InfoModal ref="modal"
:startHidden="true"
:infoParams="infoParams"
:title="steps[activeStep].title"
:subtitle="steps[activeStep].subtitle"
:helpText="steps[activeStep].helpText"
>
<component
:key="steps[activeStep].id"
:is="steps[activeStep].component"
@step:next="proceedToNextStep"
@step:back="goBackToPrevStep"
:stepProps="steps[activeStep].stepData"
/>
</InfoModal>
</template>
<script>
/* global HelperModule */
import { shallowRef } from 'vue';
import InfoModal from '../../shared/info_modal.vue';
import FirstStep from './import/first_step.vue';
import SecondStep from './import/second_step.vue';
export default {
name: 'ImportRepositoryModal',
components: { InfoModal, FirstStep, SecondStep },
props: {
repositoryUrl: String,
required: true
},
data() {
return {
activeStep: 0,
repositoryData: null,
steps: [
{
id: this.i18n.t('repositories.import_records.steps.step1.id'),
icon: this.i18n.t('repositories.import_records.steps.step1.icon'),
label: this.i18n.t('repositories.import_records.steps.step1.label'),
title: this.i18n.t('repositories.import_records.steps.step1.title'),
subtitle: this.i18n.t('repositories.import_records.steps.step1.subtitle'),
helpText: this.i18n.t('repositories.import_records.steps.step1.helpText'),
component: shallowRef(FirstStep),
stepData: null
},
{
id: this.i18n.t('repositories.import_records.steps.step2.id'),
icon: this.i18n.t('repositories.import_records.steps.step2.icon'),
label: this.i18n.t('repositories.import_records.steps.step2.label'),
title: this.i18n.t('repositories.import_records.steps.step2.title'),
subtitle: this.i18n.t('repositories.import_records.steps.step2.subtitle'),
component: shallowRef(SecondStep),
stepData: null
}
],
infoParams: {
title: this.i18n.t('repositories.import_records.info_sidebar.title'),
elements: [
{
id: this.i18n.t('repositories.import_records.info_sidebar.elements.element0.id'),
icon: this.i18n.t('repositories.import_records.info_sidebar.elements.element0.icon'),
label: this.i18n.t('repositories.import_records.info_sidebar.elements.element0.label'),
subtext: this.i18n.t('repositories.import_records.info_sidebar.elements.element0.subtext')
},
{
id: this.i18n.t('repositories.import_records.info_sidebar.elements.element1.id'),
icon: this.i18n.t('repositories.import_records.info_sidebar.elements.element1.icon'),
label: this.i18n.t('repositories.import_records.info_sidebar.elements.element1.label'),
subtext: this.i18n.t('repositories.import_records.info_sidebar.elements.element1.subtext')
},
{
id: this.i18n.t('repositories.import_records.info_sidebar.elements.element2.id'),
icon: this.i18n.t('repositories.import_records.info_sidebar.elements.element2.icon'),
label: this.i18n.t('repositories.import_records.info_sidebar.elements.element2.label'),
subtext: this.i18n.t('repositories.import_records.info_sidebar.elements.element2.subtext')
},
{
id: this.i18n.t('repositories.import_records.info_sidebar.elements.element3.id'),
icon: this.i18n.t('repositories.import_records.info_sidebar.elements.element3.icon'),
label: this.i18n.t('repositories.import_records.info_sidebar.elements.element3.label'),
subtext: this.i18n.t('repositories.import_records.info_sidebar.elements.element3.subtext')
},
{
id: this.i18n.t('repositories.import_records.info_sidebar.elements.element4.id'),
icon: this.i18n.t('repositories.import_records.info_sidebar.elements.element4.icon'),
label: this.i18n.t('repositories.import_records.info_sidebar.elements.element4.label'),
subtext: this.i18n.t('repositories.import_records.info_sidebar.elements.element4.subtext'),
linkTo: this.i18n.t('repositories.import_records.info_sidebar.elements.element4.linkTo')
}
]
}
};
},
created() {
window.importRepositoryModalComponent = this;
},
methods: {
open() {
this.$refs.modal.open();
},
proceedToNextStep(data) {
this.steps[this.activeStep + 1].stepData = data;
this.activeStep += 1;
},
goBackToPrevStep() {
this.activeStep -= 1;
}
}
};
</script>

View file

@ -0,0 +1,87 @@
<template>
<div v-if="modalOpened">
<component
:is="activeStep"
:params="params"
:uploading="uploading"
@uploadFile="uploadFile"
@generatePreview="generatePreview"
/>
</div>
</template>
<script>
/* global HelperModule */
import axios from '../../../../packs/custom_axios';
import InfoModal from '../../../shared/info_modal.vue';
import UploadStep from './upload_step.vue';
import MappingStep from './mapping_step.vue';
export default {
name: 'ImportRepositoryModal',
components: { InfoModal, UploadStep, MappingStep },
props: {
repositoryUrl: String,
required: true
},
data() {
return {
modalOpened: false,
activeStep: 'UploadStep',
uploading: false,
params: {}
};
},
created() {
window.importRepositoryModalComponent = this;
},
methods: {
open() {
this.activeStep = 'UploadStep';
this.fetchRepository();
},
fetchRepository() {
axios.get(this.repositoryUrl)
.then((response) => {
this.params = response.data.data;
this.modalOpened = true;
});
},
uploadFile(file) {
this.uploading = true;
const formData = new FormData();
// required payload
formData.append('file', file);
axios.post(this.params.attributes.urls.parse_sheet, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
.then((response) => {
this.params = { ...this.params, ...response.data, file_name: file.name };
this.activeStep = 'MappingStep';
this.uploading = false;
});
},
generatePreview(mappings, updateWithEmptyCells, onlyAddNewItems) {
this.params.mapping = mappings;
this.params.updateWithEmptyCells = updateWithEmptyCells;
this.params.onlyAddNewItems = onlyAddNewItems;
this.importRecords(true);
},
importRecords(preview = false) {
const jsonData = {
file_id: this.params.temp_file.id,
mappings: this.params.mapping,
id: this.params.id,
preview: preview,
should_overwrite_with_empty_cells: this.params.updateWithEmptyCells,
can_edit_existing_items: !this.params.onlyAddNewItems
};
axios.post(this.params.attributes.urls.import_records, jsonData);
}
}
};
</script>

View file

@ -1,190 +0,0 @@
<template>
<div ref="firstStep" class="flex flex-col gap-6 h-full">
<!-- body -->
<div class="flex flex-col gap-6 h-full w-full">
<!-- export -->
<div id="export-section" class="flex flex-col gap-3">
<h3 class="my-0 text-sn-dark-grey">
{{ i18n.t('repositories.import_records.steps.step1.importTitle') }}
</h3>
<div id="export-buttons" class="flex flex-row gap-4">
<button class="btn btn-secondary btn-sm" @click="exportFullInventory">
<i class="sn-icon sn-icon-export"></i>
{{ i18n.t('repositories.import_records.steps.step1.exportFullInvBtnText') }}
</button>
<button class="btn btn-secondary btn-sm">
<i class="sn-icon sn-icon-export"></i>
{{ i18n.t('repositories.import_records.steps.step1.exportEmptyInvBtnText') }}
</button>
</div>
</div>
<!-- import -->
<div id="import-section" class="flex flex-col gap-3 h-full w-full">
<h3 class="my-0 text-sn-dark-grey">
{{ i18n.t('repositories.import_records.steps.step1.importBtnText') }}
</h3>
<DragAndDropUpload
@file:dropped="uploadFile"
@file:error="handleError"
@file:error:clear="this.error = null"
:supportingText="`${i18n.t('repositories.import_records.steps.step1.dragAndDropSupportingText')}`"
:supportedFormats="['xlsx', 'csv', 'xls', 'txt', 'tsv']"
/>
</div>
</div>
<!-- divider -->
<div class="sci-divider"></div>
<!-- footer -->
<div class="flex justify-end">
<div v-if="error" class="flex flex-row gap-2 my-auto mr-auto text-sn-delete-red">
<i class="sn-icon sn-icon-alert-warning"></i>
<div class="my-auto">{{ error }}</div>
</div>
<div v-if="exportInventoryMessage" class="flex flex-row gap-2 my-auto mr-auto text-sn-alert-green">
<i class="sn-icon sn-icon-check"></i>
<div class="my-auto">{{ exportInventoryMessage }}</div>
</div>
<button class="btn btn-secondary" data-dismiss="modal" aria-label="Close">
{{ i18n.t('repositories.import_records.steps.step1.cancelBtnText') }}
</button>
</div>
</div>
</template>
<script>
import axios from '../../../../packs/custom_axios';
import DragAndDropUpload from '../../../shared/drag_and_drop_upload.vue';
export default {
name: 'FirstStep',
emits: ['step:next', 'info:hide'],
components: {
DragAndDropUpload
},
props: {
stepProps: {
type: Object,
required: false
}
},
data() {
return {
showingInfo: false,
error: null,
teamId: null,
parseSheetUrl: null,
exportInventoryMessage: null
};
},
async created() {
// Fetch repository data and set it to state
const repositoryData = await this.fetchSerializedRepositoryData();
this.teamId = String(repositoryData.data.attributes.team_id);
this.parseSheetUrl = repositoryData.data.attributes.urls.parse_sheet;
},
watch: {
// clearing export message
exportInventoryMessage(newVal, oldVal) {
if (newVal && newVal !== oldVal) {
setTimeout(() => {
this.exportInventoryMessage = null;
}, 3000);
}
}
},
methods: {
async fetchSerializedRepositoryData() {
const url = window.location.pathname;
try {
const response = await axios.get(url);
return response.data;
} catch (error) {
console.error(error);
}
return '';
},
async exportFullInventory() {
const exportFullInventoryUrl = `/teams/${this.teamId}/export_repositories`;
const formData = new FormData();
const repositoryIds = [this.teamId];
const fileType = 'csv';
// required payload
formData.append('repository_ids', repositoryIds);
formData.append('file_type', fileType);
try {
const response = await axios.post(exportFullInventoryUrl, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (!response.status === 200) {
throw new Error('Network response was not ok');
}
if (response.status === 200) {
this.exportInventoryMessage = response.data.message;
}
return response;
} catch (error) {
console.error(error);
}
return '';
},
async uploadFile(file) {
this.uploading = true;
// First, parse the sheet
const parsedSheetResponse = await this.parseSheet(file);
// If parsed successfully, go to next step and pass through the necessary data
if (parsedSheetResponse) {
const {
header: columnNames,
available_fields: availableFields,
columns: exampleData
} = parsedSheetResponse.data.import_data;
const fileName = file.name;
const tempFile = parsedSheetResponse.data.temp_file;
this.$emit('step:next', {
columnNames,
availableFields,
exampleData,
fileName,
tempFile
});
}
},
async parseSheet(file) {
const formData = new FormData();
// required payload
formData.append('file', file);
formData.append('team_id', this.teamId);
try {
const response = await axios.post(this.parseSheetUrl, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (!response.status === 200) {
throw new Error('Network response was not ok');
}
return response;
} catch (error) {
console.error(error);
}
return '';
},
handleError(error) {
this.error = error;
}
}
};
</script>

View file

@ -0,0 +1,261 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" data-e2e="e2e-BT-newInventoryModal-close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate" id="edit-project-modal-label" data-e2e="e2e-TX-newInventoryModal-title">
{{ i18n.t('repositories.import_records.steps.step2.title') }}
</h4>
</div>
<div class="modal-body">
<p class="text-sn-dark-grey">
{{ this.i18n.t('repositories.import_records.steps.step2.subtitle') }}
</p>
<div class="flex gap-6 items-center my-6">
<div class="flex items-center gap-1">
<div class="sci-checkbox-container">
<input type="checkbox" class="sci-checkbox" v-model="autoMapping" />
<span class="sci-checkbox-label"></span>
</div>
{{ i18n.t('repositories.import_records.steps.step2.autoMappingText') }}
</div>
<div class="flex items-center gap-1">
<div class="sci-checkbox-container my-auto">
<input type="checkbox" class="sci-checkbox" :checked="updateWithEmptyCells" @change="toggleUpdateWithEmptyCells"/>
<span class="sci-checkbox-label"></span>
</div>
{{ i18n.t('repositories.import_records.steps.step2.updateEmptyCellsText') }}
</div>
<div class="flex items-center gap-1">
<div class="sci-checkbox-container my-auto">
<input type="checkbox" class="sci-checkbox" :checked="onlyAddNewItems" @change="toggleOnlyAddNewItems" />
<span class="sci-checkbox-label"></span>
</div>
{{ i18n.t('repositories.import_records.steps.step2.onlyAddNewItemsText') }}
</div>
</div>
{{ i18n.t('repositories.import_records.steps.step2.importedFileText') }} {{ params.file_name }}
<hr class="m-0 mt-6">
<div class="grid grid-cols-[3rem_auto_1.5rem_auto_5rem_auto] px-2">
<div v-for="(column, key) in columnLabels" class="flex items-center px-2 py-2">{{ column }}</div>
<template v-for="(item, index) in params.import_data.header" :key="item">
<MappingStepTableRow
:index="index"
:item="item"
:dropdownOptions="computedDropdownOptions"
:params="params"
:selected="this.selectedItemsIndexes.includes(index)"
@selection:changed="handleChange"
:availableFields="this.availableFields"
:autoMapping="this.autoMapping"
/>
</template>
</div>
<!-- imported/ignored section -->
<div class="flex gap-1 mt-6"
v-html="i18n.t('repositories.import_records.steps.step2.importedIgnoredSection', {
imported: computedImportedIgnoredInfo.importedSum,
ignored: computedImportedIgnoredInfo.ignoredSum
})"
>
</div>
</div>
<!-- footer -->
<div class="modal-footer">
<div id="error" class="flex flex-row gap-3 text-sn-delete-red">
<i v-if="error" class="sn-icon sn-icon-alert-warning my-auto"></i>
<div class="my-auto">{{ error ? error : '' }}</div>
</div>
<button class="btn btn-secondary ml-auto" @click="close" aria-label="Close">
{{ i18n.t('repositories.import_records.steps.step2.cancelBtnText') }}
</button>
<button class="btn btn-primary" :disabled="!rowsIsValid" @click="importRecords">
{{ i18n.t('repositories.import_records.steps.step2.confirmBtnText') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from '../../../../packs/custom_axios';
import SelectDropdown from '../../../shared/select_dropdown.vue';
import MappingStepTableRow from './mapping_step_table_row.vue';
import modalMixin from '../../../shared/modal_mixin';
export default {
name: 'MappingStep',
emits: ['step:next'],
mixins: [modalMixin],
components: {
SelectDropdown,
MappingStepTableRow
},
props: {
params: {
type: Object,
required: true
},
},
data() {
return {
autoMapping: true,
updateWithEmptyCells: false,
onlyAddNewItems: false,
columnLabels: {
0: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.number'),
1: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.importedColumns'),
2: '',
3: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.scinoteColumns'),
4: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.status'),
5: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.exampleData')
},
selectedItems: [],
selectedItemsIndexes: [],
importRecordsUrl: null,
teamId: null,
repositoryId: null,
availableFields: [],
alwaysAvailableFields: [],
repositoryColumns: null,
error: null
};
},
methods: {
toggleUpdateWithEmptyCells() {
this.updateWithEmptyCells = !this.updateWithEmptyCells;
},
toggleOnlyAddNewItems() {
this.onlyAddNewItems = !this.onlyAddNewItems;
},
handleChange(payload) {
this.error = null;
const { index, key, value } = payload;
// checking if the mapping is already selected
const foundItem = this.selectedItems.find((item) => item.index === index);
// if it's not, add it
if (!foundItem && key) {
this.selectedItems = [...this.selectedItems, { index, key, value }];
this.selectedItemsIndexes.push(index);
}
// if it is but the key is null then clear it
if (foundItem && !key) {
const indexToRemoveObj = this.selectedItems.findIndex((item) => item.index === index);
const indexToRemoveStr = this.selectedItemsIndexes.indexOf(index);
if ((indexToRemoveObj !== -1) && (indexToRemoveStr !== -1)) {
this.selectedItems.splice(indexToRemoveObj, 1);
this.selectedItemsIndexes.splice(indexToRemoveStr, 1);
}
}
// if it is and the key is not null then update it
if (foundItem && key) {
const indexToRemoveObj = this.selectedItems.findIndex((item) => item.index === index);
this.selectedItems.splice(indexToRemoveObj, 1);
this.selectedItems = [...this.selectedItems, { index, key, value }];
}
this.updateAvailableItemsStatus();
},
// necessary for tracking which options are already selected
updateAvailableItemsStatus() {
let updatedAvailableFields = [];
const selectedItemsKeys = new Set(this.selectedItems.map((item) => item.key));
this.alwaysAvailableFields.forEach((field) => {
if (selectedItemsKeys.has(field.key)) {
const tempObj = { key: field.key, value: field.value, alreadySelected: true };
updatedAvailableFields.push(tempObj);
} else {
updatedAvailableFields.push(field);
}
});
this.availableFields = updatedAvailableFields;
updatedAvailableFields = [];
},
generateMapping() {
const mapping = {};
for (let i = 0; i < this.params.import_data.header.length; i++) {
const foundItem = this.selectedItems.find((item) => item.index === i);
if (foundItem) {
mapping[foundItem.index] = (foundItem.key === 'new' ? foundItem.value : foundItem.key);
} else {
mapping[i] = '';
}
}
return mapping;
},
importRecords() {
const selectedItemsKeys = new Set(this.selectedItems.map((item) => item.key));
if (!selectedItemsKeys.has('-1')) {
this.error = this.i18n.t('repositories.import_records.steps.step2.selectNamePropertyError');
return '';
}
this.$emit(
'generatePreview',
this.generateMapping(),
this.updateWithEmptyCells,
this.onlyAddNewItems
);
}
},
computed: {
computedDropdownOptions() {
const columnKeyToLabelMapping = {};
columnKeyToLabelMapping[-1] = this.i18n.t('repositories.import_records.steps.step2.computedDropdownOptions.name');
if (this.repositoryColumns) {
this.repositoryColumns.forEach((el) => {
const [key, colName, colType] = el;
columnKeyToLabelMapping[key] = this.i18n.t(`repositories.import_records.steps.step2.computedDropdownOptions.${colType}`);
});
}
if (this.availableFields) {
let options = this.availableFields.map((el) => [String(el.key), `${String(el.value)} (${columnKeyToLabelMapping[el.key]})`]);
options = [['new', this.i18n.t('repositories.import_records.steps.step2.table.tableRow.importAsNewColumn')]].concat(options);
return options;
}
return [];
},
computedImportedIgnoredInfo() {
const importedSum = this.selectedItems.length;
const ignoredSum = this.params.import_data.header.length - importedSum;
return { importedSum, ignoredSum };
},
rowsIsValid() {
let valid = true;
this.selectedItems.forEach((v) => {
if (v.key === 'new' && (!v.value.type || v.value.name.length < 2)) {
valid = false;
}
});
return valid;
}
},
created() {
this.repositoryColumns = this.params.attributes.repository_columns;
// Adding alreadySelected attribute for tracking
const tempAvailableFields = [];
Object.entries(this.params.import_data.available_fields).forEach(([key, value]) => {
const field = { key, value, alreadySelected: false };
tempAvailableFields.push(field);
});
this.availableFields = tempAvailableFields;
this.alwaysAvailableFields = tempAvailableFields;
}
};
</script>

View file

@ -1,21 +1,28 @@
<template>
<!-- columns -->
<div class="flex flex-row justify-between gap-6">
<!-- number col -->
<div class="w-6 h-10 flex items-center">{{ index + 1 }}</div>
<div class="py-1 min-h-12 px-2 flex items-center" :class="{
'bg-sn-super-light-blue': selected
}">{{ index + 1 }}</div>
<div class="w-40 h-10 flex items-center truncate" :title="item">{{ item }}</div>
<div class="py-1 truncate min-h-12 px-2 flex items-center" :title="item" :class="{
'bg-sn-super-light-blue': selected
}">{{ item }}</div>
<i class="sn-icon sn-icon-arrow-right w-6 h-10 flex items-center relative left-5"></i>
<div class="py-1 min-h-12 flex items-center justify-center text-sn-grey" :class="{
'bg-sn-super-light-blue': selected
}">
<i class="sn-icon sn-icon-arrow-right text-sn-gray"></i>
</div>
<div class="w-60 min-h-10 flex items-center flex-col gap-2">
<div class="py-1 min-h-12 flex items-center flex-col gap-2 px-2" :class="{
'bg-sn-super-light-blue': selected
}">
<!-- system generated data -->
<SelectDropdown v-if="systemGeneratedData.includes(item)"
:disabled="true"
:placeholder="String(item)"
></SelectDropdown>
<SelectDropdown
v-else
:options="dropdownOptions"
@ -43,7 +50,9 @@
</template>
</div>
<div class="w-14 h-10 flex items-center flex justify-center">
<div class="py-1 min-h-12 px-2 flex items-center" :class="{
'bg-sn-super-light-blue': selected
}">
<!-- import -->
<i v-if="this.selectedColumnType?.key && this.selectedColumnType?.value === item && !systemGeneratedData.includes(item)"
class="sn-icon sn-icon-check" :title="i18n.t('repositories.import_records.steps.step2.table.tableRow.importedColumnTitle')">
@ -71,8 +80,9 @@
<i v-else class="sn-icon sn-icon-close text-sn-sleepy-grey" :title="i18n.t('repositories.import_records.steps.step2.table.tableRow.doNotImportColumnTitle')"></i>
</div>
<div class="w-56 truncate h-10 flex items-center" :title="stepProps.exampleData[index]">{{ stepProps.exampleData[index] }}</div>
</div>
<div class="py-1 min-h-12 px-2 flex items-center" :title="params.import_data.columns[index]" :class="{
'bg-sn-super-light-blue': selected
}">{{ params.import_data.columns[index] }}</div>
</template>
<script>
@ -97,19 +107,17 @@ export default {
type: String,
required: true
},
stepProps: {
params: {
type: Object,
required: true
},
autoMapping: {
type: Boolean,
required: true
}
},
watch: {
newColumn() {
this.selectedColumnType.value = this.newColumn;
this.$emit('selection:changed', this.selectedColumnType);
},
selected: {
type: Boolean,
required: false
}
},
data() {
@ -135,6 +143,10 @@ export default {
};
},
watch: {
newColumn() {
this.selectedColumnType.value = this.newColumn;
this.$emit('selection:changed', this.selectedColumnType);
},
autoMapping(newVal, oldVal) {
if (newVal === true) {
this.autoMap();
@ -150,7 +162,7 @@ export default {
},
methods: {
autoMap() {
Object.entries(this.stepProps.availableFields).forEach(([key, value]) => {
Object.entries(this.params.import_data.available_fields).forEach(([key, value]) => {
if (this.item === value) {
this.changeSelected(key);
}
@ -164,17 +176,11 @@ export default {
if (e === 'new') {
value = this.newColumn;
} else {
value = this.stepProps.availableFields[e];
value = this.params.import_data.available_fields[e];
}
const selectedColumnType = { index: this.index, key: e, value };
this.selectedColumnType = selectedColumnType;
this.$emit('selection:changed', selectedColumnType);
},
handleIsOpen(isOpen) {
const tableRows = this.$parent.$refs.tableRowsRef;
if (isOpen) {
tableRows.style.overflow = 'hidden';
} else tableRows.style.overflow = 'auto';
}
},
mounted() {

View file

@ -1,340 +0,0 @@
<template>
<div ref="secondStep" class="flex flex-col gap-6 h-full">
<!-- body -->
<div class="flex flex-col gap-6 h-fit w-full">
<!-- toggle section -->
<div id="toggle-section" class="flex flex-row gap-6">
<!-- auto-mapping -->
<div id="auto-mapping-toggle" class="flex flex-row gap-1">
<span class="sci-toggle-checkbox-container">
<input type="checkbox"
class="sci-toggle-checkbox"
v-model="autoMapping"
/>
<span class="sci-toggle-checkbox-label"></span>
</span>
<div class="flex my-auto w-32">
{{ i18n.t('repositories.import_records.steps.step2.autoMappingText') }} {{ autoMapping ? 'ON' : 'OFF' }}
</div>
</div>
<!-- update empty cells -->
<div id="update-empty-cells" class="flex flex-row gap-1">
<div class="sci-checkbox-container my-auto">
<input
type="checkbox"
class="sci-checkbox"
:checked="updateWithEmptyCells"
@change="toggleUpdateWithEmptyCells"
/>
<label class="sci-checkbox-label"></label>
</div>
<div class="flex my-auto">
{{ i18n.t('repositories.import_records.steps.step2.updateEmptyCellsText') }}
</div>
</div>
<!-- only add new items -->
<div id="only-add-new-items" class="flex flex-row gap-1">
<div class="sci-checkbox-container my-auto">
<input
type="checkbox"
class="sci-checkbox"
:checked="onlyAddNewItems"
@change="toggleOnlyAddNewItems"
/>
<label class="sci-checkbox-label"></label>
</div>
<div class="flex my-auto">
{{ i18n.t('repositories.import_records.steps.step2.onlyAddNewItemsText') }}
</div>
</div>
</div>
<!-- imported file section -->
<div class="flex flex-row text-sn-black">
{{ i18n.t('repositories.import_records.steps.step2.importedFileText') }} {{ stepProps.fileName }}
</div>
<div id="table-section" class="flex flex-col w-full h-full gap-1">
<!-- divider -->
<div class="sci-divider"></div>
<!-- table -->
<div id="table" class="flex flex-col h-[28rem] w-full">
<!-- labels -->
<div id="column-labels" class="flex flex-row justify-between font-bold p-3">
<div class="w-6">{{ i18n.t('repositories.import_records.steps.step2.table.columnLabels.number') }}</div>
<div class="w-40">{{ i18n.t('repositories.import_records.steps.step2.table.columnLabels.importedColumns') }}</div>
<div class="w-6"></div>
<div class="w-60">{{ i18n.t('repositories.import_records.steps.step2.table.columnLabels.scinoteColumns') }}</div>
<div class="w-14">{{ i18n.t('repositories.import_records.steps.step2.table.columnLabels.status') }}</div>
<div class="w-56">{{ i18n.t('repositories.import_records.steps.step2.table.columnLabels.exampleData') }}</div>
</div>
<div id="table-rows" ref="tableRowsRef" class="w-full h-[28rem] flex flex-col py-4 overflow-auto gap-1">
<!-- rows -->
<div v-for="(item, index) in stepProps.columnNames" :key="item"
class="flex flex-col gap-4 min-h-[56px] justify-center px-4 rounded shrink-0"
:class="{'bg-sn-super-light-blue': this.selectedItemsIndexes.includes(index)}"
>
<SecondStepTableRow
:key="item"
:index="index"
:item="item"
:dropdownOptions="computedDropdownOptions"
:stepProps="stepProps"
@selection:changed="handleChange"
:availableFields="this.availableFields"
:autoMapping="this.autoMapping"
/>
</div>
</div>
</div>
</div>
</div>
<!-- divider -->
<div class="sci-divider"></div>
<!-- imported/ignored section -->
<div class="flex flex-row">
<b class="pr-1">{{ computedImportedIgnoredInfo.importedSum }}</b>
<div class="pr-1">{{ i18n.t('repositories.import_records.steps.step2.importedIgnoredSection.columnsTo') }}</div>
<b class="pr-1">{{ i18n.t('repositories.import_records.steps.step2.importedIgnoredSection.import') }}</b>
<b class="pr-1">{{ computedImportedIgnoredInfo.ignoredSum }}</b>
<div class="pr-1">{{ i18n.t('repositories.import_records.steps.step2.importedIgnoredSection.columns') }}</div>
<b>{{ i18n.t('repositories.import_records.steps.step2.importedIgnoredSection.ignored') }}</b>
</div>
<!-- divider -->
<div class="sci-divider"></div>
<!-- footer -->
<div class="flex justify-between">
<div id="error" class="flex flex-row gap-3 text-sn-delete-red">
<i v-if="error" class="sn-icon sn-icon-alert-warning my-auto"></i>
<div class="my-auto">{{ error ? error : '' }}</div>
</div>
<div id="buttons" class="flex gap-4">
<button class="btn btn-secondary" data-dismiss="modal" aria-label="Close">
{{ i18n.t('repositories.import_records.steps.step2.cancelBtnText') }}
</button>
<button class="btn btn-primary" :disabled="!rowsIsValid" @click="importRecords">
{{ i18n.t('repositories.import_records.steps.step2.confirmBtnText') }}
</button>
</div>
</div>
</div>
</template>
<script>
import axios from '../../../../packs/custom_axios';
import SelectDropdown from '../../../shared/select_dropdown.vue';
import SecondStepTableRow from './second_step_table_row.vue';
export default {
name: 'SecondStep',
emits: ['step:next'],
components: {
SelectDropdown,
SecondStepTableRow
},
props: {
stepProps: {
type: Object,
required: true
},
file: {
type: File,
required: false
}
},
data() {
return {
autoMapping: true,
updateWithEmptyCells: false,
onlyAddNewItems: false,
columnLabels: {
0: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.number'),
1: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.importedColumns'),
2: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.scinoteColumns'),
3: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.status'),
4: this.i18n.t('repositories.import_records.steps.step2.table.columnLabels.exampleData')
},
selectedItems: [],
selectedItemsIndexes: [],
importRecordsUrl: null,
teamId: null,
repositoryId: null,
availableFields: [],
alwaysAvailableFields: [],
repositoryColumns: null,
error: null
};
},
methods: {
toggleUpdateWithEmptyCells() {
this.updateWithEmptyCells = !this.updateWithEmptyCells;
},
toggleOnlyAddNewItems() {
this.onlyAddNewItems = !this.onlyAddNewItems;
},
handleChange(payload) {
this.error = null;
const { index, key, value } = payload;
// checking if the mapping is already selected
const foundItem = this.selectedItems.find((item) => item.index === index);
// if it's not, add it
if (!foundItem && key) {
this.selectedItems = [...this.selectedItems, { index, key, value }];
this.selectedItemsIndexes.push(index);
}
// if it is but the key is null then clear it
if (foundItem && !key) {
const indexToRemoveObj = this.selectedItems.findIndex((item) => item.index === index);
const indexToRemoveStr = this.selectedItemsIndexes.indexOf(index);
if ((indexToRemoveObj !== -1) && (indexToRemoveStr !== -1)) {
this.selectedItems.splice(indexToRemoveObj, 1);
this.selectedItemsIndexes.splice(indexToRemoveStr, 1);
}
}
// if it is and the key is not null then update it
if (foundItem && key) {
const indexToRemoveObj = this.selectedItems.findIndex((item) => item.index === index);
this.selectedItems.splice(indexToRemoveObj, 1);
this.selectedItems = [...this.selectedItems, { index, key, value }];
}
this.updateAvailableItemsStatus();
},
// necessary for tracking which options are already selected
updateAvailableItemsStatus() {
let updatedAvailableFields = [];
const selectedItemsKeys = new Set(this.selectedItems.map((item) => item.key));
this.alwaysAvailableFields.forEach((field) => {
if (selectedItemsKeys.has(field.key)) {
const tempObj = { key: field.key, value: field.value, alreadySelected: true };
updatedAvailableFields.push(tempObj);
} else {
updatedAvailableFields.push(field);
}
});
this.availableFields = updatedAvailableFields;
updatedAvailableFields = [];
},
generateMapping() {
const mapping = {};
for (let i = 0; i < this.stepProps.columnNames.length; i++) {
const foundItem = this.selectedItems.find((item) => item.index === i);
if (foundItem) {
mapping[foundItem.index] = (foundItem.key === 'new' ? foundItem.value : foundItem.key);
} else {
mapping[i] = '';
}
}
return mapping;
},
async importRecords() {
const selectedItemsKeys = new Set(this.selectedItems.map((item) => item.key));
if (!selectedItemsKeys.has('-1')) {
this.error = this.i18n.t('repositories.import_records.steps.step2.selectNamePropertyError');
return '';
}
const mapping = this.generateMapping();
const jsonData = {
file_id: this.stepProps.tempFile.id,
mappings: mapping,
id: this.teamId,
preview: true,
should_overwrite_with_empty_cells: this.updateWithEmptyCells,
can_edit_existing_items: !this.onlyAddNewItems
};
try {
const response = await axios.post(this.importRecordsUrl, jsonData);
if (!response.status === 200) {
throw new Error('Network response was not ok');
}
return response;
} catch (error) {
console.error(error);
}
return '';
},
async fetchSerializedRepositoryData() {
const url = window.location.pathname;
try {
const response = await axios.get(url);
return response.data;
} catch (error) {
console.error(error);
}
return '';
}
},
computed: {
computedDropdownOptions() {
const columnKeyToLabelMapping = {};
columnKeyToLabelMapping[-1] = this.i18n.t('repositories.import_records.steps.step2.computedDropdownOptions.name');
if (this.repositoryColumns) {
this.repositoryColumns.forEach((el) => {
const [key, colName, colType] = el;
columnKeyToLabelMapping[key] = this.i18n.t(`repositories.import_records.steps.step2.computedDropdownOptions.${colType}`);
});
}
if (this.availableFields) {
let options = this.availableFields.map((el) => [String(el.key), `${String(el.value)} (${columnKeyToLabelMapping[el.key]})`]);
options = [['new', this.i18n.t('repositories.import_records.steps.step2.table.tableRow.importAsNewColumn')]].concat(options);
return options;
}
return [];
},
computedImportedIgnoredInfo() {
const importedSum = this.selectedItems.length;
const ignoredSum = this.stepProps.columnNames.length - importedSum;
return { importedSum, ignoredSum };
},
rowsIsValid() {
let valid = true;
this.selectedItems.forEach((v) => {
if (v.key === 'new' && (!v.value.type || v.value.name.length < 2)) {
valid = false;
}
});
return valid;
}
},
async created() {
// Fetch repository data and set it to state
const repositoryData = await this.fetchSerializedRepositoryData();
this.teamId = String(repositoryData.data.attributes.team_id);
this.repositoryId = String(repositoryData.data.id);
this.importRecordsUrl = repositoryData.data.attributes.urls.import_records;
this.repositoryColumns = repositoryData.data.attributes.repository_columns;
// Adding alreadySelected attribute for tracking
const tempAvailableFields = [];
Object.entries(this.stepProps.availableFields).forEach(([key, value]) => {
const field = { key, value, alreadySelected: false };
tempAvailableFields.push(field);
});
this.availableFields = tempAvailableFields;
this.alwaysAvailableFields = tempAvailableFields;
// Remove infoComponent if it's still present
const infoComponent = this.$parent.$refs.infoPartRef;
infoComponent.remove();
}
};
</script>

View file

@ -0,0 +1,129 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog flex" role="document" :class="{'!w-[900px]': showingInfo}">
<div v-if="showingInfo" class="w-[300px] h-full bg-sn-super-light-grey p-6 rounded-s text-sn-dark-grey">
<h3 class="my-0 mb-4">{{ this.i18n.t('repositories.import_records.info_sidebar.title') }}</h3>
<div v-for="i in 4" :key="i" class="flex gap-3 mb-4">
<span class="btn btn-secondary icon-btn !text-sn-black">
<i class="sn-icon"
:class="i18n.t(`repositories.import_records.info_sidebar.elements.element${i - 1}.icon`)"
></i>
</span>
<div>
<div class="font-bold mb-2">{{ i18n.t(`repositories.import_records.info_sidebar.elements.element${i - 1}.label`) }}</div>
<div>{{ i18n.t(`repositories.import_records.info_sidebar.elements.element${i - 1}.subtext`) }}</div>
</div>
</div>
<div class="flex gap-3 mb-4 items-center">
<span class="btn btn-secondary icon-btn !text-sn-black">
<i class="sn-icon sn-icon-open"></i>
</span>
<a :href="i18n.t('repositories.import_records.info_sidebar.elements.element4.linkTo')" class="font-bold">
{{ i18n.t('repositories.import_records.info_sidebar.elements.element4.label') }}
</a>
</div>
</div>
<div class="modal-content grow" :class="{'!rounded-s-none': showingInfo}">
<div class="modal-header gap-4">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" data-e2e="e2e-BT-newInventoryModal-close">
<i class="sn-icon sn-icon-close"></i>
</button>
<button class="btn btn-light btn-sm mr-auto" @click="showingInfo = !showingInfo">
<i class="sn-icon sn-icon-help-s"></i>
{{ i18n.t('repositories.import_records.steps.step1.helpText') }}
</button>
<h4 class="modal-title truncate !block !mr-0" id="edit-project-modal-label" data-e2e="e2e-TX-newInventoryModal-title">
{{ i18n.t('repositories.import_records.steps.step1.title') }}
</h4>
</div>
<div class="modal-body">
<p class="text-sn-dark-grey">
{{ this.i18n.t('repositories.import_records.steps.step1.subtitle') }}
</p>
<h3 class="my-0 text-sn-dark-grey mb-3">
{{ i18n.t('repositories.import_records.steps.step1.exportTitle') }}
</h3>
<div class="flex gap-4 mb-6">
<button class="btn btn-secondary btn-sm" @click="exportFullInventory">
<i class="sn-icon sn-icon-export"></i>
{{ i18n.t('repositories.import_records.steps.step1.exportFullInvBtnText') }}
</button>
<button class="btn btn-secondary btn-sm">
<i class="sn-icon sn-icon-export"></i>
{{ i18n.t('repositories.import_records.steps.step1.exportEmptyInvBtnText') }}
</button>
</div>
<h3 class="my-0 text-sn-dark-grey mb-3">
{{ i18n.t('repositories.import_records.steps.step1.importTitle') }}
</h3>
<DragAndDropUpload
class="h-60"
@file:dropped="uploadFile"
@file:error="handleError"
@file:error:clear="this.error = null"
:supportingText="`${i18n.t('repositories.import_records.steps.step1.dragAndDropSupportingText')}`"
:supportedFormats="['xlsx', 'csv', 'xls', 'txt', 'tsv']"
/>
</div>
<div class="modal-footer">
<div v-if="error" class="flex flex-row gap-2 my-auto mr-auto text-sn-delete-red">
<i class="sn-icon sn-icon-alert-warning"></i>
<div class="my-auto">{{ error }}</div>
</div>
<div v-if="exportInventoryMessage" class="flex flex-row gap-2 my-auto mr-auto text-sn-alert-green">
<i class="sn-icon sn-icon-check"></i>
<div class="my-auto">{{ exportInventoryMessage }}</div>
</div>
<button class="btn btn-secondary" @click="close" aria-label="Close">
{{ i18n.t('repositories.import_records.steps.step1.cancelBtnText') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from '../../../../packs/custom_axios';
import DragAndDropUpload from '../../../shared/drag_and_drop_upload.vue';
import modalMixin from '../../../shared/modal_mixin';
export default {
name: 'UploadStep',
emits: ['step:next', 'info:hide'],
components: {
DragAndDropUpload
},
mixins: [modalMixin],
props: {
params: {
type: Object,
required: true
}
},
data() {
return {
showingInfo: false,
error: null,
parseSheetUrl: null,
exportInventoryMessage: null
};
},
methods: {
exportFullInventory() {
axios.post(this.params.attributes.urls.export_repository)
.then((response) => {
this.exportInventoryMessage = response.data.message;
setTimeout(() => { this.exportInventoryMessage = null; }, 5000);
})
.catch((error) => {
this.error = error;
});
},
uploadFile(file) {
this.$emit('uploadFile', file);
}
}
};
</script>

View file

@ -12,7 +12,8 @@ class RepositorySerializer < ActiveModel::Serializer
def urls
{
parse_sheet: parse_sheet_repository_path(object),
import_records: import_records_repository_path(object)
import_records: import_records_repository_path(object),
export_repository: export_repositories_team_path(object.team, file_type: :csv, repository_ids: object.id),
}
end
end

View file

@ -89,7 +89,7 @@
<%= render partial: 'save_repository_filter_modal' %>
<div id="importRepositoryModal" data-behaviour="vue">
<import-repository-modal repositoryUrl="<%= repository_path(@repository) %>" />
<import-repository-modal repository-url="<%= repository_path(@repository) %>" />
</div>
<%= javascript_include_tag 'vue_components_action_toolbar' %>

View file

@ -2219,12 +2219,7 @@ en:
importedFileText: 'Imported file:'
cancelBtnText: 'Cancel'
confirmBtnText: 'Confirm'
importedIgnoredSection:
columnsTo: 'columns to'
import: 'import.'
columns: 'columns'
ignored: 'ignored.'
importedIgnoredSection: '<b>%{imported}</b> columns to <b>import.</b> <b>%{ignored}</b> columns <b>ignored</b>.'
computedDropdownOptions:
name: 'Name'
RepositoryTextValue: 'Text'