Create 'import mapping' step [SCI-10579]

This commit is contained in:
Gregor Lasnibat 2024-04-19 16:28:00 +02:00
parent bc6e43052f
commit db3ba2aadb
16 changed files with 683 additions and 122 deletions

View file

@ -327,13 +327,14 @@ class RepositoriesController < ApplicationController
.find_by_id(import_params[:id]))
# Access the checkbox values from params
can_edit_existing_items = params[:edit_existing_items_checkbox]
should_overwrite_with_empty_cells = params[:overwrite_with_empty_cells]
can_edit_existing_items = params[:can_edit_existing_items]
should_overwrite_with_empty_cells = params[:should_overwrite_with_empty_cells]
preview = params[:preview]
# Check if there exist mapping for repository record (it's mandatory)
if import_params[:mappings].present? && import_params[:mappings].value?('-1')
import_records = repostiory_import_actions
status = import_records.import!(can_edit_existing_items, should_overwrite_with_empty_cells)
status = import_records.import!(can_edit_existing_items, should_overwrite_with_empty_cells, preview)
if status[:status] == :ok
log_activity(:import_inventory_items,
@ -343,10 +344,12 @@ class RepositoriesController < ApplicationController
number_of_rows: status[:nr_of_added],
total_nr: status[:total_nr])
log_activity(:item_added_with_import,
num_of_items: status[:nr_of_added])
if preview
render json: status, status: :ok
else
render json: {}, status: :ok
end
render json: {}, status: :ok
else
flash[:alert] =
t('repositories.import_records.partial_success_flash',
@ -558,7 +561,7 @@ class RepositoriesController < ApplicationController
end
def import_params
params.permit(:id, :file, :file_id, mappings: {}).to_h
params.permit(:id, :file, :file_id, :preview, mappings: {}).to_h
end
def repository_response(message)

View file

@ -3,12 +3,15 @@
:startHidden="true"
:infoParams="infoParams"
:title="steps[activeStep].title"
:helpText="steps[activeStep].helpText">
:subtitle="steps[activeStep].subtitle"
:helpText="steps[activeStep].helpText"
>
<component
:key="steps[activeStep].id"
:is="steps[activeStep].component"
@step:next="proceedToNext"
@step:back="activeStep -= 1"
:stepData="stepData"
@step:next="proceedToNextStep"
@step:back="goBackToPrevStep"
:stepProps="steps[activeStep].stepData"
/>
</InfoModal>
</template>
@ -19,86 +22,91 @@
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 },
components: { InfoModal, FirstStep, SecondStep },
props: {
repositoryUrl: String, required: true
repositoryUrl: String,
required: true
},
data() {
return {
activeStep: 0,
repositoryData: null,
steps: [
{
id: I18n.t('repositories.import_records.steps.step0.id'),
icon: I18n.t('repositories.import_records.steps.step0.icon'),
label: I18n.t('repositories.import_records.steps.step0.label'),
title: I18n.t('repositories.import_records.steps.step0.title'),
helpText: I18n.t('repositories.import_records.steps.step0.helpText'),
component: shallowRef(FirstStep)
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: I18n.t('repositories.import_records.info_sidebar.title'),
title: this.i18n.t('repositories.import_records.info_sidebar.title'),
elements: [
{
id: I18n.t('repositories.import_records.info_sidebar.elements.element0.id'),
icon: I18n.t('repositories.import_records.info_sidebar.elements.element0.icon'),
label: I18n.t('repositories.import_records.info_sidebar.elements.element0.label'),
subtext: I18n.t('repositories.import_records.info_sidebar.elements.element0.subtext')
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: I18n.t('repositories.import_records.info_sidebar.elements.element1.id'),
icon: I18n.t('repositories.import_records.info_sidebar.elements.element1.icon'),
label: I18n.t('repositories.import_records.info_sidebar.elements.element1.label'),
subtext: I18n.t('repositories.import_records.info_sidebar.elements.element1.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: I18n.t('repositories.import_records.info_sidebar.elements.element2.id'),
icon: I18n.t('repositories.import_records.info_sidebar.elements.element2.icon'),
label: I18n.t('repositories.import_records.info_sidebar.elements.element2.label'),
subtext: I18n.t('repositories.import_records.info_sidebar.elements.element2.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: I18n.t('repositories.import_records.info_sidebar.elements.element3.id'),
icon: I18n.t('repositories.import_records.info_sidebar.elements.element3.icon'),
label: I18n.t('repositories.import_records.info_sidebar.elements.element3.label'),
subtext: I18n.t('repositories.import_records.info_sidebar.elements.element3.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: I18n.t('repositories.import_records.info_sidebar.elements.element4.id'),
icon: I18n.t('repositories.import_records.info_sidebar.elements.element4.icon'),
label: I18n.t('repositories.import_records.info_sidebar.elements.element4.label'),
subtext: I18n.t('repositories.import_records.info_sidebar.elements.element4.subtext'),
linkTo: I18n.t('repositories.import_records.info_sidebar.elements.element4.linkTo')
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')
}
]
},
stepData: null
}
};
},
watch: {
activeStep(newVal, oldVal) {
console.log(`${oldVal} -> ${newVal}`);
},
stepData(newVal, oldVal) {
console.log(`${oldVal} -> ${newVal}`);
}
},
created() {
window.importRepositoryModalComponent = this;
},
mounted() {
},
methods: {
open() {
this.$refs.modal.open();
},
proceedToNext(data) {
console.log('incoming data', data);
this.stepData = data;
proceedToNextStep(data) {
this.steps[this.activeStep + 1].stepData = data;
this.activeStep += 1;
},
goBackToPrevStep() {
this.activeStep -= 1;
}
}
};

View file

@ -7,16 +7,16 @@
<!-- 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.step0.importTitle') }}
{{ 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.step0.exportFullInvBtnText') }}
{{ 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.step0.exportEmptyInvBtnText') }}
{{ i18n.t('repositories.import_records.steps.step1.exportEmptyInvBtnText') }}
</button>
</div>
</div>
@ -24,13 +24,13 @@
<!-- 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.step0.importBtnText') }}
{{ 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.step0.dragAndDropSupportingText')}`"
:supportingText="`${i18n.t('repositories.import_records.steps.step1.dragAndDropSupportingText')}`"
:supportedFormats="['xlsx', 'csv', 'xls', 'txt', 'tsv']"
/>
</div>
@ -50,7 +50,7 @@
<div class="my-auto">{{ exportInventoryMessage }}</div>
</div>
<button class="btn btn-secondary" data-dismiss="modal" aria-label="Close">
{{ i18n.t('repositories.import_records.steps.step0.cancelBtnText') }}
{{ i18n.t('repositories.import_records.steps.step1.cancelBtnText') }}
</button>
</div>
</div>
@ -62,10 +62,16 @@ import DragAndDropUpload from '../../../shared/drag_and_drop_upload.vue';
export default {
name: 'FirstStep',
emits: ['step:next'],
emits: ['step:next', 'info:hide'],
components: {
DragAndDropUpload
},
props: {
stepProps: {
type: Object,
required: false
}
},
data() {
return {
showingInfo: false,
@ -136,14 +142,23 @@ export default {
// First, parse the sheet
const parsedSheetResponse = await this.parseSheet(file);
// If parsed successfully, go to next step and pass the necessary data
// 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;
this.$emit('step:next', { columnNames, availableFields, exampleData });
const fileName = file.name;
const tempFile = parsedSheetResponse.data.temp_file;
this.$emit('step:next', {
columnNames,
availableFields,
exampleData,
fileName,
tempFile
});
}
},
async parseSheet(file) {

View file

@ -0,0 +1,329 @@
<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"
: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"
/>
</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" @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: false,
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;
} 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) {
const options = this.availableFields.map((el) => [String(el.key), `${String(el.value)} (${columnKeyToLabelMapping[el.key]})`]);
return options;
}
return [];
},
computedImportedIgnoredInfo() {
const importedSum = this.selectedItems.length;
const ignoredSum = this.stepProps.columnNames.length - importedSum;
return { importedSum, ignoredSum };
}
},
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,117 @@
<template>
<!-- columns -->
<div class="flex flex-row justify-between gap-6">
<!-- number col -->
<div class="w-6 my-auto">{{ index + 1 }}</div>
<div class="w-40 my-auto truncate" :title="item">{{ item }}</div>
<i class="sn-icon sn-icon-arrow-right w-6 my-auto relative left-5"></i>
<div class="w-60 my-auto">
<!-- system generated data -->
<SelectDropdown v-if="systemGeneratedData.includes(item)"
:disabled="true"
:placeholder="String(item)"
></SelectDropdown>
<SelectDropdown
v-else
:options="dropdownOptions"
@change="changeSelected"
@isOpen="handleIsOpen"
:clearable="true"
:size="'sm'"
placeholder="Do not import"
:title="this.selectedColumnType?.value"
></SelectDropdown>
</div>
<div class="w-14 my-auto flex justify-center">
<!-- 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')">
</i>
<!-- default column -->
<i v-else-if="systemGeneratedData.includes(item)"
class="sn-icon sn-icon-check text-sn-sleepy-grey" :title="i18n.t('repositories.import_records.steps.step2.table.tableRow.defaultColumnTitle')">
</i>
<!-- user defined this column -->
<i v-else-if="this.selectedColumnType?.key && this.selectedColumnType?.value !== item"
class="sn-icon sn-icon-info text-sn-science-blue"
:title="`${i18n.t('repositories.import_records.steps.step2.table.tableRow.userDefinedColumnTitle')} ${this.selectedColumnType.value}`"></i>
<!-- error: can not import -->
<!-- <i v-else-if=""></i> -->
<!-- match not found -->
<!-- <i v-else-if=""></i> -->
<!-- do not import -->
<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 my-auto" :title="stepProps.exampleData[index]">{{ stepProps.exampleData[index] }}</div>
</div>
</template>
<script>
import SelectDropdown from '../../../shared/select_dropdown.vue';
export default {
name: 'SecondStepTableRow',
emits: ['selection:changed'],
components: {
SelectDropdown
},
props: {
index: {
type: Number,
required: true
},
dropdownOptions: {
type: Array,
required: true
},
item: {
type: String,
required: true
},
stepProps: {
type: Object,
required: true
}
},
data() {
return {
selectedColumnType: null,
systemGeneratedData: [
this.i18n.t('repositories.import_records.steps.step2.table.tableRow.systemGeneratedData.itemId'),
this.i18n.t('repositories.import_records.steps.step2.table.tableRow.systemGeneratedData.createdOn'),
this.i18n.t('repositories.import_records.steps.step2.table.tableRow.systemGeneratedData.addedBy'),
this.i18n.t('repositories.import_records.steps.step2.table.tableRow.systemGeneratedData.addedOn'),
this.i18n.t('repositories.import_records.steps.step2.table.tableRow.systemGeneratedData.archivedBy'),
this.i18n.t('repositories.import_records.steps.step2.table.tableRow.systemGeneratedData.archivedOn'),
this.i18n.t('repositories.import_records.steps.step2.table.tableRow.systemGeneratedData.updatedBy'),
this.i18n.t('repositories.import_records.steps.step2.table.tableRow.systemGeneratedData.updatedOn')]
};
},
methods: {
changeSelected(e) {
const value = this.stepProps.availableFields[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';
}
}
};
</script>

View file

@ -1,10 +1,10 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog" data-backdrop="static" data-keyboard="false">
<div class="modal-dialog" role="document" :class="[{'!w-[900px]' : showingInfo}, {'!w-[600px]' : !showingInfo}]">
<div class="modal-dialog" role="document" :class="[{'!w-[900px]' : showingInfo}, {'!w-fit' : !showingInfo}]">
<div class="modal-content !p-0 bg-sn-white w-full h-full flex" :class="[{'flex-row': showingInfo}, {'flex-col': !showingInfo}]">
<div id="body-container" class="flex flex-row w-full h-full">
<!-- info -->
<div id="info-part">
<div id="info-part" ref="infoPartRef">
<InfoComponent
v-if="showingInfo"
:infoParams="infoParams"
@ -13,17 +13,22 @@
<!-- content -->
<div id="content-part" class="flex flex-col w-full p-6 gap-6">
<!-- header -->
<div id="info-modal-header" class="flex flex-row h-fit w-full justify-between">
<div id="title-with-help" class="flex flex-row gap-3">
<h3 class="modal-title text-sn-dark-grey">{{ title }}</h3>
<button class="btn btn-light btn-sm" @click="showingInfo = !showingInfo">
<i class="sn-icon sn-icon-help-s"></i>
{{ helpText }}
<div id="info-modal-header" class="flex flex-col h-fit w-full gap-2">
<div id="title-part" class="flex flex-row h-fit w-full justify-between">
<div id="title-with-help" class="flex flex-row gap-3">
<h3 class="modal-title text-sn-dark-grey">{{ title }}</h3>
<button v-if="helpText" class="btn btn-light btn-sm" @click="showingInfo = !showingInfo">
<i class="sn-icon sn-icon-help-s"></i>
{{ helpText }}
</button>
</div>
<button id="close-btn" type="button" class="close my-auto" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
</div>
<button id="close-btn" type="button" class="close my-auto" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<div id="subtitle-part" class="text-sn-dark-grey">
{{ subtitle }}
</div>
</div>
<!-- main content -->
<div id="info-modal-main-content" class="h-full">
@ -47,9 +52,13 @@ export default {
type: String,
required: true
},
subtitle: {
type: String,
required: false
},
helpText: {
type: String,
required: true
required: false
},
infoParams: {
type: Object,
@ -64,7 +73,7 @@ export default {
components: { InfoComponent },
data() {
return {
showingInfo: true
showingInfo: false
};
}
};

View file

@ -253,7 +253,8 @@ export default {
value(newValue) {
this.newValue = newValue;
},
isOpen() {
isOpen(newVal) {
this.$emit('isOpen', newVal);
if (this.isOpen) {
this.$nextTick(() => {
this.setPosition();
@ -284,7 +285,7 @@ export default {
clear() {
this.newValue = this.multiple ? [] : null;
this.query = '';
this.$emit('change', this.newValue, this.getLabels(this.newValue));
this.$emit('change', this.newValue, '');
},
close(e) {
if (e && e.target.closest('.sn-select-dropdown')) return;

View file

@ -201,9 +201,9 @@ class Repository < RepositoryBase
new_repo
end
def import_records(sheet, mappings, user, can_edit_existing_items, should_overwrite_with_empty_cells)
def import_records(sheet, mappings, user, can_edit_existing_items, should_overwrite_with_empty_cells, preview)
importer = RepositoryImportParser::Importer.new(sheet, mappings, user, self)
importer.run(can_edit_existing_items, should_overwrite_with_empty_cells)
importer.run(can_edit_existing_items, should_overwrite_with_empty_cells, preview)
end
def assigned_rows(my_module)

View file

@ -72,7 +72,6 @@ class RepositoryChecklistValue < ApplicationRecord
end
end
# TODO: after ticket for tracking changes on checklist items
def update_data!(new_data, user, preview: false)
item_ids = new_data.is_a?(String) ? JSON.parse(new_data) : new_data

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class RepositoryCellSerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
attributes :id, :value, :changes
def changes
object.value.changes
end
def value
object.value
end
end

View file

@ -4,9 +4,5 @@ class RepositoryRowSerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
attributes :id, :name, :code
def urls
{
}
end
has_many :repository_cells
end

View file

@ -3,11 +3,16 @@
class RepositorySerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
attributes :urls, :id, :team_id
attributes :urls, :id, :team_id, :repository_columns
def repository_columns
object.repository_columns.pluck(:id, :name, :data_type)
end
def urls
{
parse_sheet: parse_sheet_repository_path(object)
parse_sheet: parse_sheet_repository_path(object),
import_records: import_records_repository_path(object)
}
end
end

View file

@ -8,22 +8,23 @@ module ImportRepository
@user = options.fetch(:user)
end
def import!(can_edit_existing_items, should_overwrite_with_empty_cells)
status = run_import_actions(can_edit_existing_items, should_overwrite_with_empty_cells)
def import!(can_edit_existing_items, should_overwrite_with_empty_cells, preview)
status = run_import_actions(can_edit_existing_items, should_overwrite_with_empty_cells, preview)
@temp_file.destroy
status
end
private
def run_import_actions(can_edit_existing_items, should_overwrite_with_empty_cells)
def run_import_actions(can_edit_existing_items, should_overwrite_with_empty_cells, preview)
@temp_file.file.open do |temp_file|
@repository.import_records(
SpreadsheetParser.open_spreadsheet(temp_file),
@mappings,
@user,
can_edit_existing_items,
should_overwrite_with_empty_cells
should_overwrite_with_empty_cells,
preview
)
end
end

View file

@ -60,11 +60,12 @@ module RepositoryCsvExport
cell = row.repository_cells.find_by(repository_column_id: c_id)
csv_row << if cell
if cell.value_type == 'RepositoryAssetValue' && handle_file_name_func
handle_file_name_func.call(cell.value.asset)
else
cell.value.export_formatted
end
if cell.value_type == 'RepositoryAssetValue' && handle_file_name_func
handle_file_name_func.call(cell.value.asset)
else
cell.value.export_formatted
end
end
end
end
csv_row << row.row_consumption(row.stock_consumption) if add_consumption

View file

@ -25,14 +25,11 @@ module RepositoryImportParser
@repository_columns = @repository.repository_columns
end
def run(can_edit_existing_items, should_overwrite_with_empty_cells)
def run(can_edit_existing_items, should_overwrite_with_empty_cells, preview)
fetch_columns
return check_for_duplicate_columns if check_for_duplicate_columns
# Used for developing preview changes (will be removed)
preview = false
import_rows!(can_edit_existing_items, should_overwrite_with_empty_cells, preview: preview)
import_rows!(can_edit_existing_items, should_overwrite_with_empty_cells, preview)
end
private
@ -66,10 +63,12 @@ module RepositoryImportParser
end
end
def import_rows!(can_edit_existing_items, should_overwrite_with_empty_cells, preview: false)
def import_rows!(can_edit_existing_items, should_overwrite_with_empty_cells, preview)
errors = false
duplicate_ids = SpreadsheetParser.duplicate_ids(@sheet)
imported_rows = []
@repository.transaction do
batch_counter = 0
full_row_import_batch = []
@ -79,7 +78,7 @@ module RepositoryImportParser
next if row.blank?
# Skip duplicates
next if duplicate_ids.include?(row.first) && !preview
next if duplicate_ids.include?(row.first)
unless @header_skipped
@header_skipped = true
@ -98,7 +97,7 @@ module RepositoryImportParser
if index == @name_index
# check if row (inventory) already exists
existing_row = RepositoryRow.find_by(id: incoming_row[0].gsub(RepositoryRow::ID_PREFIX, ''))
existing_row = RepositoryRow.includes(repository_cells: :value).find_by(id: incoming_row[0].gsub(RepositoryRow::ID_PREFIX, ''))
# if it doesn't exist create it
unless existing_row
@ -122,13 +121,13 @@ module RepositoryImportParser
# otherwise add according to criteria
else
# if it does exist but shouldn't be edited, error out and break
if existing_row && can_edit_existing_items == '0'
if existing_row && (can_edit_existing_items == false)
errors = true
break
end
# if it does exist and should be edited, update the existing row
if existing_row && can_edit_existing_items == '1'
if existing_row && (can_edit_existing_items == true)
# update the existing row with incoming row data
new_full_row[:repository_row] = existing_row
end
@ -146,13 +145,14 @@ module RepositoryImportParser
next if batch_counter < IMPORT_BATCH_SIZE
import_batch_to_database(full_row_import_batch, can_edit_existing_items, should_overwrite_with_empty_cells, preview: preview)
# import_batch_to_database(full_row_import_batch, can_edit_existing_items, should_overwrite_with_empty_cells, preview: preview)
imported_rows += import_batch_to_database(full_row_import_batch, can_edit_existing_items, should_overwrite_with_empty_cells, preview)
full_row_import_batch = []
batch_counter = 0
end
# Import of the remaining rows
import_batch_to_database(full_row_import_batch, can_edit_existing_items, should_overwrite_with_empty_cells, preview: preview) if full_row_import_batch.any?
imported_rows += import_batch_to_database(full_row_import_batch, can_edit_existing_items, should_overwrite_with_empty_cells, preview) if full_row_import_batch.any?
full_row_import_batch
end
@ -162,13 +162,19 @@ module RepositoryImportParser
nr_of_added: @new_rows_added,
total_nr: @total_new_rows }
end
{ status: :ok, nr_of_added: @new_rows_added, total_nr: @total_new_rows }
changes = ActiveModelSerializers::SerializableResource.new(
imported_rows,
each_serializer: RepositoryRowSerializer,
include: [:repository_cells]
).as_json[:included]
{ status: :ok, nr_of_added: @new_rows_added, total_nr: @total_new_rows, changes: changes }
end
def import_batch_to_database(full_row_import_batch, can_edit_existing_items, should_overwrite_with_empty_cells, preview: false)
def import_batch_to_database(full_row_import_batch, can_edit_existing_items, should_overwrite_with_empty_cells, preview)
skipped_rows = []
full_row_import_batch.each do |full_row|
full_row_import_batch.map do |full_row|
# skip archived rows and rows that belong to other repositories
if full_row[:repository_row].archived || full_row[:repository_row].repository_id != @repository.id
skipped_rows << full_row[:repository_row]
@ -199,16 +205,16 @@ module RepositoryImportParser
@user.as_json(root: true, only: :settings).deep_symbolize_keys
)
existing_cell = full_row[:repository_row].repository_cells.find_by(repository_column: column)
existing_cell = full_row[:repository_row].repository_cells.find { |c| c.repository_column_id == column.id }
next if cell_value.nil? && existing_cell.nil?
if existing_cell
# existing_cell present && !can_edit_existing_items
next if can_edit_existing_items == '0'
next if can_edit_existing_items == false
# existing_cell present && can_edit_existing_items
if can_edit_existing_items == '1'
if can_edit_existing_items == true
# if incoming cell is not empty
case cell_value
@ -229,10 +235,10 @@ module RepositoryImportParser
end
# if incoming cell is empty && should_overwrite_with_empty_cells
existing_cell.value.destroy! if cell_value.nil? && should_overwrite_with_empty_cells == '1'
existing_cell.value.destroy! if cell_value.nil? && should_overwrite_with_empty_cells == true
# if incoming cell is empty && !should_overwrite_with_empty_cells
next if cell_value.nil? && should_overwrite_with_empty_cells == '0'
next if cell_value.nil? && should_overwrite_with_empty_cells == false
end
else
# no existing_cell. Create a new one.
@ -240,6 +246,8 @@ module RepositoryImportParser
cell_value.save!(validate: false)
end
end
full_row[:repository_row]
end
end

View file

@ -2033,7 +2033,7 @@ en:
error_searching: "Error searching, please try again"
button_tooltip:
new: "Create new item"
import: "Import"
import: "Update inventory"
filters: "Filters"
search: "Quick search"
filters:
@ -2192,11 +2192,12 @@ en:
import_records:
update_inventory: 'Update inventory'
steps:
step0:
id: 'step0'
step1:
id: 'step1'
icon: 'sn-icon-open'
label: 'Step 1'
title: 'Update inventory'
subtitle: 'To add or edit items, export the inventory and reimport edited inventory.'
helpText: 'Help'
exportTitle: 'Export'
exportFullInvBtnText: 'Export full inventory'
@ -2205,6 +2206,59 @@ en:
importBtnText: 'Import'
cancelBtnText: 'Cancel'
dragAndDropSupportingText: '.XLSX, .XLS or .CSV file'
step2:
id: 'step2'
icon: 'sn-icon-open'
label: 'Step 2'
title: 'Mapping data'
subtitle: 'Match your imported columns with the columns in the SciNote inventory.'
selectNamePropertyError: 'Select Name attribute field to import your items.'
autoMappingText: 'Auto-mapping'
updateEmptyCellsText: 'Update empty cells'
onlyAddNewItemsText: 'Only add new items'
importedFileText: 'Imported file:'
cancelBtnText: 'Cancel'
confirmBtnText: 'Confirm'
importedIgnoredSection:
columnsTo: 'columns to'
import: 'import.'
columns: 'columns'
ignored: 'ignored.'
computedDropdownOptions:
name: 'Name'
RepositoryTextValue: 'Text'
RepositoryNumberValue: 'Number'
RepositoryAssetValue: 'File'
RepositoryChecklistValue: 'Checklist'
RepositoryDateRangeValue: 'Date range'
RepositoryDateTimeRangeValue: 'Date-time range'
RepositoryDateTimeValue: 'Date-time'
RepositoryDateValue: 'Date'
RepositoryListValue: 'List'
RepositoryStatusValue: 'Status'
RepositoryStockValue: 'Stock'
table:
tableRow:
defaultColumnTitle: 'Default column. Mapped as identifier.'
userDefinedColumnTitle: 'Column name does not match. Column will be imported as '
importedColumnTitle: 'Column will be imported.'
doNotImportColumnTitle: 'Column will not import.'
systemGeneratedData:
itemId: 'Item ID'
createdOn: 'Created on'
addedBy: 'Added by'
addedOn: 'Added on'
archivedBy: 'Archived by'
archivedOn: 'Archived on'
updatedBy: 'Updated by'
updatedOn: 'Updated on'
columnLabels:
number: 'No.'
importedColumns: 'Imported columns'
scinoteColumns: 'SciNote columns'
status: 'Status'
exampleData: 'Example data'
info_sidebar:
title: 'Guide for updating the inventory'
elements: