diff --git a/.github/workflows/dependabot-to-linear.yml b/.github/workflows/dependabot-to-linear.yml index b48f0653145b..dd3960af9bfe 100644 --- a/.github/workflows/dependabot-to-linear.yml +++ b/.github/workflows/dependabot-to-linear.yml @@ -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 @@ -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' || '' }} diff --git a/apps/web/modules/survey/editor/components/edit-welcome-card.tsx b/apps/web/modules/survey/editor/components/edit-welcome-card.tsx index 9024a979360a..47fbaa9e54d5 100644 --- a/apps/web/modules/survey/editor/components/edit-welcome-card.tsx +++ b/apps/web/modules/survey/editor/components/edit-welcome-card.tsx @@ -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} /> diff --git a/apps/web/modules/ui/components/file-input/index.tsx b/apps/web/modules/ui/components/file-input/index.tsx index 252e3d748b0a..90d96411a6c0 100644 --- a/apps/web/modules/ui/components/file-input/index.tsx +++ b/apps/web/modules/ui/components/file-input/index.tsx @@ -63,9 +63,14 @@ export const FileInput = ({ ]; const [selectedFiles, setSelectedFiles] = useState([]); 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) { @@ -115,7 +120,7 @@ export const FileInput = ({ return; } - onFileUpload(uploadedUrls, activeTab === "video" ? "video" : "image"); + onFileUpload(uploadedUrls, fileType); }; const handleDragOver = (e: React.DragEvent) => { @@ -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(""); }; @@ -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(() => { @@ -202,14 +207,14 @@ 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 ?? ""); @@ -217,8 +222,8 @@ export const FileInput = ({ 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 (
@@ -227,7 +232,7 @@ export const FileInput = ({ )}
- {activeTab === "video" && ( + {fileType === "video" && (
)} - {activeTab === "image" && ( + {fileType === "image" && (
{selectedFiles.length > 0 ? ( multiple ? ( diff --git a/packages/survey-ui/src/components/elements/multi-select.tsx b/packages/survey-ui/src/components/elements/multi-select.tsx index 1954fc482e22..5a667fd7fcef 100644 --- a/packages/survey-ui/src/components/elements/multi-select.tsx +++ b/packages/survey-ui/src/components/elements/multi-select.tsx @@ -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" @@ -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" diff --git a/packages/survey-ui/src/components/elements/single-select.tsx b/packages/survey-ui/src/components/elements/single-select.tsx index 68fcafa00f28..fefe401c1575 100644 --- a/packages/survey-ui/src/components/elements/single-select.tsx +++ b/packages/survey-ui/src/components/elements/single-select.tsx @@ -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 */ @@ -90,7 +90,7 @@ const useDropdownCommitState = ({ }: { variant: SingleSelectVariant; selectedValue: string | undefined; - onChange: (value: string) => void; + onChange: (value: string | undefined) => void; handleDropdownOpen: () => void; handleDropdownClose: () => void; }): { @@ -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" @@ -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; @@ -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): void => { + if (required || selectedValue !== optionId) { + return; + } + + event.preventDefault(); + onChange(undefined); + }; + return (
@@ -408,6 +417,7 @@ function SingleSelectListVariant({ id={optionId} disabled={disabled} aria-required={required} + onClick={(event) => handleSelectedOptionClick(option.id, event)} /> {option.label} @@ -424,6 +434,7 @@ function SingleSelectListVariant({ isOtherSelected={isOtherSelected} otherInputRef={otherInputRef} handleOtherInputChange={handleOtherInputChange} + handleSelectedOptionClick={handleSelectedOptionClick} dir={dir} disabled={disabled} required={required} @@ -446,6 +457,7 @@ function SingleSelectListVariant({ id={optionId} disabled={disabled} aria-required={required} + onClick={(event) => handleSelectedOptionClick(option.id, event)} /> {option.label} @@ -466,6 +478,7 @@ interface OtherOptionLabelProps { isOtherSelected: boolean; otherInputRef: React.RefObject; handleOtherInputChange: (e: React.ChangeEvent) => void; + handleSelectedOptionClick: (optionId: string, event: React.MouseEvent) => void; dir: Direction; disabled: boolean; required: boolean; @@ -481,6 +494,7 @@ function OtherOptionLabel({ isOtherSelected, otherInputRef, handleOtherInputChange, + handleSelectedOptionClick, dir, disabled, required, @@ -497,6 +511,7 @@ function OtherOptionLabel({ id={`${inputId}-${otherOptionId}`} disabled={disabled} aria-required={required} + onClick={(event) => handleSelectedOptionClick(otherOptionId, event)} /> {otherOptionLabel} @@ -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" diff --git a/packages/surveys/src/components/elements/multiple-choice-single-element.tsx b/packages/surveys/src/components/elements/multiple-choice-single-element.tsx index c8c28e58b305..1691f6925111 100644 --- a/packages/surveys/src/components/elements/multiple-choice-single-element.tsx +++ b/packages/surveys/src/components/elements/multiple-choice-single-element.tsx @@ -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 { diff --git a/packages/surveys/src/components/general/block-conditional.tsx b/packages/surveys/src/components/general/block-conditional.tsx index 163d4c2e781c..8fac59089b5c 100644 --- a/packages/surveys/src/components/general/block-conditional.tsx +++ b/packages/surveys/src/components/general/block-conditional.tsx @@ -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; } diff --git a/packages/surveys/src/lib/validation/evaluator.test.ts b/packages/surveys/src/lib/validation/evaluator.test.ts index 5586043a605d..45bd54c6a058 100644 --- a/packages/surveys/src/lib/validation/evaluator.test.ts +++ b/packages/surveys/src/lib/validation/evaluator.test.ts @@ -94,7 +94,10 @@ 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"); @@ -102,13 +105,83 @@ describe("validateElementResponse", () => { 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"); diff --git a/packages/surveys/src/lib/validation/evaluator.ts b/packages/surveys/src/lib/validation/evaluator.ts index 43848298694a..59f5e51e43ad 100644 --- a/packages/surveys/src/lib/validation/evaluator.ts +++ b/packages/surveys/src/lib/validation/evaluator.ts @@ -125,6 +125,67 @@ const validateRequiredMatrix = ( return null; }; +const validateMultiSelectOtherValue = ( + element: TSurveyElement, + value: TResponseDataValue, + t: TFunction +): TValidationError | null => { + if (element.type !== TSurveyElementTypeEnum.MultipleChoiceMulti || !Array.isArray(value)) { + return null; + } + + const hasOtherOption = "choices" in element && element.choices.some((choice) => choice.id === "other"); + if (!hasOtherOption) { + return null; + } + + const sentinelIndex = value.indexOf(""); + if (sentinelIndex === -1) { + return null; + } + + const otherText = value[sentinelIndex + 1]; + if (typeof otherText !== "string" || otherText.trim() === "") { + return createRequiredError(t); + } + + return null; +}; + +const validateSingleSelectOtherValue = ( + element: TSurveyElement, + value: TResponseDataValue, + languageCode: string, + t: TFunction +): TValidationError | null => { + if (element.type !== TSurveyElementTypeEnum.MultipleChoiceSingle || typeof value !== "string") { + return null; + } + + const hasOtherOption = "choices" in element && element.choices.some((choice) => choice.id === "other"); + if (!hasOtherOption || (element.required && value === "")) { + return null; + } + + const knownChoiceIds = element.choices.filter((choice) => choice.id !== "other").map((choice) => choice.id); + if (knownChoiceIds.includes(value)) { + return null; + } + + const knownChoiceLabels = element.choices + .filter((choice) => choice.id !== "other") + .map((choice) => getLocalizedValue(choice.label, languageCode)); + if (knownChoiceLabels.includes(value)) { + return null; + } + + if (value.trim() === "") { + return createRequiredError(t); + } + + return null; +}; + /** * Check required field validation */ @@ -154,17 +215,6 @@ const checkRequiredField = ( return createRequiredError(t); } - // For multi-select: if "other" is selected (sentinel ""), require the other text to be non-empty - if (element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && Array.isArray(value)) { - const sentinelIndex = value.indexOf(""); - if (sentinelIndex !== -1) { - const otherText = value[sentinelIndex + 1]; - if (!otherText || (typeof otherText === "string" && otherText.trim() === "")) { - return createRequiredError(t); - } - } - } - return null; }; @@ -378,6 +428,16 @@ export const validateElementResponse = ( errors.push(requiredError); } + const singleSelectOtherError = validateSingleSelectOtherValue(element, value, languageCode, t); + if (singleSelectOtherError) { + errors.push(singleSelectOtherError); + } + + const multiSelectOtherError = validateMultiSelectOtherValue(element, value, t); + if (multiSelectOtherError) { + errors.push(multiSelectOtherError); + } + // Validation rules apply to matrix elements regardless of required status // Get validation rules diff --git a/scripts/dependabot-to-linear.mjs b/scripts/dependabot-to-linear.mjs index 812b83fe93ca..c3b451fa2725 100644 --- a/scripts/dependabot-to-linear.mjs +++ b/scripts/dependabot-to-linear.mjs @@ -12,7 +12,10 @@ // GITHUB_REPOSITORY "owner/repo" (provided automatically in Actions) // DEPENDABOT_ALERTS_TOKEN token with "Dependabot alerts: read" (the default // GITHUB_TOKEN cannot read this API) -// LINEAR_API_KEY | LINEAR_ACCESS_KEY Linear key with issue-create access +// LINEAR_API_KEY | LINEAR_ACCESS_KEY Linear personal API key with BOTH +// `read` (to dedupe against existing issues) and +// `write` / `issues:create` (to create new ones). +// Generated at Settings → API → Personal API keys. // Flags: // --dry-run log what would be created without writing to Linear @@ -47,12 +50,22 @@ function requireEnv(name, ...fallbacks) { throw new Error(`Missing required env var: ${[name, ...fallbacks].join(" or ")}`); } +// The Dependabot alerts endpoint uses cursor pagination (`before`/`after`), +// not `?page=`. The next-page cursor is returned in the response `Link` header +// as `; rel="next"`. Walk that until there's no next link. +function parseNextLink(linkHeader) { + if (!linkHeader) return null; + for (const part of linkHeader.split(",")) { + const match = part.match(/<([^>]+)>\s*;\s*rel="next"/); + if (match) return match[1]; + } + return null; +} + async function fetchOpenAlerts(repo, token) { const alerts = []; - let page = 1; - const perPage = 100; - for (;;) { - const url = `https://api.github.com/repos/${repo}/dependabot/alerts?state=open&per_page=${perPage}&page=${page}`; + let url = `https://api.github.com/repos/${repo}/dependabot/alerts?state=open&per_page=100`; + while (url) { const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { @@ -68,8 +81,7 @@ async function fetchOpenAlerts(repo, token) { } const batch = await res.json(); alerts.push(...batch); - if (batch.length < perPage) break; - page += 1; + url = parseNextLink(res.headers.get("link")); } return alerts; }