Skip to content
Merged
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
9 changes: 7 additions & 2 deletions .github/workflows/dependabot-to-linear.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ name: Dependabot Alerts to Linear
on:
schedule:
- cron: "0 11 * * *" # 11:00 UTC = 12:00 CET (noon); +1h during CEST
workflow_dispatch: {}
workflow_dispatch:
inputs:
dry_run:
description: "Log what would be created without writing to Linear"
type: boolean
default: false

permissions:
contents: read
Expand Down Expand Up @@ -40,4 +45,4 @@ jobs:
# Fallback used by the script if LINEAR_API_KEY is not set; must be
# listed here because the job only sees secrets exposed via env.
LINEAR_ACCESS_KEY: ${{ secrets.LINEAR_ACCESS_KEY }}
run: node scripts/dependabot-to-linear.mjs
run: node scripts/dependabot-to-linear.mjs ${{ inputs.dry_run && '--dry-run' || '' }}
10 changes: 2 additions & 8 deletions apps/web/modules/survey/editor/components/edit-welcome-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,14 @@ export const EditWelcomeCard = ({
id="welcome-card-image"
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
workspaceId={workspaceId}
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
onFileUpload={(url: string[] | undefined) => {
if (url?.length && url[0]) {
const update =
fileType === "video"
? { videoUrl: url[0], fileUrl: undefined }
: { fileUrl: url[0], videoUrl: undefined };
updateSurvey(update);
updateSurvey({ fileUrl: url[0], videoUrl: undefined });
} else {
updateSurvey({ fileUrl: undefined, videoUrl: undefined });
}
}}
fileUrl={localSurvey?.welcomeCard?.fileUrl}
videoUrl={localSurvey?.welcomeCard?.videoUrl}
isVideoAllowed={true}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
Expand Down
25 changes: 15 additions & 10 deletions apps/web/modules/ui/components/file-input/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,14 @@ export const FileInput = ({
];
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([]);
const [uploadedVideoUrl, setUploadedVideoUrl] = useState(videoUrl ?? "");
const [activeTab, setActiveTab] = useState(videoUrl ? "video" : "image");
const [activeTab, setActiveTab] = useState(isVideoAllowed && videoUrl ? "video" : "image");
const [imageUrlTemp, setImageUrlTemp] = useState(fileUrl ?? "");
const [videoUrlTemp, setVideoUrlTemp] = useState(videoUrl ?? "");
const fileType = isVideoAllowed && activeTab === "video" ? "video" : "image";

useEffect(() => {
setActiveTab(isVideoAllowed && videoUrl ? "video" : "image");
}, [isVideoAllowed, videoUrl]);

const handleUpload = async (files: File[]) => {
if (!isStorageConfigured) {
Expand Down Expand Up @@ -115,7 +120,7 @@ export const FileInput = ({
return;
}

onFileUpload(uploadedUrls, activeTab === "video" ? "video" : "image");
onFileUpload(uploadedUrls, fileType);
};

const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
Expand All @@ -134,7 +139,7 @@ export const FileInput = ({

const handleRemove = async (idx: number) => {
const newFileUrl = selectedFiles.filter((_, i) => i !== idx).map((file) => file.url);
onFileUpload(newFileUrl, activeTab === "video" ? "video" : "image");
onFileUpload(newFileUrl, fileType);
setImageUrlTemp("");
};

Expand Down Expand Up @@ -185,7 +190,7 @@ export const FileInput = ({
});

const prevUrls = Array.isArray(fileUrl) ? fileUrl : fileUrl ? [fileUrl] : [];
onFileUpload([...prevUrls, ...uploadedUrls], activeTab === "video" ? "video" : "image");
onFileUpload([...prevUrls, ...uploadedUrls], fileType);
};

useEffect(() => {
Expand All @@ -202,23 +207,23 @@ export const FileInput = ({
}, [fileUrl]);

useEffect(() => {
if (activeTab === "image" && typeof imageUrlTemp === "string") {
if (fileType === "image" && typeof imageUrlTemp === "string") {
// Temporarily store the current video URL before switching tabs.
setVideoUrlTemp(videoUrl ?? "");

if (imageUrlTemp) {
onFileUpload([imageUrlTemp], "image");
}
} else if (activeTab === "video") {
} else if (fileType === "video") {
// Temporarily store the current image URL before switching tabs.
setImageUrlTemp(fileUrl ?? "");

if (videoUrlTemp) {
onFileUpload([videoUrlTemp], "video");
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run when activeTab changes to avoid infinite loops
}, [activeTab]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run when file type changes to avoid loops
}, [fileType]);

return (
<div className="w-full cursor-default">
Expand All @@ -227,7 +232,7 @@ export const FileInput = ({
<OptionsSwitch options={options} currentOption={activeTab} handleOptionChange={setActiveTab} />
)}
<div>
{activeTab === "video" && (
{fileType === "video" && (
<div className={cn(isVideoAllowed && "rounded-b-lg border-x border-b border-slate-200 p-4")}>
<VideoSettings
uploadedVideoUrl={uploadedVideoUrl}
Expand All @@ -239,7 +244,7 @@ export const FileInput = ({
</div>
)}

{activeTab === "image" && (
{fileType === "image" && (
<div className={cn(isVideoAllowed && "rounded-b-lg border-x border-b border-slate-200 p-4")}>
{selectedFiles.length > 0 ? (
multiple ? (
Expand Down
4 changes: 2 additions & 2 deletions packages/survey-ui/src/components/elements/multi-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ function DropdownVariant({
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
required
aria-required={true}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
Expand Down Expand Up @@ -399,7 +399,7 @@ function ListVariant({
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
required
aria-required={true}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
Expand Down
25 changes: 20 additions & 5 deletions packages/survey-ui/src/components/elements/single-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ interface SingleSelectProps {
/** Currently selected option ID */
value?: string;
/** Callback function called when selection changes */
onChange: (value: string) => void;
onChange: (value: string | undefined) => void;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Custom label for the required indicator */
Expand Down Expand Up @@ -90,7 +90,7 @@ const useDropdownCommitState = ({
}: {
variant: SingleSelectVariant;
selectedValue: string | undefined;
onChange: (value: string) => void;
onChange: (value: string | undefined) => void;
handleDropdownOpen: () => void;
handleDropdownClose: () => void;
}): {
Expand Down Expand Up @@ -300,7 +300,7 @@ function SingleSelectDropdownVariant({
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
required
aria-required={true}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
Expand Down Expand Up @@ -337,7 +337,7 @@ interface ListVariantProps {
required: boolean;
options: SingleSelectOption[];
selectedValue: string | undefined;
onChange: (value: string) => void;
onChange: (value: string | undefined) => void;
hasOtherOption: boolean;
otherOptionId?: string;
otherOptionLabel: string;
Expand Down Expand Up @@ -382,6 +382,15 @@ function SingleSelectListVariant({
const regularOptions = options.filter((option) => option.id !== "none");
const noneOptions = options.filter((option) => option.id === "none");

const handleSelectedOptionClick = (optionId: string, event: React.MouseEvent<HTMLButtonElement>): void => {
if (required || selectedValue !== optionId) {
return;
}

event.preventDefault();
onChange(undefined);
};

return (
<div className="relative" data-element-input>
<ElementError errorMessage={errorMessage} dir={dir} />
Expand All @@ -408,6 +417,7 @@ function SingleSelectListVariant({
id={optionId}
disabled={disabled}
aria-required={required}
onClick={(event) => handleSelectedOptionClick(option.id, event)}
/>
<span className={cn("mx-3 grow", OPTION_LABEL_CLASS)}>{option.label}</span>
</span>
Expand All @@ -424,6 +434,7 @@ function SingleSelectListVariant({
isOtherSelected={isOtherSelected}
otherInputRef={otherInputRef}
handleOtherInputChange={handleOtherInputChange}
handleSelectedOptionClick={handleSelectedOptionClick}
dir={dir}
disabled={disabled}
required={required}
Expand All @@ -446,6 +457,7 @@ function SingleSelectListVariant({
id={optionId}
disabled={disabled}
aria-required={required}
onClick={(event) => handleSelectedOptionClick(option.id, event)}
/>
<span className={cn("mx-3 grow", OPTION_LABEL_CLASS)}>{option.label}</span>
</span>
Expand All @@ -466,6 +478,7 @@ interface OtherOptionLabelProps {
isOtherSelected: boolean;
otherInputRef: React.RefObject<HTMLInputElement | null>;
handleOtherInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleSelectedOptionClick: (optionId: string, event: React.MouseEvent<HTMLButtonElement>) => void;
dir: Direction;
disabled: boolean;
required: boolean;
Expand All @@ -481,6 +494,7 @@ function OtherOptionLabel({
isOtherSelected,
otherInputRef,
handleOtherInputChange,
handleSelectedOptionClick,
dir,
disabled,
required,
Expand All @@ -497,6 +511,7 @@ function OtherOptionLabel({
id={`${inputId}-${otherOptionId}`}
disabled={disabled}
aria-required={required}
onClick={(event) => handleSelectedOptionClick(otherOptionId, event)}
/>
<span className={cn("mr-3 ml-3 grow", OPTION_LABEL_CLASS)}>{otherOptionLabel}</span>
</span>
Expand All @@ -508,7 +523,7 @@ function OtherOptionLabel({
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
required
aria-required={true}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,11 @@ export function MultipleChoiceSingleElement({
if (isOtherSelected) setOtherValue(value ?? "");
}, [isOtherSelected, value]);

const handleChange = (selectedValue: string) => {
if (selectedValue === otherOption?.id) {
const handleChange = (selectedValue: string | undefined) => {
if (selectedValue === undefined) {
setOtherValue("");
onChange({ [element.id]: undefined });
} else if (selectedValue === otherOption?.id) {
setOtherValue("");
onChange({ [element.id]: "" });
} else {
Expand Down
9 changes: 5 additions & 4 deletions packages/surveys/src/components/general/block-conditional.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,14 +219,15 @@ export function BlockConditional({
const validateElementForm = (element: TSurveyElement, form: HTMLFormElement): boolean => {
const response = value[element.id];

if (element.type !== TSurveyElementTypeEnum.CTA && !form.checkValidity()) {
form.requestSubmit();
return false;
}

if (
element.type === TSurveyElementTypeEnum.Address ||
element.type === TSurveyElementTypeEnum.ContactInfo
) {
if (!form.checkValidity()) {
form.requestSubmit();
return false;
}
return true;
}

Expand Down
77 changes: 75 additions & 2 deletions packages/surveys/src/lib/validation/evaluator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,21 +94,94 @@ describe("validateElementResponse", () => {
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick" },
required: true,
choices: [{ id: "opt1", label: { default: "Option 1" } }],
choices: [
{ id: "opt1", label: { default: "Option 1" } },
{ id: "other", label: { default: "Other" } },
],
} as unknown as TSurveyElement;

const result = validateElementResponse(element, ["opt1", ""], "en");
expect(result.valid).toBe(false);
expect(result.errors[0].ruleId).toBe("required");
});

test("should return error when optional multi-select has other selected but no text", () => {
const element = {
id: "mc1",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick" },
required: false,
choices: [
{ id: "opt1", label: { default: "Option 1" } },
{ id: "other", label: { default: "Other" } },
],
} as unknown as TSurveyElement;

const result = validateElementResponse(element, ["Option 1", ""], "en");
expect(result.valid).toBe(false);
expect(result.errors[0].ruleId).toBe("required");
});

test("should return error when optional single-select has other selected but no text", () => {
const element = {
id: "single1",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick" },
required: false,
choices: [
{ id: "opt1", label: { default: "Option 1" } },
{ id: "other", label: { default: "Other" } },
],
} as unknown as TSurveyElement;

const result = validateElementResponse(element, "", "en");
expect(result.valid).toBe(false);
expect(result.errors[0].ruleId).toBe("required");
});

test("should return error when optional single-select has blank other text", () => {
const element = {
id: "single1",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick" },
required: false,
choices: [
{ id: "opt1", label: { default: "Option 1" } },
{ id: "other", label: { default: "Other" } },
],
} as unknown as TSurveyElement;

const result = validateElementResponse(element, " ", "en");
expect(result.valid).toBe(false);
expect(result.errors[0].ruleId).toBe("required");
});

test("should return valid when optional single-select has no response", () => {
const element = {
id: "single1",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick" },
required: false,
choices: [
{ id: "opt1", label: { default: "Option 1" } },
{ id: "other", label: { default: "Other" } },
],
} as unknown as TSurveyElement;

const result = validateElementResponse(element, undefined, "en");
expect(result.valid).toBe(true);
});

test("should return valid when required multi-select has other with text (legacy sentinel)", () => {
const element = {
id: "mc1",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick" },
required: true,
choices: [{ id: "opt1", label: { default: "Option 1" } }],
choices: [
{ id: "opt1", label: { default: "Option 1" } },
{ id: "other", label: { default: "Other" } },
],
} as unknown as TSurveyElement;

const result = validateElementResponse(element, ["opt1", "", "custom"], "en");
Expand Down
Loading
Loading