Skip to content

Refactor: Decompose SurveyModel into focused controllers#11409

Open
andrewtelnov wants to merge 16 commits into
masterfrom
refactor/modularize-survey-model
Open

Refactor: Decompose SurveyModel into focused controllers#11409
andrewtelnov wants to merge 16 commits into
masterfrom
refactor/modularize-survey-model

Conversation

@andrewtelnov

@andrewtelnov andrewtelnov commented Jun 10, 2026

Copy link
Copy Markdown
Member

Goal

survey.ts (~8.6k lines) mixes many concerns inside SurveyModel. This PR incrementally relocates cohesive clusters into focused, composed classes — keeping SurveyModel as the public API surface while the internals become smaller, testable units.

Process

This is an integration PR. Each refactoring step is reviewed as its own small PR targeting this branch; once all planned steps land, this PR merges to master in one step (which also keeps the subsequent master → V3 merge to a single operation).

Steps

Verification

Per step: full survey-core unit suite green, eslint --max-warnings=0 clean, build + typings OK. Public API unchanged except the removals listed above.

Reorganize validation logic in SurveyModel into smaller, single-purpose
methods with clear section headers and documentation. This improves code
organization and maintainability without changing the public API or behavior.

## Changes

Refactored validation into four organized sections:

1. **Page Navigation Validation** — Break down performValidationOnPageChanging():
   - getPageIndex() — Extract target page index
   - isNavigatingBackwardOrSkipping() — Clear skip condition
   - validateIntermediatePages() — Validate pages between current and target

2. **Validation Orchestration** — Break down validateOnNavigate():
   - canSkipValidation() — Clear, documented skip conditions
   - proceedWithNavigation() — Post-validation navigation action
   - validateCurrentPageBeforeNavigation() — Current page validation
   - validateAllPagesBeforeCompletion() — All-pages validation (onComplete mode)
   - shouldValidateAllPages() — Validation mode decision

3. **Core Page Validation** — Document and organize:
   - validatePageCore() — Core validation with async support
   - fireValidatedErrorsOnPage() — Event firing

4. **Question Value Change Validation** — Break down into:
   - shouldValidateQuestionOnChange() — Decide if validation needed
   - validateParentPanelsIfNeeded() — Parent panel validation
   - validateQuestionOnValueChangedCore() — Core question validation
   - validateOnValueChanging() — Multiple-question validation

## Benefits

- Smaller methods with single responsibility
- Self-documenting code with clear method names
- Better code organization with section headers
- Honest architecture without false decoupling
- All 4,325 tests pass, no API changes

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@andrewtelnov andrewtelnov marked this pull request as draft June 10, 2026 10:03
@andrewtelnov andrewtelnov changed the title Refactor: Organize validation logic into focused methods Refactor: Decompose SurveyModel into focused controllers Jun 10, 2026
* Refactor: Extract triggers and conditions logic into SurveyTriggersRunner

- Created SurveyTriggersRunner class to manage trigger execution and condition running
- Moved private methods: checkTriggers, runSurveyTriggers, checkTriggersAndRunConditions, hasRequiredValidQuestionTrigger, runConditions, runConditionOnValueChanged, runConditionsCore, runQuestionsTriggers, checkOnPageTriggers
- Moved state management fields: isTriggerIsRunning, triggerKeys, questionTriggersKeys, condition runner flags
- Kept public delegators on SurveyModel: runTriggers, runExpressions, getValueChangedKeys
- Tests: 2611 passed (up from 1529), remaining failures appear unrelated to this extraction

* Add delegating methods for backward compatibility

- Added private delegating methods: runConditions, checkTriggers, checkTriggersAndRunConditions
- These methods forward to the SurveyTriggersRunner for backward compatibility with internal callers
- Tests: 4305 passing, only 20 failing (down from 1714 failures)

* Introduce canRunTriggersOrConditions function to replace 4 properties

* Remove the legacy: getFilteredValues function

* Remove canBeCompletedByTrigger from interface

* Remove knowledge about notifyElementsOnAnyValueOrVariableChanged

* Inherit from ISurveyData
#11415)

* Refactor: Integrate SurveyCompletionController into SurveyModel for improved completion handling

* Move code into survey,.isCompleted setter
#11430)

* feat: introduce SurveySingleInputController to manage single input navigation and state

- Added SurveySingleInputController to encapsulate logic related to single visible questions and inputs.
- Refactored SurveyModel to utilize SurveySingleInputController for handling current single element, navigation, and validation.
- Updated methods for changing pages and elements to leverage the new controller, improving code organization and readability.
- Removed redundant code related to single element management from SurveyModel.

* Refactor: streamline visibility checks in SurveySingleInputController and SurveyModel
* feat: add SurveyPageStructureController for managing survey page structure

* Refactor: streamline page update logic in SurveyPageStructureController and SurveyModel

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR continues the incremental refactor of SurveyModel by extracting cohesive internal responsibilities (triggers/conditions, validation, completion, lazy rendering, single-input navigation, page structure, and scroll/focus) into dedicated controller classes while keeping SurveyModel as the public API entry point.

Changes:

  • Added controller classes (SurveyTriggersRunner, SurveyValidationController, SurveyCompletionController, LazyRenderingController, SurveySingleInputController, SurveyPageStructureController, SurveyScrollFocusController) and wired them into SurveyModel.
  • Updated trigger execution to support a runAll mode and moved triggers/conditions orchestration into SurveyTriggersRunner.
  • Adjusted tests and .gitignore to match the refactor steps.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/survey-core/tests/surveytests.ts Removes a unit test related to getFilteredValues() behavior in design mode.
packages/survey-core/src/trigger.ts Extends trigger execution options with runAll to force expression evaluation.
packages/survey-core/src/survey.ts Integrates new controllers and removes/moves large internal clusters out of SurveyModel.
packages/survey-core/src/survey-validation-controller.ts New controller encapsulating validation (including server validation and value-change validation).
packages/survey-core/src/survey-triggers-runner.ts New controller for triggers and condition/expression orchestration with deferral hooks.
packages/survey-core/src/survey-single-input-controller.ts New controller for single-question/single-input navigation and state handling.
packages/survey-core/src/survey-scroll-focus-controller.ts New controller for focus/scroll logic around page changes and focusing questions.
packages/survey-core/src/survey-page-structure-controller.ts New controller for single-page/preview container page orchestration.
packages/survey-core/src/survey-completion-controller.ts New controller for completing flow and trigger-driven completion state.
packages/survey-core/src/lazy-rendering-controller.ts New controller for lazy rendering settings/state and DOM re-checks.
.gitignore Adds an ignore entry for a survey-core folder (currently spelled promts).

Comment on lines 3426 to 3428
getFilteredProperties(): any {
return { survey: this };
}
Comment on lines 20026 to 20029
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", () => {
Comment thread packages/survey-core/src/survey.ts Outdated
}
return this.singleInputController.performNext();
}
private validateSingleElement(el: Question, onComplete: (hasErrors: boolean) => void): boolean {
Comment on lines +1 to +20
import { Base } from "./base";
import { IElement, ISurveyElement } from "./base-interfaces";
import { PageModel } from "./page";
import { Question } from "./question";
import { CurrentPageChangedEvent } from "./survey-events-api";

export interface ISurveySingleInputHost {
visiblePages: Array<PageModel>;
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, onComplete: (hasErrors: boolean) => void): boolean;
}
Comment on lines +218 to +221
this.serverValidationEventCount = 0;
this.survey.setIsValidatingOnServer(false);
if (!options && !options.survey) return;
var self = options.survey;
@andrewtelnov andrewtelnov marked this pull request as ready for review June 14, 2026 10:33
isCompleted: boolean;
isNavigationBlocked: boolean;
currentPage: PageModel;
hasCookie: boolean;

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.

If in the future we'll make port to mobile, then probably such works as "cookie" better to hide inside JS/TS implementation


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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants