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 ( - {!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/index.stories.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/Form/index.stories.tsx index 30e1c5f87f..6e8ba63128 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,44 @@ export const WithAllowCreate: Story = { ], }, } + +/** + * A blocked, predefined question: `locked` disables its fields, removes its edit + * menu and drag handle, and makes its inputs non-interactive, while `notice` + * renders an in-card alert explaining why it's fixed. The remaining question + * stays fully editable. The notice never shows in the answering/preview form. + */ +export const WithBlockedQuestion: Story = { + args: { + elements: [ + { + type: "question", + question: { + 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, + locked: true, + notice: { + title: "Predefined eNPS question", + description: + "This question powers your Employee NPS score, so it can't be edited, moved, or removed.", + }, + }, + }, + { + 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/QuestionTypes/BaseQuestion/index.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/index.tsx index 548d192402..228b2442c4 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/index.tsx +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/QuestionTypes/BaseQuestion/index.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from "react" +import { F0Alert } from "@/components/F0Alert" import { F0Button } from "@/components/F0Button" import { F0Icon } from "@/components/F0Icon" -import { AcademicCap, Add, Check, CheckDouble } from "@/icons/app" +import { AcademicCap, Add, Check, CheckDouble, LockLocked } from "@/icons/app" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" import { @@ -41,6 +42,7 @@ export const BaseQuestion = ({ locked: questionLocked, type: questionType, hiddenActions, + notice, }: BaseQuestionProps) => { const { onQuestionChange, @@ -56,6 +58,8 @@ export const BaseQuestion = ({ const locked = containingSection?.locked || questionLocked + const inLockedSection = !!containingSection?.locked + const isWithinSection = !!containingSection const [isNewQuestionDropdownOpen, setIsNewQuestionDropdownOpen] = @@ -131,11 +135,34 @@ export const BaseQuestion = ({
+ {notice && !answering && ( + + )}
@@ -199,6 +226,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 ? ( diff --git a/packages/react/src/sds/surveys/SurveyFormBuilder/Section/index.tsx b/packages/react/src/sds/surveys/SurveyFormBuilder/Section/index.tsx index ebdb862479..a03a0a921f 100644 --- a/packages/react/src/sds/surveys/SurveyFormBuilder/Section/index.tsx +++ b/packages/react/src/sds/surveys/SurveyFormBuilder/Section/index.tsx @@ -2,8 +2,10 @@ import { Reorder } from "motion/react" import { useEffect, useMemo, useRef, useState } from "react" import { F0Button } from "@/components/F0Button" +import { F0TagRaw } from "@/components/tags/F0TagRaw" import { Dropdown } from "@/experimental/Navigation/Dropdown" -import { Delete, Ellipsis, LayersFront } from "@/icons/app" +import { Tooltip } from "@/experimental/Overlays/Tooltip" +import { Delete, Ellipsis, LayersFront, LockLocked } from "@/icons/app" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" @@ -19,7 +21,7 @@ const TEXT_AREA_STYLE: object = { export const Section = ({ id, - title, + title = "", description, questions = [], locked, @@ -86,73 +88,122 @@ export const Section = ({ const inputDisabled = disabled || locked || answering + // When the section is read-only (locked/disabled/answering) an empty title or + // description has nothing to show and no way to edit, so it's hidden. When the + // section is editable the inputs always render — empty ones surface a + // placeholder so authors can fill them in. + const showTitle = !inputDisabled || !!title + const showDescription = !inputDisabled || !!description + const titleRef = useRef(null) useEffect(() => { titleRef.current?.focus({ preventScroll: true }) }, []) + // Blocked section: a white "LOCKED" tag sits at the rightmost of the title. + // The section description isn't shown inline; instead it's surfaced as a + // tooltip on the tag (see below). + const lockedTag = + locked && !answering ? ( + + ) : null + return (
-
-
- - {!disabled && !answering && !locked && ( -
- - + {(showTitle || (locked && !answering)) && ( +
+ {showTitle && ( + - + )} + {lockedTag && ( +
+ {description ? ( + // The description explains why the section is blocked; it + // surfaces on hover/focus of the tag instead of inline. + + {lockedTag} + + ) : ( + lockedTag + )} +
+ )} + {!disabled && !answering && !locked && ( +
+ + + +
+ )}
)} -
-