Merge pull request #7451 from lasniscinote/gl_SCI_10578

(dev) Create 'update inventory' modal first step [SCI-10578]
This commit is contained in:
Martin Artnik 2024-04-09 14:24:45 +02:00 committed by GitHub
commit 745c52a158
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 462 additions and 46 deletions

View file

@ -53,13 +53,21 @@ class RepositoriesController < ApplicationController
end
def show
current_team_switch(@repository.team) unless @repository.shared_with?(current_team)
@display_edit_button = can_create_repository_rows?(@repository)
@display_delete_button = can_delete_repository_rows?(@repository)
@display_duplicate_button = can_create_repository_rows?(@repository)
@snapshot_provisioning = @repository.repository_snapshots.provisioning.any?
respond_to do |format|
format.html do
current_team_switch(@repository.team) unless @repository.shared_with?(current_team)
@display_edit_button = can_create_repository_rows?(@repository)
@display_delete_button = can_delete_repository_rows?(@repository)
@display_duplicate_button = can_create_repository_rows?(@repository)
@snapshot_provisioning = @repository.repository_snapshots.provisioning.any?
@busy_printer = LabelPrinter.where.not(current_print_job_ids: []).first
@busy_printer = LabelPrinter.where.not(current_print_job_ids: []).first
end
format.json do
# render serialized repository json
render json: @repository, serializer: RepositorySerializer
end
end
end
def table_toolbar
@ -281,13 +289,9 @@ class RepositoriesController < ApplicationController
session: session
)
if parsed_file.too_large?
repository_response(t('general.file.size_exceeded',
file_size: Rails.configuration.x.file_max_size_mb))
return render json: { error: t('general.file.size_exceeded', file_size: Rails.configuration.x.file_max_size_mb) }, status: :unprocessable_entity
elsif parsed_file.has_too_many_rows?
repository_response(
t('repositories.import_records.error_message.items_limit',
items_size: Constants::IMPORT_REPOSITORY_ITEMS_LIMIT)
)
return render json: { error: t('repositories.import_records.error_message.items_limit', items_size: Constants::IMPORT_REPOSITORY_ITEMS_LIMIT) }, status: :unprocessable_entity
else
sheet = SpreadsheetParser.open_spreadsheet(import_params[:file])
duplicate_ids = SpreadsheetParser.duplicate_ids(sheet)
@ -298,22 +302,22 @@ class RepositoriesController < ApplicationController
@import_data = parsed_file.data
if @import_data.header.blank? || @import_data.columns.blank?
return repository_response(t('repositories.parse_sheet.errors.empty_file'))
return render json: { error: t('repositories.parse_sheet.errors.empty_file') }, status: :unprocessable_entity
end
if (@temp_file = parsed_file.generate_temp_file)
render json: {
html: render_to_string(partial: 'repositories/parse_records_modal', formats: :html)
import_data: @import_data,
temp_file: @temp_file
}
else
repository_response(t('repositories.parse_sheet.errors.temp_file_failure'))
return render json: { error: t('repositories.parse_sheet.errors.temp_file_failure') }, status: :unprocessable_entity
end
end
rescue ArgumentError, CSV::MalformedCSVError
repository_response(t('repositories.parse_sheet.errors.invalid_file',
encoding: ''.encoding))
return render json: { error: t('repositories.parse_sheet.errors.invalid_file', encoding: ''.encoding) }, status: :unprocessable_entity
rescue TypeError
repository_response(t('repositories.parse_sheet.errors.invalid_extension'))
return render json: { error: t('repositories.parse_sheet.errors.invalid_extension') }, status: :unprocessable_entity
end
end
@ -326,7 +330,7 @@ class RepositoriesController < ApplicationController
should_overwrite_with_empty_cells = params[:overwrite_with_empty_cells]
# Check if there exist mapping for repository record (it's mandatory)
if import_params[:mappings].value?('-1')
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)

View file

@ -54,9 +54,7 @@ const app = createApp({
// Info modal
infoParams: {
title: 'Guide for updating the inventory',
modalTitle: 'Update inventory',
helpText: 'Help',
steps: [
elements: [
{
id: 'el1',
icon: 'sn-icon-export',

View file

@ -1,26 +1,91 @@
<template>
<infoModal ref="modal" :startHidden="true" :infoParams="{}" :title="'UPDATE'" :helpText="'HELP ME'">
UPDAE
</infoModal>
<InfoModal ref="modal"
:startHidden="true"
:infoParams="infoParams"
:title="steps[activeStep].title"
:helpText="steps[activeStep].helpText">
<component
:is="steps[activeStep].component"
@step:next="proceedToNext"
@step:back="activeStep -= 1"
:stepData="stepData"
/>
</InfoModal>
</template>
<script>
/* global HelperModule */
import axios from '../../../packs/custom_axios.js';
import { shallowRef } from 'vue';
import InfoModal from '../../shared/info_modal.vue';
import FirstStep from './import/first_step.vue';
export default {
name: 'ImportRepositoryModal',
components: {InfoModal},
components: { InfoModal, FirstStep },
props: {
repositoryUrl: String, required: true
},
data() {
return {
infoParams: {}
activeStep: 0,
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)
}
],
infoParams: {
title: 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: 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: 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: 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: 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')
}
]
},
stepData: null
};
},
watch: {
activeStep(newVal, oldVal) {
console.log(`${oldVal} -> ${newVal}`);
},
stepData(newVal, oldVal) {
console.log(`${oldVal} -> ${newVal}`);
}
},
created() {
window.importRepositoryModalComponent = this;
},
@ -29,6 +94,11 @@ export default {
methods: {
open() {
this.$refs.modal.open();
},
proceedToNext(data) {
console.log('incoming data', data);
this.stepData = data;
this.activeStep += 1;
}
}
};

View file

@ -0,0 +1,175 @@
<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.step0.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') }}
</button>
<button class="btn btn-secondary btn-sm">
<i class="sn-icon sn-icon-export"></i>
{{ i18n.t('repositories.import_records.steps.step0.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.step0.importBtnText') }}
</h3>
<DragAndDropUpload
@file:dropped="uploadFile"
@file:error="handleError"
@file:error:clear="this.error = null"
:supportingText="`${i18n.t('repositories.import_records.steps.step0.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.step0.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'],
components: {
DragAndDropUpload
},
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 the necessary data
if (parsedSheetResponse) {
const {
header: columnNames,
available_fields: availableFields,
columns: exampleData
} = parsedSheetResponse.data.import_data;
this.$emit('step:next', { columnNames, availableFields, exampleData });
}
},
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,125 @@
<template>
<div
ref="dragAndDropUpload"
@drop.prevent="dropFile"
@dragenter.prevent="dragEnter($event)"
@dragleave.prevent="dragLeave($event)"
@dragover.prevent
class="flex h-full w-full p-6 rounded border border-sn-light-grey bg-sn-super-light-blue"
>
<div id="centered-content" class="flex flex-col gap-4 items-center h-fit w-fit m-auto">
<!-- icon -->
<i class="sn-icon sn-icon-import text-sn-dark-grey"></i>
<!-- text section -->
<div class="flex flex-col gap-1">
<div class="text-sn-dark-grey">
<span class="text-sn-science-blue hover:cursor-pointer" @click="handleImportClick">
{{ i18n.t('repositories.import_records.dragAndDropUpload.importText.firstPart') }}
</span> {{ i18n.t('repositories.import_records.dragAndDropUpload.importText.secondPart') }}
</div>
<div class="text-sn-grey">
{{ supportingText }}
</div>
</div>
</div>
<!-- hidden input for importing via 'Import' click -->
<input type="file" ref="fileInput" style="display: none" @change="handleFileSelect">
</div>
</template>
<script>
export default {
name: 'DragAndDropUpload',
props: {
supportingText: {
type: String,
required: true
},
supportedFormats: {
type: Array,
required: true,
default: () => []
}
},
emits: ['file:dropped', 'file:error'],
data() {
return {
draggingFile: false,
uploading: false
};
},
methods: {
validateFile(file) {
// check if it's a single file
if (file.length > 1) {
const error = I18n.t('repositories.import_records.dragAndDropUpload.notSingleFileError');
this.$emit('file:error', error);
return false;
}
// check if it's a correct file type
const fileExtension = file.name.split('.')[1];
if (!this.supportedFormats.includes(fileExtension)) {
const error = I18n.t('repositories.import_records.dragAndDropUpload.wrongFileTypeError');
this.$emit('file:error', error);
return false;
}
// check if file is not empty
if (!file.size > 0) {
const error = I18n.t('repositories.import_records.dragAndDropUpload.emptyFileError');
this.$emit('file:error', error);
return false;
}
// check if it's conforming to size limit
if (file.size > GLOBAL_CONSTANTS.FILE_MAX_SIZE_MB * 1024 * 1024) {
const error = `${I18n.t('repositories.import_records.dragAndDropUpload.fileTooLargeError')} ${GLOBAL_CONSTANTS.FILE_MAX_SIZE_MB}`;
this.$emit('file:error', error);
return false;
}
return true;
},
dragEnter(e) {
// Detect if dragged element is a file
// https://stackoverflow.com/a/8494918
const dt = e.dataTransfer;
if (dt.types && (dt.types.indexOf ? dt.types.indexOf('Files') !== -1 : dt.types.contains('Files'))) {
this.draggingFile = true;
}
},
dragLeave() {
this.draggingFile = false;
},
dropFile(e) {
if (e.dataTransfer && e.dataTransfer.files.length) {
this.draggingFile = false;
this.uploading = true;
const droppedFile = e.dataTransfer.files[0];
const fileIsValid = this.validateFile(droppedFile);
// successful drop
if (fileIsValid) {
this.$emit('file:dropped', droppedFile);
this.$emit('file:error:clear');
}
}
},
handleImportClick() {
this.$refs.fileInput.click();
},
handleFileSelect(event) {
const file = event.target.files[0];
const fileIsValid = this.validateFile(file);
if (fileIsValid) {
this.$emit('file:dropped', file);
}
}
}
};
</script>

View file

@ -1,13 +1,13 @@
<template>
<div class="!w-[300px] rounded bg-sn-super-light-grey gap-4 p-6 flex flex-col h-full">
<div id="info-component-header">
<h3 class="modal-title text-sn-dark-grey">{{ params.title }}</h3>
<h3 class="modal-title text-sn-dark-grey">{{ infoParams.title }}</h3>
</div>
<div class="grid grid-flow-row h-fit" v-for="(element, _index) in params.elements" :key="element.id">
<div class="grid grid-flow-row h-fit" v-for="(element, _index) in infoParams.elements" :key="element.id">
<a v-if="element.linkTo" :href="element.linkTo" target="_blank" class="flex flex-row gap-3 w-fit text-sn-blue hover:no-underline hover:text-sn-blue-hover">
<button class="btn btn-secondary btn-sm icon-btn hover:!border-sn-light-grey">
<i :class="element.icon" class="h-fit"></i>
<i :class="`sn-icon ${element.icon}`" class="h-fit size-9"></i>
</button>
<div class="flex flex-col gap-2 w-fit">
<div class="text-sn-blue font-bold hover:text-sn-blue-hover my-auto">{{ element.label }}</div>
@ -30,7 +30,7 @@
export default {
name: 'InfoComponent',
props: {
params: {
infoParams: {
type: Object,
required: true
}

View file

@ -5,13 +5,13 @@
<div id="body-container" class="flex flex-row w-full h-full">
<!-- info -->
<div id="info-part">
<info-component
<InfoComponent
v-if="showingInfo"
:params="this.infoParams"
:infoParams="infoParams"
/>
</div>
<!-- content -->
<div id="content-part" class="flex flex-col w-full p-6 gap-3">
<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">
@ -26,7 +26,7 @@
</button>
</div>
<!-- main content -->
<div id="info-modal-main-content">
<div id="info-modal-main-content" class="h-full">
<slot></slot>
</div>
</div>
@ -44,10 +44,12 @@ export default {
name: 'InfoModal',
props: {
title: {
type: String, required: true
type: String,
required: true
},
helpText: {
type: String, required: true
type: String,
required: true
},
infoParams: {
type: Object,
@ -59,9 +61,7 @@ export default {
}
},
mixins: [modalMixin],
components: {
'info-component': InfoComponent
},
components: { InfoComponent },
data() {
return {
showingInfo: true

View file

@ -21,5 +21,5 @@ export default {
this.$emit('open');
$(this.$refs.modal).modal('show');
}
},
}
}
};

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class RepositorySerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
attributes :urls, :id, :team_id
def urls
{
parse_sheet: parse_sheet_repository_path(object)
}
end
end

View file

@ -12,13 +12,16 @@
/>
</div>
<div ref="infoModal">
<div ref="infoModalWrapper">
<button @click="showInfo = true" class="btn btn-primary">Show Info Modal</button>
<info-modal
v-if="showInfo"
@close="showInfo = false"
:infoParams="infoParams"
>
ref="modal"
:start-hidden="false"
:info-params="infoParams"
title="Some title"
help-text="Help">
<div>I am a component that gets consumed by the slot</div>
</info-modal>
</div>

View file

@ -2135,29 +2135,57 @@ en:
list_error: "%{key}: %{val}"
import_records:
update_inventory: 'Update inventory'
steps:
step0:
id: 'step0'
icon: 'sn-icon-open'
label: 'Step 1'
title: 'Update inventory'
helpText: 'Help'
exportTitle: 'Export'
exportFullInvBtnText: 'Export full inventory'
exportEmptyInvBtnText: 'Export empty inventory'
importTitle: 'Import'
importBtnText: 'Import'
cancelBtnText: 'Cancel'
dragAndDropSupportingText: '.XLSX, .XLS or .CSV file'
info_sidebar:
title: 'Guide for updating the inventory'
elements:
element0:
id: 'el0'
icon: 'sn-icon-export'
label: 'Export inventory'
subtext: "Before making edits, we advise you to export the latest inventory information. If you're only adding new items, consider exporting empty inventory."
element1:
id: 'el1'
icon: 'sn-icon-edit'
label: 'Edit your data'
subtext: 'Make sure to include header names in first row, followed by item data.'
element2:
id: 'el2'
icon: 'sn-icon-import'
label: 'Import new or update items'
subtext: 'Upload your data using .xlsx, .csv or .txt files.'
element3:
id: 'el3'
icon: 'sn-icon-tables'
label: 'Merge your data'
subtext: 'Complete the process by merging the columns you want to update.'
element4:
id: 'el4'
icon: 'sn-icon-open'
label: 'Learn more'
linkTo: 'https://knowledgebase.scinote.net/en/knowledge/how-to-add-items-to-an-inventory'
dragAndDropUpload:
notSingleFileError: 'Single file import only. Please import one file at a time.'
wrongFileTypeError: 'The file has invalid extension (.csv, .xlsx, .txt or .tsv.)'
emptyFileError: 'You have uploaded empty file. There is not much to import.'
fileTooLargeError: 'File too large. Max file size limit is'
importText:
firstPart: 'Import'
secondPart: 'or drag and drop'
import: 'Import'
no_header_name: 'No column name'
success_flash: "%{number_of_rows} of %{total_nr} new item(s) successfully imported."