diff --git a/src/ValidationsFactory.js b/src/ValidationsFactory.js index 23e0210e..fdb301af 100644 --- a/src/ValidationsFactory.js +++ b/src/ValidationsFactory.js @@ -13,6 +13,7 @@ class Validations { firstPage = 0; data = {}; insideLoop = false; + isMobile = false; constructor(element, options) { this.element = element; Object.assign(this, options); @@ -35,11 +36,13 @@ class Validations { * Check if element/container is visible. */ isVisible() { - // Disable validations if field is hidden + const deviceConfig = this.element.config?.deviceVisibility || { + showForDesktop: true, + showForMobile: true + }; const visibleInDevice = - this.element.visibleInDevice === null || this.element.visibleInDevice === undefined - ? true - : this.element.visibleInDevice; + (this.isMobile && deviceConfig.showForMobile) || + (!this.isMobile && deviceConfig.showForDesktop); if (!visibleInDevice) { return false; } @@ -62,7 +65,7 @@ class Validations { class ArrayOfFieldsValidations extends Validations { async addValidations(validations) { for (const item of this.element) { - await ValidationsFactory(item, { screen: this.screen, data: this.data, parentVisibilityRule: this.parentVisibilityRule, insideLoop: this.insideLoop }).addValidations(validations); + await ValidationsFactory(item, { screen: this.screen, data: this.data, parentVisibilityRule: this.parentVisibilityRule, insideLoop: this.insideLoop, isMobile: this.isMobile }).addValidations(validations); } } } @@ -75,7 +78,7 @@ class ScreenValidations extends Validations { // add validations for page 1 if (this.element.config[this.firstPage]) { pagesValidated = [this.firstPage]; - const screenValidations = ValidationsFactory(this.element.config[this.firstPage].items, { screen: this.element, data: this.data }); + const screenValidations = ValidationsFactory(this.element.config[this.firstPage].items, { screen: this.element, data: this.data, isMobile: this.isMobile }); await screenValidations.addValidations(validations); pagesValidated = []; } @@ -96,7 +99,7 @@ class FormNestedScreenValidations extends Validations { const definition = nestedScreen.config; let parentVisibilityRule = this.parentVisibilityRule ? this.parentVisibilityRule : this.element.config.conditionalHide; if (definition && definition[0] && definition[0].items) { - await ValidationsFactory(definition[0].items, { screen: nestedScreen, data: this.data, parentVisibilityRule }).addValidations(validations); + await ValidationsFactory(definition[0].items, { screen: nestedScreen, data: this.data, parentVisibilityRule, isMobile: this.isMobile }).addValidations(validations); } } } @@ -146,7 +149,7 @@ class FormLoopValidations extends Validations { loopField['$each'] = {}; this.checkForSiblings(validations); const firstRow = (get(this.data, this.element.config.name) || [{}])[0]; - await ValidationsFactory(this.element.items, { screen: this.screen, data: {_parent: this.data, ...firstRow }, parentVisibilityRule: this.element.config.conditionalHide, insideLoop: true }).addValidations(loopField['$each']); + await ValidationsFactory(this.element.items, { screen: this.screen, data: {_parent: this.data, ...firstRow }, parentVisibilityRule: this.element.config.conditionalHide, insideLoop: true, isMobile: this.isMobile }).addValidations(loopField['$each']); } checkForSiblings(validations) { const siblings = []; @@ -207,7 +210,7 @@ class FormMultiColumnValidations extends Validations { if (!this.isVisible()) { return; } - await ValidationsFactory(this.element.items, { screen: this.screen, data: this.data, parentVisibilityRule: this.element.config.conditionalHide }).addValidations(validations); + await ValidationsFactory(this.element.items, { screen: this.screen, data: this.data, parentVisibilityRule: this.element.config.conditionalHide, isMobile: this.isMobile }).addValidations(validations); } } @@ -229,7 +232,7 @@ class PageNavigateValidations extends Validations { if (pagesValidated.length > 0 && !pagesValidated.includes(screenPageId)) { if (this.screen.config[screenNumber] && this.screen.config[screenNumber].items) { pagesValidated.push(screenPageId); - await ValidationsFactory(this.screen.config[this.element.config.eventData].items, { screen: this.screen, data: this.data }).addValidations(validations); + await ValidationsFactory(this.screen.config[this.element.config.eventData].items, { screen: this.screen, data: this.data, isMobile: this.isMobile }).addValidations(validations); } } } @@ -308,7 +311,7 @@ class FormElementValidations extends Validations { // Check Device Visibility let visibleInDevice = true; try { - const isMobileScreen = this.$root.$children[0].$refs.renderer.definition.isMobile; + const isMobileScreen = this.isMobile; visibleInDevice = (isMobileScreen && deviceConfig.showForMobile) || (!isMobileScreen && deviceConfig.showForDesktop); @@ -375,7 +378,7 @@ class FormElementValidations extends Validations { }; } if (this.element.items) { - ValidationsFactory(this.element.items, { screen: this.screen, data: this.data }).addValidations(validations); + ValidationsFactory(this.element.items, { screen: this.screen, data: this.data, isMobile: this.isMobile }).addValidations(validations); } } camelCase(name) { diff --git a/src/components/renderer/form-collection-record-control.vue b/src/components/renderer/form-collection-record-control.vue index 50db5fcb..6eb1968c 100644 --- a/src/components/renderer/form-collection-record-control.vue +++ b/src/components/renderer/form-collection-record-control.vue @@ -9,6 +9,7 @@ :computed="computed" :custom-css="customCss" :watchers="watchers" + :is-mobile="isMobile" :_parent="_parent" /> @@ -43,7 +44,8 @@ export default { collectionmode: { type: Object }, - taskdraft: Object + taskdraft: Object, + isMobile: {type: Boolean, default: false} }, data() { return { diff --git a/src/components/renderer/form-collection-view-control.vue b/src/components/renderer/form-collection-view-control.vue index 98a3bce9..77ff8496 100644 --- a/src/components/renderer/form-collection-view-control.vue +++ b/src/components/renderer/form-collection-view-control.vue @@ -9,6 +9,7 @@ :computed="computed" :custom-css="customCss" :watchers="watchers" + :is-mobile="isMobile" :_parent="_parent" /> @@ -38,6 +39,7 @@ export default { type: Object }, taskdraft: Object, + isMobile: {type: Boolean, default: false}, }, data() { return { diff --git a/src/components/renderer/form-loop.vue b/src/components/renderer/form-loop.vue index 8cbb7bb8..749590c2 100644 --- a/src/components/renderer/form-loop.vue +++ b/src/components/renderer/form-loop.vue @@ -7,6 +7,7 @@ :computed="null" :custom-css="null" :watchers="null" + :is-mobile="isMobile" :is-loop="true" :debug-context="'Loop #' + loopIndex" :mode="mode" @@ -54,7 +55,9 @@ export default { VueFormRenderer }, mixins: [], - props: ["value", "config", "transientData", "name", "mode", "formConfig"], + // Renderer props stay untyped because legacy screens may pass non-canonical runtime types. + // eslint-disable-next-line prettier/prettier + props: ["value", "config", "transientData", "name", "mode", "formConfig", "isMobile"], // NOSONAR data() { return { matrix: [], diff --git a/src/components/renderer/form-nested-screen.vue b/src/components/renderer/form-nested-screen.vue index 4c7ca872..e6f8df1e 100644 --- a/src/components/renderer/form-nested-screen.vue +++ b/src/components/renderer/form-nested-screen.vue @@ -11,6 +11,7 @@ :computed="computed" :custom-css="customCSS" :watchers="watchers" + :is-mobile="isMobile" debug-context="Nested Screen" @css-errors="cssErrors = $event" :_parent="_parent" @@ -42,6 +43,7 @@ export default { validationData: null, _parent: null, ancestorScreens: {type: Array, default: () => []}, + isMobile: {type: Boolean, default: false}, }, data() { return { diff --git a/src/components/renderer/form-record-list.vue b/src/components/renderer/form-record-list.vue index 4fae897b..9e76e604 100644 --- a/src/components/renderer/form-record-list.vue +++ b/src/components/renderer/form-record-list.vue @@ -182,6 +182,7 @@ :current-page="form" :computed="formComputed" :watchers="formWatchers" + :is-mobile="isMobile" debug-context="Record List Add" :_parent="validationData" @update="updateRowDataNamePrefix" @@ -210,6 +211,7 @@ :current-page="form" :computed="formComputed" :watchers="formWatchers" + :is-mobile="isMobile" debug-context="Record List Edit" :_parent="validationData" @update="updateRowDataNamePrefix" @@ -293,7 +295,8 @@ export default { "source", "paginationOption", "designerMode", - "bgcolormodern" + "bgcolormodern", + "isMobile" ], data() { return { diff --git a/src/components/screen-renderer.vue b/src/components/screen-renderer.vue index e20fe109..226099a7 100644 --- a/src/components/screen-renderer.vue +++ b/src/components/screen-renderer.vue @@ -39,6 +39,7 @@ :vdata="value" :_parent="_parent || value?._parent" :_initial-page="currentPage" + :is-mobile="isMobile" :taskdraft="taskdraft" @after-submit="afterSubmit" @submit="submit" @@ -71,6 +72,10 @@ export default { type: String, default: "" }, + isMobile: { + type: Boolean, + default: false + }, taskdraft: Object }, data() { diff --git a/src/components/vue-form-renderer.vue b/src/components/vue-form-renderer.vue index 26fc57cc..19d40186 100644 --- a/src/components/vue-form-renderer.vue +++ b/src/components/vue-form-renderer.vue @@ -12,6 +12,7 @@ :value="data" :_parent="_parent || data?._parent" :definition="definition" + :is-mobile="responsiveIsMobile" :current-page="currentPage" data-cy="screen-renderer" :show-errors="showErrors" @@ -72,8 +73,7 @@ export default { config: this.config, computed: this.computed, customCss: this.customCss, - watchers: this.watchers, - isMobile: false + watchers: this.watchers }, formSubmitErrorClass: "", // watcher URLs @@ -114,7 +114,8 @@ export default { } }, scrollable: null, - containerObserver: null + containerObserver: null, + containerObserverFrame: null }; }, computed: { @@ -188,6 +189,12 @@ export default { // Initialize the clipboard module this.$store.dispatch('clipboardModule/initializeClipboard'); }, + beforeDestroy() { + this.containerObserver.disconnect(); + if (this.containerObserverFrame) { + cancelAnimationFrame(this.containerObserverFrame); + } + }, methods: { ...mapActions("globalErrorsModule", [ "validate", @@ -367,10 +374,15 @@ export default { this.$emit("update-page-task"); this.$refs.renderer.setCurrentPage(page); }, - onContainerObserver(entries) { - // Control coordinates - const controlEl = entries[0].target.getBoundingClientRect(); - this.parseCss(); + onContainerObserver() { + if (this.containerObserverFrame) { + cancelAnimationFrame(this.containerObserverFrame); + } + this.containerObserverFrame = requestAnimationFrame(() => { + this.containerObserverFrame = null; + this.checkIfIsMobile(); + this.parseCss(); + }); }, saveClipboarToLocalStorage(items){ localStorage.setItem("savedClipboard", JSON.stringify(items)); diff --git a/src/mixins/DeviceDetector.js b/src/mixins/DeviceDetector.js index 85bdd6c9..159ccafa 100644 --- a/src/mixins/DeviceDetector.js +++ b/src/mixins/DeviceDetector.js @@ -1,6 +1,22 @@ export const MAX_MOBILE_WIDTH = 480; export const originalDevicePixelRatio = window.devicePixelRatio; export default { + props: { + isMobile: { + type: Boolean, + default: false + } + }, + data() { + return { + detectedIsMobile: false + }; + }, + computed: { + responsiveIsMobile() { + return this.isMobile || this.detectedIsMobile; + } + }, created() { window.addEventListener("resize", this.resizeHandler); }, @@ -17,10 +33,10 @@ export default { this.checkIfIsMobile(); }, checkIfIsMobile() { - const renderer = document.getElementById("vue-form-renderer"); + const renderer = this.$refs.formRendererContainer; const isModelerInspector = this.data && this.data.$type && this.data.$type.startsWith("bpmn:"); - if (this.definition && !isModelerInspector) { - this.definition.isMobile = + if (!isModelerInspector) { + this.detectedIsMobile = renderer && renderer.offsetWidth <= MAX_MOBILE_WIDTH && originalDevicePixelRatio === window.devicePixelRatio; diff --git a/src/mixins/Json2Vue.js b/src/mixins/Json2Vue.js index 6c7366a5..1260f6c2 100644 --- a/src/mixins/Json2Vue.js +++ b/src/mixins/Json2Vue.js @@ -448,6 +448,7 @@ export default { await ValidationsFactory(definition, { screen: definition, firstPage, + isMobile: this.isMobile, data: { _parent: this._parent, ...this.vdata diff --git a/src/mixins/VisibilityRule.js b/src/mixins/VisibilityRule.js index a57c0085..a772cc06 100644 --- a/src/mixins/VisibilityRule.js +++ b/src/mixins/VisibilityRule.js @@ -2,10 +2,10 @@ import { Parser } from 'expr-eval'; export default { methods: { - visibilityRuleIsVisible(rule, name, deviceVisibility) { - const visibility = deviceVisibility || { showForDesktop: true, showForMobile: true, isMobile: false }; + visibilityRuleIsVisible(rule, name, deviceVisibility, isMobile = false) { + const visibility = deviceVisibility || { showForDesktop: true, showForMobile: true }; const visibleInDevice = - (visibility.isMobile && visibility.showForMobile) || (!visibility.isMobile && visibility.showForDesktop); + (isMobile && visibility.showForMobile) || (!isMobile && visibility.showForDesktop); try { if (rule && rule.trim().length > 0) { diff --git a/src/mixins/extensions/FormDynamicPanel.js b/src/mixins/extensions/FormDynamicPanel.js index 5c049ea1..1c764504 100644 --- a/src/mixins/extensions/FormDynamicPanel.js +++ b/src/mixins/extensions/FormDynamicPanel.js @@ -20,8 +20,7 @@ export default { items: element.items, } ], - watchers: [], - isMobile: false + watchers: [] }; }, @@ -57,6 +56,7 @@ export default { return this.createComponent("ScreenRenderer", { ":definition": this.byRef(nested), ":value": valueExpression, + ":is-mobile": "isMobile", ":loop-context": loopContextExpression, ":_parent": "getValidationData()", ":components": this.byRef(this.components), diff --git a/src/mixins/extensions/LoadFieldComponents.js b/src/mixins/extensions/LoadFieldComponents.js index 2166fd91..53ca739d 100644 --- a/src/mixins/extensions/LoadFieldComponents.js +++ b/src/mixins/extensions/LoadFieldComponents.js @@ -1,4 +1,12 @@ /* eslint-disable no-param-reassign */ +const RESPONSIVE_COMPONENTS = new Set([ + "FormLoop", + "FormRecordList", + "FormNestedScreen", + "FormCollectionRecordControl", + "FormCollectionViewControl" +]); + export default { data() { return { @@ -6,6 +14,11 @@ export default { }; }, methods: { + addResponsiveProperties(properties, componentName) { + if (RESPONSIVE_COMPONENTS.has(componentName)) { + properties[":is-mobile"] = "isMobile"; + } + }, searchForRecordList(items) { items.forEach((item) => { if (item instanceof Array) { @@ -86,6 +99,7 @@ export default { if (componentName === "FormNestedScreen") { properties[":_parent"] = "_parent"; } + this.addResponsiveProperties(properties, componentName); // Add cypress testing tags if (element.config.name) { properties["data-cy"] = `screen-field-${element.config.name}`; diff --git a/src/mixins/extensions/LoopContainer.js b/src/mixins/extensions/LoopContainer.js index 794d4498..0f570088 100644 --- a/src/mixins/extensions/LoopContainer.js +++ b/src/mixins/extensions/LoopContainer.js @@ -28,8 +28,7 @@ export default { items: element.items } ], - watchers: definition.watchers, - isMobile: definition.isMobile + watchers: definition.watchers }; let loopContext = ""; @@ -42,6 +41,7 @@ export default { const child = this.createComponent("ScreenRenderer", { ":definition": this.byRef(nested), ":value": "loopRow", + ":is-mobile": "isMobile", ":loop-context": `'${loopContext}.' + index`, ":_parent": "getValidationData()", ":components": this.byRef(this.components), diff --git a/src/mixins/extensions/VisibilityRule.js b/src/mixins/extensions/VisibilityRule.js index 5a181173..a438e3ed 100644 --- a/src/mixins/extensions/VisibilityRule.js +++ b/src/mixins/extensions/VisibilityRule.js @@ -3,25 +3,25 @@ import VisibilityRule from '../VisibilityRule'; export default { mounted() { this.extensions.push({ - onloaditems({ element, wrapper, definition }) { + onloaditems({ element, wrapper }) { const visibility = element.config.deviceVisibility || { showForDesktop: true, showForMobile: true } const restrictDeviceVisibility = !visibility.showForDesktop || !visibility.showForMobile; - - element.visibleInDevice = - (definition.isMobile && visibility.showForMobile) || - (!definition.isMobile && visibility.showForDesktop); - if (element.config.conditionalHide || restrictDeviceVisibility) { - const deviceVisibility = JSON.stringify( { ...visibility, isMobile: definition.isMobile } ); + const deviceVisibility = JSON.stringify(visibility); wrapper.setAttribute( 'v-show', `visibilityRuleIsVisible(${JSON.stringify(element.config.conditionalHide)}, - ${JSON.stringify(element.config.name)}, ${deviceVisibility})` + ${JSON.stringify(element.config.name)}, ${deviceVisibility}, isMobile)` ); } }, onbuild({ screen }) { + this.addProp(screen, "isMobile", { + type: Boolean, + default: false, + }); + this.addWatch(screen, "isMobile", "this.loadValidationRules();"); screen.mixins.push(VisibilityRule); }, }); diff --git a/tests/e2e/specs/LoopSelectList.spec.js b/tests/e2e/specs/LoopSelectList.spec.js index 2208395a..81b3ab4e 100644 --- a/tests/e2e/specs/LoopSelectList.spec.js +++ b/tests/e2e/specs/LoopSelectList.spec.js @@ -1,4 +1,68 @@ describe("Select List Cache", () => { + const dataSourceResponse = { + status: 200, + response: { + data: [ + { + id: 1, + created_by_id: 2, + updated_by_id: 2, + created_at: "2021-11-08 10:29:56", + updated_at: "2021-11-08 10:29:56", + data: { + id: 1, + name: "Bolivia" + }, + collection_id: 3, + title: "1", + created_by: { + id: 2, + email: "admin@processmaker.com" + }, + updated_by: { + id: 2, + email: "admin@processmaker.com" + } + }, + { + id: 2, + created_by_id: 2, + updated_by_id: 2, + created_at: "2021-11-08 10:29:56", + updated_at: "2021-11-08 10:29:56", + data: { + id: 2, + name: "United States" + }, + collection_id: 3, + title: "2", + created_by: { + id: 2, + email: "admin@processmaker.com" + }, + updated_by: { + id: 2, + email: "admin@processmaker.com" + } + } + ], + meta: { + filter: "", + sort_by: "", + sort_order: "", + count: 2, + total_pages: 1, + current_page: 1, + from: 1, + last_page: 1, + path: "/api/1.0/collections/3/records", + per_page: 9223372036854775807, + to: 2, + total: 2 + } + } + }; + function addHeader(win, name, value) { const meta = win.document.createElement("meta"); meta.setAttribute("name", name); @@ -9,70 +73,107 @@ describe("Select List Cache", () => { cy.intercept( "GET", "/api/1.0/requests/data_sources/3/resources/ListAll/data**", - JSON.stringify({ - status: 200, - response: { - data: [ - { - id: 1, - created_by_id: 2, - updated_by_id: 2, - created_at: "2021-11-08 10:29:56", - updated_at: "2021-11-08 10:29:56", - data: { - id: 1, - name: "Bolivia" - }, - collection_id: 3, - title: "1", - created_by: { - id: 2, - email: "admin@processmaker.com" - }, - updated_by: { - id: 2, - email: "admin@processmaker.com" - } - }, + JSON.stringify(dataSourceResponse) + ).as("getDataSource"); + }); + + it("Preserves a Data Connector selection across responsive breakpoints", () => { + cy.intercept( + "GET", + "/api/1.0/requests/data_sources/3/resources/ListAll/data**", + (request) => { + request.reply({ + delay: 250, + body: JSON.stringify(dataSourceResponse) + }); + } + ).as("responsiveDataSource"); + + cy.visit("/", { + onBeforeLoad(win) { + addHeader(win, "screen-cache-enabled", "false"); + addHeader(win, "screen-cache-timeout", "3000"); + } + }); + + cy.loadFromJson("loop_select_list.json", 0); + cy.get("#screen-builder-container").then(($builder) => { + const config = $builder[0].__vue__.$refs.builder.config; + const loop = config[0].items[0]; + loop.config.settings.times = "1"; + config[0].items.push({ + label: "Line Input", + config: { + name: "desktop_only", + type: "text", + label: "Desktop Only", + validation: [ { - id: 2, - created_by_id: 2, - updated_by_id: 2, - created_at: "2021-11-08 10:29:56", - updated_at: "2021-11-08 10:29:56", - data: { - id: 2, - name: "United States" - }, - collection_id: 3, - title: "2", - created_by: { - id: 2, - email: "admin@processmaker.com" - }, - updated_by: { - id: 2, - email: "admin@processmaker.com" - } + value: "required", + helper: "Checks if the field has a value", + content: "Required" } ], - meta: { - filter: "", - sort_by: "", - sort_order: "", - count: 2, - total_pages: 1, - current_page: 1, - from: 1, - last_page: 1, - path: "/api/1.0/collections/3/records", - per_page: 9223372036854775807, - to: 2, - total: 2 + deviceVisibility: { + showForDesktop: true, + showForMobile: false } - } - }) - ).as("getDataSource"); + }, + component: "FormInput", + inspector: [], + "editor-control": "FormInput", + "editor-component": "FormInput" + }); + }); + + cy.showValidationOnLoad(); + cy.get("[data-cy=mode-preview]").click(); + cy.wait("@responsiveDataSource"); + + const select = '[data-cy="screen-field-country"]'; + const selectedLabel = `${select} .multiselect__single`; + const desktopOnly = '[data-cy="screen-field-desktop_only"]'; + let originalSelect; + const assertSelectionState = () => { + cy.get(selectedLabel).should("contain", "Bolivia"); + cy.get(select).click(); + cy.get(select).contains("United States").should("exist"); + cy.get("body").type("{esc}"); + cy.get("#screen-builder-container").then(($builder) => { + expect($builder[0].__vue__.previewData.loop_1[0].country).to.equal("1"); + }); + }; + const assertScreenValidity = (invalid) => { + cy.window().should((win) => { + const renderer = win.vueInstance.$children[0].$refs.renderer; + expect(renderer.getMainScreen().$v.$invalid).to.equal(invalid); + }); + }; + + cy.get(select).selectOption("Bolivia"); + assertSelectionState(); + cy.get(desktopOnly).should("be.visible"); + assertScreenValidity(true); + cy.get(select).then(($select) => { + originalSelect = $select[0]; + }); + + cy.get("[data-cy=device-screen-mobile-button]").click(); + cy.get(select).should(($select) => { + expect($select[0]).to.equal(originalSelect); + }); + assertSelectionState(); + cy.get(desktopOnly).should("not.be.visible"); + assertScreenValidity(false); + + cy.get("[data-cy=device-screen-desktop-button]").click(); + cy.get(select).should(($select) => { + expect($select[0]).to.equal(originalSelect); + }); + assertSelectionState(); + cy.get(desktopOnly).should("be.visible"); + assertScreenValidity(true); + cy.get("@responsiveDataSource.all").should("have.length", 1); }); it("None Cached - Verify number of service calls for loop that contains a multiselect list", () => {