From b039e253c95d66f8ff63ad0d6e4650900efab3fa Mon Sep 17 00:00:00 2001 From: Saul Dominguez Date: Thu, 18 Jun 2026 17:43:20 +0200 Subject: [PATCH 1/9] feat(SurveyFormBuilder): blocked questions & sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted from #4307 — only the sds/surveys changes (blocked questions and sections, locked tags, answering-form notices), plus the F0TagRaw className prop and the surveyFormBuilder.labels.locked i18n key they depend on. Co-Authored-By: Claude Opus 4.8 --- .../src/components/tags/F0TagRaw/F0TagRaw.tsx | 11 +- .../src/components/tags/F0TagRaw/types.ts | 4 + .../providers/i18n/i18n-provider-defaults.ts | 1 + .../SurveyAnsweringForm.tsx | 17 +- .../__tests__/SurveyAnsweringForm.test.tsx | 25 +++ .../hooks/useSurveyFormSchema.tsx | 3 +- .../sds/surveys/SurveyAnsweringForm/types.ts | 5 + .../SurveyFormBuilder/Form/QuestionItem.tsx | 43 +++-- .../Form/SectionHeaderItem.tsx | 39 ++-- .../TableOfContent/useTableOfContentItems.ts | 3 +- ...urveyFormBuilder.blockedQuestions.test.tsx | 180 ++++++++++++++++++ .../Form/__tests__/index.test.tsx | 76 ++++++++ .../Form/__tests__/useReorderHandler.test.ts | 128 +++++++++++++ .../SurveyFormBuilder/Form/index.stories.tsx | 54 +++++- .../surveys/SurveyFormBuilder/Form/index.tsx | 133 ++++++++++--- .../Form/useReorderHandler.ts | 21 ++ .../QuestionTypes/BaseQuestion/index.tsx | 62 +++++- .../QuestionTypes/BaseQuestion/types.ts | 17 +- .../BaseQuestion/useQuestionDisabled.ts | 7 +- .../QuestionTypes/BaseScoreQuestion/index.tsx | 16 +- .../QuestionTypes/CheckboxQuestion/index.tsx | 44 +++-- .../QuestionTypes/DateQuestion/index.tsx | 28 ++- .../DropdownSingleQuestion/index.tsx | 2 +- .../QuestionTypes/FileQuestion/index.tsx | 28 ++- .../QuestionTypes/LinkQuestion/index.tsx | 28 ++- .../QuestionTypes/NumericQuestion/index.tsx | 54 +++--- .../QuestionTypes/SelectQuestion/index.tsx | 2 +- .../QuestionTypes/TextQuestion/index.tsx | 26 ++- .../SurveyFormBuilder/Section/Item/index.tsx | 2 +- .../SurveyFormBuilder/Section/index.tsx | 161 ++++++++++------ .../SurveyFormBuilder/Section/types.ts | 15 +- .../sds/surveys/SurveyFormBuilder/types.ts | 14 ++ 32 files changed, 990 insertions(+), 259 deletions(-) create mode 100644 packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/SurveyFormBuilder.blockedQuestions.test.tsx create mode 100644 packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/useReorderHandler.test.ts diff --git a/packages/react/src/components/tags/F0TagRaw/F0TagRaw.tsx b/packages/react/src/components/tags/F0TagRaw/F0TagRaw.tsx index eddaf38047..026fe1d220 100644 --- a/packages/react/src/components/tags/F0TagRaw/F0TagRaw.tsx +++ b/packages/react/src/components/tags/F0TagRaw/F0TagRaw.tsx @@ -2,13 +2,17 @@ import { forwardRef } from "react" import { F0Icon } from "@/components/F0Icon" import { useTextFormatEnforcer } from "@/lib/text" +import { cn } from "@/lib/utils" import type { F0TagRawProps } from "./types" import { BaseTag } from "../internal/BaseTag" export const F0TagRaw = forwardRef( - ({ text, additionalAccessibleText, icon, onlyIcon, info }, ref) => { + ( + { text, additionalAccessibleText, icon, onlyIcon, info, className }, + ref + ) => { useTextFormatEnforcer( text, { disallowEmpty: true }, @@ -18,7 +22,10 @@ export const F0TagRaw = forwardRef( return (
-
- -
+ {!hideResourceHeader && ( +
+ +
+ )} {loading ? ( ) : !hasQuestions ? ( diff --git a/packages/react/src/sds/surveys/SurveyAnsweringForm/__tests__/SurveyAnsweringForm.test.tsx b/packages/react/src/sds/surveys/SurveyAnsweringForm/__tests__/SurveyAnsweringForm.test.tsx index d3ca640613..b476de2378 100644 --- a/packages/react/src/sds/surveys/SurveyAnsweringForm/__tests__/SurveyAnsweringForm.test.tsx +++ b/packages/react/src/sds/surveys/SurveyAnsweringForm/__tests__/SurveyAnsweringForm.test.tsx @@ -205,6 +205,31 @@ describe("SurveyAnsweringForm", () => { }) }) + describe("question notice", () => { + it("does not render an authoring notice in the answering form", () => { + render( + + ) + + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.queryByText("Author-only notice")).not.toBeInTheDocument() + }) + }) + describe("validation on submit", () => { it("does not call onSubmit when required fields are empty", async () => { const onSubmit = vi.fn() diff --git a/packages/react/src/sds/surveys/SurveyAnsweringForm/hooks/useSurveyFormSchema.tsx b/packages/react/src/sds/surveys/SurveyAnsweringForm/hooks/useSurveyFormSchema.tsx index 09d5f07ff3..a8a054fea2 100644 --- a/packages/react/src/sds/surveys/SurveyAnsweringForm/hooks/useSurveyFormSchema.tsx +++ b/packages/react/src/sds/surveys/SurveyAnsweringForm/hooks/useSurveyFormSchema.tsx @@ -237,7 +237,6 @@ function buildFieldForQuestion( description: q.description, type: q.type, required: q.required, - locked: q.locked, } switch (q.type) { @@ -679,7 +678,7 @@ export function useSurveyFormSchema( if (mode === "all-questions") { sections[sectionId] = { - title: section.title, + title: section.title ?? "", description: section.description, withInset: true, } diff --git a/packages/react/src/sds/surveys/SurveyAnsweringForm/types.ts b/packages/react/src/sds/surveys/SurveyAnsweringForm/types.ts index ce13a7f6e0..76294b906f 100644 --- a/packages/react/src/sds/surveys/SurveyAnsweringForm/types.ts +++ b/packages/react/src/sds/surveys/SurveyAnsweringForm/types.ts @@ -75,6 +75,11 @@ interface SurveyAnsweringFormDialogProps extends SurveyAnsweringFormSharedProps /** Inline mode: read-only rendering embedded in the page, no dialog */ interface SurveyAnsweringFormInlineProps extends SurveyAnsweringFormSharedProps { inline: true + /** + * Hide the built-in ResourceHeader (title + description). Useful when the + * embedding page already renders its own resource header above the form. + */ + hideResourceHeader?: boolean mode?: never module?: never position?: never diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/QuestionItem.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/QuestionItem.tsx index 077cf33569..071ec26250 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/QuestionItem.tsx +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/QuestionItem.tsx @@ -45,7 +45,7 @@ export const QuestionItem = ({ setDraggedItemId(null) } - const questionLocked = item.question.locked || containingSection?.locked + const questionLocked = containingSection?.locked const dragEnabled = !disabled && !answering && !questionLocked return ( @@ -66,25 +66,34 @@ export const QuestionItem = ({
- {!disabled && !answering && ( -
{ - if (dragEnabled) { - dragControls.start(e) - } - }} - > - -
- )} + {!disabled && + !answering && + (questionLocked ? ( + // Blocked question: drop the drag affordance but keep the handle's + // gutter so the card stays the same width and alignment as the + // editable questions around it. +
+ ) : ( +
{ + if (dragEnabled) { + dragControls.start(e) + } + }} + > + +
+ ))} - {!disabled && !answering && ( -
{ - if (dragEnabled) { + {!disabled && + !answering && + (item.section.locked ? ( + // Blocked section: drop the drag affordance but keep the + // handle's gutter so the header stays aligned with the + // editable rows around it. +
+ ) : ( +
{ dragControls.start(e) - } - }} - > - -
- )} + }} + > + +
+ ))}
diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/TableOfContent/useTableOfContentItems.ts b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/TableOfContent/useTableOfContentItems.ts index 6000dff6d8..b640dd1f38 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/TableOfContent/useTableOfContentItems.ts +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/TableOfContent/useTableOfContentItems.ts @@ -303,8 +303,7 @@ export const useTableOfContentItems = ( icon: getQuestionIcon(question.type as QuestionType), onClick: handleItemClick, ...(!disabled && - !answering && - !question.locked && { + !answering && { otherActions: buildQuestionActions( question.id, question.type as QuestionType, diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/SurveyFormBuilder.blockedQuestions.test.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/SurveyFormBuilder.blockedQuestions.test.tsx new file mode 100644 index 0000000000..db5a00b1e0 --- /dev/null +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/SurveyFormBuilder.blockedQuestions.test.tsx @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from "vitest" + +import { zeroRender as render, screen } from "@/testing/test-utils" + +import { SurveyFormBuilderElement } from "../../types" +import { SurveyFormBuilder } from "../index" + +// --- Test fixtures --- + +const makeQuestion = ( + id: string, + title: string, + opts: { description?: string } = {} +): SurveyFormBuilderElement => ({ + type: "question", + question: { + id, + title, + type: "text" as const, + ...(opts.description !== undefined && { description: opts.description }), + }, +}) + +const makeSection = ( + id: string, + title: string, + questions: { id: string; title: string; description?: string }[], + locked = false +): SurveyFormBuilderElement => ({ + type: "section", + section: { + id, + title, + questions: questions.map((q) => ({ + id: q.id, + title: q.title, + ...(q.description !== undefined && { description: q.description }), + type: "text" as const, + })), + locked, + }, +}) + +// --- Blocked questions UX/UI --- + +describe("SurveyFormBuilder — blocked questions", () => { + it("does not show the not-allowed cursor on an editable question card", () => { + const { container } = render( + + ) + + expect(container.querySelector("#co-creation-question-q1")).not.toHaveClass( + "cursor-not-allowed" + ) + }) + + it("disables the title and description inputs of a question in a blocked section", () => { + render( + + ) + + expect(screen.getByDisplayValue("Blocked title")).toBeDisabled() + expect(screen.getByDisplayValue("Blocked description")).toBeDisabled() + }) + + it("keeps the title input editable on an unblocked question", () => { + render( + + ) + + expect(screen.getByDisplayValue("Editable title")).not.toBeDisabled() + }) + + it("propagates blocked UI to questions inside a blocked section", () => { + const { container } = render( + + ) + + // The question never sets `locked` itself — it inherits it from the section. + expect(container.querySelector("#co-creation-question-q1")).toHaveClass( + "cursor-not-allowed" + ) + expect(screen.getByDisplayValue("Q1")).toBeDisabled() + }) + + it("shows the not-allowed cursor over a blocked section header card", () => { + const { container } = render( + + ) + + expect(container.querySelector("#co-creation-section-s1")).toHaveClass( + "cursor-not-allowed" + ) + }) + + it("does not show the not-allowed cursor on an editable section header card", () => { + const { container } = render( + + ) + + expect(container.querySelector("#co-creation-section-s1")).not.toHaveClass( + "cursor-not-allowed" + ) + }) + + it("renders a lock affordance instead of the actions menu on a question in a blocked section", () => { + const { rerender } = render( + + ) + + // Blocked question surfaces the static "Locked" button. + expect(screen.getByRole("button", { name: "Locked" })).toBeInTheDocument() + + // An editable question has no lock affordance. + rerender( + + ) + expect(screen.queryByRole("button", { name: "Locked" })).toBeNull() + }) +}) diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/index.test.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/index.test.tsx index 475c59b655..aa2bc1cadf 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/index.test.tsx +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/index.test.tsx @@ -120,6 +120,82 @@ describe("SurveyFormBuilder", () => { expect(notAllowed.length).toBeGreaterThanOrEqual(2) // section header + question }) + it("removes the drag affordance for a locked question but keeps its gutter", () => { + // An editable question renders a grabbable drag handle. + const editable: SurveyFormBuilderElement[] = [ + makeQuestion("q1", "Editable"), + ] + const { container: editableContainer } = render( + + ) + expect( + editableContainer.querySelectorAll("[class*='cursor-grab']").length + ).toBeGreaterThan(0) + + const locked: SurveyFormBuilderElement[] = [ + makeSection( + "s1", + "Locked Section", + [{ id: "q1", title: "Blocked" }], + true + ), + ] + const { container: lockedContainer } = render( + + ) + // No drag affordance on a locked question… + expect( + lockedContainer.querySelectorAll("[class*='cursor-grab']") + ).toHaveLength(0) + // …but the handle gutter is preserved so it stays aligned with editable ones. + expect( + lockedContainer.querySelectorAll("[class*='scale-75']").length + ).toBeGreaterThan(0) + }) + + it("shows a locked question's own note as a title-less tooltip on hover", async () => { + const elements: SurveyFormBuilderElement[] = [ + { + type: "section", + section: { + id: "s1", + title: "Predefined section", + description: "These questions are predefined.", + locked: true, + questions: [ + { + id: "q1", + title: "Blocked", + type: "text", + lockedNote: "This is the standard onboarding question.", + }, + ], + }, + }, + ] + + render() + + // The note isn't shown inline — it surfaces as a tooltip on hover. + const card = document.getElementById("co-creation-question-q1") + expect(card).not.toBeNull() + expect( + screen.queryByText("This is the standard onboarding question.") + ).not.toBeInTheDocument() + + await userEvent.hover(card!) + + // Shows the question's own note… + expect( + (await screen.findAllByText("This is the standard onboarding question.")) + .length + ).toBeGreaterThan(0) + // …and not the section's Locked-tag copy. + expect( + screen.queryByText("These questions are predefined.") + ).not.toBeInTheDocument() + }) + it("shows confirmation dialog when moving the last question out of a section", async () => { // This test verifies the dialog appears. We cannot simulate real DnD with // Reorder.Group in jsdom, so we test the component state by providing diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/useReorderHandler.test.ts b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/useReorderHandler.test.ts new file mode 100644 index 0000000000..40a0dee158 --- /dev/null +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/__tests__/useReorderHandler.test.ts @@ -0,0 +1,128 @@ +import { act, renderHook } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { SurveyFormBuilderElement } from "../../types" +import { useReorderHandler } from "../useReorderHandler" +import { FlatFormItem, flattenElements } from "../utils" + +// --- Fixtures --- + +const makeSection = ( + id: string, + questions: string[], + locked = false +): SurveyFormBuilderElement => ({ + type: "section", + section: { + id, + title: id, + questions: questions.map((qId) => ({ + id: qId, + title: qId, + type: "text" as const, + })), + locked, + }, +}) + +const question = (id: string): FlatFormItem => ({ + type: "question", + id: `question-${id}`, + question: { id, title: id, type: "text" }, +}) + +const header = (id: string, locked = false): FlatFormItem => ({ + type: "section-header", + id: `section-header-${id}`, + section: { id, title: id, locked }, +}) + +const standalone = (id: string): SurveyFormBuilderElement => ({ + type: "question", + question: { id, title: id, type: "text" }, +}) + +function setup(elements: SurveyFormBuilderElement[]) { + const onChange = vi.fn() + const flatItems = flattenElements(elements) + const { result } = renderHook(() => + useReorderHandler({ flatItems, onChange }) + ) + return { onChange, reorder: result.current.handleFlatReorder } +} + +describe("useReorderHandler — locked sections", () => { + it("rejects dropping a question from another section into a locked section", () => { + const { onChange, reorder } = setup([ + makeSection("locked", ["q1"], true), + makeSection("open", ["q2"]), + ]) + + // Simulate dragging q2 (from the open section) into the locked section. + act(() => { + reorder([ + header("locked", true), + question("q1"), + question("q2"), + header("open"), + ]) + }) + + // The move must be rejected — onChange is never called, so the controlled + // list snaps the dragged question back to its origin. + expect(onChange).not.toHaveBeenCalled() + }) + + it("rejects dropping a standalone question into a locked section", () => { + const { onChange, reorder } = setup([ + makeSection("locked", ["q1"], true), + standalone("q2"), + ]) + + act(() => { + reorder([header("locked", true), question("q1"), question("q2")]) + }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it("allows a cross-section move between unlocked sections when a locked section exists", () => { + const { onChange, reorder } = setup([ + makeSection("locked", ["q1"], true), + makeSection("a", ["q2a", "q2b"]), + makeSection("b", ["q3"]), + ]) + + // Move q2b from section "a" into the unlocked section "b". + act(() => { + reorder([ + header("locked", true), + question("q1"), + header("a"), + question("q2a"), + header("b"), + question("q3"), + question("q2b"), + ]) + }) + + expect(onChange).toHaveBeenCalledTimes(1) + const result = onChange.mock.calls[0][0] as SurveyFormBuilderElement[] + const sectionB = result.find( + (el): el is Extract => + el.type === "section" && el.section.id === "b" + ) + expect(sectionB?.section.questions?.map((q) => q.id)).toEqual(["q3", "q2b"]) + }) + + it("allows reordering questions within an unlocked section", () => { + const { onChange, reorder } = setup([makeSection("open", ["q1", "q2"])]) + + // Swap q1 and q2 within the same unlocked section. + act(() => { + reorder([header("open"), question("q2"), question("q1")]) + }) + + expect(onChange).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/index.stories.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/index.stories.tsx index 30e1c5f87f..504e0d2200 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/index.stories.tsx +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/index.stories.tsx @@ -49,8 +49,9 @@ export const Default: Story = { type: "section", section: { id: "section-1", - title: "Section 1", - description: "Section 1 description", + title: "Company-wide questions", + description: + "These questions are predefined and can't be edited, moved, or removed.", locked: true, questions: [ { @@ -359,3 +360,52 @@ export const WithAllowCreate: Story = { ], }, } + +/** + * A blocked, predefined section: `locked` on the section disables its fields, + * removes its edit menu and drag handle, and makes the questions inside it + * non-interactive. Its title/description explain why it's fixed and surface as + * the "Locked" tag tooltip (and on hover over the questions). Questions can't be + * locked on their own — only via their section. The standalone question after + * it stays fully editable. The locked treatment never shows in the + * answering/preview form. + */ +export const WithBlockedSection: Story = { + args: { + elements: [ + { + type: "section", + section: { + id: "section-enps", + title: "Predefined eNPS question", + description: + "This question powers your Employee NPS score, so it can't be edited, moved, or removed.", + locked: true, + questions: [ + { + id: "q-enps", + title: "How likely are you to recommend us as a place to work?", + description: "0 is not at all likely, 10 is extremely likely.", + type: "rating" as const, + options: Array.from({ length: 11 }, (_, value) => ({ + value, + label: String(value), + })), + required: true, + lockedNote: + "The standard eNPS question — its wording and 0–10 scale are fixed so scores stay comparable over time.", + }, + ], + }, + }, + { + type: "question", + question: { + id: "q-reason", + title: "What's the main reason for your score?", + type: "longText" as const, + }, + }, + ], + }, +} diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/index.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/index.tsx index b298b620e1..c7ac492ee0 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/index.tsx +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/index.tsx @@ -1,5 +1,5 @@ import { motion, Reorder } from "motion/react" -import { useEffect, useMemo } from "react" +import { useEffect, useMemo, type ReactNode } from "react" import { withDataTestId } from "@/lib/data-testid" import { cn } from "@/lib/utils" @@ -110,6 +110,16 @@ const _SurveyFormBuilder = ({ return result }, [elements]) + const lockedSectionIds = useMemo(() => { + const result = new Set() + for (const element of elements) { + if (element.type === "section" && element.section.locked) { + result.add(element.section.id) + } + } + return result + }, [elements]) + const { handleFlatReorder, handleConfirmLastQuestionMove, @@ -164,32 +174,103 @@ const _SurveyFormBuilder = ({ as="div" >
- {reorderableItems.map((item, index) => { - const gapClass = - index === 0 - ? "" - : inSectionQuestionIds.has(item.id) - ? "mt-4" - : "mt-8" - - if (item.type === "section-header") { - return ( - - ) + {(() => { + const nodes: ReactNode[] = [] + + for ( + let index = 0; + index < reorderableItems.length; + index++ + ) { + const item = reorderableItems[index] + + // A locked section renders as one muted grey rounded + // panel wrapping its header and all of its questions. The + // right padding mirrors the drag-and-drop gutter reserved + // on the left so the cards sit symmetrically in the panel. + if ( + item.type === "section-header" && + lockedSectionIds.has(item.section.id) + ) { + const groupItems: typeof reorderableItems = [item] + let next = index + 1 + while ( + next < reorderableItems.length && + reorderableItems[next].type === "question" && + inSectionQuestionIds.has(reorderableItems[next].id) + ) { + groupItems.push(reorderableItems[next]) + next++ + } + + nodes.push( +
+ {groupItems.map((groupItem) => { + if (groupItem.type === "section-header") { + return ( + + ) + } + if (groupItem.type === "question") { + // The grey panel delimits the section, so the + // "end of section" divider is suppressed here. + return ( + + ) + } + return null + })} +
+ ) + + index = next - 1 + continue + } + + const gapClass = + index === 0 + ? "" + : inSectionQuestionIds.has(item.id) + ? "mt-4" + : "mt-8" + + if (item.type === "section-header") { + nodes.push( + + ) + } else if (item.type === "question") { + nodes.push( + + ) + } } - return ( - - ) - })} + + return nodes + })()}
{shouldShowAddButton && } diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/useReorderHandler.ts b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/useReorderHandler.ts index 96f89c16b7..cd66627e0d 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/Form/useReorderHandler.ts +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/useReorderHandler.ts @@ -114,6 +114,27 @@ export function useReorderHandler({ finalItems = reorderedItems } + // Reject moves that would drop a foreign question into a locked section. + // A locked section's membership is frozen: only the questions that + // originally belonged to it may stay inside it. If any other question + // ends up within a locked section, bail out without calling onChange so + // the controlled list snaps the dragged item back to its origin. + if (lockedSectionIds.size > 0) { + let activeSectionId: string | null = null + for (const item of finalItems) { + if (item.type === "section-header") { + activeSectionId = item.id + } else if (item.type === "question" && activeSectionId) { + if ( + lockedSectionIds.has(activeSectionId) && + !originalSectionQuestions.get(activeSectionId)?.has(item.id) + ) { + return + } + } + } + } + // Build set of all question IDs that originally belonged to any section. const allInSectionQuestionIds = new Set() for (const qIds of originalSectionQuestions.values()) { diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/index.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/index.tsx index 548d192402..d8d760a5a3 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/index.tsx +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/index.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "react" import { F0Button } from "@/components/F0Button" import { F0Icon } from "@/components/F0Icon" -import { AcademicCap, Add, Check, CheckDouble } from "@/icons/app" +import { Tooltip } from "@/experimental/Overlays/Tooltip" +import { AcademicCap, Add, Check, CheckDouble, LockLocked } from "@/icons/app" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" import { @@ -38,9 +39,9 @@ export const BaseQuestion = ({ description, children, required, - locked: questionLocked, type: questionType, hiddenActions, + lockedNote, }: BaseQuestionProps) => { const { onQuestionChange, @@ -54,7 +55,9 @@ export const BaseQuestion = ({ const containingSection = getSectionContainingQuestion(id) - const locked = containingSection?.locked || questionLocked + // A question is only ever locked by being inside a locked section — it can't + // be locked on its own. + const locked = !!containingSection?.locked const isWithinSection = !!containingSection @@ -127,12 +130,19 @@ export const BaseQuestion = ({ const showCursorNotAllowed = !answering && inputDisabled - return ( + const questionCard = (
@@ -199,6 +209,22 @@ export const BaseQuestion = ({ />
)} + {!answering && locked && ( + // Blocked question: a static lock sits where the actions "⋯" menu + // would be, signalling the card is predefined and can't be edited. +
+ +
+ )}
{answering ? ( description ? ( @@ -349,4 +375,28 @@ export const BaseQuestion = ({ )}
) + + // Blocked question: an instant, title-less tooltip on hover. It prefers the + // question's own `lockedNote` (what this specific question is) and otherwise + // falls back to the section's explanation so a locked question always shows + // something. + const lockTooltipProps: { description: string } | null = !locked + ? null + : lockedNote + ? { description: lockedNote } + : containingSection?.notice?.description + ? { description: containingSection.notice.description } + : containingSection?.description + ? { description: containingSection.description } + : null + + if (lockTooltipProps && !answering) { + return ( + + {questionCard} + + ) + } + + return questionCard } diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/types.ts b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/types.ts index b89b4218fd..f8b92fda49 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/types.ts +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/types.ts @@ -1,6 +1,11 @@ -import { HiddenAction, HiddenActions, QuestionType } from "../../types" +import { + HiddenAction, + HiddenActions, + QuestionNotice, + QuestionType, +} from "../../types" -export type { HiddenAction, HiddenActions } +export type { HiddenAction, HiddenActions, QuestionNotice } export type BaseQuestionProps = { id: string @@ -9,8 +14,14 @@ export type BaseQuestionProps = { type: QuestionType children: React.ReactNode required?: boolean - locked?: boolean hiddenActions?: HiddenActions + /** + * Optional note shown as a tooltip when the question is locked (i.e. it sits + * inside a locked section). Use it to say what this specific question is — + * distinct from the section's own lock explanation. It is NOT a lock flag: + * questions can only be locked via their section. + */ + lockedNote?: string } export type BaseQuestionPropsForOtherQuestionComponents = Omit< diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/useQuestionDisabled.ts b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/useQuestionDisabled.ts index 0b7bcdd9e0..5875258447 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/useQuestionDisabled.ts +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/useQuestionDisabled.ts @@ -1,14 +1,11 @@ import { useSurveyFormBuilderContext } from "../../Context" -export function useQuestionDisabled(questionProps: { - id: string - locked?: boolean -}): boolean { +export function useQuestionDisabled(questionProps: { id: string }): boolean { const { answering, getSectionContainingQuestion } = useSurveyFormBuilderContext() const containingSection = getSectionContainingQuestion(questionProps.id) - const questionLocked = questionProps.locked || containingSection?.locked + const questionLocked = containingSection?.locked return answering ? false : questionLocked || true } diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseScoreQuestion/index.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseScoreQuestion/index.tsx index b728218d7e..1751bd1ca0 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseScoreQuestion/index.tsx +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseScoreQuestion/index.tsx @@ -17,8 +17,18 @@ export const BaseScoreQuestion = ({ value, ...baseQuestionComponentProps }: BaseScoreQuestionProps) => { - const { onQuestionChange, disabled, answering } = - useSurveyFormBuilderContext() + const { + onQuestionChange, + disabled, + answering, + getSectionContainingQuestion, + } = useSurveyFormBuilderContext() + + // A question inside a locked section is non-interactive in the authoring + // view, but stays selectable for respondents (answering). + const locked = getSectionContainingQuestion( + baseQuestionComponentProps.id + )?.locked const ratingType = detectRatingOptionType(options) const isEmojiMode = ratingType === "emojis" @@ -54,7 +64,7 @@ export const BaseScoreQuestion = ({ selected={value === option.value} onClick={handleChangeValue} onChangeLabel={handleChangeLabel} - disabled={disabled && !answering} + disabled={(disabled || locked) && !answering} isEmojiMode={answering ? false : isEmojiMode} /> ))} diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/CheckboxQuestion/index.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/CheckboxQuestion/index.tsx index 2dedfa9223..29726f1d53 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/CheckboxQuestion/index.tsx +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/CheckboxQuestion/index.tsx @@ -33,8 +33,12 @@ export const CheckboxQuestion = ({ label: labelProp, ...baseQuestionComponentProps }: CheckboxQuestionProps) => { - const { onQuestionChange, answering, disabled } = - useSurveyFormBuilderContext() + const { + onQuestionChange, + answering, + disabled, + getSectionContainingQuestion, + } = useSurveyFormBuilderContext() const questionDisabled = useQuestionDisabled(baseQuestionComponentProps) @@ -43,31 +47,31 @@ export const CheckboxQuestion = ({ if (answering) { return ( -
- { - onQuestionChange?.({ - ...baseQuestionComponentProps, - type: "checkbox", - label: labelProp, - value: checked || null, - }) - }} - disabled={questionDisabled} - title={labelProp} - /> -
+ { + onQuestionChange?.({ + ...baseQuestionComponentProps, + type: "checkbox", + label: labelProp, + value: checked || null, + }) + }} + disabled={questionDisabled} + title={labelProp} + />
) } - const inputDisabled = disabled || baseQuestionComponentProps.locked + const inputDisabled = + disabled || + getSectionContainingQuestion(baseQuestionComponentProps.id)?.locked return ( -
+