Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d25603f
Refactor: Organize validation logic into focused methods
andrewtelnov Jun 10, 2026
d9733fb
Refactor/triggers orchestration (#11411)
andrewtelnov Jun 10, 2026
a8589ba
Create Lazy rendering controller (#11412)
andrewtelnov Jun 10, 2026
76b0646
Refactor: Integrate SurveyCompletionController into SurveyModel for i…
andrewtelnov Jun 10, 2026
87a180d
Merge branch 'master' into refactor/modularize-survey-model
andrewtelnov Jun 10, 2026
ac7157b
Refactor: Introduce SurveyValidationController for improved validatio…
andrewtelnov Jun 10, 2026
d0dc690
feat: introduce SurveySingleInputController to manage single input na…
andrewtelnov Jun 12, 2026
1f77ca8
Refactor/page structure controller (#11433)
andrewtelnov Jun 12, 2026
8207547
feat: add SurveyScrollFocusController for improved focus management i…
andrewtelnov Jun 13, 2026
af8d2d4
Remove un-needed inheritence in controllers from Base
andrewtelnov Jun 14, 2026
413847c
feat: add LazyRenderingController and SurveyTriggersRunner for improv…
andrewtelnov Jun 14, 2026
730f275
refactor: rename controllers and interfaces for consistency in survey…
andrewtelnov Jun 14, 2026
c6ac2c6
refactor: update validateSingleElement to accept PanelModelBase in ad…
andrewtelnov Jun 14, 2026
9910c64
Merge branch 'master' into refactor/modularize-survey-model
andrewtelnov Jun 19, 2026
caebd6a
refactor(survey-core): modularize cookie handling in SurveyCompletion…
andrewtelnov Jun 19, 2026
8ecf021
feat(survey-core): add SurveyStickyController for improved sticky ele…
andrewtelnov Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
packages/survey-core/promts/
158 changes: 158 additions & 0 deletions packages/survey-core/src/survey-completion-controller.ts
Original file line number Diff line number Diff line change
@@ -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<any>;

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 + "=;");
}
}
61 changes: 61 additions & 0 deletions packages/survey-core/src/survey-lazy-rendering-controller.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to have rootElement here? This controller has a survey and can could get root element from it.

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);
}
}
}
108 changes: 108 additions & 0 deletions packages/survey-core/src/survey-page-structure-controller.ts
Original file line number Diff line number Diff line change
@@ -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<PageModel>;
visiblePages: Array<PageModel>;
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 = <PageModel>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(<any>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>): 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;
}
}
Loading
Loading