diff --git a/package-lock.json b/package-lock.json index 04a19bf..413e715 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "survey-generator", "version": "0.0.0", "dependencies": { + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@tailwindcss/typography": "^0.5.12", @@ -18,6 +20,7 @@ "@tanstack/valibot-form-adapter": "^0.13.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "cmdk": "^1.0.0", "lucide-react": "^0.323.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -1223,6 +1226,42 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", @@ -1419,6 +1458,43 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", + "integrity": "sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz", @@ -2933,6 +3009,19 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", + "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", + "dependencies": { + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", diff --git a/package.json b/package.json index d10596a..64f79f8 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "test": "vitest" }, "dependencies": { + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@tailwindcss/typography": "^0.5.12", @@ -21,6 +23,7 @@ "@tanstack/valibot-form-adapter": "^0.13.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "cmdk": "^1.0.0", "lucide-react": "^0.323.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/components/survey-generator/create-survey.tsx b/src/components/survey-generator/create-survey.tsx index 1ae43cc..af0ec2b 100644 --- a/src/components/survey-generator/create-survey.tsx +++ b/src/components/survey-generator/create-survey.tsx @@ -59,8 +59,13 @@ export const CreateSurvey = () => { case "choice": return { type: "choice", + variant: "multiple", question: "", - options: [] as ChoiceQuestion["options"], + options: [ + { + id: generateId(), + }, + ] as ChoiceQuestion["options"], } as ChoiceQuestion; } }; diff --git a/src/components/survey-generator/question-blocks/choice.tsx b/src/components/survey-generator/question-blocks/choice.tsx index abb2b59..1419987 100644 --- a/src/components/survey-generator/question-blocks/choice.tsx +++ b/src/components/survey-generator/question-blocks/choice.tsx @@ -1,14 +1,34 @@ import { Button } from "@/components/ui/button"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { generateId } from "@/lib/utils"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn, generateId } from "@/lib/utils"; import { ChoiceQuestion, SurveyDefinition } from "@/types/survey"; -import { FormApi } from "@tanstack/react-form"; +import { FormApi, useField } from "@tanstack/react-form"; import { valibotValidator } from "@tanstack/valibot-form-adapter"; -import { X } from "lucide-react"; +import { + CheckCircle2, + CheckSquare2, + ChevronDown, + CircleDot, + LucideIcon, + X, +} from "lucide-react"; +import { useState } from "react"; import { QuestionCard, QuestionCardDeleteButton, + QuestionCardHeader, QuestionCardItem, QuestionCardTitle, } from "../question-card"; @@ -18,17 +38,83 @@ type Props = { form: FormApi; }; -/* -[ ] Toggle Single/Multiple -*/ +const variants: { + [key in ChoiceQuestion["variant"]]: { + label: string; + icon: LucideIcon; + }; +} = { + single: { + label: "Single Choice", + icon: CheckCircle2, + }, + multiple: { + label: "Multiple Choice", + icon: CheckSquare2, + }, + dropdown: { + label: "Dropdown", + icon: ChevronDown, + }, +}; + +const iconClassName = "text-muted-foreground"; export const ChoiceFormField = ({ questionIndex, form }: Props) => { + const [isVariantSelectorOpen, setIsVarianSelectorOpen] = useState(false); + + const variantField = useField({ + name: `questions[${questionIndex}].variant`, + form, + }); + + const variantValue = variantField.getValue() as ChoiceQuestion["variant"]; + const selectedVariant = variants[variantValue]; + return ( form.removeFieldValue(`questions`, questionIndex)} /> - Choice Question + + + + + + + + + + {Object.entries(variants).map(([value, variant]) => ( + { + variantField.setValue(value); + setIsVarianSelectorOpen(false); + }} + > + + {variant.label} + + ))} + + + + + + Choice Question + ( @@ -62,6 +148,15 @@ export const ChoiceFormField = ({ questionIndex, form }: Props) => { children={(subField) => { return (
+ {variantValue === "single" && ( + + )} + {variantValue === "multiple" && ( + + )} + {variantValue === "dropdown" && ( + + )} )); +const QuestionCardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); + const QuestionCardTitle = React.forwardRef< HTMLDivElement, React.HTMLAttributes @@ -54,7 +69,8 @@ const QuestionCardDeleteButton = React.forwardRef< export { QuestionCard, + QuestionCardDeleteButton, + QuestionCardHeader, QuestionCardItem, QuestionCardTitle, - QuestionCardDeleteButton, }; diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..423a839 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,153 @@ +import * as React from "react"; +import { type DialogProps } from "@radix-ui/react-dialog"; +import { Command as CommandPrimitive } from "cmdk"; +import { Search } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c23630e --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..bbba7e0 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/src/hooks/useStorage.ts b/src/hooks/useStorage.ts index b2383cc..947f8bc 100644 --- a/src/hooks/useStorage.ts +++ b/src/hooks/useStorage.ts @@ -1,11 +1,12 @@ -import { SurveyDefinition, SurveyDefinitionWithId } from "@/types/survey"; +import { generateId } from "@/lib/utils"; +import { SurveyDefinition } from "@/types/survey"; import { useEffect, useMemo, useState } from "react"; const storageKey = "surveys"; export const useStorage = () => { const [allSurveys, setAllSurveys] = useState< - Record + Record >({}); useEffect(() => { @@ -13,7 +14,7 @@ export const useStorage = () => { }, []); const save = (surveyDefinition: SurveyDefinition) => { - const id = surveyDefinition.id || Math.random().toString(36).substring(7); + const id = surveyDefinition.id || generateId(); const surveys = getSurveys(); surveys[id] = { ...surveyDefinition, id }; localStorage.setItem(storageKey, JSON.stringify(surveys)); @@ -23,7 +24,7 @@ export const useStorage = () => { return id; }; - const getSurveys = (): Record => { + const getSurveys = (): Record => { const surveys = localStorage.getItem(storageKey) || "{}"; return JSON.parse(surveys); }; diff --git a/src/index.css b/src/index.css index 4800dff..8abdb15 100644 --- a/src/index.css +++ b/src/index.css @@ -4,8 +4,8 @@ @layer base { :root { - --background: red; - --foreground: pink; + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ff39fab..9f36d13 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -6,5 +6,5 @@ export function cn(...inputs: ClassValue[]) { } export function generateId() { - return Math.random().toString(36).substring(7); + return Math.random().toString(36).substring(2, 8); } diff --git a/src/types/survey.ts b/src/types/survey.ts index be571b4..7a19573 100644 --- a/src/types/survey.ts +++ b/src/types/survey.ts @@ -20,6 +20,11 @@ export const ChoiceQuestion = v.merge([ SurveyId, v.object({ type: v.literal("choice"), + variant: v.union([ + v.literal("single"), + v.literal("multiple"), + v.literal("dropdown"), + ]), question: v.string([ v.minLength(3, "Question must be at least 3 characters"), ]), @@ -29,7 +34,8 @@ export const ChoiceQuestion = v.merge([ value: v.string([ v.minLength(1, "Option must be at least 1 character"), ]), - }) + }), + [v.minLength(2, "There must be at least 2 options")] ), }), ]);