diff --git a/packages/survey-core/src/panel.ts b/packages/survey-core/src/panel.ts index f1357dc366..fbdda525cb 100644 --- a/packages/survey-core/src/panel.ts +++ b/packages/survey-core/src/panel.ts @@ -491,8 +491,9 @@ export class PanelModelBase extends SurveyElement const survey: any = this.survey; const showInRuntime = survey && survey.showPageTitles && this.hasDescription; if (this.isDesignMode) { + const hasEnteredDescription = survey && survey.showPageTitles && !!this.description; let show = this.hasTitle && - (showInRuntime && !!this.description || + (hasEnteredDescription || (this.showDescription && settings.designMode.showEmptyDescriptions)); if (!!survey && !!survey.beforeShowInplaceDescriptionEditorCallback) { show = survey.beforeShowInplaceDescriptionEditorCallback(this, !!show); diff --git a/packages/survey-core/src/survey-element.ts b/packages/survey-core/src/survey-element.ts index 564ae79e13..145fcc87d3 100644 --- a/packages/survey-core/src/survey-element.ts +++ b/packages/survey-core/src/survey-element.ts @@ -1,6 +1,6 @@ import { JsonObjectProperty, Serializer } from "./jsonobject"; import { property } from "./decorators"; -import { Base } from "./base"; +import { ArrayChanges, Base } from "./base"; import { EventBase } from "./event"; import { IAction } from "./actions/action"; import { AdaptiveActionContainer } from "./actions/adaptive-container"; @@ -101,15 +101,21 @@ export abstract class SurveyElementCore extends Base implements ILocalizableOwne protected resetDescriptionVisibility(): void { this.resetPropertyValue("hasDescription"); } + private updateDescriptionVisibility(): void { + if (this.getPropertyValueWithoutDefault("hasDescription") === undefined) return; + this.setPropertyValue("hasDescription", this.calcDescriptionVisibility()); + } + protected propertyValueChanged(name: string, oldValue: any, newValue: any, arrayChanges?: ArrayChanges, target?: Base): void { + super.propertyValueChanged(name, oldValue, newValue, arrayChanges, target); + if (name === "description" || (name === "title" && this.isDesignMode)) { + this.updateDescriptionVisibility(); + } + } /** * Explanatory text displayed under the title. * @see hasDescription */ - @property({ - localizable: { markdown: true }, onSet: (newDescription, self) => { - self.resetDescriptionVisibility(); - } - }) description: string; + @property({ localizable: { markdown: true } }) description: string; get locDescription(): LocalizableString { return this.getLocalizableString("description"); } diff --git a/packages/survey-core/tests/paneltests.ts b/packages/survey-core/tests/paneltests.ts index d43bb94454..072b15e64d 100644 --- a/packages/survey-core/tests/paneltests.ts +++ b/packages/survey-core/tests/paneltests.ts @@ -793,6 +793,152 @@ describe("Panel", () => { (survey).beforeShowInplaceDescriptionEditorCallback = undefined; }); + test("PageModel: _showDescription passes the default visibility to the callback, not the callback's own result", () => { + const oldShowEmpty = settings.designMode.showEmptyDescriptions; + settings.designMode.showEmptyDescriptions = false; + const survey = new SurveyModel(); + const page = survey.addNewPage("page"); + page.title = "My page"; + page.description = "My description"; + survey.setDesignMode(true); + // A callback that hides the description. Its result must not become its own default afterwards. + const defaults: Array = []; + (survey).beforeShowInplaceDescriptionEditorCallback = (el: any, show: boolean): boolean => { + if (el === page) defaults.push(show); + return false; + }; + + expect(page._showDescription, "hidden by the callback").toBeFalsy(); + defaults.length = 0; + expect(page._showDescription, "still hidden by the callback").toBeFalsy(); + // The page has a description, so the default stays true however the callback voted before. + expect(defaults, "the callback gets the default visibility, not its previous result").toStrictEqual([true]); + + (survey).beforeShowInplaceDescriptionEditorCallback = undefined; + settings.designMode.showEmptyDescriptions = oldShowEmpty; + }); + test("PanelModel: hasDescription is reset when the description is changed via its localizable string", () => { + const survey = new SurveyModel(); + const page = survey.addNewPage("page"); + const panel = page.addNewPanel("panel"); + panel.title = "My panel"; + survey.setDesignMode(true); + (survey).beforeShowInplaceDescriptionEditorCallback = (el: any): boolean => !!el.description; + + expect(panel.hasDescription, "empty -> hidden").toBeFalsy(); + // The property grid and the in-place editor modify the localizable string directly. + panel.locDescription.text = "Hello"; + expect(panel.hasDescription, "non-empty -> shown").toBeTruthy(); + panel.locDescription.text = ""; + expect(panel.hasDescription, "cleared -> hidden").toBeFalsy(); + + (survey).beforeShowInplaceDescriptionEditorCallback = undefined; + }); + test("PanelModel: hasDescription is reset when the title is changed, since the callback depends on it", () => { + const survey = new SurveyModel(); + const page = survey.addNewPage("page"); + const panel = page.addNewPanel("panel"); + panel.description = "My description"; + survey.setDesignMode(true); + (survey).beforeShowInplaceDescriptionEditorCallback = (el: any): boolean => !!el.title && !!el.description; + + expect(panel.hasDescription, "no title -> hidden").toBeFalsy(); + panel.title = "My panel"; + expect(panel.hasDescription, "title set -> shown").toBeTruthy(); + panel.locTitle.text = ""; + expect(panel.hasDescription, "title cleared -> hidden").toBeFalsy(); + + (survey).beforeShowInplaceDescriptionEditorCallback = undefined; + }); + test("PanelModel: beforeShowInplaceDescriptionEditorCallback is called on every title/description change", () => { + const survey = new SurveyModel(); + const page = survey.addNewPage("page"); + const panel = page.addNewPanel("panel"); + survey.setDesignMode(true); + // The callback decides on both the title and the description. + const calls: Array = []; + (survey).beforeShowInplaceDescriptionEditorCallback = (el: any): boolean => { + const show = !!el.title && !!el.description; + if (el === panel) calls.push({ title: el.title, description: el.description, show: show }); + return show; + }; + // The first render calculates the visibility. + expect(panel.hasDescription, "no title, no description -> hidden").toBeFalsy(); + calls.length = 0; + + // Nothing below reads hasDescription: each change must call the callback by itself, otherwise + // the UI is never told to re-render the description. + panel.description = "My description"; + expect(calls.length, "called after the description is set").toBe(1); + expect(calls[0], "the callback sees the new description").toStrictEqual( + { title: "", description: "My description", show: false }); + + panel.title = "My panel"; + expect(calls.length, "called after the title is set").toBe(2); + expect(calls[1], "the callback sees the new title").toStrictEqual( + { title: "My panel", description: "My description", show: true }); + + panel.title = ""; + expect(calls.length, "called after the title is cleared").toBe(3); + expect(calls[2], "the callback sees the cleared title").toStrictEqual( + { title: "", description: "My description", show: false }); + + // The value is already calculated, so reading it calls the callback no more. + expect(panel.hasDescription, "no title -> hidden").toBeFalsy(); + expect(calls.length, "reading hasDescription calls the callback no more").toBe(3); + + (survey).beforeShowInplaceDescriptionEditorCallback = undefined; + }); + test("PanelModel: a title/description change notifies hasDescription with the new value", () => { + const survey = new SurveyModel(); + const page = survey.addNewPage("page"); + const panel = page.addNewPanel("panel"); + panel.description = "My description"; + survey.setDesignMode(true); + (survey).beforeShowInplaceDescriptionEditorCallback = (el: any): boolean => !!el.title && !!el.description; + + const notified: Array = []; + panel.onPropertyChanged.add((_, opt) => { if (opt.name === "hasDescription") notified.push(opt.newValue); }); + + // The first render calculates the visibility. + expect(panel.hasDescription, "no title -> hidden").toBeFalsy(); + expect(notified.length, "calculating the visibility notifies nothing").toBe(0); + + // The UI re-renders on the notification, so it must carry the new visibility, not undefined. + panel.title = "My panel"; + expect(notified, "hasDescription notified as true when the title is set").toStrictEqual([true]); + + panel.title = ""; + expect(notified, "hasDescription notified as false when the title is cleared").toStrictEqual([true, false]); + + (survey).beforeShowInplaceDescriptionEditorCallback = undefined; + }); + test("PanelModel: a title change does not calculate the description visibility before it is rendered", () => { + const survey = new SurveyModel(); + const page = survey.addNewPage("page"); + const panel = page.addNewPanel("panel"); + panel.description = "My description"; + survey.setDesignMode(true); + let callCount = 0; + (survey).beforeShowInplaceDescriptionEditorCallback = (el: any): boolean => { + if (el === panel) callCount++; + return !!el.title && !!el.description; + }; + + const notified: Array = []; + panel.onPropertyChanged.add((_, opt) => notified.push(opt.name)); + + // hasDescription has never been requested, so nothing recalculates it and nothing is notified. + panel.title = "My panel"; + expect(callCount, "the callback is not called for a panel that is not rendered yet").toBe(0); + expect(notified.indexOf("hasDescription"), "hasDescription is not notified").toBe(-1); + + // It is calculated on the first read instead. + expect(panel.hasDescription, "title and description are set -> shown").toBeTruthy(); + expect(callCount, "the callback is called on the first read").toBe(1); + + (survey).beforeShowInplaceDescriptionEditorCallback = undefined; + }); test("Page/Panel.getProgressInfo()", () => { const page = new PageModel("q1"); diff --git a/packages/survey-core/tests/surveyquestiontests.ts b/packages/survey-core/tests/surveyquestiontests.ts index c16e5a763c..6e2752f363 100644 --- a/packages/survey-core/tests/surveyquestiontests.ts +++ b/packages/survey-core/tests/surveyquestiontests.ts @@ -300,6 +300,69 @@ describe("Survey_Questions", () => { expect(question1.hasDescriptionUnderTitle, "survey hidden - underTitle").toBe(false); expect(question1.hasDescriptionUnderInput, "survey hidden - underInput").toBe(false); }); + test("Question: beforeShowInplaceDescriptionEditorCallback is called on every title/description change", () => { + const survey = new SurveyModel(); + const page = survey.addNewPage("Page 1"); + const question = new QuestionTextModel("q1"); + page.addQuestion(question); + survey.setDesignMode(true); + // A question title falls back to the question name, so the callback checks the title + // localizable string to tell an explicitly titled question from an untitled one. + const calls: Array = []; + (survey).beforeShowInplaceDescriptionEditorCallback = (el: any): boolean => { + const show = !!el.locTitle.text && !!el.description; + if (el === question) calls.push({ title: el.locTitle.text, description: el.description, show: show }); + return show; + }; + // The first render calculates the visibility. + expect(question.hasDescriptionUnderTitle, "no title, no description -> hidden").toBe(false); + calls.length = 0; + + // Nothing below reads the visibility: each change must call the callback by itself. + question.description = "My description"; + expect(calls.length, "called after the description is set").toBe(1); + expect(calls[0], "the callback sees the new description").toStrictEqual( + { title: "", description: "My description", show: false }); + + question.title = "My question"; + expect(calls.length, "called after the title is set").toBe(2); + expect(calls[1], "the callback sees the new title").toStrictEqual( + { title: "My question", description: "My description", show: true }); + expect(question.hasDescriptionUnderTitle, "title set -> shown").toBe(true); + + question.title = ""; + expect(calls.length, "called after the title is cleared").toBe(3); + expect(calls[2], "the callback sees the cleared title").toStrictEqual( + { title: "", description: "My description", show: false }); + expect(question.hasDescriptionUnderTitle, "title cleared -> hidden").toBe(false); + + (survey).beforeShowInplaceDescriptionEditorCallback = undefined; + }); + test("Question: hasDescription is recalculated when the title/description localizable string changes", () => { + // The property grid and the in-place editor modify the localizable strings directly, and the + // property setters are not called in this case. + const survey = new SurveyModel(); + const page = survey.addNewPage("Page 1"); + const question = new QuestionTextModel("q1"); + page.addQuestion(question); + question.description = "My description"; + survey.setDesignMode(true); + (survey).beforeShowInplaceDescriptionEditorCallback = (el: any): boolean => !!el.locTitle.text && !!el.description; + + expect(question.hasDescriptionUnderTitle, "no title -> hidden").toBe(false); + question.locTitle.text = "My question"; + expect(question.hasDescriptionUnderTitle, "title set via locString -> shown").toBe(true); + question.locTitle.text = ""; + expect(question.hasDescriptionUnderTitle, "title cleared via locString -> hidden").toBe(false); + + question.locTitle.text = "My question"; + question.locDescription.text = ""; + expect(question.hasDescriptionUnderTitle, "description cleared via locString -> hidden").toBe(false); + question.locDescription.text = "My description"; + expect(question.hasDescriptionUnderTitle, "description set via locString -> shown").toBe(true); + + (survey).beforeShowInplaceDescriptionEditorCallback = undefined; + }); test("Use value of checkbox question as an array", () => { var survey = new SurveyModel(); var page = survey.addNewPage("Page 1");