diff --git a/app/new/page.tsx b/app/new/page.tsx index 7ce1af0..d3c749e 100644 --- a/app/new/page.tsx +++ b/app/new/page.tsx @@ -8,19 +8,22 @@ import { AnimatedBackground } from "@/components/AnimatedBackground" import { getHomeMainClasses } from "@/lib/utils" import { ANALYTICS_EVENTS } from "@/lib/analytics-events" import { track } from "@/lib/mixpanel" -import type { FileOutputConfig } from "@/types/wizard" +import type { DataQuestionSource, FileOutputConfig } from "@/types/wizard" import { Github } from "lucide-react" import Link from "next/link" import filesData from "@/data/files.json" +import { buildFileOptionsFromQuestion } from "@/lib/wizard-utils" + +const fileQuestionSet = filesData as DataQuestionSource[] +const fileQuestion = fileQuestionSet[0] ?? null +const fileOptionsFromData = buildFileOptionsFromQuestion(fileQuestion) export default function NewInstructionsPage() { const [showWizard, setShowWizard] = useState(false) const [selectedFileId, setSelectedFileId] = useState(null) - const fileOptions = useMemo(() => { - return (filesData as FileOutputConfig[]).filter((file) => file.enabled !== false) - }, []) + const fileOptions = useMemo(() => fileOptionsFromData, []) const handleFileCtaClick = (file: FileOutputConfig) => { setSelectedFileId(file.id) diff --git a/components/instructions-wizard.tsx b/components/instructions-wizard.tsx index 6cb8e92..811eb0c 100644 --- a/components/instructions-wizard.tsx +++ b/components/instructions-wizard.tsx @@ -1,11 +1,11 @@ "use client" -import { useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { Button } from "@/components/ui/button" import { Undo2 } from "lucide-react" -import type { DataQuestionSource, FileOutputConfig, FrameworkConfig, InstructionsWizardProps, Responses, WizardAnswer, WizardConfirmationIntent, WizardQuestion, WizardStep } from "@/types/wizard" -import rawFrameworks from "@/data/frameworks.json" +import type { DataQuestionSource, FileOutputConfig, InstructionsWizardProps, Responses, WizardAnswer, WizardConfirmationIntent, WizardQuestion, WizardStep } from "@/types/wizard" +import frameworksData from "@/data/frameworks.json" import generalData from "@/data/general.json" import architectureData from "@/data/architecture.json" import performanceData from "@/data/performance.json" @@ -16,43 +16,40 @@ import FinalOutputView from "./final-output-view" import { WizardAnswerGrid } from "./wizard-answer-grid" import { WizardCompletionSummary } from "./wizard-completion-summary" import { WizardConfirmationDialog } from "./wizard-confirmation-dialog" +import { WizardEditAnswerDialog } from "./wizard-edit-answer-dialog" import { generateInstructions } from "@/lib/instructions-api" import { buildCompletionSummary } from "@/lib/wizard-summary" import { serializeWizardResponses } from "@/lib/wizard-response" -import { buildStepFromQuestionSet, getFormatLabel, mapAnswerSourceToWizard } from "@/lib/wizard-utils" +import { buildFileOptionsFromQuestion, buildStepFromQuestionSet, getFormatLabel, mapAnswerSourceToWizard } from "@/lib/wizard-utils" import type { GeneratedFileResult } from "@/types/output" -const fileOptions = filesData as FileOutputConfig[] -const defaultFileOption = - fileOptions.find((file) => file.isDefault) ?? - fileOptions.find((file) => file.enabled !== false) ?? - fileOptions[0] ?? - null - const FRAMEWORK_STEP_ID = "frameworks" const FRAMEWORK_QUESTION_ID = "frameworkSelection" const DEVCONTEXT_ROOT_URL = "https://devcontext.xyz/" -const frameworksStep: WizardStep = { - id: FRAMEWORK_STEP_ID, - title: "Choose Your Framework", - questions: [ - { - id: FRAMEWORK_QUESTION_ID, - question: "Which framework are you working with?", - answers: (rawFrameworks as FrameworkConfig[]).map((framework) => ({ - value: framework.id, - label: framework.label, - icon: framework.icon, - disabled: framework.enabled === false, - disabledLabel: framework.enabled === false ? "Soon" : undefined, - docs: framework.docs, - isDefault: framework.isDefault, - })), - }, - ], -} +const frameworkQuestionSet = frameworksData as DataQuestionSource[] +const frameworksStep = buildStepFromQuestionSet( + FRAMEWORK_STEP_ID, + "Choose Your Framework", + frameworkQuestionSet +) +const frameworkQuestion = frameworksStep.questions.find((question) => question.id === FRAMEWORK_QUESTION_ID) ?? null + +const fileQuestionSet = filesData as DataQuestionSource[] +const fileQuestion = fileQuestionSet[0] ?? null +const fileOptions = buildFileOptionsFromQuestion(fileQuestion) +const defaultFileOption = + fileOptions.find((file) => file.isDefault) ?? + fileOptions[0] ?? + null +const fileSummaryQuestion = fileQuestion + ? { + id: fileQuestion.id, + question: fileQuestion.question, + isReadOnlyOnSummary: fileQuestion.isReadOnlyOnSummary, + } + : null const generalStep = buildStepFromQuestionSet( "general", @@ -101,6 +98,16 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza const [pendingConfirmation, setPendingConfirmation] = useState(null) const [generatedFile, setGeneratedFile] = useState(null) const [isGenerating, setIsGenerating] = useState(false) + const [isFrameworkFastTrackPromptVisible, setIsFrameworkFastTrackPromptVisible] = useState(false) + const [autoFilledQuestionMap, setAutoFilledQuestionMap] = useState>({}) + const [autoFillNotice, setAutoFillNotice] = useState(null) + const [activeEditQuestionId, setActiveEditQuestionId] = useState(null) + + useEffect(() => { + if (!isComplete && activeEditQuestionId) { + setActiveEditQuestionId(null) + } + }, [isComplete, activeEditQuestionId]) const selectedFile = useMemo(() => { if (selectedFileId) { @@ -123,6 +130,26 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza [dynamicSteps] ) + const nonFrameworkSteps = useMemo( + () => wizardSteps.filter((step) => step.id !== FRAMEWORK_STEP_ID), + [wizardSteps] + ) + + const wizardQuestionsById = useMemo(() => { + const lookup: Record = {} + + wizardSteps.forEach((step) => { + step.questions.forEach((question) => { + lookup[question.id] = question + }) + }) + + return lookup + }, [wizardSteps]) + + const editingQuestion = activeEditQuestionId ? wizardQuestionsById[activeEditQuestionId] ?? null : null + const editingAnswerValue = editingQuestion ? responses[editingQuestion.id] : undefined + const currentStep = wizardSteps[currentStepIndex] ?? null const currentQuestion = currentStep?.questions[currentQuestionIndex] ?? null @@ -131,9 +158,22 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza [wizardSteps] ) + const remainingQuestionCount = useMemo( + () => nonFrameworkSteps.reduce((count, step) => count + step.questions.length, 0), + [nonFrameworkSteps] + ) + const completionSummary = useMemo( - () => buildCompletionSummary(selectedFile ?? null, selectedFileFormatLabel, wizardSteps, responses), - [selectedFile, selectedFileFormatLabel, wizardSteps, responses] + () => + buildCompletionSummary( + fileSummaryQuestion, + selectedFile ?? null, + selectedFileFormatLabel, + wizardSteps, + responses, + autoFilledQuestionMap + ), + [fileSummaryQuestion, selectedFile, selectedFileFormatLabel, wizardSteps, responses, autoFilledQuestionMap] ) const currentAnswerValue = currentQuestion ? responses[currentQuestion.id] : undefined @@ -167,6 +207,41 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza ? `Use default (${defaultAnswer.label})` : "Use default" + const showFrameworkPivot = !isComplete && isFrameworkFastTrackPromptVisible + const showQuestionControls = !isComplete && !isFrameworkFastTrackPromptVisible + + const markQuestionsAutoFilled = useCallback((questionIds: string[]) => { + if (questionIds.length === 0) { + return + } + + setAutoFilledQuestionMap((prev) => { + const next = { ...prev } + questionIds.forEach((id) => { + if (id !== FRAMEWORK_QUESTION_ID) { + next[id] = true + } + }) + return next + }) + }, []) + + const clearAutoFilledFlag = useCallback((questionId: string) => { + setAutoFilledQuestionMap((prev) => { + if (!prev[questionId]) { + return prev + } + + const next = { ...prev } + delete next[questionId] + return next + }) + }, []) + + const closeEditDialog = useCallback(() => { + setActiveEditQuestionId(null) + }, []) + if (!currentStep || !currentQuestion) { return null } @@ -198,6 +273,10 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza } const goToPrevious = () => { + if (isFrameworkFastTrackPromptVisible) { + setIsFrameworkFastTrackPromptVisible(false) + } + const isFirstQuestionInStep = currentQuestionIndex === 0 const isFirstStep = currentStepIndex === 0 @@ -218,7 +297,7 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza setIsComplete(false) } - const loadFrameworkQuestions = async (frameworkId: string, frameworkLabel: string) => { + const loadFrameworkQuestions = async (frameworkId: string, frameworkLabel?: string) => { try { setGeneratedFile(null) @@ -233,14 +312,25 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza answers: question.answers.map(mapAnswerSourceToWizard), })) + const followUpQuestionCount = + mappedQuestions.length + suffixSteps.reduce((count, step) => count + step.questions.length, 0) + + setAutoFilledQuestionMap({}) + setAutoFillNotice(null) + + const resolvedFrameworkLabel = + frameworkLabel ?? frameworkQuestion?.answers.find((answer) => answer.value === frameworkId)?.label ?? frameworkId + setDynamicSteps([ { id: `framework-${frameworkId}`, - title: `${frameworkLabel} Preferences`, + title: `${resolvedFrameworkLabel} Preferences`, questions: mappedQuestions, }, ]) + setIsFrameworkFastTrackPromptVisible(followUpQuestionCount > 0) + setResponses((prev) => { const next = { ...prev } mappedQuestions.forEach((question) => { @@ -255,48 +345,139 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza } catch (error) { console.error(`Unable to load questions for framework "${frameworkId}"`, error) setDynamicSteps([]) + setIsFrameworkFastTrackPromptVisible(false) } finally { } } - const handleAnswerClick = async (answer: WizardAnswer) => { + const applyDefaultsAcrossWizard = () => { + setGeneratedFile(null) + setAutoFilledQuestionMap({}) + + const autoFilledIds: string[] = [] + + setResponses((prev) => { + const next: Responses = { ...prev } + + wizardSteps.forEach((step) => { + step.questions.forEach((question) => { + if (question.id === FRAMEWORK_QUESTION_ID) { + return + } + + const defaultAnswers = question.answers.filter((answer) => answer.isDefault && !answer.disabled) + + if (defaultAnswers.length === 0) { + return + } + + autoFilledIds.push(question.id) + + next[question.id] = question.allowMultiple + ? defaultAnswers.map((answer) => answer.value) + : defaultAnswers[0]?.value + }) + }) + + return next + }) + + markQuestionsAutoFilled(autoFilledIds) + + if (autoFilledIds.length > 0) { + setAutoFillNotice("We applied the recommended defaults for you. Tweak any section before generating.") + } else { + setAutoFillNotice(null) + } + + setIsFrameworkFastTrackPromptVisible(false) + + const lastStepIndex = Math.max(wizardSteps.length - 1, 0) + const lastStep = wizardSteps[lastStepIndex] + const lastQuestionIndex = lastStep ? Math.max(lastStep.questions.length - 1, 0) : 0 + + setCurrentStepIndex(lastStepIndex) + setCurrentQuestionIndex(lastQuestionIndex) + setIsComplete(true) + } + + const beginStepByStepFlow = () => { + const firstNonFrameworkIndex = wizardSteps.findIndex((step) => step.id !== FRAMEWORK_STEP_ID) + + if (firstNonFrameworkIndex !== -1) { + setCurrentStepIndex(firstNonFrameworkIndex) + setCurrentQuestionIndex(0) + } + + setIsFrameworkFastTrackPromptVisible(false) + setIsComplete(false) + setAutoFillNotice(null) + } + + const handleEditEntry = (entryId: string) => { + if (!entryId) { + return + } + + const question = wizardQuestionsById[entryId] + + if (!question) { + return + } + + setActiveEditQuestionId(entryId) + } + + const handleQuestionAnswerSelection = async ( + question: WizardQuestion, + answer: WizardAnswer, + { skipAutoAdvance = false }: { skipAutoAdvance?: boolean } = {} + ) => { if (answer.disabled) { return } setGeneratedFile(null) - const previousValue = responses[currentQuestion.id] let nextValue: Responses[keyof Responses] let didAddSelection = false - if (currentQuestion.allowMultiple) { - const prevArray = Array.isArray(previousValue) ? previousValue : [] - if (prevArray.includes(answer.value)) { - nextValue = prevArray.filter((item) => item !== answer.value) + setResponses((prev) => { + const prevValue = prev[question.id] + + if (question.allowMultiple) { + const prevArray = Array.isArray(prevValue) ? prevValue : [] + + if (prevArray.includes(answer.value)) { + nextValue = prevArray.filter((item) => item !== answer.value) + } else { + nextValue = [...prevArray, answer.value] + didAddSelection = true + } } else { - nextValue = [...prevArray, answer.value] - didAddSelection = true + if (prevValue === answer.value) { + nextValue = undefined + } else { + nextValue = answer.value + didAddSelection = true + } } - } else { - if (previousValue === answer.value) { - nextValue = undefined - } else { - nextValue = answer.value - didAddSelection = true + + return { + ...prev, + [question.id]: nextValue, } - } + }) - setResponses((prev) => ({ - ...prev, - [currentQuestion.id]: nextValue, - })) + clearAutoFilledFlag(question.id) + + const isFrameworkQuestion = question.id === FRAMEWORK_QUESTION_ID - const isFrameworkQuestion = currentQuestion.id === FRAMEWORK_QUESTION_ID const shouldAutoAdvance = + !skipAutoAdvance && !isFrameworkQuestion && - ((currentQuestion.allowMultiple && Array.isArray(nextValue) && nextValue.length > 0 && didAddSelection) || - (!currentQuestion.allowMultiple && nextValue !== undefined && nextValue !== null && didAddSelection)) + ((question.allowMultiple && Array.isArray(nextValue) && nextValue.length > 0 && didAddSelection) || + (!question.allowMultiple && nextValue !== undefined && nextValue !== null && didAddSelection)) if (shouldAutoAdvance) { setTimeout(() => { @@ -309,10 +490,19 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza await loadFrameworkQuestions(answer.value, answer.label) } else { setDynamicSteps([]) + setIsFrameworkFastTrackPromptVisible(false) } } } + const handleAnswerClick = (answer: WizardAnswer) => { + if (!currentQuestion) { + return + } + + void handleQuestionAnswerSelection(currentQuestion, answer) + } + const applyDefaultAnswer = async () => { if (!defaultAnswer || defaultAnswer.disabled) { return @@ -329,6 +519,8 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza [currentQuestion.id]: nextValue, })) + clearAutoFilledFlag(currentQuestion.id) + const isFrameworkQuestion = currentQuestion.id === FRAMEWORK_QUESTION_ID if (isFrameworkQuestion) { @@ -348,6 +540,9 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza setIsComplete(false) setGeneratedFile(null) setIsGenerating(false) + setIsFrameworkFastTrackPromptVisible(false) + setAutoFilledQuestionMap({}) + setAutoFillNotice(null) } const resetWizard = () => { @@ -456,15 +651,18 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza : undefined const showChangeFile = Boolean(onClose && selectedFile) + const topButtonLabel = showFrameworkPivot ? "Choose a different network" : "Start Over" + const topButtonHandler = showFrameworkPivot ? () => goToPrevious() : () => requestResetWizard() + const wizardLayout = (
{selectedFile ? ( @@ -484,14 +682,38 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza ) : null} - {isComplete ? ( - void generateInstructionsFile()} - isGenerating={isGenerating} - /> - ) : ( + {showFrameworkPivot ? ( +
+
+
+
+

Skip the deep dive?

+

+ We can auto-apply the recommended answers for the next {remainingQuestionCount}{" "} + {remainingQuestionCount === 1 ? "question" : "questions"} across these sections. (You can still tweak the defaults.) +

+
+
+
+ + +
+
+
+ +
+
+ ) : null} + + {showQuestionControls ? (
@@ -531,7 +753,18 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza
- )} + ) : null} + + {isComplete ? ( + void generateInstructionsFile()} + isGenerating={isGenerating} + autoFillNotice={autoFillNotice} + onEditEntry={handleEditEntry} + /> + ) : null} {pendingConfirmation ? ( {wizardLayout} + {editingQuestion ? ( + { + await handleQuestionAnswerSelection(editingQuestion, selectedAnswer, { skipAutoAdvance: true }) + + if (!editingQuestion.allowMultiple) { + closeEditDialog() + } + }} + onClose={closeEditDialog} + /> + ) : null} {generatedFile ? ( void onGenerate: () => void isGenerating: boolean + autoFillNotice?: string | null + onEditEntry?: (entryId: string) => void } export function WizardCompletionSummary({ @@ -13,14 +15,33 @@ export function WizardCompletionSummary({ onBack, onGenerate, isGenerating, + autoFillNotice, + onEditEntry, }: WizardCompletionSummaryProps) { return (
-
-

Review your selections

-

- Adjust anything before we create your instruction files. -

+
+
+
+

Review your selections

+

+ Adjust anything before we create your instruction files. +

+
+
+ + +
+
+ {autoFillNotice ? ( +
+ {autoFillNotice} +
+ ) : null}
@@ -29,28 +50,52 @@ export function WizardCompletionSummary({ key={entry.id} className="rounded-2xl border border-border/70 bg-background/90 p-5" > -

{entry.question}

+
+

{entry.question}

+ {entry.isAutoFilled ? ( + + Default applied + + ) : null} +
{entry.hasSelection ? (
    - {entry.answers.map((answer) => ( -
  • {answer}
  • + {entry.answers.map((answer, index) => ( +
  • +
    + {answer} + {index === 0 && !entry.isReadOnlyOnSummary ? ( + + ) : null} +
    +
  • ))}
) : ( -

No selection

+
+ No selection + {!entry.isReadOnlyOnSummary ? ( + + ) : null} +
)}
))}
- -
- - -
) } diff --git a/components/wizard-edit-answer-dialog.tsx b/components/wizard-edit-answer-dialog.tsx new file mode 100644 index 0000000..e477b23 --- /dev/null +++ b/components/wizard-edit-answer-dialog.tsx @@ -0,0 +1,41 @@ +import { Button } from "@/components/ui/button" +import { WizardAnswerGrid } from "./wizard-answer-grid" +import type { Responses, WizardAnswer, WizardQuestion } from "@/types/wizard" + +type WizardEditAnswerDialogProps = { + question: WizardQuestion + value: Responses[keyof Responses] | undefined + onAnswerSelect: (answer: WizardAnswer) => void | Promise + onClose: () => void +} + +export function WizardEditAnswerDialog({ question, value, onAnswerSelect, onClose }: WizardEditAnswerDialogProps) { + const isSelected = (candidate: string) => { + if (question.allowMultiple) { + return Array.isArray(value) && value.includes(candidate) + } + + return value === candidate + } + + return ( +
+
+
+
+ Edit selection +

{question.question}

+ {question.allowMultiple ? ( +

You can pick more than one option.

+ ) : null} +
+ +
+ + +
+
+ ) +} diff --git a/data/files.json b/data/files.json index 6c6c41c..70a852a 100644 --- a/data/files.json +++ b/data/files.json @@ -1,29 +1,36 @@ [ { - "id": "instructions-md", - "label": "Copilot Instructions (Markdown)", - "filename": "copilot-instructions.md", - "format": "markdown", - "enabled": true, - "icon": "markdown", - "docs": "https://docs.github.com/en/copilot", - "isDefault": true - }, - { - "id": "agents-md", - "label": "Agents Development Guide", - "filename": "agents.md", - "format": "markdown", - "enabled": true, - "icon": "markdown", - "docs": "https://docs.github.com/en/copilot" - }, - { - "id": "cursor-rules", - "label": "Cursor Rules", - "filename": ".cursor/rules", - "format": "json", - "enabled": true, - "docs": "https://docs.cursor.com/workflows/rules" + "id": "outputFileSelection", + "question": "Which instructions file do you want to generate?", + "isReadOnlyOnSummary": true, + "answers": [ + { + "value": "instructions-md", + "label": "Copilot Instructions (Markdown)", + "filename": "copilot-instructions.md", + "format": "markdown", + "icon": "markdown", + "docs": "https://docs.github.com/en/copilot", + "isDefault": true, + "enabled": true + }, + { + "value": "agents-md", + "label": "Agents Development Guide", + "filename": "agents.md", + "format": "markdown", + "icon": "markdown", + "docs": "https://docs.github.com/en/copilot", + "enabled": true + }, + { + "value": "cursor-rules", + "label": "Cursor Rules", + "filename": ".cursor/rules", + "format": "json", + "docs": "https://docs.cursor.com/workflows/rules", + "enabled": true + } + ] } ] diff --git a/data/frameworks.json b/data/frameworks.json index 9352e42..271cb96 100644 --- a/data/frameworks.json +++ b/data/frameworks.json @@ -1,24 +1,29 @@ [ { - "id": "react", - "label": "React", - "icon": "react", - "enabled": true, - "docs": "https://react.dev/learn", - "isDefault": true - }, - { - "id": "nextjs", - "label": "Next.js", - "icon": "nextdotjs", - "enabled": true, - "docs": "https://nextjs.org/docs" - }, - { - "id": "angular", - "label": "Angular", - "icon": "angular", - "enabled": true, - "docs": "https://angular.io/docs" + "id": "frameworkSelection", + "question": "Which framework are you working with?", + "responseKey": "frameworkSelection", + "isReadOnlyOnSummary": true, + "answers": [ + { + "value": "react", + "label": "React", + "icon": "react", + "docs": "https://react.dev/learn", + "isDefault": true + }, + { + "value": "nextjs", + "label": "Next.js", + "icon": "nextdotjs", + "docs": "https://nextjs.org/docs" + }, + { + "value": "angular", + "label": "Angular", + "icon": "angular", + "docs": "https://angular.io/docs" + } + ] } -] +] \ No newline at end of file diff --git a/data/questions/react.json b/data/questions/react.json index 6bcc702..b621022 100644 --- a/data/questions/react.json +++ b/data/questions/react.json @@ -212,8 +212,7 @@ "cons": [ "More resource-heavy" ], - "example": "npm install --save-dev cypress", - "isDefault": true + "example": "npm install --save-dev cypress" }, { "value": "playwright", @@ -227,9 +226,10 @@ "cons": [ "Steeper learning curve" ], - "example": "npm install --save-dev playwright" + "example": "npm install --save-dev playwright", + "isDefault": true } ], "explanation": "E2E testing ensures workflows function correctly in the browser." } -] +] \ No newline at end of file diff --git a/lib/__tests__/data-defaults.test.ts b/lib/__tests__/data-defaults.test.ts index 5028ddd..e55eda1 100644 --- a/lib/__tests__/data-defaults.test.ts +++ b/lib/__tests__/data-defaults.test.ts @@ -1,88 +1,42 @@ -import { describe, it } from 'vitest' -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import { isRecord, loadQuestionDataEntries } from '../test-utils/instruction-data' +import type { QuestionDataEntry } from '../test-utils/instruction-data' -const DATA_ROOT = join(process.cwd(), 'data') - -type QuestionCandidate = { - node: Record - path: string -} - -const isRecord = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null -} +describe('Instruction data defaults', () => { + const questionsByFile = new Map() -const collectJsonFiles = (dir: string): string[] => { - return readdirSync(dir).flatMap((entry) => { - const fullPath = join(dir, entry) - const stats = statSync(fullPath) + loadQuestionDataEntries().forEach((entry) => { + const bucket = questionsByFile.get(entry.relativePath) - if (stats.isDirectory()) { - return collectJsonFiles(fullPath) + if (bucket) { + bucket.push(entry) + } else { + questionsByFile.set(entry.relativePath, [entry]) } - - return entry.endsWith('.json') ? [fullPath] : [] }) -} - -const collectQuestionCandidates = (node: unknown, currentPath: string): QuestionCandidate[] => { - if (Array.isArray(node)) { - return node.flatMap((value, index) => collectQuestionCandidates(value, `${currentPath}[${index}]`)) - } - - if (!isRecord(node)) { - return [] - } - - const candidates: QuestionCandidate[] = [] - const { answers } = node as { answers?: unknown } - - if (Array.isArray(answers)) { - candidates.push({ node, path: currentPath }) - } - - for (const [key, value] of Object.entries(node)) { - if (key === 'answers') { - continue - } - candidates.push(...collectQuestionCandidates(value, `${currentPath}.${key}`)) - } - - return candidates -} - -describe('Instruction data defaults', () => { - const jsonFiles = collectJsonFiles(DATA_ROOT) - - jsonFiles.forEach((filePath) => { - it(`ensures ${relative(process.cwd(), filePath)} questions have exactly one default`, () => { - const raw = readFileSync(filePath, 'utf8') - const parsed = JSON.parse(raw) as unknown - const questions = collectQuestionCandidates(parsed, '$') - const relativePath = relative(process.cwd(), filePath) - - questions.forEach((candidate) => { - const question = candidate.node - const { answers } = question as { answers?: unknown } - - if (!Array.isArray(answers) || answers.length === 0) { - return - } - - const questionId = typeof question.id === 'string' ? question.id : candidate.path - const defaultCount = answers.reduce((count, answer) => { - const { isDefault } = (isRecord(answer) ? answer : {}) as { isDefault?: unknown } - return isDefault === true ? count + 1 : count - }, 0) - - if (defaultCount !== 1) { - throw new Error( + Array.from(questionsByFile.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .forEach(([relativePath, entries]) => { + it(`ensures ${relativePath} questions have exactly one default`, () => { + entries.forEach(({ node, pointer }) => { + const { answers } = node as { answers?: unknown } + + if (!Array.isArray(answers) || answers.length === 0) { + return + } + + const questionId = typeof node.id === 'string' ? node.id : pointer + const defaultCount = answers.reduce((count, answer) => { + const { isDefault } = (isRecord(answer) ? answer : {}) as { isDefault?: unknown } + return isDefault === true ? count + 1 : count + }, 0) + + expect( + defaultCount, `Expected exactly one default answer in question '${questionId}' within ${relativePath}, but found ${defaultCount}.` - ) - } + ).toBe(1) + }) }) }) - }) }) diff --git a/lib/__tests__/question-default-presence.test.ts b/lib/__tests__/question-default-presence.test.ts new file mode 100644 index 0000000..a0f0dae --- /dev/null +++ b/lib/__tests__/question-default-presence.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { isRecord, loadQuestionDataEntries } from '../test-utils/instruction-data' + +describe('Instruction question defaults', () => { + it('ensures every question provides at least one default answer', () => { + const missingDefaults: string[] = [] + + loadQuestionDataEntries().forEach(({ node, pointer, relativePath }) => { + const { answers } = node as { answers?: unknown } + + if (!Array.isArray(answers) || answers.length === 0) { + return + } + + const questionId = typeof node.id === 'string' ? node.id : pointer + const hasDefault = answers.some((answer) => { + if (!isRecord(answer)) { + return false + } + + const { isDefault } = answer as { isDefault?: unknown } + return isDefault === true + }) + + if (!hasDefault) { + missingDefaults.push(`No default answer for question '${questionId}' in ${relativePath}.`) + } + }) + + expect(missingDefaults, missingDefaults.join('\n')).toHaveLength(0) + }) +}) diff --git a/lib/test-utils/instruction-data.ts b/lib/test-utils/instruction-data.ts new file mode 100644 index 0000000..51559c8 --- /dev/null +++ b/lib/test-utils/instruction-data.ts @@ -0,0 +1,77 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' + +export type QuestionDataEntry = { + filePath: string + relativePath: string + pointer: string + node: Record +} + +const DATA_ROOT = join(process.cwd(), 'data') + +export const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null +} + +const collectJsonFiles = (dir: string): string[] => { + return readdirSync(dir).flatMap((entry) => { + const fullPath = join(dir, entry) + const stats = statSync(fullPath) + + if (stats.isDirectory()) { + return collectJsonFiles(fullPath) + } + + return entry.endsWith('.json') ? [fullPath] : [] + }) +} + +type QuestionCandidate = { + pointer: string + node: Record +} + +const collectQuestionCandidates = (node: unknown, pointer: string): QuestionCandidate[] => { + if (Array.isArray(node)) { + return node.flatMap((value, index) => collectQuestionCandidates(value, `${pointer}[${index}]`)) + } + + if (!isRecord(node)) { + return [] + } + + const candidates: QuestionCandidate[] = [] + const { answers } = node as { answers?: unknown } + + if (Array.isArray(answers)) { + candidates.push({ node, pointer }) + } + + for (const [key, value] of Object.entries(node)) { + if (key === 'answers') { + continue + } + + candidates.push(...collectQuestionCandidates(value, `${pointer}.${key}`)) + } + + return candidates +} + +export const loadQuestionDataEntries = (): QuestionDataEntry[] => { + const jsonFiles = collectJsonFiles(DATA_ROOT) + + return jsonFiles.flatMap((filePath) => { + const raw = readFileSync(filePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + const relativePath = relative(process.cwd(), filePath) + + return collectQuestionCandidates(parsed, '$').map((candidate) => ({ + filePath, + relativePath, + pointer: candidate.pointer, + node: candidate.node, + })) + }) +} diff --git a/lib/wizard-summary.ts b/lib/wizard-summary.ts index db1811b..421f215 100644 --- a/lib/wizard-summary.ts +++ b/lib/wizard-summary.ts @@ -1,22 +1,36 @@ import type { FileOutputConfig, Responses, WizardStep } from "@/types/wizard" +type SummaryQuestionDetails = { + id: string + question: string + isReadOnlyOnSummary?: boolean +} + export type CompletionSummaryEntry = { id: string question: string hasSelection: boolean answers: string[] + isAutoFilled?: boolean + isReadOnlyOnSummary?: boolean } const buildFileSummaryEntry = ( + fileQuestion: SummaryQuestionDetails | null, selectedFile: FileOutputConfig | null, selectedFileFormatLabel: string | null ): CompletionSummaryEntry => { + const questionId = fileQuestion?.id ?? "instructions-file" + const questionLabel = fileQuestion?.question ?? "Instructions file" + const questionReadOnly = fileQuestion?.isReadOnlyOnSummary ?? false + if (!selectedFile) { return { - id: "instructions-file", - question: "Instructions file", + id: questionId, + question: questionLabel, hasSelection: false, answers: [], + isReadOnlyOnSummary: questionReadOnly, } } @@ -27,21 +41,24 @@ const buildFileSummaryEntry = ( ].filter((entry): entry is string => Boolean(entry)) return { - id: "instructions-file", - question: "Instructions file", + id: questionId, + question: questionLabel, hasSelection: true, answers, + isReadOnlyOnSummary: questionReadOnly, } } export const buildCompletionSummary = ( + fileQuestion: SummaryQuestionDetails | null, selectedFile: FileOutputConfig | null, selectedFileFormatLabel: string | null, steps: WizardStep[], - responses: Responses + responses: Responses, + autoFilledMap: Record = {} ): CompletionSummaryEntry[] => { const summary: CompletionSummaryEntry[] = [ - buildFileSummaryEntry(selectedFile, selectedFileFormatLabel), + buildFileSummaryEntry(fileQuestion, selectedFile, selectedFileFormatLabel), ] steps.forEach((step) => { @@ -64,6 +81,8 @@ export const buildCompletionSummary = ( question: question.question, hasSelection: selectedAnswers.length > 0, answers: selectedAnswers.map((answer) => answer.label), + isAutoFilled: Boolean(autoFilledMap[question.id]), + isReadOnlyOnSummary: Boolean(question.isReadOnlyOnSummary), }) }) }) diff --git a/lib/wizard-utils.ts b/lib/wizard-utils.ts index 6ca0e7e..c901c21 100644 --- a/lib/wizard-utils.ts +++ b/lib/wizard-utils.ts @@ -1,4 +1,4 @@ -import type { DataAnswerSource, DataQuestionSource, WizardAnswer, WizardStep } from "@/types/wizard" +import type { DataAnswerSource, DataQuestionSource, FileOutputConfig, WizardAnswer, WizardStep } from "@/types/wizard" /** * Maps a data answer source to a wizard answer format @@ -14,6 +14,9 @@ export const mapAnswerSourceToWizard = (answer: DataAnswerSource): WizardAnswer infoLines.push(`Cons: ${answer.cons.join(", ")}`) } + const isDisabled = answer.disabled ?? (answer.enabled === false) + const disabledLabel = answer.disabledLabel ?? (answer.enabled === false ? "Soon" : undefined) + return { value: answer.value, label: answer.label, @@ -23,8 +26,11 @@ export const mapAnswerSourceToWizard = (answer: DataAnswerSource): WizardAnswer docs: answer.docs, tags: answer.tags, isDefault: answer.isDefault, - disabled: answer.disabled, - disabledLabel: answer.disabledLabel, + disabled: isDisabled, + disabledLabel, + filename: answer.filename, + format: answer.format, + enabled: answer.enabled, } } @@ -43,10 +49,32 @@ export const buildStepFromQuestionSet = ( question: question.question, allowMultiple: question.allowMultiple, responseKey: question.responseKey, + isReadOnlyOnSummary: question.isReadOnlyOnSummary, answers: question.answers.map(mapAnswerSourceToWizard), })), }) +export const buildFileOptionsFromQuestion = ( + question?: DataQuestionSource | null +): FileOutputConfig[] => { + if (!question) { + return [] + } + + return question.answers + .filter((answer) => answer.enabled !== false) + .map((answer) => ({ + id: answer.value, + label: answer.label, + filename: answer.filename ?? answer.label, + format: answer.format ?? "markdown", + enabled: answer.enabled, + icon: answer.icon, + docs: answer.docs, + isDefault: answer.isDefault, + })) +} + const formatLabelMap: Record = { markdown: "Markdown", json: "JSON", diff --git a/types/wizard.ts b/types/wizard.ts index d87b51c..6c8524e 100644 --- a/types/wizard.ts +++ b/types/wizard.ts @@ -1,12 +1,3 @@ -export type FrameworkConfig = { - id: string - label: string - icon?: string - enabled?: boolean - docs?: string - isDefault?: boolean -} - export type DataAnswerSource = { value: string label: string @@ -19,6 +10,9 @@ export type DataAnswerSource = { isDefault?: boolean disabled?: boolean disabledLabel?: string + enabled?: boolean + filename?: string + format?: string } export type DataQuestionSource = { @@ -26,6 +20,7 @@ export type DataQuestionSource = { question: string allowMultiple?: boolean responseKey?: string + isReadOnlyOnSummary?: boolean answers: DataAnswerSource[] } @@ -51,6 +46,9 @@ export type WizardAnswer = { disabled?: boolean disabledLabel?: string docs?: string + filename?: string + format?: string + enabled?: boolean } export type WizardQuestion = { @@ -58,6 +56,7 @@ export type WizardQuestion = { question: string allowMultiple?: boolean responseKey?: string + isReadOnlyOnSummary?: boolean answers: WizardAnswer[] }