diff --git a/.gitignore b/.gitignore index 5c61dac55f..8252f25a24 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,4 @@ packages/survey-react-ui/tests/bundle/* packages/survey-js-ui/tests/bundle/* packages/survey-core/test_output.txt .claude/ -packages/survey-core/promts/ \ No newline at end of file +packages/survey-core/promts/ diff --git a/packages/survey-core/src/survey-completion-controller.ts b/packages/survey-core/src/survey-completion-controller.ts new file mode 100644 index 0000000000..5f263cbb24 --- /dev/null +++ b/packages/survey-core/src/survey-completion-controller.ts @@ -0,0 +1,158 @@ +import { HashTable } from "./helpers"; +import { DomDocumentHelper } from "./global_variables_utils"; +import { PageModel } from "./page"; +import { settings } from "./settings"; +import { Trigger } from "./trigger"; +import { CompleteEvent, CompletingEvent } from "./survey-events-api"; + +export interface ISurveyCompletionHost { + isCompleted: boolean; + isNavigationBlocked: boolean; + currentPage: PageModel; + cookieName: string; + surveyPostId: string; + onComplete: { fire(sender: any, options: CompleteEvent): void }; + onCompleting: { fire(sender: any, options: CompletingEvent, onComplete?: () => void, onFirstAsync?: () => void): void }; + setCompletedState(value: string, text: string): void; + notify(message: string, type: string): void; + sendResult(): void; + navigateTo(): void; +} + +export class SurveyCompletionController { + private completedByTriggers: HashTable; + + private survey: ISurveyCompletionHost; + + constructor(survey: ISurveyCompletionHost) { + this.survey = survey; + } + + public doComplete(isCompleteOnTrigger: boolean = false, completeTrigger?: Trigger): boolean | undefined { + const survey = this.survey; + if (survey.isCompleted) return; + + return this.checkOnCompletingEvent(isCompleteOnTrigger, completeTrigger, (allow: boolean) => { + if (allow) { + survey.isCompleted = true; + this.saveDataOnComplete(isCompleteOnTrigger, completeTrigger); + this.setCookie(); + } else { + survey.isCompleted = false; + } + }); + } + + public saveDataOnComplete(isCompleteOnTrigger: boolean = false, completeTrigger?: Trigger): void { + const survey = this.survey; + let previousCookie = this.hasCookie; + const showSaveInProgress = (text: string) => { + savingDataStarted = true; + survey.setCompletedState("saving", text); + }; + const showSaveError = (text: string) => { + survey.setCompletedState("error", text); + }; + const showSaveSuccess = (text: string) => { + survey.setCompletedState("success", text); + survey.navigateTo(); + }; + const clearSaveMessages = (text: string) => { + survey.setCompletedState("", ""); + }; + var savingDataStarted = false; + var onCompleteOptions = { + isCompleteOnTrigger: isCompleteOnTrigger, + completeTrigger: completeTrigger, + showSaveInProgress: showSaveInProgress, + showSaveError: showSaveError, + showSaveSuccess: showSaveSuccess, + clearSaveMessages: clearSaveMessages, + //Obsolete functions + showDataSaving: showSaveInProgress, + showDataSavingError: showSaveError, + showDataSavingSuccess: showSaveSuccess, + showDataSavingClear: clearSaveMessages + }; + survey.onComplete.fire(survey, onCompleteOptions); + if (!previousCookie && survey.surveyPostId) { + survey.sendResult(); + } + if (!savingDataStarted) { + survey.navigateTo(); + } + } + + private checkOnCompletingEvent(isCompleteOnTrigger: boolean, completeTrigger: Trigger, onComplete: (allow: boolean) => void): boolean | undefined { + const survey = this.survey; + let result: boolean | undefined = undefined; + const options: CompletingEvent = { + allowComplete: true, + allow: true, + isCompleteOnTrigger: isCompleteOnTrigger, + completeTrigger: completeTrigger + }; + const doCompleteFunc = () => { + survey.isNavigationBlocked = false; + const allow = options.allowComplete && options.allow; + if (!!options.message) { + survey.notify(options.message, allow ? "success" : "error"); + } + result = allow; + onComplete(allow); + }; + survey.onCompleting.fire(survey, options, doCompleteFunc, () => survey.isNavigationBlocked = true); + return result; + } + + public canBeCompleted(trigger: Trigger, isCompleted: boolean): boolean { + if (!settings.triggers.changeNavigationButtonsOnComplete) return false; + const prevCanBeCompleted = this.canBeCompletedByTrigger; + if (!this.completedByTriggers)this.completedByTriggers = {}; + if (isCompleted) { + this.completedByTriggers[trigger.id] = { trigger: trigger, pageId: this.survey.currentPage?.id }; + } else { + delete this.completedByTriggers[trigger.id]; + } + return prevCanBeCompleted !== this.canBeCompletedByTrigger; + } + + public get canBeCompletedByTrigger(): boolean { + if (!this.completedByTriggers) return false; + const keys = Object.keys(this.completedByTriggers); + if (keys.length === 0) return false; + const id = this.survey.currentPage?.id; + if (!id) return true; + for (let i = 0; i < keys.length; i++) { + if (id === this.completedByTriggers[keys[i]].pageId) return true; + } + return false; + } + + public get completedTrigger(): Trigger { + if (!this.canBeCompletedByTrigger) return undefined; + const key = Object.keys(this.completedByTriggers)[0]; + return this.completedByTriggers[key].trigger; + } + + public resetCompletedByTriggers(): void { + this.completedByTriggers = undefined; + } + + public get hasCookie(): boolean { + const cookieName = this.survey.cookieName; + if (!cookieName) return false; + const cookies = DomDocumentHelper.getCookie(); + return !!cookies && cookies.indexOf(cookieName + "=true") > -1; + } + public setCookie(): void { + const cookieName = this.survey.cookieName; + if (!cookieName) return; + DomDocumentHelper.setCookie(cookieName + "=true; expires=Fri, 31 Dec 9999 0:0:0 GMT"); + } + public deleteCookie(): void { + const cookieName = this.survey.cookieName; + if (!cookieName) return; + DomDocumentHelper.setCookie(cookieName + "=;"); + } +} diff --git a/packages/survey-core/src/survey-lazy-rendering-controller.ts b/packages/survey-core/src/survey-lazy-rendering-controller.ts new file mode 100644 index 0000000000..35b1c2ce3e --- /dev/null +++ b/packages/survey-core/src/survey-lazy-rendering-controller.ts @@ -0,0 +1,61 @@ +import { settings } from "./settings"; +import { PageModel } from "./page"; +import { activateLazyRenderingChecks } from "./utils/dom-utils"; + +export interface ISurveyLazyRenderingHost { + currentPage: PageModel; + rootElement: HTMLElement; + creator?: { rootElement?: HTMLElement }; +} + +export class SurveyLazyRenderingController { + private enabledValue: boolean; + private firstBatchSizeValue: number; + private isSuspendedValue: boolean = false; + + private survey: ISurveyLazyRenderingHost; + + constructor(survey: ISurveyLazyRenderingHost) { + this.survey = survey; + } + + public get enabled(): boolean { + return this.enabledValue === true; + } + public set enabled(val: boolean) { + if (this.enabled === val) return; + this.enabledValue = val; + const page: PageModel = this.survey.currentPage; + if (!!page) { + page.updateRows(); + } + } + public get isLazyRendering(): boolean { + return this.enabled || settings.lazyRender.enabled; + } + public get firstBatchSize(): number { + return this.firstBatchSizeValue || settings.lazyRender.firstBatchSize; + } + public set firstBatchSize(val: number) { + this.firstBatchSizeValue = val; + } + public get isSuspended(): boolean { + return this.isSuspendedValue; + } + public suspend(): void { + if (!this.isLazyRendering) return; + this.isSuspendedValue = true; + } + public release(): void { + if (!this.isLazyRendering) return; + this.isSuspendedValue = false; + } + public updateRowsOnRemovingElements(): void { + if (!this.isLazyRendering) return; + const page = this.survey.currentPage; + if (!!page) { + const htmlElement = (this.survey.rootElement || this.survey.creator?.rootElement)?.querySelector(`#${page.id}`); + activateLazyRenderingChecks(htmlElement); + } + } +} diff --git a/packages/survey-core/src/survey-page-structure-controller.ts b/packages/survey-core/src/survey-page-structure-controller.ts new file mode 100644 index 0000000000..acb0bbb02c --- /dev/null +++ b/packages/survey-core/src/survey-page-structure-controller.ts @@ -0,0 +1,108 @@ +import { IElement } from "./base-interfaces"; +import { Helpers } from "./helpers"; +import { Serializer } from "./jsonobject"; +import { PageModel } from "./page"; + +export interface ISurveyPageStructureHost { + pages: Array; + visiblePages: Array; + currentPage: PageModel; + isSinglePage: boolean; + isShowingPreview: boolean; + singleInputController: { currentSingleElement: IElement }; +} + +export class SurveyPageStructureController { + private pageContainerValue: PageModel; + private gotoPageFromPreview: PageModel; + private changeCurrentPageFromPreviewValue: boolean; + + private survey: ISurveyPageStructureHost; + + constructor(survey: ISurveyPageStructureHost) { + this.survey = survey; + } + + public get pageContainer(): PageModel { + return this.pageContainerValue; + } + public get changeCurrentPageFromPreview(): boolean { + return this.changeCurrentPageFromPreviewValue; + } + public notifyPreviewCancelled(currentPage: PageModel): void { + this.gotoPageFromPreview = currentPage; + } + public updatePagesContainer(): void { + const survey = this.survey; + const singleName = "single-page"; + const previewName = "preview-page"; + let rootPage: PageModel = undefined; + if (survey.isSinglePage) { + const cPage = this.pageContainerValue; + if (cPage && cPage.name === previewName) { + rootPage = cPage.elements[0]; + this.disposeContainerPage(); + } else { + rootPage = this.createRootPage(singleName, survey.pages); + } + } + if (survey.isShowingPreview) { + rootPage = this.createRootPage(previewName, rootPage ? [rootPage] : survey.pages); + } + if (rootPage) { + rootPage.setSurveyImpl(survey); + this.pageContainerValue = rootPage; + survey.currentPage = rootPage; + if (!!survey.singleInputController.currentSingleElement) { + survey.visiblePages.forEach(page => page.updateRows()); + } + } + let isCurrentPageSet = false; + if (!survey.isSinglePage && !survey.isShowingPreview) { + this.disposeContainerPage(); + let curPage = this.gotoPageFromPreview; + this.gotoPageFromPreview = null; + const vpCount = this.survey.visiblePages.length; + if (Helpers.isValueEmpty(curPage) && vpCount > 0) { + curPage = survey.visiblePages[vpCount - 1]; + } + if (!!curPage) { + isCurrentPageSet = true; + this.changeCurrentPageFromPreviewValue = true; + survey.currentPage = curPage; + this.changeCurrentPageFromPreviewValue = false; + } + } + if (!survey.currentPage && survey.visiblePages.length > 0 && !isCurrentPageSet) { + survey.currentPage = survey.visiblePages[0]; + } + if (survey.isShowingPreview) { + survey.pages.forEach(page => { + page.onFirstRendering(); + }); + } + survey.pages.forEach(page => { + if (page.wasRendered) { + page.updateElementCss(true); + } + }); + } + private createRootPage(name: string, pages: Array): PageModel { + const container = Serializer.createClass("page"); + container.name = name; + container.isPageContainer = true; + pages.forEach(page => { + if (!page.isStartPage) { + container.addElement(page); + } + }); + return container; + } + private disposeContainerPage(): void { + let cPage = this.pageContainerValue; + const elements = [].concat(cPage.elements); + elements.forEach(el => cPage.removeElement(el)); + cPage.dispose(); + this.pageContainerValue = undefined; + } +} diff --git a/packages/survey-core/src/survey-scroll-focus-controller.ts b/packages/survey-core/src/survey-scroll-focus-controller.ts new file mode 100644 index 0000000000..c20c99d53f --- /dev/null +++ b/packages/survey-core/src/survey-scroll-focus-controller.ts @@ -0,0 +1,71 @@ +import { IElement } from "./base-interfaces"; +import { PageModel } from "./page"; +import { Question } from "./question"; + +export interface ISurveyScrollFocusHost { + readonly activePage: PageModel; + currentPage: PageModel; + currentSingleElement: IElement; + readonly autoFocusFirstQuestion: boolean; + readonly skippedPages: Array<{ from: any, to: any }>; + isCurrentPageRendering: boolean; +} + +export class SurveyScrollFocusController { + private focusingQuestionInfo: { question: Question, onError: boolean }; + + private survey: ISurveyScrollFocusHost; + + constructor(survey: ISurveyScrollFocusHost) { + this.survey = survey; + } + + public get isFocusingQuestion(): boolean { + return !!this.focusingQuestionInfo; + } + public focusQuestionByInstance(question: Question, onError: boolean = false): boolean { + if (!question || !question.isVisible || !question.page) return false; + const oldQuestion = this.focusingQuestionInfo?.question; + if (oldQuestion === question) return false; + const survey = this.survey; + this.focusingQuestionInfo = { question: question, onError: onError }; + const curElement = survey.currentSingleElement; + survey.skippedPages.push({ from: curElement || survey.currentPage, to: curElement ? question : question.page }); + const isNeedWaitForPageRendered = survey.activePage !== question.page && !question.page.isStartPage; + if (isNeedWaitForPageRendered) { + survey.currentPage = question.page; + } + survey.currentSingleElement = question; + if (!isNeedWaitForPageRendered) { + this.focusQuestionInfo(); + } + return true; + } + public focusQuestionInfo(): void { + const question = this.focusingQuestionInfo?.question; + if (!!question && !question.isDisposed) { + question.focus(this.focusingQuestionInfo.onError); + } + this.focusingQuestionInfo = undefined; + } + public focusFirstQuestion(): void { + if (this.focusingQuestionInfo) return; + const page = this.survey.activePage; + if (page) { + page.scrollToTop(); + page.focusFirstQuestion(); + } + } + public scrollToTopOnPageChange(doScroll: boolean = true): void { + const survey = this.survey; + const page = survey.activePage; + if (!page) return; + if (doScroll) { + page.scrollToTop(); + } + if (survey.isCurrentPageRendering && survey.autoFocusFirstQuestion && !this.focusingQuestionInfo) { + page.focusFirstQuestion(); + survey.isCurrentPageRendering = false; + } + } +} diff --git a/packages/survey-core/src/survey-single-input-controller.ts b/packages/survey-core/src/survey-single-input-controller.ts new file mode 100644 index 0000000000..f49756b453 --- /dev/null +++ b/packages/survey-core/src/survey-single-input-controller.ts @@ -0,0 +1,228 @@ +import { IElement, ISurveyElement } from "./base-interfaces"; +import { PageModel } from "./page"; +import { PanelModelBase } from "./panel"; +import { Question } from "./question"; +import { CurrentPageChangedEvent } from "./survey-events-api"; + +export interface ISurveySingleInputHost { + visiblePages: Array; + currentPage: PageModel; + autoFocusFirstQuestion: boolean; + questionsOnPageMode: string; + isDesignMode: boolean; + onCurrentPageChanged: { fire(sender: any, options: CurrentPageChangedEvent): void }; + createPageChangeEventOptions(newValue: PageModel, oldValue: PageModel, newQuestion?: Question, oldQuestion?: Question): CurrentPageChangedEvent; + currentPageChanging(options: any, onSuccess: () => void): void; + updateButtonsVisibility(): void; + sendPartialResult(): void; + checkTriggers(key: any, isOnNextPage: boolean, isOnComplete?: boolean, isOnNavigation?: boolean, name?: string): void; + validateSingleElement(el: Question | PanelModelBase, onComplete: (hasErrors: boolean) => void): boolean; +} + +export class SurveySingleInputController { + private elementValue: IElement; + + private survey: ISurveySingleInputHost; + + constructor(survey: ISurveySingleInputHost) { + this.survey = survey; + } + + public get currentSingleElement(): IElement { + return this.elementValue; + } + public set currentSingleElement(val: IElement) { + if (!!val && !this.isSingleVisibleQuestion) return; + const survey = this.survey; + if (!!val && val.isQuestion && this.isSingleVisibleQuestionVal(survey.questionsOnPageMode)) { + while(val.parent && val.parent.isPanel) { + val = (val.parent); + } + } + const oldVal = this.currentSingleElement; + if (val !== oldVal) { + const valQuestion = val?.isQuestion ? val : undefined; + const oldValQuestion = oldVal?.isQuestion ? oldVal : undefined; + const page = (val)?.page; + const options: any = !!page && !!oldVal ? survey.createPageChangeEventOptions(page, (oldVal).page, valQuestion, oldValQuestion) : undefined; + const changingFunc = () => { + this.elementValue = val; + if (!!val) { + if (this.isSingleVisibleInput && val.isQuestion) { + (val).onSetAsSingleInput(); + } + page.updateRows(); + if (page !== survey.currentPage) { + survey.currentPage = page; + } else { + if (!!valQuestion && survey.autoFocusFirstQuestion) { + valQuestion.focus(); + } + } + survey.updateButtonsVisibility(); + if (!!options) { + survey.onCurrentPageChanged.fire(survey, options); + } + } else { + survey.visiblePages.forEach(page => page.updateRows()); + } + }; + + if (!options) { + changingFunc(); + } else { + survey.currentPageChanging(options, changingFunc); + } + } + } + public get isSingleVisibleQuestion(): boolean { + return !this.survey.isDesignMode && (this.isSingleVisibleQuestionVal(this.survey.questionsOnPageMode) || this.isSingleVisibleInput); + } + public get isSingleVisibleInput(): boolean { + return !this.survey.isDesignMode && this.survey.questionsOnPageMode == "inputPerPage"; + } + private isSingleVisibleQuestionVal(val: string): boolean { + return val === "questionPerPage" || val === "questionOnPage"; + } + private getSingleElements(includeEl?: IElement): Array { + const res = new Array(); + const pages = this.survey.visiblePages; + const isSingleInput = this.isSingleVisibleInput; + for (var i: number = 0; i < pages.length; i++) { + const p = pages[i]; + if (!p.isStartPage) { + const els: Array = []; + if (isSingleInput) { + p.addQuestionsToList(els, true); + } else { + p.elements.forEach(el => els.push(el)); + } + els.forEach(el => { if (el === includeEl || el.isVisible) res.push(el); }); + } + } + return res; + } + public changeCurrentSingleElementOnVisibilityChanged(): void { + const el = this.currentSingleElement; + if (!el || el.isVisible) return; + const els = this.getSingleElements(el); + const index = els.indexOf(el); + const newEl = (index > 0) ? els[index - 1] : (index < els.length - 1 ? els[index + 1] : undefined); + this.currentSingleElement = newEl; + } + public moveToFirstElement(): void { + if (!this.isSingleVisibleQuestion) return; + const els = this.getSingleElements(); + if (els.length > 0) { + this.currentSingleElement = els[0]; + } + } + public performNext(): boolean { + const survey = this.survey; + const q: any = this.currentSingleElement; + if (!q) return false; + if (this.isSingleVisibleInput) { + if (!q.validateSingleInput()) return false; + if (q.nextSingleInput()) { + survey.updateButtonsVisibility(); + return true; + } + } + const res = survey.validateSingleElement(q, (hasErrors: boolean) => { + if (!hasErrors) { + this.performNextAfterValidation(q); + } + }); + return res === true; + } + private performNextAfterValidation(q: Question): boolean { + this.survey.sendPartialResult(); + const questions = this.getSingleElements(); + const index = questions.indexOf(q); + if (index < 0 || index === questions.length - 1) return false; + let keys: any = {}; + if (q.isQuestion) { + keys[q.name] = q.value; + } else { + if (q.isPanel) { + keys = q.getValue(); + } + } + this.survey.checkTriggers(keys, true, false, true, q.name); + if (q === this.currentSingleElement) { + this.currentSingleElement = questions[index + 1]; + } + } + public prevPageSingleElement(curElement: IElement): boolean { + if (this.isSingleVisibleInput) { + if ((curElement).prevSingleInput()) { + this.survey.updateButtonsVisibility(); + return true; + } + } + const questions = this.getSingleElements(); + const index = questions.indexOf(curElement); + if (index === 0) return false; + this.currentSingleElement = questions[index - 1]; + return true; + } + public getFirstLastElementState(): { isFirst?: boolean, isLast?: boolean } { + let fVal: boolean | undefined = undefined; + let lVal: boolean | undefined = undefined; + const q = this.currentSingleElement; + if (!!q) { + let isFirstInput = true; + let isLastInput = true; + if (this.isSingleVisibleInput) { + const inputState = (q).getSingleInputElementPos(); + if (inputState !== 0) { + isFirstInput = inputState === -1; + isLastInput = inputState === 1; + } + } + const questions = this.getSingleElements(); + const index = questions.indexOf(q); + if (index >= 0) { + fVal = isFirstInput && index === 0; + lVal = isLastInput && index === questions.length - 1; + } + } + return { isFirst: fVal, isLast: lVal }; + } + public changeCurrentPage(newPage: PageModel): boolean { + const curEl = this.currentSingleElement; + if (!!curEl && newPage !== (curEl).page) { + this.currentSingleElement = newPage.getFirstVisibleElement(); + return true; + } + return false; + } + public setCurrentElement(val: ISurveyElement): void { + const page = (val).page; + if (!!page && !this.isSingleVisibleQuestion) { + this.survey.currentPage = page; + } else { + this.currentSingleElement = val; + } + } + public resetToFirstElement(): void { + if (this.currentSingleElement) { + const questions = this.getSingleElements(); + this.currentSingleElement = questions.length > 0 ? questions[0] : undefined; + } + } + public syncWithCurrentPage(): void { + const el: any = this.currentSingleElement; + const curPage = this.survey.currentPage; + if (!!el && !!curPage && el.page !== curPage) { + this.currentSingleElement = curPage.getFirstVisibleElement(); + } + } + public onCancelPreview(): void { + const page = (this.currentSingleElement)?.page; + if (!!page) { + page.updateRows(); + this.survey.currentPage = page; + } + } +} diff --git a/packages/survey-core/src/survey-sticky-controller.ts b/packages/survey-core/src/survey-sticky-controller.ts new file mode 100644 index 0000000000..e4cf487127 --- /dev/null +++ b/packages/survey-core/src/survey-sticky-controller.ts @@ -0,0 +1,76 @@ +export interface ISurveyStickyHost { + rootElement: HTMLElement; + readonly tocModel: { updateStickyTOCSize(rootElement: HTMLElement): void }; + onScrollCallback?: () => void; +} + +export class SurveyStickyController { + private survey: ISurveyStickyHost; + + public scrollerElement: HTMLElement; + public scrollHandler: () => void; + + constructor(survey: ISurveyStickyHost) { + this.survey = survey; + } + + // private _lastScrollTop = 0; + public isElementShouldBeSticky(selector: string): boolean { + if (!selector) return false; + const scrollerElement = this.scrollerElement; + const topStickyContainer = scrollerElement?.querySelector(selector); + if (!!topStickyContainer) { + // const scrollDirection = this.rootElement.scrollTop > this._lastScrollTop ? "down" : "up"; + // this._lastScrollTop = this.rootElement.scrollTop; + return !!scrollerElement && scrollerElement.scrollTop > 0 && topStickyContainer.getBoundingClientRect().y <= scrollerElement.getBoundingClientRect().y; + } + return false; + } + + public onScroll(): void { + const rootElement = this.survey.rootElement; + if (!!rootElement) { + if (this.isElementShouldBeSticky(".sv-components-container-center")) { + rootElement.classList && rootElement.classList.add("sv-root--sticky-top"); + } else { + rootElement.classList && rootElement.classList.remove("sv-root--sticky-top"); + } + const tocModel = this.survey.tocModel; + if (!!tocModel) { + tocModel.updateStickyTOCSize(rootElement); + } + } + if (this.survey.onScrollCallback) { + this.survey.onScrollCallback(); + } + } + + public addScrollEventListener(): void { + const rootElement = this.survey.rootElement; + this.scrollerElement = rootElement.getElementsByClassName("sv-scroll__scroller")[0] as HTMLElement; + const scrollerElement = this.scrollerElement; + this.scrollHandler = () => { this.onScroll(); }; + rootElement.addEventListener("scroll", this.scrollHandler); + if (!!rootElement.getElementsByTagName("form")[0]) { + rootElement.getElementsByTagName("form")[0].addEventListener("scroll", this.scrollHandler); + } + if (!!scrollerElement) { + scrollerElement.addEventListener("scroll", this.scrollHandler); + } + } + + public removeScrollEventListener(): void { + const rootElement = this.survey.rootElement; + const scrollerElement = this.scrollerElement; + if (!!rootElement && !!this.scrollHandler) { + rootElement.removeEventListener("scroll", this.scrollHandler); + if (!!rootElement.getElementsByTagName("form")[0]) { + rootElement.getElementsByTagName("form")[0].removeEventListener("scroll", this.scrollHandler); + } + if (!!scrollerElement) { + scrollerElement.removeEventListener("scroll", this.scrollHandler); + } + } + this.scrollerElement = undefined; + } +} diff --git a/packages/survey-core/src/survey-triggers-controller.ts b/packages/survey-core/src/survey-triggers-controller.ts new file mode 100644 index 0000000000..6fea5a9f03 --- /dev/null +++ b/packages/survey-core/src/survey-triggers-controller.ts @@ -0,0 +1,219 @@ +import { Question } from "./question"; +import { CalculatedValue } from "./calculatedValue"; +import { PageModel } from "./page"; +import { Helpers } from "./helpers"; +import { settings } from "./settings"; +import { SurveyTrigger } from "./trigger"; +import { ISurveyData } from "./base-interfaces"; + +export type TriggersRunType = "trigger" | "questionTrigger" | "condition"; + +export interface ISurveyTriggersHost extends ISurveyData { + triggers: Array; + calculatedValues: CalculatedValue[]; + pages: PageModel[]; + canRunTriggersOrConditions(type: TriggersRunType): boolean; + getQuestionByValueName(name: string): Question; + getAllQuestions(): Question[]; + getCurrentPageQuestions(includeInvisible?: boolean): Array; + runConditionCore(properties: any): void; +} + +export class SurveyTriggersController { + private isTriggerIsRunning: boolean = false; + private triggerKeys: any = null; + private questionTriggersKeys: any; + private isRunningConditionsValue: boolean = false; + private isValueChangedOnRunningCondition: boolean = false; + private conditionRunnerCounter: number = 0; + private onConditionsCompletedActions: { [id: string]: () => void } = {}; + private isRunningConditionOnValueChanged: boolean = false; + + private survey: ISurveyTriggersHost; + + constructor(survey: ISurveyTriggersHost) { + this.survey = survey; + } + + public get isRunningConditions(): boolean { + return this.isRunningConditionsValue; + } + + public deferUntilConditionsCompleted(id: string, func: () => void): boolean { + if (!this.isRunningConditions) return false; + this.onConditionsCompletedActions[id] = func; + return true; + } + + public runTriggers(): void { + this.checkTriggers({}, false, false, false, undefined, true); + } + + public runExpressions(): void { + this.runConditions(); + } + + public getValueChangedKeys(): any { + return this.isRunningConditionOnValueChanged ? this.questionTriggersKeys : undefined; + } + + public checkOnPageTriggers(isOnComplete: boolean): void { + var questions = this.survey.getCurrentPageQuestions(true); + var values: { [index: string]: any } = {}; + for (var i = 0; i < questions.length; i++) { + var question = questions[i]; + var name = question.getValueName(); + values[name] = this.survey.getValue(name); + } + var caclValues = this.survey.calculatedValues; + for (var i = 0; i < caclValues.length; i++) { + values[caclValues[i].name] = caclValues[i].value; + } + this.checkTriggers(values, true, isOnComplete); + } + + public checkTriggers( + key: any, + isOnNextPage: boolean, + isOnComplete: boolean = false, + isOnNavigation: boolean = false, + name?: string, + runAll: boolean = false + ): void { + if (!this.survey.canRunTriggersOrConditions("trigger")) return; + if (this.isTriggerIsRunning) { + for (var k in key) { + this.triggerKeys[k] = key[k]; + } + return; + } + let isQuestionInvalid = false; + if (!isOnComplete && name && this.hasRequiredValidQuestionTrigger) { + const question = this.survey.getQuestionByValueName(name); + isQuestionInvalid = question && !question.validate(false); + } + this.isTriggerIsRunning = true; + this.triggerKeys = key; + const properties = this.survey.getFilteredProperties(); + const options = { + isOnNextPage: isOnNextPage, + isOnComplete: isOnComplete, + isOnNavigation: isOnNavigation, + keys: this.triggerKeys, + properties: properties, + runAll: false + }; + let originalKeys = Helpers.createCopy(this.triggerKeys); + const maxIterations = 3; + for (let i = 0; i < maxIterations; i++) { + options.runAll = runAll && i === 0; + this.runSurveyTriggers(options, isQuestionInvalid); + if ( + !this.survey.canRunTriggersOrConditions("trigger") || + Helpers.isTwoValueEquals(originalKeys, this.triggerKeys) + ) + break; + this.triggerKeys = Helpers.createDiff(this.triggerKeys, originalKeys); + originalKeys = Helpers.createCopy(this.triggerKeys); + } + this.isTriggerIsRunning = false; + } + + private runSurveyTriggers(options: any, isQuestionInvalid: boolean): void { + for (let i = 0; i < this.survey.triggers.length; i++) { + const trigger = this.survey.triggers[i]; + if (isQuestionInvalid && trigger.requireValidQuestion) continue; + options.keys = this.triggerKeys; + trigger.checkExpression(options); + } + } + + public checkTriggersAndRunConditions( + name: string, + newValue: any, + oldValue: any + ): void { + var triggerKeys: { [index: string]: any } = {}; + triggerKeys[name] = { newValue: newValue, oldValue: oldValue }; + this.runConditionOnValueChanged(name, newValue); + this.checkTriggers(triggerKeys, false, false, false, name); + } + + private get hasRequiredValidQuestionTrigger(): boolean { + for (let i = 0; i < this.survey.triggers.length; i++) { + if (this.survey.triggers[i].requireValidQuestion) return true; + } + return false; + } + + private runConditions(): void { + if ( + !this.survey.canRunTriggersOrConditions("condition") || + this.isRunningConditions + ) + return; + this.isRunningConditionsValue = true; + var properties = this.survey.getFilteredProperties(); + this.runConditionsCore(properties); + this.isRunningConditionsValue = false; + if ( + this.isValueChangedOnRunningCondition && + this.conditionRunnerCounter < settings.maxConditionRunCountOnValueChanged + ) { + this.isValueChangedOnRunningCondition = false; + this.conditionRunnerCounter++; + this.runConditions(); + } else { + this.isValueChangedOnRunningCondition = false; + this.conditionRunnerCounter = 0; + const actions = this.onConditionsCompletedActions; + this.onConditionsCompletedActions = {}; + Object.keys(actions).forEach(id => actions[id]()); + if (!this.isRunningConditionOnValueChanged) { + this.questionTriggersKeys = undefined; + } + } + } + + private runConditionOnValueChanged(name: string, value: any): void { + if (!this.questionTriggersKeys) { + this.questionTriggersKeys = {}; + } + this.questionTriggersKeys[name] = value; + if (this.isRunningConditions) { + this.isValueChangedOnRunningCondition = true; + } else { + this.isRunningConditionOnValueChanged = true; + this.runConditions(); + this.isRunningConditionOnValueChanged = false; + this.runQuestionsTriggers(name, value); + this.questionTriggersKeys = undefined; + } + } + + private runConditionsCore(properties: any): void { + var pages = this.survey.pages; + for (var i = 0; i < this.survey.calculatedValues.length; i++) { + this.survey.calculatedValues[i].resetCalculation(); + } + for (var i = 0; i < this.survey.calculatedValues.length; i++) { + this.survey.calculatedValues[i].doCalculation( + this.survey.calculatedValues, + properties + ); + } + this.survey.runConditionCore(properties); + for (let i = 0; i < pages.length; i++) { + pages[i].runCondition(properties); + } + } + + private runQuestionsTriggers(name: string, value: any): void { + if (!this.survey.canRunTriggersOrConditions("questionTrigger")) return; + const questions = this.survey.getAllQuestions(); + questions.forEach(q => { + q.runTriggers(name, value, this.questionTriggersKeys); + }); + } + +} diff --git a/packages/survey-core/src/survey-validation-controller.ts b/packages/survey-core/src/survey-validation-controller.ts new file mode 100644 index 0000000000..de98f872e6 --- /dev/null +++ b/packages/survey-core/src/survey-validation-controller.ts @@ -0,0 +1,320 @@ +import { Helpers } from "./helpers"; +import { PageModel } from "./page"; +import { PanelModelBase } from "./panel"; +import { Question, ValidationContext } from "./question"; +import { SurveyError } from "./survey-error"; +import { CustomError } from "./error"; +import { ServerValidateQuestionsEvent, ValidatePageEvent } from "./survey-events-api"; + +export interface ISurveyValidationHost { + activePage: PageModel; + currentPage: PageModel; + visiblePages: Array; + isLastPage: boolean; + autoFocusFirstError: boolean; + isValidateOnComplete: boolean; + isValidateOnValueChanging: boolean; + isValidateOnValueChanged: boolean; + onValidatePage: { isEmpty: boolean, fire(sender: any, options: ValidatePageEvent): void }; + onServerValidateQuestions: { isEmpty: boolean, length: number, fireByCreatingOptions(sender: any, createOptions: () => ServerValidateQuestionsEvent): void }; + canSkipValidation(doComplete: boolean, isPreview?: boolean): boolean; + getServerValidationData(allData: boolean): { [index: string]: any }; + proceedWithNavigation(doComplete: boolean, isPreview: boolean): void; + navigateAfterServerValidation(isPreview: boolean, page: PageModel): void; + setIsValidatingOnServer(val: boolean): void; +} + +export class SurveyValidationController { + private serverValidationEventCount: number; + + private survey: ISurveyValidationHost; + + constructor(survey: ISurveyValidationHost) { + this.survey = survey; + } + + private get isValidateOnValueChange(): boolean { + return this.survey.isValidateOnValueChanged || this.survey.isValidateOnValueChanging; + } + + //#region Validation primitives + public validateElements(elements: Array, fireCallback: boolean = true, focusFirstError: boolean = false, onAsyncValidation?: (hasErrors: boolean) => void, changeCurrentPage?: boolean): boolean { + if (!!onAsyncValidation) { + fireCallback = true; + } + const callbackResult = !!onAsyncValidation ? (res: boolean) => { onAsyncValidation(!res); } : undefined; + const context = new ValidationContext({ fireCallback: fireCallback, focusOnFirstError: focusFirstError, callbackResult: callbackResult, changeCurrentPage: !!changeCurrentPage }); + for (const element of elements) { + element.validateElement(context); + } + context.finish(); + return context.runningResult; + } + /** + * Core page validation logic. Handles async callbacks and event firing. + */ + public validatePage(page: PageModel, isFocusOnFirstError?: boolean, onAsyncValidation?: (hasErrors: boolean) => void): boolean { + if (isFocusOnFirstError === undefined) { + isFocusOnFirstError = this.survey.autoFocusFirstError; + } + if (!page) return true; + + let callback: any = undefined; + let syncCallbackHasErrors: boolean | undefined; + + if (onAsyncValidation) { + callback = (res: boolean) => { + if (syncCallbackHasErrors === undefined) { + syncCallbackHasErrors = !res; + } else { + const handlerHasErrors = this.fireValidatedErrorsOnPage(page); + onAsyncValidation(!res || handlerHasErrors); + } + }; + } + + const res = page.validate(true, isFocusOnFirstError, callback); + + if (syncCallbackHasErrors === undefined && onAsyncValidation) { + syncCallbackHasErrors = false; + return res; + } + + const handlerHasErrors = this.fireValidatedErrorsOnPage(page); + if (onAsyncValidation && syncCallbackHasErrors !== undefined) { + onAsyncValidation(syncCallbackHasErrors || handlerHasErrors); + } + + return res && !handlerHasErrors; + } + /** + * Fires the onValidatePage event for any errors on the page. + * Returns true if the event handler added new errors. + */ + private fireValidatedErrorsOnPage(page: PageModel): boolean { + if (this.survey.onValidatePage.isEmpty || !page) return false; + + const questionsOnPage = new Array(); + page.questions.forEach(q => { + q.getNestedQuestions(true, true, true).forEach(nQ => questionsOnPage.push(nQ)); + }); + const questions = new Array(); + const errors = new Array(); + + for (let i = 0; i < questionsOnPage.length; i++) { + const q = questionsOnPage[i]; + if (q.errors.length > 0) { + questions.push(q); + for (let j = 0; j < q.errors.length; j++) { + errors.push(q.errors[j]); + } + } + } + + const errorsCountBeforeFire = errors.length; + this.survey.onValidatePage.fire(this.survey, { + questions: questions, + errors: errors, + page: page, + }); + + return errors.length > errorsCountBeforeFire; + } + //#endregion + + //#region Validation orchestration (navigation flow) + /** + * Validates before allowing page navigation. Decides what validation is needed + * based on checkErrorsMode and other validation settings. + */ + public validateOnNavigate(doComplete: boolean, isPreview?: boolean): boolean { + if (this.survey.canSkipValidation(doComplete, isPreview)) { + this.survey.proceedWithNavigation(doComplete, isPreview); + return true; + } + + const onAsyncValidationComplete = (hasErrors: boolean) => { + if (!hasErrors) { + this.survey.proceedWithNavigation(doComplete, isPreview); + } + }; + + if (this.survey.isValidateOnComplete) { + return this.validateAllPagesBeforeCompletion(onAsyncValidationComplete); + } + + return this.validateCurrentPageBeforeNavigation(onAsyncValidationComplete); + } + /** + * Validates the current page before proceeding with navigation. + */ + private validateCurrentPageBeforeNavigation(onComplete: (hasErrors: boolean) => void): boolean { + return this.validatePage(this.survey.activePage, true, onComplete) !== false; + } + /** + * Validates all pages before completion (when using onComplete validation mode). + * Returns false if validation has async operations, true if validation complete. + */ + private validateAllPagesBeforeCompletion(onComplete: (hasErrors: boolean) => void): boolean { + // On non-final pages, skip validation and proceed + if (!this.survey.isLastPage) { + this.survey.proceedWithNavigation(true, false); + return true; + } + + // On final page, validate all pages + return this.validateElements(this.survey.visiblePages, true, this.survey.autoFocusFirstError, onComplete, true) !== true; + } + //#endregion + + //#region Page navigation validation + /** + * Validates all pages between `startIndex` and `targetIndex` (exclusive). + */ + public validateIntermediatePages(startIndex: number, targetIndex: number): boolean { + for (let i = startIndex; i < targetIndex; i++) { + const page = this.survey.visiblePages[i]; + if (!page.validate(true, true)) return false; + page.passed = true; + } + return true; + } + //#endregion + + //#region Server validation + public doServerValidation(doComplete: boolean, isPreview: boolean = false, page?: PageModel): boolean { + const serverValidateEvent = this.survey.onServerValidateQuestions; + if (!serverValidateEvent || serverValidateEvent.isEmpty) return false; + if (!doComplete && this.survey.isValidateOnComplete) return false; + this.survey.setIsValidatingOnServer(true); + const isFunc = typeof serverValidateEvent === "function"; + this.serverValidationEventCount = !isFunc ? serverValidateEvent.length : 1; + if (isFunc) { + (serverValidateEvent)(this.survey, this.createServerValidationOptions(doComplete, isPreview, page)); + } else { + serverValidateEvent.fireByCreatingOptions(this.survey, () => { return this.createServerValidationOptions(doComplete, isPreview, page); }); + } + return true; + } + private createServerValidationOptions(doComplete: boolean, isPreview: boolean, page: PageModel): ServerValidateQuestionsEvent { + var self = this; + const options = { + data: <{ [index: string]: any }>{}, + errors: {}, + survey: this.survey, + complete: function () { + self.completeServerValidation(options, isPreview, page); + }, + }; + options.data = this.survey.getServerValidationData(doComplete && this.survey.isValidateOnComplete); + return options; + } + private completeServerValidation(options: any, isPreview: boolean, page: PageModel) { + if (this.serverValidationEventCount > 1) { + this.serverValidationEventCount--; + if (!!options && !!options.errors && Object.keys(options.errors).length === 0) return; + } + this.serverValidationEventCount = 0; + this.survey.setIsValidatingOnServer(false); + if (!options || !options.survey) return; + var self = options.survey; + let isValid = true; + if (options.errors) { + var hasToFocus = this.survey.autoFocusFirstError; + for (var name in options.errors) { + var question = self.getQuestionByName(name); + if (question && question["errors"]) { + isValid = false; + question.addError(new CustomError(options.errors[name], this.survey)); + if (hasToFocus) { + hasToFocus = false; + if (!!question.page) { + this.survey.currentPage = question.page; + } + question.focus(true); + } + } + } + this.fireValidatedErrorsOnPage(this.survey.currentPage); + } + if (isValid) { + this.survey.navigateAfterServerValidation(isPreview, page); + } + } + //#endregion + + //#region Question value change validation + /** + * Validates a question when its value changes, respecting validation settings. + */ + public validateQuestionOnValueChanged(question: Question): void { + if (this.shouldValidateQuestionOnChange(question)) { + this.validateQuestionOnValueChangedCore(question); + return; + } + + this.validateParentPanelsIfNeeded(question.parent); + } + /** + * Determines if a question should be validated when its value changes. + */ + private shouldValidateQuestionOnChange(question: Question): boolean { + return ( + this.survey.isValidateOnValueChanged || + question.getAllErrors().length > 0 + ); + } + /** + * Validates parent panels up the hierarchy if they have errors. + */ + private validateParentPanelsIfNeeded(parent: any): void { + let panelParent = parent as PanelModelBase; + while(!!panelParent) { + if (panelParent.errors && panelParent.errors.length > 0) { + panelParent.validateContainerOnly(); + return; + } + panelParent = panelParent.parent as PanelModelBase; + } + } + /** + * Core validation logic for a question value change. + * Fires page events if needed. + */ + private validateQuestionOnValueChangedCore(question: Question): boolean { + const oldErrorCount = question.getAllErrors().length; + const res = question.validate( + true, + false, + !this.survey.isValidateOnValueChanging, + undefined, + this.survey.isValidateOnValueChanging + ); + + if ( + !!question.page && + this.isValidateOnValueChange && + (oldErrorCount > 0 || question.getAllErrors().length > 0) + ) { + this.fireValidatedErrorsOnPage(question.page as PageModel); + } + + return res; + } + /** + * Validates multiple questions bound to the same value. + * Used when multiple questions share a value name. + */ + public validateQuestionsOnValueChanging(questions: Array, newValue: any): boolean { + let res = true; + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + if (!Helpers.isTwoValueEquals(q.valueForSurvey, newValue)) { + q.value = newValue; + } + res = this.validateQuestionOnValueChangedCore(q) && res && q.errors.length === 0; + } + return res; + } + //#endregion +} diff --git a/packages/survey-core/src/survey.ts b/packages/survey-core/src/survey.ts index 0936bf204f..701c1d8b8a 100644 --- a/packages/survey-core/src/survey.ts +++ b/packages/survey-core/src/survey.ts @@ -33,6 +33,14 @@ import { SurveyElementCore, SurveyElement } from "./survey-element"; import { surveyCss } from "./defaultCss/defaultCss"; import { ISurveyTriggerOwner, SurveyTrigger, Trigger } from "./trigger"; import { CalculatedValue } from "./calculatedValue"; +import { SurveyTriggersController, TriggersRunType } from "./survey-triggers-controller"; +import { SurveyLazyRenderingController, ISurveyLazyRenderingHost } from "./survey-lazy-rendering-controller"; +import { SurveyCompletionController, ISurveyCompletionHost } from "./survey-completion-controller"; +import { SurveyValidationController, ISurveyValidationHost } from "./survey-validation-controller"; +import { SurveySingleInputController, ISurveySingleInputHost } from "./survey-single-input-controller"; +import { SurveyPageStructureController, ISurveyPageStructureHost } from "./survey-page-structure-controller"; +import { SurveyScrollFocusController, ISurveyScrollFocusHost } from "./survey-scroll-focus-controller"; +import { SurveyStickyController, ISurveyStickyHost } from "./survey-sticky-controller"; import { PageModel } from "./page"; import { TextContextProcessor, TextPreProcessorValue } from "./textPreProcessor"; import { IValueGetterContext, IValueGetterContextGetValueParams, IValueGetterInfo, PropertyGetterContext, ValueGetter, ValueGetterContextCore, VariableGetterContext } from "./conditions/conditionProcessValue"; @@ -41,7 +49,7 @@ import { CustomError } from "./error"; import { LocalizableString } from "./localizablestring"; // import { StylesManager } from "./stylesmanager"; import { SurveyTimerModel, ISurveyTimerText } from "./surveyTimerModel"; -import { IQuestionPlainData, Question, ValidationContext } from "./question"; +import { IQuestionPlainData, Question } from "./question"; import { QuestionSelectBase } from "./question_baseselect"; import { ItemValue } from "./itemvalue"; import { PanelModelBase, PanelModel, QuestionRowModel } from "./panel"; @@ -1262,6 +1270,68 @@ export class SurveyModel extends SurveyElementCore } return this.navigationLayoutModelValue; } + private triggersControllerValue: SurveyTriggersController | undefined; + private get triggersController(): SurveyTriggersController { + if (!this.triggersControllerValue) { + this.triggersControllerValue = new SurveyTriggersController(this); + } + return this.triggersControllerValue; + } + private lazyRenderingControllerValue: SurveyLazyRenderingController | undefined; + private get lazyRenderingController(): SurveyLazyRenderingController { + if (!this.lazyRenderingControllerValue) { + this.lazyRenderingControllerValue = new SurveyLazyRenderingController(this); + } + return this.lazyRenderingControllerValue; + } + private completionControllerValue: SurveyCompletionController | undefined; + private get completionController(): SurveyCompletionController { + if (!this.completionControllerValue) { + this.completionControllerValue = new SurveyCompletionController(this); + } + return this.completionControllerValue; + } + private validationControllerValue: SurveyValidationController | undefined; + private get validationController(): SurveyValidationController { + if (!this.validationControllerValue) { + this.validationControllerValue = new SurveyValidationController(this); + } + return this.validationControllerValue; + } + private singleInputControllerValue: SurveySingleInputController | undefined; + private get singleInputController(): SurveySingleInputController { + if (!this.singleInputControllerValue) { + this.singleInputControllerValue = new SurveySingleInputController(this); + } + return this.singleInputControllerValue; + } + private pageStructureControllerValue: SurveyPageStructureController | undefined; + private get pageStructureController(): SurveyPageStructureController { + if (!this.pageStructureControllerValue) { + this.pageStructureControllerValue = new SurveyPageStructureController(this); + } + return this.pageStructureControllerValue; + } + private scrollFocusControllerValue: SurveyScrollFocusController | undefined; + private get scrollFocusController(): SurveyScrollFocusController { + if (!this.scrollFocusControllerValue) { + this.scrollFocusControllerValue = new SurveyScrollFocusController(this); + } + return this.scrollFocusControllerValue; + } + private stickyControllerValue: SurveyStickyController | undefined; + private get stickyController(): SurveyStickyController { + if (!this.stickyControllerValue) { + this.stickyControllerValue = new SurveyStickyController(this); + } + return this.stickyControllerValue; + } + private canRunTriggersOrConditions(type: TriggersRunType): boolean { + if (this.isCompleted) return false; + if (type === "trigger") return !this.isDisplayMode && this.triggers.length > 0; + if (type === "questionTrigger") return !this.isDisplayMode && !this.isDesignMode; + return this.isEndLoadingFromJson !== "processing"; + } @property() sjsVersion: string; @property() $schema: string; processClosedPopup(question: IQuestion, popupModel: PopupModel): void { @@ -1273,7 +1343,7 @@ export class SurveyModel extends SurveyElementCore title: this.getLocalizationString("saveAgainButton"), action: () => { if (this.isCompleted) { - this.saveDataOnComplete(); + this.completionController.saveDataOnComplete(); } else { this.doComplete(); } @@ -1467,7 +1537,6 @@ export class SurveyModel extends SurveyElementCore */ public showHeaderOnCompletePage: true | false | "auto" = "auto"; - private lazyRenderEnabledValue: boolean; @property() showBrandInfo: boolean; @property() enterKeyAction: "moveToNextEditor" | "loseFocus" | "default"; /** @@ -1481,15 +1550,10 @@ export class SurveyModel extends SurveyElementCore * @see [settings.lazyRender](https://surveyjs.io/form-library/documentation/api-reference/settings#lazyRender) */ public get lazyRenderEnabled(): boolean { - return this.lazyRenderEnabledValue === true; + return this.lazyRenderingController.enabled; } public set lazyRenderEnabled(val: boolean) { - if (this.lazyRenderEnabled === val) return; - this.lazyRenderEnabledValue = val; - const page: PageModel = this.currentPage; - if (!!page) { - page.updateRows(); - } + this.lazyRenderingController.enabled = val; } /** * @deprecated Use the [`lazyRenderEnabled`](https://surveyjs.io/form-library/documentation/api-reference/survey-data-model#lazyRenderEnabled) property instead. @@ -1501,35 +1565,16 @@ export class SurveyModel extends SurveyElementCore this.lazyRenderEnabled = val; } public get isLazyRendering(): boolean { - return this.lazyRenderEnabled || settings.lazyRender.enabled; + return this.lazyRenderingController.isLazyRendering; } - @property() lazyRenderFirstBatchSizeValue: number; public get lazyRenderFirstBatchSize(): number { - return this.lazyRenderFirstBatchSizeValue || settings.lazyRender.firstBatchSize; + return this.lazyRenderingController.firstBatchSize; } public set lazyRenderFirstBatchSize(val: number) { - this.lazyRenderFirstBatchSizeValue = val; + this.lazyRenderingController.firstBatchSize = val; } - - protected _isLazyRenderingSuspended = false; public get isLazyRenderingSuspended(): boolean { - return this._isLazyRenderingSuspended; - } - protected suspendLazyRendering(): void { - if (!this.isLazyRendering) return; - this._isLazyRenderingSuspended = true; - } - protected releaseLazyRendering(): void { - if (!this.isLazyRendering) return; - this._isLazyRenderingSuspended = false; - } - private updateLazyRenderingRowsOnRemovingElements() { - if (!this.isLazyRendering) return; - var page = this.currentPage; - if (!!page) { - const htmlElement = (this.rootElement || this.creator?.rootElement)?.querySelector(`#${page.id}`); - activateLazyRenderingChecks(htmlElement); - } + return this.lazyRenderingController.isSuspended; } /** * A list of triggers in the survey. @@ -2475,7 +2520,7 @@ export class SurveyModel extends SurveyElementCore * @see onTriggerExecuted */ public runTriggers(): void { - this.checkTriggers(this.getFilteredValues(), false); + this.triggersController.runTriggers(); } public get renderedCompletedHtml(): string { var item = this.getExpressionItemOnRunCondition( @@ -3386,31 +3431,6 @@ export class SurveyModel extends SurveyElementCore } return result; } - getFilteredValues(): any { - const values: { [index: string]: any } = {}; - for (var key in this.variablesHash) values[key] = this.variablesHash[key]; - this.addCalculatedValuesIntoFilteredValues(values); - if (!this.isDesignMode) { - const keys = this.getValuesKeys(); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - values[key] = this.getDataValueCore(this.valuesHash, key); - } - this.getAllQuestions().forEach(q => { - if (q.hasFilteredValue) { - values[q.getFilteredName()] = q.getFilteredValue(true); - } - }); - } - return values; - } - private addCalculatedValuesIntoFilteredValues(values: { - [index: string]: any, - }) { - var caclValues = this.calculatedValues; - for (var i = 0; i < caclValues.length; i++) - values[caclValues[i].name] = caclValues[i].value; - } getFilteredProperties(): any { return { survey: this }; } @@ -3487,7 +3507,8 @@ export class SurveyModel extends SurveyElementCore */ public get visiblePages(): Array { if (this.isDesignMode) return this.pages; - if (!!this.pageContainerValue && (this.isShowingPreview || this.isSinglePage)) return [this.pageContainerValue]; + const pageContainer = this.pageStructureController.pageContainer; + if (!!pageContainer && (this.isShowingPreview || this.isSinglePage)) return [pageContainer]; var result = new Array(); for (var i = 0; i < this.pages.length; i++) { if (this.isPageInVisibleList(this.pages[i])) { @@ -3575,11 +3596,7 @@ export class SurveyModel extends SurveyElementCore var vPages = this.visiblePages; if (newPage != null && vPages.indexOf(newPage) < 0) return; if (newPage == this.currentPage) return; - const curEl = this.currentSingleElement; - if (!this.isShowingPreview && !!curEl && newPage !== (curEl).page) { - this.currentSingleElement = newPage.getFirstVisibleElement(); - return; - } + if (!this.isShowingPreview && this.singleInputController.changeCurrentPage(newPage)) return; let oldValue = this.currentPage; const options = this.createPageChangeEventOptions(newPage, oldValue); const changingFunc = () => { @@ -3593,7 +3610,7 @@ export class SurveyModel extends SurveyElementCore this.currentPageChanged(newPage, oldValue); } }; - if (this.isShowingPreview || !!curEl) { + if (this.isShowingPreview || !!this.currentSingleElement) { changingFunc(); } else { this.currentPageChanging(options, changingFunc); @@ -3638,12 +3655,7 @@ export class SurveyModel extends SurveyElementCore if (val.isPage) { this.currentPage = val; } else { - const page = (val).page; - if (!!page && !this.isSingleVisibleQuestion) { - this.currentPage = page; - } else { - this.currentSingleElement = val; - } + this.singleInputController.setCurrentElement(val); } } } @@ -3666,23 +3678,27 @@ export class SurveyModel extends SurveyElementCore } return res; } + /** + * Validates before allowing page navigation to `page`. + * Handles validation of intermediate pages when navigating forward. + */ private performValidationOnPageChanging(page: PageModel): boolean { if (this.isDesignMode) return false; if (this.canGoTroughValidation()) return true; - const index = this.visiblePages.indexOf(page); - if (index < 0 || index >= this.visiblePageCount) return false; - if (index === this.currentPageNo) return false; - if (index < this.currentPageNo || this.checkErrorsMode === "onComplete" || this.validationAllowSwitchPages) - return true; - if (!this.validateCurrentPage()) return false; - for (let i = this.currentPageNo + 1; i < index; i++) { - const page = this.visiblePages[i]; - if (!page.validate(true, true)) return false; - page.passed = true; - } - return true; - } + const targetIndex = this.visiblePages.indexOf(page); + if (targetIndex < 0) return false; + if (targetIndex === this.currentPageNo) return false; + + // Skip validation for backward navigation or when checkErrorsMode is "onComplete" + if (this.isNavigatingBackwardOrSkipping(targetIndex)) return true; + + // Validate current page before proceeding forward + if (!this.validationController.validatePage(this.activePage, true)) return false; + + // Validate all intermediate pages + return this.validationController.validateIntermediatePages(this.currentPageNo + 1, targetIndex); + } private updateCurrentPage(): void { if (this.isCurrentPageAvailable) return; this.currentPage = this.firstVisiblePage; @@ -3789,23 +3805,10 @@ export class SurveyModel extends SurveyElementCore * @see autoFocusFirstQuestion */ public focusFirstQuestion() { - if (this.focusingQuestionInfo) return; - var page = this.activePage; - if (page) { - page.scrollToTop(); - page.focusFirstQuestion(); - } + this.scrollFocusController.focusFirstQuestion(); } scrollToTopOnPageChange(doScroll: boolean = true): void { - var page = this.activePage; - if (!page) return; - if (doScroll) { - page.scrollToTop(); - } - if (this.isCurrentPageRendering && this.autoFocusFirstQuestion && !this.focusingQuestionInfo) { - page.focusFirstQuestion(); - this.isCurrentPageRendering = false; - } + this.scrollFocusController.scrollToTopOnPageChange(doScroll); } /** * Returns the current survey state. @@ -3839,14 +3842,44 @@ export class SurveyModel extends SurveyElementCore if (this.isShowingPreview) return this.currentPage ? "preview" : "empty"; return this.currentPage ? "running" : "empty"; } - @property({ defaultValue: false }) private isCompleted: boolean; + @property({ + defaultValue: false, + onSetting: (val: boolean, target: SurveyModel, prevVal?: boolean) => { + if (val === true && prevVal !== true) { + target.onIsCompletedSetting(); + } + return val; + }, + onSet: (val: boolean, target: SurveyModel) => { + if (val === true) { + target.onIsCompletedSet(); + } + } + }) private isCompleted: boolean; + private onIsCompletedSetting(): void { + this.triggersController.checkOnPageTriggers(true); + this.stopTimer(); + this.notifyQuestionsOnHidingContent(this.currentPage); + } + private onIsCompletedSet(): void { + this.cancelPreview(); + this.clearUnusedValues(); + } private get isShowingPreview(): boolean { return this.getPropertyValue("isShowingPreview", false); } private set isShowingPreview(val: boolean) { if (this.isShowingPreview == val) return; this.setPropertyValue("isShowingPreview", val); - this.onShowingPreviewChanged(); + this.updatePagesContainer(); + } + private updatePagesContainer(): void { + if (!this.isDesignMode) { + this.getAllQuestions().forEach(q => q.updateElementVisibility()); + this.setPropertyValue("currentPage", undefined); + this.pageStructureController.updatePagesContainer(); + this.updateButtonsVisibility(); + } } @property({ defaultValue: false }) private isStartedState: boolean; @property({ defaultValue: false }) private isCompletedBefore: boolean; @@ -3914,7 +3947,7 @@ export class SurveyModel extends SurveyElementCore this.isCompleted = false; this.isCompletedBefore = false; this.isLoading = false; - this.completedByTriggers = undefined; + this.completionController.resetCompletedByTriggers(); this.skippedPages = []; this.lastActiveQuestion = undefined; if (clearData) { @@ -3929,10 +3962,7 @@ export class SurveyModel extends SurveyElementCore this.onFirstPageIsStartedChanged(); if (goToFirstPage) { this.currentPage = this.firstVisiblePage; - if (this.currentSingleElement) { - const questions = this.getSingleElements(); - this.currentSingleElement = questions.length > 0 ? questions[0] : undefined; - } + this.singleInputController.resetToFirstElement(); } if (clearData) { this.updateValuesWithDefaults(); @@ -4008,7 +4038,7 @@ export class SurveyModel extends SurveyElementCore isPrevPage: diff === -1, isGoingForward: qDiff > 0, isGoingBackward: qDiff < 0, - isAfterPreview: this.changeCurrentPageFromPreview === true + isAfterPreview: this.pageStructureController.changeCurrentPageFromPreview === true }; } public getProgress(): number { @@ -4126,9 +4156,7 @@ export class SurveyModel extends SurveyElementCore * @see deleteCookie */ public get hasCookie(): boolean { - if (!this.cookieName) return false; - var cookies = DomDocumentHelper.getCookie(); - return cookies && cookies.indexOf(this.cookieName + "=true") > -1; + return this.completionController.hasCookie; } /** * Sets a cookie with a specified [`cookieName`](https://surveyjs.io/form-library/documentation/api-reference/survey-data-model#cookieName) in the browser. If the `cookieName` property value is defined, this method is automatically called on survey completion. @@ -4136,8 +4164,7 @@ export class SurveyModel extends SurveyElementCore * @see deleteCookie */ public setCookie() { - if (!this.cookieName) return; - DomDocumentHelper.setCookie(this.cookieName + "=true; expires=Fri, 31 Dec 9999 0:0:0 GMT"); + this.completionController.setCookie(); } /** * Deletes a cookie with a specified [`cookieName`](https://surveyjs.io/form-library/documentation/api-reference/survey-data-model#cookieName) from the browser. @@ -4145,8 +4172,7 @@ export class SurveyModel extends SurveyElementCore * @see setCookie */ public deleteCookie() { - if (!this.cookieName) return; - DomDocumentHelper.setCookie(this.cookieName + "=;"); + this.completionController.deleteCookie(); } /** * @deprecated Use the [`validationEnabled`](https://surveyjs.io/form-library/documentation/api-reference/survey-data-model#validationEnabled) property instead. @@ -4190,86 +4216,66 @@ export class SurveyModel extends SurveyElementCore return this.doCurrentPageComplete(false); } public performNext(): boolean { - const q: any = this.currentSingleElement; - if (!q) return this.nextPage(); + if (!this.currentSingleElement) return this.nextPage(); this.resetNavigationButton(); - if (this.isSingleVisibleInput) { - if (!q.validateSingleInput()) return false; - if (q.nextSingleInput()) { - this.updateButtonsVisibility(); - return true; - } - } + return this.singleInputController.performNext(); + } + private validateSingleElement(el: Question | PanelModelBase, onComplete: (hasErrors: boolean) => void): boolean { if (!this.validationEnabled) { - this.performNextAfterValidation(q); + onComplete(false); return true; } - const res = this.validateElements([q], true, false, (hasErrors: boolean) => { - if (!hasErrors) { - this.performNextAfterValidation(q); - } - }); - return res === true; - } - private performNextAfterValidation(q: Question): boolean { - this.sendPartialResult(); - const questions = this.getSingleElements(); - const index = questions.indexOf(q); - if (index < 0 || index === questions.length - 1) return false; - let keys: any = {}; - if (q.isQuestion) { - keys[q.name] = q.value; - } else { - if (q.isPanel) { - keys = q.getValue(); - } - } - this.checkTriggers(keys, true, false, true, q.name); - if (q === this.currentSingleElement) { - this.currentSingleElement = questions[index + 1]; - } + return this.validationController.validateElements([el], true, false, onComplete); } public performPrevious(): boolean { return this.prevPage(); } - private validateOnNavigate(doComplete: boolean, isPreview?: boolean): boolean { - const skipValidation = this.canGoTroughValidation() || doComplete && this.validationAllowComplete - || !doComplete && (this.validationAllowSwitchPages || this.isValidateOnComplete) || isPreview && this.isValidateOnComplete; - const doFunc = (): void => { - if (isPreview) { - this.showPreviewCore(); - } else { - this.doCurrentPageCompleteCore(doComplete); - } - }; - if (skipValidation) { - doFunc(); - return true; - } - const func = (hasErrors: boolean) => { - if (!hasErrors) { - doFunc(); - } - }; - if (this.isValidateOnComplete) { - if (!this.isLastPage) { - doFunc(); - return true; - } - return this.validate(true, this.autoFocusFirstError, func, true) !== true; + + /** + * Performs the post-validation navigation action (show preview or complete page). + */ + private proceedWithNavigation(doComplete: boolean, isPreview: boolean): void { + if (isPreview) { + this.showPreviewCore(); + } else { + this.doCurrentPageCompleteCore(doComplete); } - return this.validateCurrentPage(func) !== false; } private canGoTroughValidation(): boolean { return !this.isEditMode || !this.validationEnabled; } + /** + * Determines if validation can be skipped based on mode and settings. + */ + private canSkipValidation(doComplete: boolean, isPreview?: boolean): boolean { + // Skip if not in edit mode or validation is disabled + if (this.canGoTroughValidation()) return true; + + // Skip based on validation settings + if (doComplete && this.validationAllowComplete) return true; + if (!doComplete && (this.validationAllowSwitchPages || this.isValidateOnComplete)) return true; + if (isPreview && this.isValidateOnComplete) return true; + + return false; + } + /** + * Determines if we should skip validation based on navigation direction + * or validation settings. + */ + private isNavigatingBackwardOrSkipping(targetIndex: number): boolean { + return ( + targetIndex < this.currentPageNo || + this.checkErrorsMode === "onComplete" || + this.validationAllowSwitchPages + ); + } public get isCurrentPageHasErrors(): boolean { - return this.validateActivePage() === false; + return this.validationController.validatePage(this.activePage) === false; } /** * Returns `true` if the current page does not contain errors. * @see currentPage */ public get isCurrentPageValid(): boolean { - return this.validateActivePage(); + return this.validationController.validatePage(this.activePage); } public hasCurrentPageErrors(onAsyncValidation?: (hasErrors: boolean) => void): boolean { return this.hasPageErrors(undefined, onAsyncValidation); @@ -4305,7 +4311,7 @@ export class SurveyModel extends SurveyElementCore page = this.activePage; } if (!page) return true; - return this.validatePageCore(page, true, onAsyncValidation); + return this.validationController.validatePage(page, true, onAsyncValidation); } public hasErrors(fireCallback: boolean = true, focusOnFirstError: boolean = false, onAsyncValidation?: (hasErrors: boolean) => void): boolean { const res = this.validate(fireCallback, focusOnFirstError, onAsyncValidation); @@ -4323,19 +4329,7 @@ export class SurveyModel extends SurveyElementCore * @see validatePage */ public validate(fireCallback: boolean = true, focusFirstError: boolean = false, onAsyncValidation?: (hasErrors: boolean) => void, changeCurrentPage?: boolean): boolean { - return this.validateElements(this.visiblePages, fireCallback, focusFirstError, onAsyncValidation, changeCurrentPage); - } - private validateElements(elements: Array, fireCallback: boolean = true, focusFirstError: boolean = false, onAsyncValidation?: (hasErrors: boolean) => void, changeCurrentPage?: boolean): boolean { - if (!!onAsyncValidation) { - fireCallback = true; - } - const callbackResult = !!onAsyncValidation ? (res: boolean) => { onAsyncValidation(!res); } : undefined; - const context = new ValidationContext({ fireCallback: fireCallback, focusOnFirstError: focusFirstError, callbackResult: callbackResult, changeCurrentPage: !!changeCurrentPage }); - for (const element of elements) { - element.validateElement(context); - } - context.finish(); - return context.runningResult; + return this.validationController.validateElements(this.visiblePages, fireCallback, focusFirstError, onAsyncValidation, changeCurrentPage); } public ensureUniqueNames(element: ISurveyElement = null): void { if (element == null) { @@ -4411,59 +4405,6 @@ export class SurveyModel extends SurveyElementCore num++; return base + num; } - private validateActivePage(isFocusOnFirstError?: boolean): boolean { - return this.validatePageCore(this.activePage, isFocusOnFirstError); - } - private validatePageCore(page: PageModel, isFocusOnFirstError?: boolean, onAsyncValidation?: (hasErrors: boolean) => void): boolean { - if (isFocusOnFirstError === undefined) { - isFocusOnFirstError = this.focusOnFirstError; - } - if (!page) return true; - let callback: any = undefined; - let syncCallbackHasErrors: boolean | undefined; - if (onAsyncValidation) { - callback = (res: boolean) => { - if (syncCallbackHasErrors === undefined) { - syncCallbackHasErrors = !res; - } else { - const handlerHasErrors = this.fireValidatedErrorsOnPage(page); - onAsyncValidation(!res || handlerHasErrors); - } - }; - } - const res = page.validate(true, isFocusOnFirstError, callback); - if (syncCallbackHasErrors === undefined && onAsyncValidation) { - syncCallbackHasErrors = false; - return res; - } - const handlerHasErrors = this.fireValidatedErrorsOnPage(page); - if (onAsyncValidation && syncCallbackHasErrors !== undefined) { - onAsyncValidation(syncCallbackHasErrors || handlerHasErrors); - } - return res && !handlerHasErrors; - } - private fireValidatedErrorsOnPage(page: PageModel): boolean { - if (this.onValidatePage.isEmpty || !page) return false; - const questionsOnPage = this.getNestedQuestionsByQuestionArray(page.questions, true); - var questions = new Array(); - var errors = new Array(); - for (var i = 0; i < questionsOnPage.length; i++) { - var q = questionsOnPage[i]; - if (q.errors.length > 0) { - questions.push(q); - for (var j = 0; j < q.errors.length; j++) { - errors.push(q.errors[j]); - } - } - } - const errorsCountBeforeFire = errors.length; - this.onValidatePage.fire(this, { - questions: questions, - errors: errors, - page: page, - }); - return errors.length > errorsCountBeforeFire; - } /** * Switches the survey to the previous page. * @@ -4477,7 +4418,7 @@ export class SurveyModel extends SurveyElementCore this.resetNavigationButton(); const curElement = this.currentSingleElement; if (this.doSkipOnPrevPage(curElement)) return true; - if (curElement) return this.prevPageSingleElement(curElement); + if (curElement) return this.singleInputController.prevPageSingleElement(curElement); const vPages = this.visiblePages; const index = vPages.indexOf(this.currentPage); @@ -4504,19 +4445,6 @@ export class SurveyModel extends SurveyElementCore } return !!elTo; } - private prevPageSingleElement(curElement: IElement): boolean { - if (this.isSingleVisibleInput) { - if ((curElement).prevSingleInput()) { - this.updateButtonsVisibility(); - return true; - } - } - const questions = this.getSingleElements(); - const index = questions.indexOf(curElement); - if (index === 0) return false; - this.currentSingleElement = questions[index - 1]; - return true; - } /** * Completes the survey if it currently displays the last page and the page contains no validation errors. If both these conditions are met, this method returns `true`; otherwise, `false`. * @@ -4528,7 +4456,7 @@ export class SurveyModel extends SurveyElementCore if (this.isValidateOnComplete) { this.cancelPreview(); } - let res = this.doCurrentPageComplete(true); + this.doCurrentPageComplete(true); return this.isCompleted; } /** @@ -4565,7 +4493,7 @@ export class SurveyModel extends SurveyElementCore public showPreview(): boolean { this.resetNavigationButton(); if (!this.isValidateOnComplete && this.doServerValidation(true, true)) return false; - if (!this.validateOnNavigate(true, true)) return false; + if (!this.validationController.validateOnNavigate(true, true)) return false; return this.isShowingPreview; } private showPreviewCore(): void { @@ -4582,28 +4510,23 @@ export class SurveyModel extends SurveyElementCore */ public cancelPreview(currentPage: any = null): void { if (!this.isShowingPreview) return; - this.gotoPageFromPreview = currentPage; + this.pageStructureController.notifyPreviewCancelled(currentPage); this.isShowingPreview = false; - const page = (this.currentSingleElement)?.page; - if (!!page) { - page.updateRows(); - this.currentPage = page; - } + this.singleInputController.onCancelPreview(); } - private gotoPageFromPreview: PageModel; public cancelPreviewByPage(panel: IPanel): any { this.cancelPreview(panel); } protected doCurrentPageComplete(doComplete: boolean): boolean { if (this.isValidatingOnServer) return false; this.resetNavigationButton(); - return this.validateOnNavigate(doComplete) === true; + return this.validationController.validateOnNavigate(doComplete) === true; } private doCurrentPageCompleteCore(doComplete: boolean): boolean { if (this.doServerValidation(doComplete)) return false; if (doComplete) { if (this.currentPage)this.currentPage.passed = true; - return this.doComplete(this.canBeCompletedByTrigger, this.completedTrigger); + return this.doComplete(this.completionController.canBeCompletedByTrigger, this.completionController.completedTrigger); } this.doNextPage(); return true; @@ -4615,13 +4538,10 @@ export class SurveyModel extends SurveyElementCore this.questionsOnPageMode = val ? "singlePage" : "standard"; } public get isSingleVisibleQuestion(): boolean { - return !this.isDesignMode && (this.isSingleVisibleQuestionVal(this.questionsOnPageMode) || this.isSingleVisibleInput); + return this.singleInputController.isSingleVisibleQuestion; } public get isSingleVisibleInput(): boolean { - return !this.isDesignMode && this.questionsOnPageMode == "inputPerPage"; - } - private isSingleVisibleQuestionVal(val: string): boolean { - return val === "questionPerPage" || val === "questionOnPage"; + return this.singleInputController.isSingleVisibleInput; } /** * Specifies how to distribute survey elements between pages. @@ -4692,147 +4612,13 @@ export class SurveyModel extends SurveyElementCore this.pageVisibilityChanged(this.pages[0], !this.isStartedState); } private runningPages: any; - private pageContainerValue: PageModel; - private onShowingPreviewChanged() { - this.updatePagesContainer(); - } - private createRootPage(name: string, pages: Array): PageModel { - const container = Serializer.createClass("page"); - container.name = name; - container.isPageContainer = true; - pages.forEach(page => { - if (!page.isStartPage) { - container.addElement(page); - } - }); - return container; - } - private disposeContainerPage(): void { - let cPage = this.pageContainerValue; - const elements = [].concat(cPage.elements); - elements.forEach(el => cPage.removeElement(el)); - cPage.dispose(); - this.pageContainerValue = undefined; - } - private updatePagesContainer(): void { - if (this.isDesignMode) return; - this.getAllQuestions().forEach(q => q.updateElementVisibility()); - this.setPropertyValue("currentPage", undefined); - const singleName = "single-page"; - const previewName = "preview-page"; - let rootPage: PageModel = undefined; - if (this.isSinglePage) { - const cPage = this.pageContainerValue; - if (cPage && cPage.name === previewName) { - rootPage = cPage.elements[0]; - this.disposeContainerPage(); - } else { - rootPage = this.createRootPage(singleName, this.pages); - } - } - if (this.isShowingPreview) { - rootPage = this.createRootPage(previewName, rootPage ? [rootPage] : this.pages); - } - if (rootPage) { - rootPage.setSurveyImpl(this); - this.pageContainerValue = rootPage; - this.currentPage = rootPage; - if (!!this.currentSingleElementValue) { - this.visiblePages.forEach(page => page.updateRows()); - } - } - let isCurrentPageSet = false; - if (!this.isSinglePage && !this.isShowingPreview) { - this.disposeContainerPage(); - let curPage = this.gotoPageFromPreview; - this.gotoPageFromPreview = null; - if (Helpers.isValueEmpty(curPage) && this.visiblePageCount > 0) { - curPage = this.visiblePages[this.visiblePageCount - 1]; - } - if (!!curPage) { - isCurrentPageSet = true; - this.changeCurrentPageFromPreview = true; - this.currentPage = curPage; - this.changeCurrentPageFromPreview = false; - } - } - if (!this.currentPage && this.visiblePageCount > 0 && !isCurrentPageSet) { - this.currentPage = this.visiblePages[0]; - } - if (this.isShowingPreview) { - this.pages.forEach(page => { - page.onFirstRendering(); - }); - } - this.pages.forEach(page => { - if (page.wasRendered) { - page.updateElementCss(true); - } - }); - this.updateButtonsVisibility(); - } - private currentSingleElementValue: IElement; - private getSingleElements(includeEl?: IElement): Array { - const res = new Array(); - const pages = this.pages; - const isSingleInput = this.isSingleVisibleInput; - for (var i: number = 0; i < pages.length; i++) { - const p = pages[i]; - if (!p.isStartPage && p.isVisible) { - const els: Array = []; - if (isSingleInput) { - p.addQuestionsToList(els, true); - } else { - p.elements.forEach(el => els.push(el)); - } - els.forEach(el => { if (el === includeEl || el.isVisible) res.push(el); }); - } - } - return res; - } public get currentSingleElement(): IElement { - return !this.isShowingPreview ? this.currentSingleElementValue : undefined; + if (this.isShowingPreview) return undefined; + return this.singleInputController.currentSingleElement; } public set currentSingleElement(val: IElement) { - if (!!val && val.isQuestion && this.isSingleVisibleQuestionVal(this.questionsOnPageMode)) { - while(val.parent && val.parent.isPanel) { - val = (val.parent); - } - } - const oldVal = this.currentSingleElement; - if (val !== oldVal && !this.isCompleted) { - const valQuestion = val?.isQuestion ? val : undefined; - const oldValQuestion = oldVal?.isQuestion ? oldVal : undefined; - const page = (val)?.page; - const options: any = !!page && !!oldVal ? this.createPageChangeEventOptions(page, (oldVal).page, valQuestion, oldValQuestion) : undefined; - const changingFunc = () => { - this.currentSingleElementValue = val; - if (!!val) { - if (this.isSingleVisibleInput && val.isQuestion) { - (val).onSetAsSingleInput(); - } - page.updateRows(); - if (page !== this.currentPage) { - this.currentPage = page; - } else { - if (!!valQuestion && this.autoFocusFirstQuestion) { - valQuestion.focus(); - } - } - this.updateButtonsVisibility(); - if (!!options) { - this.onCurrentPageChanged.fire(this, options); - } - } else { - this.visiblePages.forEach(page => page.updateRows()); - } - }; - - if (!options) { - changingFunc(); - } else { - this.currentPageChanging(options, changingFunc); - } + if (!this.isCompleted) { + this.singleInputController.currentSingleElement = val; } } public get currentSingleQuestion(): Question { @@ -4851,15 +4637,6 @@ export class SurveyModel extends SurveyElementCore const options: any = { question: question, nestedQuestions: nestedQuestions }; this.onGetLoopQuestions.fire(this, options); } - private changeCurrentSingleElementOnVisibilityChanged(): void { - const el = this.currentSingleElement; - if (!el || el.isVisible) return; - const els = this.getSingleElements(el); - const index = els.indexOf(el); - const newEl = (index > 0) ? els[index - 1] : (index < els.length - 1 ? els[index + 1] : undefined); - this.currentSingleElement = newEl; - } - private changeCurrentPageFromPreview: boolean; protected onQuestionsOnPageModeChanged(oldValue: string): void { if (this.isShowingPreview || this.isDesignMode) return; this.skippedPages = []; @@ -4874,12 +4651,7 @@ export class SurveyModel extends SurveyElementCore this.updatePagesContainer(); } this.setupSingleInputNavigationActions(); - if (this.isSingleVisibleQuestion) { - const els = this.getSingleElements(); - if (els.length > 0) { - this.currentSingleElement = els[0]; - } - } + this.singleInputController.moveToFirstElement(); } private setupSingleInputNavigationActions(): void { const actionAddId = "sv-singleinput-add"; @@ -4957,26 +4729,8 @@ export class SurveyModel extends SurveyElementCore } private updateIsFirstLastPageState() { const curPage = this.currentPage; - let fVal: boolean | undefined = undefined; - let lVal: boolean | undefined = undefined; const q = this.currentSingleElement; - if (!!q) { - let isFirstInput = true; - let isLastInput = true; - if (this.isSingleVisibleInput) { - const inputState = (q).getSingleInputElementPos(); - if (inputState !== 0) { - isFirstInput = inputState === -1; - isLastInput = inputState === 1; - } - } - const questions = this.getSingleElements(); - const index = questions.indexOf(q); - if (index >= 0) { - fVal = isFirstInput && index === 0; - lVal = isLastInput && index === questions.length - 1; - } - } + const { isFirst: fVal, isLast: lVal } = this.singleInputController.getFirstLastElementState(); this.setPropertyValue("isFirstPage", !!curPage && curPage === this.firstVisiblePage && (!q || fVal === true)); this.setPropertyValue("isLastPage", !!curPage && curPage === this.lastVisiblePage && (!q || lVal === true)); this.setPropertyValue("isFirstElement", fVal); @@ -4995,12 +4749,12 @@ export class SurveyModel extends SurveyElementCore return page && page.getMaxTimeToFinish() <= 0; } private calcIsShowNextButton(): boolean { - return this.state === "running" && !this.isLastPageOrElement && !this.canBeCompletedByTrigger; + return this.state === "running" && !this.isLastPageOrElement && !this.completionController.canBeCompletedByTrigger; } public calcIsCompleteButtonVisible(): boolean { const state = this.state; return this.isEditMode && (this.state === "running" && - (this.isLastPageOrElement && !this.showPreviewBeforeComplete || this.canBeCompletedByTrigger) + (this.isLastPageOrElement && !this.showPreviewBeforeComplete || this.completionController.canBeCompletedByTrigger) || state === "preview") && this.showCompleteButton; } private calcIsPreviewButtonVisible(): boolean { @@ -5050,80 +4804,7 @@ export class SurveyModel extends SurveyElementCore * @returns `false` if survey completion is cancelled within the [`onCompleting`](https://surveyjs.io/form-library/documentation/api-reference/survey-data-model#onCompleting) event handler; otherwise, `true`. */ public doComplete(isCompleteOnTrigger: boolean = false, completeTrigger?: Trigger): boolean | undefined { - if (this.isCompleted) return; - - return this.checkOnCompletingEvent(isCompleteOnTrigger, completeTrigger, (allow) => { - if (allow) { - this.checkOnPageTriggers(true); - this.stopTimer(); - this.notifyQuestionsOnHidingContent(this.currentPage); - this.isCompleted = true; - this.cancelPreview(); - this.clearUnusedValues(); - this.saveDataOnComplete(isCompleteOnTrigger, completeTrigger); - this.setCookie(); - } else { - this.isCompleted = false; - } - }); - } - private saveDataOnComplete(isCompleteOnTrigger: boolean = false, completeTrigger?: Trigger) { - let previousCookie = this.hasCookie; - const showSaveInProgress = (text: string) => { - savingDataStarted = true; - this.setCompletedState("saving", text); - }; - const showSaveError = (text: string) => { - this.setCompletedState("error", text); - }; - const showSaveSuccess = (text: string) => { - this.setCompletedState("success", text); - this.navigateTo(); - }; - const clearSaveMessages = (text: string) => { - this.setCompletedState("", ""); - }; - var savingDataStarted = false; - var onCompleteOptions = { - isCompleteOnTrigger: isCompleteOnTrigger, - completeTrigger: completeTrigger, - showSaveInProgress: showSaveInProgress, - showSaveError: showSaveError, - showSaveSuccess: showSaveSuccess, - clearSaveMessages: clearSaveMessages, - //Obsolete functions - showDataSaving: showSaveInProgress, - showDataSavingError: showSaveError, - showDataSavingSuccess: showSaveSuccess, - showDataSavingClear: clearSaveMessages - }; - this.onComplete.fire(this, onCompleteOptions); - if (!previousCookie && this.surveyPostId) { - this.sendResult(); - } - if (!savingDataStarted) { - this.navigateTo(); - } - } - private checkOnCompletingEvent(isCompleteOnTrigger: boolean, completeTrigger: Trigger, onComplete: (allow: boolean) => void): boolean | undefined { - let result: boolean | undefined = undefined; - const options: CompletingEvent = { - allowComplete: true, - allow: true, - isCompleteOnTrigger: isCompleteOnTrigger, - completeTrigger: completeTrigger - }; - const doCompleteFunc = () => { - this.isNavigationBlocked = false; - const allow = options.allowComplete && options.allow; - if (!!options.message) { - this.notify(options.message, allow ? "success" : "error"); - } - result = allow; - onComplete(allow); - }; - this.onCompleting.fire(this, options, doCompleteFunc, () => this.isNavigationBlocked = true); - return result; + return this.completionController.doComplete(isCompleteOnTrigger, completeTrigger); } /** * Starts the survey. Applies only if the survey has a [start page](https://surveyjs.io/form-library/documentation/design-survey/create-a-multi-page-survey#start-page). @@ -5133,7 +4814,7 @@ export class SurveyModel extends SurveyElementCore public start(): boolean { if (!this.firstPageIsStartPage) return false; this.isCurrentPageRendering = true; - if (!this.validatePageCore(this.startPage, true)) return false; + if (!this.validationController.validatePage(this.startPage, true)) return false; this.isStartedState = false; this.notifyQuestionsOnHidingContent(this.pages[0]); this.startTimerFromUI(); @@ -5151,98 +4832,50 @@ export class SurveyModel extends SurveyElementCore public get isValidatingOnServer(): boolean { return this.getPropertyValue("isValidatingOnServer", false); } - private serverValidationEventCount: number; private setIsValidatingOnServer(val: boolean) { if (val == this.isValidatingOnServer) return; this.setPropertyValue("isValidatingOnServer", val); this.onIsValidatingOnServerChanged(); } - private createServerValidationOptions(doComplete: boolean, isPreview: boolean, page: PageModel): ServerValidateQuestionsEvent { - var self = this; - const options = { - data: <{ [index: string]: any }>{}, - errors: {}, - survey: this, - complete: function () { - self.completeServerValidation(options, isPreview, page); - }, - }; - if (doComplete && this.isValidateOnComplete) { - options.data = this.data; - } else { - var questions = this.activePage.questions; - for (var i = 0; i < questions.length; i++) { - var question = questions[i]; - if (!question.visible) continue; - var value = this.getValue(question.getValueName()); - if (!this.isValueEmpty(value)) - options.data[question.getValueName()] = value; - } - } - return options; - } protected onIsValidatingOnServerChanged() { } protected doServerValidation(doComplete: boolean, isPreview: boolean = false, page?: PageModel): boolean { - if ( - !this.onServerValidateQuestions || - (>this.onServerValidateQuestions).isEmpty - ) - return false; - if (!doComplete && this.isValidateOnComplete) return false; - this.setIsValidatingOnServer(true); - const isFunc = typeof this.onServerValidateQuestions === "function"; - this.serverValidationEventCount = !isFunc ? this.onServerValidateQuestions.length : 1; - if (isFunc) { - (this.onServerValidateQuestions)(this, this.createServerValidationOptions(doComplete, isPreview, page)); - } else { - (>this.onServerValidateQuestions).fireByCreatingOptions(this, () => { return this.createServerValidationOptions(doComplete, isPreview, page); }); - } - return true; + return this.validationController.doServerValidation(doComplete, isPreview, page); } - private completeServerValidation(options: any, isPreview: boolean, page: PageModel) { - if (this.serverValidationEventCount > 1) { - this.serverValidationEventCount--; - if (!!options && !!options.errors && Object.keys(options.errors).length === 0) return; - } - this.serverValidationEventCount = 0; - this.setIsValidatingOnServer(false); - if (!options && !options.survey) return; - var self = options.survey; - let isValid = true; - if (options.errors) { - var hasToFocus = this.autoFocusFirstError; - for (var name in options.errors) { - var question = self.getQuestionByName(name); - if (question && question["errors"]) { - isValid = false; - question.addError(new CustomError(options.errors[name], this)); - if (hasToFocus) { - hasToFocus = false; - if (!!question.page) { - this.currentPage = question.page; - } - question.focus(true); - } - } + /** + * Returns the data to send to the server for validation: all survey data or non-empty values of visible questions on the active page. + */ + private getServerValidationData(allData: boolean): { [index: string]: any } { + if (allData) return this.data; + const data: { [index: string]: any } = {}; + const questions = this.activePage.questions; + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + if (!question.visible) continue; + const value = this.getValue(question.getValueName()); + if (!this.isValueEmpty(value)) { + data[question.getValueName()] = value; } - this.fireValidatedErrorsOnPage(this.currentPage); } - if (isValid) { - if (isPreview) { - this.showPreviewCore(); + return data; + } + /** + * Performs the navigation action after a successful server validation. + */ + private navigateAfterServerValidation(isPreview: boolean, page: PageModel): void { + if (isPreview) { + this.showPreviewCore(); + } else { + if (page) { + this.currentPage = page; } else { - if (page) { - this.currentPage = page; - } else { - if (self.isLastPage) self.doComplete(); - else self.doNextPage(); - } + if (this.isLastPage)this.doComplete(); + else this.doNextPage(); } } } protected doNextPage() { var curPage = this.currentPage; - this.checkOnPageTriggers(false); + this.triggersController.checkOnPageTriggers(false); this.sendPartialResult(); if (!this.isCompleted) { if (curPage === this.currentPage) { @@ -5263,35 +4896,10 @@ export class SurveyModel extends SurveyElementCore } } canBeCompleted(trigger: Trigger, isCompleted: boolean): void { - if (!settings.triggers.changeNavigationButtonsOnComplete) return; - const prevCanBeCompleted = this.canBeCompletedByTrigger; - if (!this.completedByTriggers)this.completedByTriggers = {}; - if (isCompleted) { - this.completedByTriggers[trigger.id] = { trigger: trigger, pageId: this.currentPage?.id }; - } else { - delete this.completedByTriggers[trigger.id]; - } - if (prevCanBeCompleted !== this.canBeCompletedByTrigger) { + if (this.completionController.canBeCompleted(trigger, isCompleted)) { this.updateButtonsVisibility(); } } - private completedByTriggers: HashTable; - private get canBeCompletedByTrigger(): boolean { - if (!this.completedByTriggers) return false; - const keys = Object.keys(this.completedByTriggers); - if (keys.length === 0) return false; - const id = this.currentPage?.id; - if (!id) return true; - for (let i = 0; i < keys.length; i++) { - if (id === this.completedByTriggers[keys[i]].pageId) return true; - } - return false; - } - private get completedTrigger(): Trigger { - if (!this.canBeCompletedByTrigger) return undefined; - const key = Object.keys(this.completedByTriggers)[0]; - return this.completedByTriggers[key].trigger; - } /** * Returns HTML content displayed on the [complete page](https://surveyjs.io/form-library/documentation/design-survey/create-a-multi-page-survey#complete-page). * @@ -5425,8 +5033,7 @@ export class SurveyModel extends SurveyElementCore htmlElement: htmlElement, }); this.rootElement = htmlElement; - this.scrollerElement = htmlElement.getElementsByClassName("sv-scroll__scroller")[0]; - this.addScrollEventListener(); + this.stickyController.addScrollEventListener(); } forceProcessResponsiveness(): void { if (!!this._processingResponsivenessFunc) { @@ -5436,9 +5043,8 @@ export class SurveyModel extends SurveyElementCore beforeDestroySurveyElement() { this._processingResponsivenessFunc = undefined; this.destroyResizeObserver(); - this.removeScrollEventListener(); + this.stickyControllerValue?.removeScrollEventListener(); this.rootElement = undefined; - this.scrollerElement = undefined; } /** * An event that is raised when the survey's width or height is changed. @@ -5495,11 +5101,11 @@ export class SurveyModel extends SurveyElementCore private isCurrentPageRendering: boolean = true; private isCurrentPageRendered: boolean = undefined; afterRenderPage(htmlElement: HTMLElement) { - if (!this.isDesignMode && !this.focusingQuestionInfo) { + if (!this.isDesignMode && !this.scrollFocusController.isFocusingQuestion) { const doScroll = this.isCurrentPageRendered === false; setTimeout(() => this.scrollToTopOnPageChange(doScroll), 1); } - this.focusQuestionInfo(); + this.scrollFocusController.focusQuestionInfo(); this.isCurrentPageRendered = true; if (this.onAfterRenderPage.isEmpty) return; this.onAfterRenderPage.fire(this, { @@ -5858,9 +5464,9 @@ export class SurveyModel extends SurveyElementCore } elementPage.forceRenderElement(elementToForceRender as IElement, () => { const htmlElement = surveyRootElement?.querySelector(`#${options.elementId}`); - this.suspendLazyRendering(); + this.lazyRenderingController.suspend(); SurveyElement.ScrollElementToTop(htmlElement, optScrollIfVisible, optScrollIntoViewOptions, () => { - this.releaseLazyRendering(); + this.lazyRenderingController.release(); const pageRootElement = surveyRootElement.querySelector(`#${elementPage.id}`); activateLazyRenderingChecks(pageRootElement); optOnScolledCallback && optOnScolledCallback(); @@ -5872,9 +5478,9 @@ export class SurveyModel extends SurveyElementCore SurveyElement.ScrollElementToViewCore(elementToScroll, false, optScrollIfVisible, optScrollIntoViewOptions, optOnScolledCallback); } else { const htmlElement = surveyRootElement?.querySelector(`#${options.elementId}`); - this.suspendLazyRendering(); + this.lazyRenderingController.suspend(); SurveyElement.ScrollElementToTop(htmlElement, optScrollIfVisible, optScrollIntoViewOptions, () => { - this.releaseLazyRendering(); + this.lazyRenderingController.release(); activateLazyRenderingChecks(htmlElement); optOnScolledCallback && optOnScolledCallback(); }); @@ -6316,48 +5922,21 @@ export class SurveyModel extends SurveyElementCore } } } - private validateQuestionOnValueChanged(question: Question) { + /** + * Validates a question when its value changes, unless the change is caused by clicking a navigation button. + */ + private validateQuestionOnValueChanged(question: Question): void { if (this.isNavigationButtonPressed) return; - if ( - this.isValidateOnValueChanged || - question.getAllErrors().length > 0 - ) { - this.validateQuestionOnValueChangedCore(question); - return; - } - let parent: PanelModelBase = question.parent; - while(!!parent) { - if (parent.errors && parent.errors.length > 0) { - parent.validateContainerOnly(); - return; - } - parent = parent.parent; - } - } - private validateQuestionOnValueChangedCore(question: Question): boolean { - var oldErrorCount = question.getAllErrors().length; - let res = question.validate(true, false, !this.isValidateOnValueChanging, undefined, this.isValidateOnValueChanging); - if ( - !!question.page && this.isValidateOnValueChange && - (oldErrorCount > 0 || question.getAllErrors().length > 0) - ) { - this.fireValidatedErrorsOnPage(question.page); - } - return res; + this.validationController.validateQuestionOnValueChanged(question); } + /** + * Validates the questions bound to a value when it is changing. + */ private validateOnValueChanging(valueName: string, newValue: any): boolean { if (this.isLoadingFromJson) return false; const questions = this.getQuestionsByValueName(valueName); if (!questions) return false; - let res = true; - for (let i: number = 0; i < questions.length; i++) { - const q = questions[i]; - if (!this.isTwoValueEquals(q.valueForSurvey, newValue)) { - q.value = newValue; - } - res = this.validateQuestionOnValueChangedCore(q) && res && q.errors.length == 0; - } - return res; + return this.validationController.validateQuestionsOnValueChanging(questions, newValue); } private fireOnValueChanged(name: string, value: any, question: Question): void { this.onValueChanged.fire(this, { @@ -6398,10 +5977,8 @@ export class SurveyModel extends SurveyElementCore } private notifyElementsOnAnyValueOrVariableChanged(name: string, questionName?: string) { if (this.isEndLoadingFromJson === "processing") return; - if (this.isRunningConditions) { - this.conditionNotifyElementsOnAnyValueOrVariableChanged = true; - return; - } + if (this.triggersController.deferUntilConditionsCompleted("notifyElementsOnAnyValueOrVariableChanged", + () => this.notifyElementsOnAnyValueOrVariableChanged(""))) return; for (var i = 0; i < this.pages.length; i++) { this.pages[i].onAnyValueChanged(name, questionName); } @@ -6428,17 +6005,6 @@ export class SurveyModel extends SurveyElementCore ); } } - private checkOnPageTriggers(isOnComplete: boolean) { - var questions = this.getCurrentPageQuestions(true); - var values: { [index: string]: any } = {}; - for (var i = 0; i < questions.length; i++) { - var question = questions[i]; - var name = question.getValueName(); - values[name] = this.getValue(name); - } - this.addCalculatedValuesIntoFilteredValues(values); - this.checkTriggers(values, true, isOnComplete); - } private getCurrentPageQuestions( includeInvsible: boolean = false ): Array { @@ -6452,153 +6018,28 @@ export class SurveyModel extends SurveyElementCore } return result; } - private isTriggerIsRunning: boolean = false; - private triggerKeys: any = null; - private checkTriggers(key: any, isOnNextPage: boolean, isOnComplete: boolean = false, isOnNavigation: boolean = false, name?: string): void { - if (this.isCompleted || this.triggers.length == 0 || this.isDisplayMode) return; - if (this.isTriggerIsRunning) { - for (var k in key) { - this.triggerKeys[k] = key[k]; - } - return; - } - let isQuestionInvalid = false; - if (!isOnComplete && name && this.hasRequiredValidQuestionTrigger) { - const question = this.getQuestionByValueName(name); - isQuestionInvalid = question && !question.validate(false); - } - this.isTriggerIsRunning = true; - this.triggerKeys = key; - const properties = this.getFilteredProperties(); - const options = { isOnNextPage: isOnNextPage, isOnComplete: isOnComplete, isOnNavigation: isOnNavigation, - keys: this.triggerKeys, properties: properties }; - let originalKeys = Helpers.createCopy(this.triggerKeys); - const maxIterations = 3; - for (let i = 0; i < maxIterations; i++) { - this.runSurveyTriggers(options, isQuestionInvalid); - if (this.isCompleted || Helpers.isTwoValueEquals(originalKeys, this.triggerKeys)) break; - this.triggerKeys = Helpers.createDiff(this.triggerKeys, originalKeys); - originalKeys = Helpers.createCopy(this.triggerKeys); - } - let prevCanBeCompleted = this.canBeCompletedByTrigger; - if (prevCanBeCompleted !== this.canBeCompletedByTrigger) { - this.updateButtonsVisibility(); - } - this.isTriggerIsRunning = false; - } - private runSurveyTriggers(options: any, isQuestionInvalid: boolean): void { - for (let i = 0; i < this.triggers.length; i++) { - const trigger = this.triggers[i]; - if (isQuestionInvalid && trigger.requireValidQuestion) continue; - options.keys = this.triggerKeys; - trigger.checkExpression(options); - } - } - private checkTriggersAndRunConditions(name: string, newValue: any, oldValue: any): void { - var triggerKeys: { [index: string]: any } = {}; - triggerKeys[name] = { newValue: newValue, oldValue: oldValue }; - this.runConditionOnValueChanged(name, newValue); - this.checkTriggers(triggerKeys, false, false, false, name); - } - private get hasRequiredValidQuestionTrigger(): boolean { - for (let i = 0; i < this.triggers.length; i++) { - if (this.triggers[i].requireValidQuestion) return true; - } - return false; - } private doElementsOnLoad() { for (var i = 0; i < this.pages.length; i++) { this.pages[i].onSurveyLoad(); } } - private isRunningConditionsValue: boolean; - private get isRunningConditions(): boolean { - return this.isRunningConditionsValue; - } - private isValueChangedOnRunningCondition: boolean = false; - private conditionRunnerCounter: number = 0; - private conditionUpdateVisibleIndexes: boolean = false; - private conditionNotifyElementsOnAnyValueOrVariableChanged: boolean = false; /** * Recalculates all [expressions](https://surveyjs.io/form-library/documentation/design-survey/conditional-logic#expressions) in the survey. */ public runExpressions(): void { - this.runConditions(); + this.triggersController.runExpressions(); } - private runConditions() { - if ( - this.isCompleted || - this.isEndLoadingFromJson === "processing" || - this.isRunningConditions - ) - return; - this.isRunningConditionsValue = true; - var properties = this.getFilteredProperties(); - this.runConditionsCore(properties); - this.isRunningConditionsValue = false; - if ( - this.isValueChangedOnRunningCondition && - this.conditionRunnerCounter < - settings.maxConditionRunCountOnValueChanged - ) { - this.isValueChangedOnRunningCondition = false; - this.conditionRunnerCounter++; - this.runConditions(); - } else { - this.isValueChangedOnRunningCondition = false; - this.conditionRunnerCounter = 0; - if (this.conditionUpdateVisibleIndexes) { - this.conditionUpdateVisibleIndexes = false; - this.updateVisibleIndexes(); - } - if (this.conditionNotifyElementsOnAnyValueOrVariableChanged) { - this.conditionNotifyElementsOnAnyValueOrVariableChanged = false; - this.notifyElementsOnAnyValueOrVariableChanged(""); - } - if (!this.isRunningConditionOnValueChanged) { - this.questionTriggersKeys = undefined; - } - } - } - private questionTriggersKeys: any; - private isRunningConditionOnValueChanged: boolean; public getValueChangedKeys(): any { - return this.isRunningConditionOnValueChanged ? this.questionTriggersKeys : undefined; + return this.triggersController.getValueChangedKeys(); } - private runConditionOnValueChanged(name: string, value: any) { - if (!this.questionTriggersKeys) { - this.questionTriggersKeys = {}; - } - this.questionTriggersKeys[name] = value; - if (this.isRunningConditions) { - this.isValueChangedOnRunningCondition = true; - } else { - this.isRunningConditionOnValueChanged = true; - this.runConditions(); - this.isRunningConditionOnValueChanged = false; - this.runQuestionsTriggers(name, value); - this.questionTriggersKeys = undefined; - } + private runConditions(): void { + this.triggersController.runExpressions(); } - private runConditionsCore(properties: any) { - var pages = this.pages; - for (var i = 0; i < this.calculatedValues.length; i++) { - this.calculatedValues[i].resetCalculation(); - } - for (var i = 0; i < this.calculatedValues.length; i++) { - this.calculatedValues[i].doCalculation(this.calculatedValues, properties); - } - super.runConditionCore(properties); - for (let i = 0; i < pages.length; i++) { - pages[i].runCondition(properties); - } + private checkTriggers(key: any, isOnNextPage: boolean, isOnComplete: boolean = false, isOnNavigation: boolean = false, name?: string): void { + this.triggersController.checkTriggers(key, isOnNextPage, isOnComplete, isOnNavigation, name); } - private runQuestionsTriggers(name: string, value: any): void { - if (this.isDisplayMode || this.isDesignMode) return; - const questions = this.getAllQuestions(); - questions.forEach(q => { - q.runTriggers(name, value, this.questionTriggersKeys); - }); + private checkTriggersAndRunConditions(name: string, newValue: any, oldValue: any): void { + this.triggersController.checkTriggersAndRunConditions(name, newValue, oldValue); } /** * @deprecated Self-hosted Form Library [no longer supports integration with SurveyJS Demo Service](https://surveyjs.io/stay-updated/release-notes/v2.0.0#form-library-removes-apis-for-integration-with-surveyjs-demo-service). @@ -6659,14 +6100,11 @@ export class SurveyModel extends SurveyElementCore private updateVisibleIndexes(page?: IPage) { if (this.isLoadingFromJson || !!this.isEndLoadingFromJson) return; if ( - this.isRunningConditions && this.onQuestionVisibleChanged.isEmpty && - this.onPageVisibleChanged.isEmpty - ) { + this.onPageVisibleChanged.isEmpty && //Run update visible index only one time on finishing running conditions - this.conditionUpdateVisibleIndexes = true; - return; - } + this.triggersController.deferUntilConditionsCompleted("updateVisibleIndexes", () => this.updateVisibleIndexes()) + ) return; if (this.isRunningElementsBindings) { this.updateVisibleIndexAfterBindings = true; return; @@ -6790,7 +6228,7 @@ export class SurveyModel extends SurveyElementCore this.doElementsOnLoad(); this.onQuestionsOnPageModeChanged("standard"); this.isEndLoadingFromJson = "conditions"; - this.runConditions(); + this.triggersController.runExpressions(); this.notifyElementsOnAnyValueOrVariableChanged(""); this.isEndLoadingFromJson = null; this.updateVisibleIndexes(); @@ -6892,7 +6330,7 @@ export class SurveyModel extends SurveyElementCore this.variablesHash[name] = newValue; this.notifyElementsOnAnyValueOrVariableChanged(name); if (!Helpers.isTwoValueEquals(oldValue, newValue)) { - this.checkTriggersAndRunConditions(name, newValue, oldValue); + this.triggersController.checkTriggersAndRunConditions(name, newValue, oldValue); this.onVariableChanged.fire(this, { name: name, value: newValue }); } } @@ -7065,7 +6503,7 @@ export class SurveyModel extends SurveyElementCore this.updateCurrentPage(); } this.updateVisibleIndexes(); - this.updateLazyRenderingRowsOnRemovingElements(); + this.lazyRenderingController.updateRowsOnRemovingElements(); } private generateNewName(elements: Array, baseName: string): string { var keys: { [index: string]: any } = {}; @@ -7102,7 +6540,7 @@ export class SurveyModel extends SurveyElementCore if (questions[i].hasInput && questions[i].isEmpty()) return; } if (this.isLastPage && (this.autoAdvanceEnabled !== true || !this.autoAdvanceAllowComplete)) return; - if (!this.validateActivePage(false)) return; + if (!this.validationController.validatePage(this.activePage, false)) return; const curPage = this.currentPage; const goNextPage = () => { if (curPage !== this.currentPage) return; @@ -7206,7 +6644,7 @@ export class SurveyModel extends SurveyElementCore this.updateVisibleIndexes(question.page); } if (!newValue) { - this.changeCurrentSingleElementOnVisibilityChanged(); + this.singleInputController.changeCurrentSingleElementOnVisibilityChanged(); } this.onQuestionVisibleChanged.fire(this, { question: question, @@ -7221,13 +6659,9 @@ export class SurveyModel extends SurveyElementCore } this.updateVisibleIndexes(); if (!newValue) { - this.changeCurrentSingleElementOnVisibilityChanged(); - } - const el: any = this.currentSingleElement; - const curPage = this.currentPage; - if (!!el && !!curPage && el.page !== curPage) { - this.currentSingleElement = curPage.getFirstVisibleElement(); + this.singleInputController.changeCurrentSingleElementOnVisibilityChanged(); } + this.singleInputController.syncWithCurrentPage(); this.onPageVisibleChanged.fire(this, { page: page, visible: newValue, @@ -7238,7 +6672,7 @@ export class SurveyModel extends SurveyElementCore if (!!panel.page) { this.updateVisibleIndexes(panel.page); if (!newValue) { - this.changeCurrentSingleElementOnVisibilityChanged(); + this.singleInputController.changeCurrentSingleElementOnVisibilityChanged(); } } this.onPanelVisibleChanged.fire(this, { panel: panel, visible: newValue }); @@ -7286,7 +6720,7 @@ export class SurveyModel extends SurveyElementCore question: question, name: question.name, }); - this.updateLazyRenderingRowsOnRemovingElements(); + this.lazyRenderingController.updateRowsOnRemovingElements(); } questionRenamed( question: IQuestion, @@ -7409,7 +6843,7 @@ export class SurveyModel extends SurveyElementCore panelRemoved(panel: PanelModel): void { this.updateVisibleIndexes(panel.page); this.onPanelRemoved.fire(this, { panel: panel, name: panel.name }); - this.updateLazyRenderingRowsOnRemovingElements(); + this.lazyRenderingController.updateRowsOnRemovingElements(); } validateQuestion(question: Question, errors: Array, fireCallback: boolean): void { if (!this.onValidateQuestion.isEmpty) { @@ -8011,7 +7445,6 @@ export class SurveyModel extends SurveyElementCore private finishSetValueFromTrigger(): void { this.setValueFromTriggerCounter --; } - private focusingQuestionInfo: any; private isMovingQuestion: boolean; public startMovingQuestion(): void { this.isMovingQuestion = true; @@ -8044,30 +7477,7 @@ export class SurveyModel extends SurveyElementCore return this.focusQuestionByInstance(this.getQuestionByName(name, true)); } focusQuestionByInstance(question: Question, onError: boolean = false): boolean { - if (!question || !question.isVisible || !question.page) return false; - const oldQuestion = this.focusingQuestionInfo?.question; - if (oldQuestion === question) return false; - this.focusingQuestionInfo = { question: question, onError: onError }; - const curElement = this.currentSingleElement; - this.skippedPages.push({ from: curElement || this.currentPage, to: curElement ? question : question.page }); - const isNeedWaitForPageRendered = this.activePage !== question.page && !question.page.isStartPage; - if (isNeedWaitForPageRendered) { - this.currentPage = question.page; - } - if (this.isSingleVisibleQuestion && !this.isDesignMode) { - this.currentSingleElement = question; - } - if (!isNeedWaitForPageRendered) { - this.focusQuestionInfo(); - } - return true; - } - private focusQuestionInfo(): void { - const question = this.focusingQuestionInfo?.question; - if (!!question && !question.isDisposed) { - question.focus(this.focusingQuestionInfo.onError); - } - this.focusingQuestionInfo = undefined; + return this.scrollFocusController.focusQuestionByInstance(question, onError); } public questionEditFinishCallback(question: Question, event: any): void { @@ -8282,7 +7692,7 @@ export class SurveyModel extends SurveyElementCore */ public dispose(): void { this.unConnectEditingObj(); - this.removeScrollEventListener(); + this.stickyControllerValue?.removeScrollEventListener(); this.destroyResizeObserver(); this.rootElement = undefined; if (this.layoutElements) { @@ -8309,16 +7719,8 @@ export class SurveyModel extends SurveyElementCore disposeCallback: () => void; private onScrollCallback: () => void; - // private _lastScrollTop = 0; public _isElementShouldBeSticky(selector: string): boolean { - if (!selector) return false; - const topStickyContainer = this.scrollerElement?.querySelector(selector); - if (!!topStickyContainer) { - // const scrollDirection = this.rootElement.scrollTop > this._lastScrollTop ? "down" : "up"; - // this._lastScrollTop = this.rootElement.scrollTop; - return !!this.scrollerElement && this.scrollerElement.scrollTop > 0 && topStickyContainer.getBoundingClientRect().y <= this.scrollerElement.getBoundingClientRect().y; - } - return false; + return this.stickyController.isElementShouldBeSticky(selector); } public get rootScrollDisabled() { @@ -8329,40 +7731,7 @@ export class SurveyModel extends SurveyElementCore } public onScroll(): void { - if (!!this.rootElement) { - if (this._isElementShouldBeSticky(".sv-components-container-center")) { - this.rootElement.classList && this.rootElement.classList.add("sv-root--sticky-top"); - } else { - this.rootElement.classList && this.rootElement.classList.remove("sv-root--sticky-top"); - } - if (!!this.tocModel) { - this.tocModel.updateStickyTOCSize(this.rootElement); - } - } - if (this.onScrollCallback) { - this.onScrollCallback(); - } - } - public addScrollEventListener(): void { - this.scrollHandler = () => { this.onScroll(); }; - this.rootElement.addEventListener("scroll", this.scrollHandler); - if (!!this.rootElement.getElementsByTagName("form")[0]) { - this.rootElement.getElementsByTagName("form")[0].addEventListener("scroll", this.scrollHandler); - } - if (!!this.scrollerElement) { - this.scrollerElement.addEventListener("scroll", this.scrollHandler); - } - } - public removeScrollEventListener(): void { - if (!!this.rootElement && !!this.scrollHandler) { - this.rootElement.removeEventListener("scroll", this.scrollHandler); - if (!!this.rootElement.getElementsByTagName("form")[0]) { - this.rootElement.getElementsByTagName("form")[0].removeEventListener("scroll", this.scrollHandler); - } - if (!!this.scrollerElement) { - this.scrollerElement.removeEventListener("scroll", this.scrollHandler); - } - } + this.stickyController.onScroll(); } public questionErrorComponent = "sv-question-error"; } diff --git a/packages/survey-core/src/trigger.ts b/packages/survey-core/src/trigger.ts index 6504a295b0..7da4a85728 100644 --- a/packages/survey-core/src/trigger.ts +++ b/packages/survey-core/src/trigger.ts @@ -98,7 +98,7 @@ export class Trigger extends Base { protected isExecutingOnNextPage: boolean; protected isExecutingOnNavigation: boolean; public checkExpression(options: { isOnNextPage: boolean, isOnComplete: boolean, isOnNavigation: boolean, - keys: any, properties?: HashTable, }): void { + keys: any, properties?: HashTable, runAll?: boolean, }): void { this.isExecutingOnNextPage = options.isOnNextPage; this.isExecutingOnNavigation = options.isOnNavigation || options.isOnNextPage; if (!this.canBeExecuted(options.isOnNextPage)) return; @@ -109,9 +109,9 @@ export class Trigger extends Base { if (!this.runExpressionByProperty("expression", props, (val: any) => { this.triggerResult(val === true, props); }, (runner: ExpressionRunner): boolean => { - return this.isCheckRequired(runner, options.keys); + return options.runAll === true || this.isCheckRequired(runner, options.keys); })) { - if (this.isCheckRequired(null, options.keys) && this.canSuccessOnEmptyExpression()) { + if ((options.runAll === true || this.isCheckRequired(null, options.keys)) && this.canSuccessOnEmptyExpression()) { this.triggerResult(true, props); } } diff --git a/packages/survey-core/tests/surveytests.ts b/packages/survey-core/tests/surveytests.ts index 56fc9c60a5..4bf9df98b5 100644 --- a/packages/survey-core/tests/surveytests.ts +++ b/packages/survey-core/tests/surveytests.ts @@ -19343,7 +19343,7 @@ describe("Survey", () => { querySelector: () => topStickyContainer, scrollTop: 0 }; - survey.scrollerElement = rootElement; + survey["stickyController"].scrollerElement = rootElement; expect(survey._isElementShouldBeSticky(".test"), "no scrolling").toBeFalsy(); @@ -20026,15 +20026,6 @@ describe("Survey", () => { survey.pages[1].onFirstRendering(); expect(counter, "page[1].onFirstRendering(), do nothing").toBe(1); }); - test("Do not include questions.values into survey.getFilteredValue in design time", () => { - const survey = new SurveyModel({ - elements: [{ type: "text", name: "q1", defaultValue: 1 }], - calculatedValues: [{ name: "val1", expression: "2" }] - }); - expect(survey.getFilteredValues(), "survey in running state").toEqual({ q1: 1, val1: 2 }); - survey.setDesignMode(true); - expect(survey.getFilteredValues(), "survey at design time").toEqual({ val1: 2 }); - }); test("onValueChanged event & isExpressionRunning parameter", () => { const survey = new SurveyModel({ elements: [