Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 9 additions & 2 deletions packages/react/src/components/tags/F0TagRaw/F0TagRaw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement, F0TagRawProps>(
({ text, additionalAccessibleText, icon, onlyIcon, info }, ref) => {
(
{ text, additionalAccessibleText, icon, onlyIcon, info, className },
ref
) => {
useTextFormatEnforcer(
text,
{ disallowEmpty: true },
Expand All @@ -18,7 +22,10 @@ export const F0TagRaw = forwardRef<HTMLDivElement, F0TagRawProps>(
return (
<BaseTag
ref={ref}
className="border-[1px] border-solid border-f1-border-secondary"
className={cn(
"border-[1px] border-solid border-f1-border-secondary",
className
)}
left={
icon ? (
<F0Icon
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/components/tags/F0TagRaw/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export type F0TagRawProps = {
* Info text to display an i icon and a tooltip next to the tag
*/
info?: string
/**
* Extra classes merged onto the tag (e.g. to give it a background).
*/
className?: string
} & (
| {
icon: IconType
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,25 +66,34 @@ export const QuestionItem = ({
<div
className={cn(
"group/element flex flex-row items-start gap-1",
// Mirror the drag-and-drop gutter (handle w-6 + gap-1 ≈ 28px) on
// the right so every card keeps the same width.
!disabled && !answering && "pr-7",
isDragging && "cursor-grabbing"
)}
>
{!disabled && !answering && (
<div
className={cn(
"mt-2 flex aspect-square w-6 scale-75 items-center opacity-0 hover:opacity-40 group-hover/element:opacity-40",
!isDragging && "cursor-grab",
!dragEnabled && "cursor-not-allowed"
)}
onPointerDown={(e) => {
if (dragEnabled) {
dragControls.start(e)
}
}}
>
<F0Icon icon={Handle} size="sm" />
</div>
)}
{!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.
<div className="mt-2 aspect-square w-6 scale-75" aria-hidden />
) : (
<div
className={cn(
"mt-2 flex aspect-square w-6 scale-75 items-center opacity-0 hover:opacity-40 group-hover/element:opacity-40",
!isDragging && "cursor-grab"
)}
onPointerDown={(e) => {
if (dragEnabled) {
dragControls.start(e)
}
}}
>
<F0Icon icon={Handle} size="sm" />
</div>
))}
<QuestionComponent
{...({
...item.question,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ export const SectionHeaderItem = ({
setDraggedItemId(null)
}

const dragEnabled = !disabled && !answering && !item.section.locked

return (
<Reorder.Item
value={item}
Expand All @@ -56,25 +54,32 @@ export const SectionHeaderItem = ({
<div
className={cn(
"flex flex-row items-start gap-1 w-full",
// Mirror the drag-and-drop gutter (handle w-6 + gap-1 ≈ 28px) on
// the right so the header aligns with the cards' width.
!disabled && !answering && "pr-7",
isDragging && "cursor-grabbing"
)}
>
{!disabled && !answering && (
<div
className={cn(
"mt-2 flex aspect-square w-6 scale-75 items-center opacity-0 hover:opacity-40 group-hover/element:opacity-40",
!isDragging && "cursor-grab",
!dragEnabled && "cursor-not-allowed"
)}
onPointerDown={(e) => {
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.
<div className="mt-2 aspect-square w-6 scale-75" aria-hidden />
) : (
<div
className={cn(
"mt-2 flex aspect-square w-6 scale-75 items-center opacity-0 hover:opacity-40 group-hover/element:opacity-40",
!isDragging && "cursor-grab"
)}
onPointerDown={(e) => {
dragControls.start(e)
}
}}
>
<F0Icon icon={Handle} size="sm" />
</div>
)}
}}
>
<F0Icon icon={Handle} size="sm" />
</div>
))}
<SectionComponent {...item.section} hideQuestions />
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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,
},
},
],
},
}
133 changes: 107 additions & 26 deletions packages/react/src/sds/surveys/SurveyFormBuilder/Form/index.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -110,6 +110,16 @@ const _SurveyFormBuilder = ({
return result
}, [elements])

const lockedSectionIds = useMemo(() => {
const result = new Set<string>()
for (const element of elements) {
if (element.type === "section" && element.section.locked) {
result.add(element.section.id)
}
}
return result
}, [elements])

const {
handleFlatReorder,
handleConfirmLastQuestionMove,
Expand Down Expand Up @@ -164,32 +174,103 @@ const _SurveyFormBuilder = ({
as="div"
>
<div className="flex flex-col">
{reorderableItems.map((item, index) => {
const gapClass =
index === 0
? ""
: inSectionQuestionIds.has(item.id)
? "mt-4"
: "mt-8"

if (item.type === "section-header") {
return (
<SectionHeaderItem
key={item.id}
item={item}
className={gapClass}
/>
)
{(() => {
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(
<div
key={`locked-${item.section.id}`}
className={cn(
"rounded-2xl bg-f1-background-secondary pb-8 pt-4",
index === 0 ? "" : "mt-8"
)}
>
{groupItems.map((groupItem) => {
if (groupItem.type === "section-header") {
return (
<SectionHeaderItem
key={groupItem.id}
item={groupItem}
className=""
/>
)
}
if (groupItem.type === "question") {
// The grey panel delimits the section, so the
// "end of section" divider is suppressed here.
return (
<QuestionItem
key={groupItem.id}
item={groupItem}
showEndOfSection={false}
className="mt-4"
/>
)
}
return null
})}
</div>
)

index = next - 1
continue
}

const gapClass =
index === 0
? ""
: inSectionQuestionIds.has(item.id)
? "mt-4"
: "mt-8"

if (item.type === "section-header") {
nodes.push(
<SectionHeaderItem
key={item.id}
item={item}
className={gapClass}
/>
)
} else if (item.type === "question") {
nodes.push(
<QuestionItem
key={item.id}
item={item}
showEndOfSection={sectionEndIds.has(item.id)}
className={gapClass}
/>
)
}
}
return (
<QuestionItem
key={item.id}
item={item}
showEndOfSection={sectionEndIds.has(item.id)}
className={gapClass}
/>
)
})}

return nodes
})()}
</div>
</Reorder.Group>
{shouldShowAddButton && <AddButton />}
Expand Down
Loading
Loading