diff --git a/frontend/static/script/js/core/record-workspace.js b/frontend/static/script/js/core/record-workspace.js index 926b4eca1..d56d3764b 100644 --- a/frontend/static/script/js/core/record-workspace.js +++ b/frontend/static/script/js/core/record-workspace.js @@ -74,7 +74,10 @@ }; nav.appendChild(button); form.appendChild(panel); return panel; }); + var activeIndex = 0; + var restoreTimer; function show(index) { + activeIndex = index; panels.forEach(function (p, i) { p.hidden = i !== index; nav.children[i].setAttribute('aria-selected', String(i === index)); }); } children.forEach(function (child) { @@ -85,6 +88,8 @@ var actions = form.querySelector('.record-actions'); if (actions) form.appendChild(actions); function validateAll() { + clearTimeout(restoreTimer); + restoreTimer = setTimeout(function () { show(activeIndex); }, 0); panels.forEach(function (p) { p.hidden = false; }); form.querySelectorAll('details.record-details').forEach(function (p) { p.open = true; }); } @@ -93,6 +98,8 @@ form.addEventListener('keydown', function (event) { if (event.key === 'Enter' && event.target.tagName !== 'TEXTAREA' && event.target.tagName !== 'BUTTON') validateAll(); }, true); form.addEventListener('submit', validateAll, true); function reveal(input) { + var disclosure = input.closest('details'); + if (disclosure) disclosure.open = true; var index = panels.findIndex(function (p) { return p.contains(input); }); if (index >= 0) show(index); } diff --git a/frontend/static/script/js/modules/js/branches.js b/frontend/static/script/js/modules/js/branches.js index cfbb5a24b..a184d5431 100644 --- a/frontend/static/script/js/modules/js/branches.js +++ b/frontend/static/script/js/modules/js/branches.js @@ -87,32 +87,38 @@ PosnicPro.branches = { PosnicPro.branches.loadFullEdit(id); }, loadFullEdit: function (id) { + var request = (this.editRequest || 0) + 1; this.editRequest = request; + var save = $('#setting_add button[type=submit]').prop('disabled', true); + $('#setting_add').validate().resetForm(); + PosnicPro.settings.stateRequest = (PosnicPro.settings.stateRequest || 0) + 1; PosnicPro.branchSignature.mount('#branch-signature-settings', { branchId: id }); // Option lists first (idempotent - they repopulate by id wherever the // controls live), then this branch's values over them. - PosnicPro.settings.loadSelectSettingCountry(); - PosnicPro.settings.loadSelectSettingCurrency(); - PosnicPro.settings.timeZone(); - PosnicPro.get({ url: 'branches/getBranchDetails', data: { id: id } }, function (response) { - if (response.type !== 'success') { PosnicPro.alert(response.type, response.message); return; } - var d = response.data; - PosnicPro.record_id = id; - $('#edit_branch_target,#be_logo_target').val(id); - $('#be_page_branch_name').text(d.branch_name || ''); - $('#store_name').val(d.branch_name); - $('#store_email').val(d.store_email); - PosnicPro.settings.store_telephone.setNumber(d.store_telephone || ''); - $('#store_alternativephone').val(d.store_alternativephone); - $('#store_address').val(d.store_address); - $('#printing_address').val(d.printing_address); - $('#city').val(d.city); - $('#pincode').val(d.pincode); - $('#website').val(d.website); - $('#branch_gstin_number').val(d.branch_gstin_number); - // Selects fill after their (async) option lists land. - setTimeout(function () { + $.when(PosnicPro.settings.loadSelectSettingCountry(), + PosnicPro.settings.loadSelectSettingCurrency(), PosnicPro.settings.timeZone()).done(function () { + if (PosnicPro.branches.editRequest !== request) return; + PosnicPro.get({ url: 'branches/getBranchDetails', data: { id: id } }, function (response) { + if (PosnicPro.branches.editRequest !== request) return; + if (response.type !== 'success') { PosnicPro.alert(response.type, response.message); return; } + var d = response.data; + PosnicPro.record_id = id; + $('#edit_branch_target,#be_logo_target').val(id); + $('#be_page_branch_name').text(d.branch_name || ''); + $('#store_name').val(d.branch_name); + $('#store_email').val(d.store_email); + $('#branch_upi_id').val(d.branch_upi_id || ''); + $('#branch_upi_name').val(d.branch_upi_name || ''); + PosnicPro.settings.store_telephone.setNumber(d.store_telephone || ''); + $('#store_alternativephone').val(d.store_alternativephone); + $('#store_address').val(d.store_address); + $('#printing_address').val(d.printing_address); + $('#city').val(d.city); + $('#pincode').val(d.pincode); + $('#website').val(d.website); + $('#branch_gstin_number').val(d.branch_gstin_number); + // All reference lists are ready before selecting saved values. $('#setting_country').val(d.country).trigger('change.select2'); - PosnicPro.settings.loadSelectSettingState(d.country_id, d.state); + PosnicPro.settings.loadSelectSettingState(d.country_id, d.state || ''); PosnicPro.settings.applyTaxProfile(d.country); $('#currency_setting').val(d.currency_text).trigger('change.select2'); var cv = (d.currency_value && d.currency_value[0]) || { currency_text: 'INR', currency_sign: '₹' }; @@ -126,18 +132,19 @@ PosnicPro.branches = { $('#storedate').val(d.client_dateformat).trigger('change.select2'); $('#serverdate').val(d.server_dateformat); $('#dateText').val(d.dateformat_text); - }, 600); - var image_path = (d.logo && d.logo !== 'store.png') ? d.logo : 'static/images/default/store.png'; - $('#previewing').attr('src', image_path); - $('#setting_logo_value').val(d.logo || 'store.png'); - $('#be_register_rows').empty(); - $.each(d.register || [], function (i, r) { - var name = (r && (r.register_name || r.name)) || (typeof r === 'string' ? r : ''); - if (name) { PosnicPro.branches.beAddRegisterRow(name); } + var image_path = (d.logo && d.logo !== 'store.png') ? d.logo : 'static/images/default/store.png'; + $('#previewing').attr('src', image_path); + $('#setting_logo_value').val(d.logo || 'store.png'); + $('#be_register_rows').empty(); + save.prop('disabled', false); + $.each(d.register || [], function (i, r) { + var name = (r && (r.register_name || r.name)) || (typeof r === 'string' ? r : ''); + if (name) { PosnicPro.branches.beAddRegisterRow(name); } + }); + }, function (xhr) { + var response = jQuery.parseJSON(xhr.responseText); + PosnicPro.alert(response.type, response.message); }); - }, function (xhr) { - var response = jQuery.parseJSON(xhr.responseText); - PosnicPro.alert(response.type, response.message); }); }, beAddRegisterRow: function (name) { diff --git a/frontend/static/script/js/modules/js/settings.js b/frontend/static/script/js/modules/js/settings.js index 72585ea62..3c0e0c6ee 100644 --- a/frontend/static/script/js/modules/js/settings.js +++ b/frontend/static/script/js/modules/js/settings.js @@ -1,6 +1,7 @@ PosnicPro.settings = { /* which reference lists have been fetched this session */ _refLoaded: {}, + _refPending: {}, /* * ORDERS PER TABLE: one number, typed through two controls. @@ -571,7 +572,7 @@ PosnicPro.settings = { }, /*store details Add/Update function*/ generalSetting: function (value) { - if ($('#store_name').val() !== '' && $('#store_telephone').val() !== '' && PosnicPro.validateEmail($('#store_email').val()) && $('#setting_country').val() !== '' && $('#setting_state').val() !== '' && $('#currency').val() !== '' && $('#time_zone').val() !== '' && $('#storedate').val() !== '' && $('#store_address').val() !== '' && $('#printing_address').val() !== '') { + if ($('#setting_add').valid()) { var loader = $(".loader-view-generalsetting"); $("
").appendTo(loader); var currency = $('#currency_type').val(); @@ -603,6 +604,7 @@ PosnicPro.settings = { data: JSON.stringify(Object.assign(formData, timezoneValue, countryId, registers)) }; PosnicPro.put(params, function (response) { + loader.find(".loadingSpinner:first").remove(); if (response.type === 'success' && !editingCurrent) { loader.find(".loadingSpinner:first").remove(); PosnicPro.alert(response.type, response.message); @@ -711,6 +713,7 @@ PosnicPro.settings = { } PosnicPro.alert(response.type, response.message); }, function (xhr) { + loader.find(".loadingSpinner:first").remove(); var response = jQuery.parseJSON(xhr.responseText); PosnicPro.alert(response.type, response.message); }); @@ -1545,6 +1548,8 @@ if ($wrapper.length) { }); }, resetEditButton: function (id) { + var target = $('#edit_branch_target').val(); + if (target) { PosnicPro.branches.loadFullEdit(target); return; } var branch_id = PosnicPro.local.get('branch_id_set'); PosnicPro.settings.viewSettings(branch_id); }, @@ -2842,8 +2847,10 @@ loadSelectSettingCountry: function (force) { racing inside the in-flight window, both passing a guard that asked whether options existed yet - they did not, so both fetched and both appended, forever. */ - if (!force && PosnicPro.settings._refLoaded.country) { return; } + if (!force && PosnicPro.settings._refLoaded.country) { return PosnicPro.settings._refPending.country.promise(); } PosnicPro.settings._refLoaded.country = true; + var ready = $.Deferred(); + PosnicPro.settings._refPending.country = ready; var countrySelect = $('.setCountry'); var params = { url: 'setting/getJSONCountry', @@ -2857,23 +2864,32 @@ loadSelectSettingCountry: function (force) { countrySelect.append(option).select2(); }); countrySelect.val(PosnicPro.local.get("country_setting")).trigger('change.select2'); + ready.resolve(); }, function (xhr) { /* a failed fetch must not lock the list out for the session */ PosnicPro.settings._refLoaded.country = false; + ready.reject(); var response = jQuery.parseJSON(xhr.responseText); PosnicPro.alert(response.type, response.message); }); + return ready.promise(); }, loadSelectSettingState: function (id, selectedState) { var request = (this.stateRequest || 0) + 1; this.stateRequest = request; if (selectedState === undefined) selectedState = $('#setting_country').val() === PosnicPro.local.get('country_setting') ? PosnicPro.local.get('state_setting') : ''; var stateSelect = $('#setting_state'); + // Display the saved state immediately, even while the country list is loading. + stateSelect.empty().append(new Option('', '')); + if (selectedState) stateSelect.append(new Option(selectedState, selectedState)); + stateSelect.val(selectedState || '').trigger('change.select2'); + var initialState = stateSelect.val(); var params = { url: 'setting/getJSONState', data: { id: id } }; PosnicPro.get(params, function (response) { if (PosnicPro.settings.stateRequest !== request) return; + if (stateSelect.val() !== initialState) selectedState = stateSelect.val(); stateSelect.empty().append(new Option('', '')); (response.data.stateJsonArray || []).forEach(function (state) { stateSelect.append(new Option(state, state)); }); var match = Array.from(stateSelect[0].options).find(function (option) { return option.value.trim().toLowerCase() === String(selectedState || '').trim().toLowerCase(); }); @@ -2894,8 +2910,10 @@ loadSelectSettingCountry: function (force) { * options across four navigation cycles). Pass true to force a reload. */ loadSelectSettingCurrency: function (force) { - if (!force && PosnicPro.settings._refLoaded.currency) { return; } + if (!force && PosnicPro.settings._refLoaded.currency) { return PosnicPro.settings._refPending.currency.promise(); } PosnicPro.settings._refLoaded.currency = true; + var ready = $.Deferred(); + PosnicPro.settings._refPending.currency = ready; var currencySelect = $('#currency_setting'); var params = { url: 'setting/getJSONCurrency' @@ -2908,12 +2926,7 @@ loadSelectSettingCurrency: function (force) { currencySelect.append(option).trigger('change'); }); currencySelect.val(PosnicPro.local.get("currency_setting")).trigger('change.select2'); - currencySelect.trigger({ - type: 'select2:select', - params: { - data: response - } - }).on('select2:select', function (e) { + currencySelect.off('select2:select.settingCurrency').on('select2:select.settingCurrency', function (e) { let data = e.params.data; $('#currencyText').val(data.element.attributes['data-currency-symbol'].value); $('#currencyTextname').val(data.element.attributes['data-currency-text'].value); @@ -2923,10 +2936,14 @@ loadSelectSettingCurrency: function (force) { " '; $('#currency_type').append(currencyDataOPtion); }); + ready.resolve(); }, function (xhr) { + PosnicPro.settings._refLoaded.currency = false; + ready.reject(); var response = jQuery.parseJSON(xhr.responseText); PosnicPro.alert(response.type, response.message); }); + return ready.promise(); }, /* * Reference data loads ONCE. Countries, currencies and timezones do not @@ -2937,8 +2954,10 @@ loadSelectSettingCurrency: function (force) { * options across four navigation cycles). Pass true to force a reload. */ timeZone: function (force) { - if (!force && PosnicPro.settings._refLoaded.timezone) { return; } + if (!force && PosnicPro.settings._refLoaded.timezone) { return PosnicPro.settings._refPending.timezone.promise(); } PosnicPro.settings._refLoaded.timezone = true; + var ready = $.Deferred(); + PosnicPro.settings._refPending.timezone = ready; var timezoneSelect = $('#time_zone'); var params = { url: 'setting/getJSONTimeZone' @@ -2952,10 +2971,14 @@ loadSelectSettingCurrency: function (force) { }); let timezone = PosnicPro.timeZone(); timezoneSelect.val(timezone).trigger('change.select2'); + ready.resolve(); }, function (xhr) { + PosnicPro.settings._refLoaded.timezone = false; + ready.reject(); var response = jQuery.parseJSON(xhr.responseText); PosnicPro.alert(response.type, response.message); }); + return ready.promise(); }, paymentKey: function () { var loader = $(".loader-qrsetting"); @@ -4307,6 +4330,13 @@ $("#textlocal_setting").submit(function (event) { } }); $("#setting_add").validate({ + // Include inactive tabs and Select2 source selects in validation. + ignore: 'input[type="hidden"], :disabled', + errorPlacement: function (error, element) { + var select = element.next('.select2'); + var phone = element.closest('.iti'); + error.insertAfter(select.length ? select : (phone.length ? phone : element)); + }, highlight: function (element, errorClass) { $(element).css("border-color", "#f9616d"); }, @@ -4314,6 +4344,12 @@ $("#setting_add").validate({ $(element).css("border-color", "#eae8e8"); }, rules: { + setting_country: { required: true }, + setting_state: { required: true }, + currency_setting: { required: true }, + currency_type: { required: true }, + time_zone: { required: true }, + storedate: { required: true }, store_name: { required: true, minlength: 3, diff --git a/frontend/static/script/js/modules/js/users.js b/frontend/static/script/js/modules/js/users.js index e23cc6f0b..6c39b65ec 100644 --- a/frontend/static/script/js/modules/js/users.js +++ b/frontend/static/script/js/modules/js/users.js @@ -401,7 +401,8 @@ PosnicPro.users = { $(selectBranch).each(function (index) { var branch_id = selectBranch[index].id; var branch_name = selectBranch[index].text; - var branch_image = selectBranch[index].element.attributes['data-logo'].value; + var option = selectBranch[index].element; + var branch_image = (option && option.getAttribute('data-logo')) || 'store.png'; branchData.push({branch_id: branch_id, branch_name: branch_name, branch_image: branch_image}); }); var branchType = { @@ -1870,7 +1871,13 @@ PosnicPro.users = { }, clickNormalForm: function () { - $(".user_add").validate({ + var validator = $("#user_form_upload").validate({ + // Tabs and Select2 hide real required controls. Only omit the API account fields. + ignore: 'input[type="hidden"], :disabled, #app_name, #users_api', + errorPlacement: function (error, element) { + var select = element.next('.select2'); + error.insertAfter(select.length ? select : element); + }, errorClass: 'error error_users', highlight: function (element, errorClass) { $(element).css("border-color", "#f9616d"); @@ -1943,7 +1950,7 @@ PosnicPro.users = { required: "Type the password again", minlength: "Password must be at least 5 characters", maxlength: "Password should not be more than 20 characters", - equalTo: "Not match retype Password" + equalTo: "Passwords do not match. Enter the same password in both fields." }, email: { required: "Enter the email address", @@ -1961,15 +1968,29 @@ PosnicPro.users = { jQuery.validator.addMethod("lettersonly", function (value, element) { return this.optional(element) || /^[a-z\s]+$/i.test(value); }, "Use letters, numbers and spaces only"); - if ($('.user_add').valid()) { // checks form for validity - var checked = $("#user_form_upload .onoffswitch-checkbox[data-id]:checked, #user_form_upload .pos-action-box:checked").length; - if (checked > 0) { - $("#checkbox_error").text("").removeClass('error'); - PosnicPro.users.userForm(); - } else { - PosnicPro.users.showPermissionError(); + var valid = $('#user_form_upload').valid(); + var checked = $("#user_form_upload .onoffswitch-checkbox[data-id]:checked, #user_form_upload .pos-action-box:checked").length; + $('#user_save_errors').remove(); + if (!checked) PosnicPro.users.showPermissionError(); + else $('#checkbox_error').text('').removeClass('error').hide(); + if (!valid || !checked) { + var summary = $(''); + (validator.errorList || []).forEach(function (item) { + $('
').text(item.message).appendTo(summary); + }); + if (!checked) $('
').text($('#checkbox_error').text()).appendTo(summary); + $('#user_form_upload .record-actions').before(summary); + // Field errors take priority, but the summary also explains missing access. + if (!valid && validator.errorList && validator.errorList.length) { + var first = validator.errorList[0].element; + $('#user_form_upload').trigger('record-reveal', [first]); + var focus = $(first).next('.select2').find('.select2-selection')[0] || first; + focus.focus(); + focus.scrollIntoView({ block: 'nearest' }); } + return; } + PosnicPro.users.userForm(); }, clickApiForm: function () { $("#app_name,#users_api").on('keyup', function () { @@ -2018,6 +2039,8 @@ PosnicPro.users = { } }, userClearForm: function () { + $('#user_save_errors').remove(); + $('#checkbox_error').text('').removeClass('error').hide(); $("#branchtype").select2({ placeholder: "Select branch" }); diff --git a/frontend/static/style/css/record-workspace.css b/frontend/static/style/css/record-workspace.css index 0eb607843..965bc9690 100644 --- a/frontend/static/style/css/record-workspace.css +++ b/frontend/static/style/css/record-workspace.css @@ -125,3 +125,9 @@ [dir="rtl"] .record-dialog-small.sidebarshow { left: 0; } .record-account-types > div > .row { grid-template-columns: 1fr; } } + +/* Units is a two-field editor, not a full-height drawer. */ +#infobar-settings-sidebar-unit.record-dialog.sidebarshow { + height: fit-content !important; + min-height: 0 !important; +} diff --git a/tests/branch-edit-save.test.js b/tests/branch-edit-save.test.js new file mode 100644 index 000000000..aa4c278c7 --- /dev/null +++ b/tests/branch-edit-save.test.js @@ -0,0 +1,128 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); +const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8'); +const settings = read('frontend/static/script/js/modules/js/settings.js'); +const branches = read('frontend/static/script/js/modules/js/branches.js'); +function method(w, source, name, next) { + return w.eval('({' + source.slice(source.indexOf(name + ': function'), source.indexOf(next + ': function', source.indexOf(name + ': function'))) + '})')[name]; +} +function fixture() { + const dom = new JSDOM(read('frontend/modules/branchEdit.html'), { runScripts: 'outside-only' }); + const w = dom.window; + w.eval(read('frontend/static/script/js/jquery.min.js')); + w.eval(read('frontend/static/script/js/jquery.validate.min.js')); + w.eval(read('frontend/static/script/js/validpassword-showhidepassword.js')); + const $ = w.jQuery; + $.fn.select2 = function(action) { + if (action === 'data') return Array.from(this[0].selectedOptions).map(element => ({ element })); + return this; + }; + const calls = []; + w.PosnicPro = { + i18n: { t: (key, text) => text }, local: { get: () => 'session-branch' }, + settings: { _refLoaded: {}, _refPending: {}, store_telephone: { + setNumber: value => $('#store_telephone').val(value), + getNumber: () => '+919008427482', isValidNumber: () => true, setCountry: () => {} + }, applyTaxProfile: () => {} }, branches: {}, branchSignature: { mount: () => {} }, + getFormData: form => Object.fromEntries(form.serializeArray().map(x => [x.name, x.value])), + put: params => calls.push(params), timeZone: () => 'Asia/Calcutta' + }; + w.PosnicPro.settings.generalSetting = method(w, settings, 'generalSetting', 'emailPhpStoreSettings'); + w.eval(settings.slice(settings.indexOf('$("#setting_add").validate({'), settings.indexOf('$("#setting_image_add").submit'))); + // Avoid jQuery visibility-based focus in the layout-free DOM; validation still runs fully. + $('#setting_add').validate().settings.focusInvalid = false; + w.eval(read('frontend/static/script/js/core/record-workspace.js')); + w.PosnicPro.recordWorkspace.init(); + const values = { store_name: 'Shop', store_email: 'shop+123@example.technology', store_telephone: '9008427482', store_address: 'Shop address', printing_address: 'Receipt address', edit_branch_target: 'edited-branch' }; + for (const [id, value] of Object.entries(values)) $('#' + id).val(value); + for (const [id, value] of Object.entries({ setting_country: 'India', setting_state: 'Tamil Nadu', currency_setting: 'Indian Rupee', currency_type: 'INR', time_zone: 'Asia/Calcutta' })) { + const option = new w.Option(value, value, true, true); + option.setAttribute('data-setting-id', '101'); option.setAttribute('data-timezone-name', value); + $('#' + id).append(option); + } + return { dom, w, $, calls }; +} +const tick = () => new Promise(resolve => setTimeout(resolve, 10)); +test('branch Save accepts plus-address email, sends the target and phone, and restores tabs', async () => { + const { dom, $, calls } = fixture(); + $('#setting_add button[type=submit]')[0].click(); + await tick(); + assert.equal(calls.length, 1); + const payload = JSON.parse(calls[0].data); + assert.equal(payload.store_email, 'shop+123@example.technology'); + assert.equal(payload.store_telephone, '+919008427482'); + assert.equal(payload.target_branch_id, 'edited-branch'); + assert.equal(payload.setting_state, 'Tamil Nadu'); + assert.equal($('.record-form-panel').filter((i, node) => !node.hidden).length, 1); + dom.window.close(); +}); +for (const [field, tab] of [['printing_address', 2], ['currency_setting', 1], ['setting_state', 0]]) { + test('missing ' + field + ' blocks Save with an inline error in the right tab', async () => { + const { dom, $, calls } = fixture(); + $('#' + field).val(''); + $('#setting_add button[type=submit]')[0].click(); + await tick(); + assert.equal(calls.length, 0); + assert.ok($('label.error[for="' + field + '"]').text()); + assert.equal($('#setting_add-section-' + tab)[0].hidden, false); + assert.equal($('.record-form-panel').filter((i, node) => !node.hidden).length, 1); + if (field === 'printing_address') assert.equal($('#printing_address').closest('details')[0].open, true); + dom.window.close(); + }); +} +test('slow reference lists complete before branch hydration; earlier branch responses cannot overwrite it', () => { + const { dom, w, $ } = fixture(); + const pending = []; + w.PosnicPro.get = (params, done) => pending.push({ params, done }); + const s = w.PosnicPro.settings; + s.loadSelectSettingCountry = method(w, settings, 'loadSelectSettingCountry', 'loadSelectSettingState'); + s.loadSelectSettingCurrency = method(w, settings, 'loadSelectSettingCurrency', 'timeZone'); + s.timeZone = method(w, settings, 'timeZone', 'paymentKey'); + s.loadSelectSettingState = method(w, settings, 'loadSelectSettingState', 'loadSelectSettingCurrency'); + const b = w.PosnicPro.branches; + b.loadFullEdit = method(w, branches, 'loadFullEdit', 'beAddRegisterRow'); + b.beAddRegisterRow = () => {}; + b.loadFullEdit('first'); b.loadFullEdit('second'); + assert.equal(pending.length, 3); + assert.equal($('#setting_add button[type=submit]').prop('disabled'), true); + pending.shift().done({ data: { countries: [{ value: 'India', id: '101' }] } }); + pending.shift().done({ data: { currency: [{ value: 'Indian Rupee', id: '1', text: 'INR', symbol: 'Rs' }] } }); + pending.shift().done({ data: [{ text: 'Asia/Calcutta', value: 'India' }] }); + assert.equal(pending.length, 1); + assert.equal(pending[0].params.data.id, 'second'); + const data = { branch_name: 'Saved shop', store_email: 'shop+123@example.com', store_telephone: '+919008427482', country: 'India', country_id: '101', state: 'Tamil Nadu', currency_text: 'Indian Rupee', currency_value: [{currency_text: 'INR',currency_sign: 'Rs'}], currency_type: 'INR', time_zone: 'Asia/Calcutta', client_dateformat: 'dd/mm/yyyy' }; + pending.shift().done({ type: 'success', data }); + assert.equal($('#setting_state').val(), 'Tamil Nadu'); + assert.equal($('#store_telephone').val(), data.store_telephone); + pending.shift().done({ data: { stateJsonArray: ['Tamil Nadu', 'Other'], countrySortName: 'in' } }); + assert.equal($('#setting_state').val(), 'Tamil Nadu'); + assert.equal($('#currency_setting').val(), 'Indian Rupee'); + assert.equal($('#setting_add button[type=submit]').prop('disabled'), false); + b.loadFullEdit('old'); const old = pending.shift(); b.loadFullEdit('new'); + pending.shift().done({ type: 'success', data: {...data, branch_name: 'Newest'} }); + old.done({ type: 'success', data }); + assert.equal($('#store_name').val(), 'Newest'); + dom.window.close(); +}); + +test('state option refresh preserves the saved phone with the real phone widget', () => { + const { dom, w, $ } = fixture(); + w.eval(read('frontend/static/script/js/utils.js')); + w.eval(read('frontend/static/script/js/intlTelInput.min.js')); + const phone = w.intlTelInput($('#store_telephone')[0], { initialCountry: 'in' }); + w.PosnicPro.settings.store_telephone = phone; + phone.setNumber('+919008427482'); + let respond; + w.PosnicPro.get = (params, done) => { respond = done; }; + const s = w.PosnicPro.settings; + s.loadSelectSettingState = method(w, settings, 'loadSelectSettingState', 'loadSelectSettingCurrency'); + s.loadSelectSettingState('101', 'Tamil Nadu'); + respond({ data: { stateJsonArray: ['Tamil Nadu'], countrySortName: 'in' } }); + assert.equal(phone.getNumber(), '+919008427482'); + assert.equal($('#setting_state').val(), 'Tamil Nadu'); + assert.equal(phone.isValidNumber(), true); + phone.destroy(); dom.window.close(); +}); diff --git a/tests/user-save-validation.test.js b/tests/user-save-validation.test.js new file mode 100644 index 000000000..aef6d6119 --- /dev/null +++ b/tests/user-save-validation.test.js @@ -0,0 +1,83 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); +const { DEFAULT_ROLES } = require('../api/src/constants/roles.constants'); +const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8'); +const source = read('frontend/static/script/js/modules/js/users.js'); +function method(w, name, next) { + const start = source.indexOf(' ' + name + ': function'); + return w.eval('({' + source.slice(start, source.indexOf(' ' + next + ': function', start)) + '})')[name]; +} +function fixture() { + const dom = new JSDOM(read('frontend/modals/user.html'), { runScripts: 'outside-only' }); + const w = dom.window; + w.eval(read('frontend/static/script/js/jquery.min.js')); + w.eval(read('frontend/static/script/js/jquery.validate.min.js')); + w.eval(read('frontend/static/script/js/validpassword-showhidepassword.js')); + w.HTMLElement.prototype.scrollIntoView = function () {}; + const $ = w.jQuery; + $.fn.select2 = function(action) { + return action === 'data' ? Array.from(this[0].selectedOptions).map(element => ({ element, id: element.value, text: element.textContent })) : this; + }; + const requests = []; + w.PosnicPro = { i18n: { t: (key, text) => text }, users: {}, + getFormData: form => Object.fromEntries(form.serializeArray().map(x => [x.name, x.value])), + request: params => requests.push(params) + }; + const u = w.PosnicPro.users; + for (const [name,next] of [['showPermissionError','clickNormalForm'],['clickNormalForm','clickApiForm'],['userForm','insertLocalStorage'],['user','viewUser'],['fillPosForm','collectPosForm'],['collectPosForm','updatePinRowVisibility'],['applyRoleToForm','setManagerPin']]) u[name] = method(w,name,next); + u.POS_ACTION_KEYS = ['discount_apply', 'refund', 'register_open']; u.updatePinRowVisibility = () => {}; + w.eval(read('frontend/static/script/js/core/record-workspace.js')); u.rolesCache = []; + w.PosnicPro.recordWorkspace.init(); + w.eval(source.slice(source.indexOf('$("#clicksubmit").click('), source.indexOf("$('#click_collapse').click("))); + $('#user_form_upload input[type=checkbox]').prop('checked',false); + for (const [id,value] of Object.entries({users_firstname:'Test',users_lastname:'Person',users_name:'test22',users_email:'test22@gmail.com',users_password:'Test@12345',users_retype_password:'Test@12345',logo_user:'user.svg'})) $('#' + id).val(value); + $('#normalUser,#defaultMethod').prop('checked',true); + $('#branchtype').empty().append(new w.Option('Shop', 'branch-id',true,true)); + return { dom, w, $, u, requests }; +} +const tick = () => new Promise(resolve => setTimeout(resolve, 10)); +test('Save reports profile and permission errors together, then sends exactly one request after correction', async () => { + const {dom,$,requests} = fixture(); + $('#users_retype_password').val('Mismatch@1'); + $('#clicksubmit')[0].click(); await tick(); + assert.equal(requests.length,0); + assert.match($('#user_save_errors').text(),/Passwords do not match/); + assert.match($('#user_save_errors').text(),/Permission is required/); + assert.equal($('#user_form_upload-section-0')[0].hidden,false); + $('#users_retype_password').val('Test@12345'); + $('#clicksubmit')[0].click(); await tick(); + assert.equal($('#user_form_upload-section-1')[0].hidden,false); + assert.match($('#checkbox_error').text(),/Permission/); + $('#sales_read').prop('checked',true); + $('#clicksubmit')[0].click(); await tick(); + assert.equal(requests.length,1); + assert.equal(requests[0].url,'users'); assert.equal(requests[0].method,'POST'); + const payload=JSON.parse(requests[0].data); + assert.equal(payload.branch_data[0].branch_id,'branch-id'); + assert.equal(payload.branch_data[0].branch_image,'store.png'); + assert.equal(payload.name,'test22'); + assert.equal($('#user_save_errors').length,0); + dom.window.close(); +}); +test('Accountant role reaches the users endpoint even though its checked permissions are disabled', async () => { + const {dom,w,$,u,requests} = fixture(); + const accountant = {...DEFAULT_ROLES.find(role=>role.key==='accountant'),_id:'accountant-role'}; + u.rolesCache=[accountant]; + $('#user_role_id').append(new w.Option('Accountant','accountant-role')).val('accountant-role'); + u.applyRoleToForm('accountant-role'); + $('#clicksubmit')[0].click(); await tick(); + assert.equal(requests.length,1); + assert.equal(JSON.parse(requests[0].data).role_id,'accountant-role'); + dom.window.close(); +}); +test('missing Select2 branch selection has a visible error and never sends the request', async () => { + const {dom,$,requests} = fixture(); + $('#sales_read').prop('checked',true); $('#branchtype').val([]).hide(); + $('#clicksubmit')[0].click(); await tick(); + assert.equal(requests.length,0); assert.match($('#user_save_errors').text(),/Choose a shop/); + assert.equal($('#user_form_upload-section-1')[0].hidden,false); + dom.window.close(); +});