Browse Source

feat: add Enter shortcut for interview questions

Alvin Unreal 3 months ago
parent
commit
5c7c77411e
3 changed files with 112 additions and 6 deletions
  1. 48 1
      src/interview/interview.test.ts
  2. 63 4
      src/interview/ui.ts
  3. 1 1
      src/utils/system-collapse.test.ts

+ 48 - 1
src/interview/interview.test.ts

@@ -1618,9 +1618,50 @@ describe('renderInterviewPage', () => {
     expect(html).toContain(
       "window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });",
     );
-    expect(html).toContain('overlayText.textContent = "Submitting Answers...";');
+    expect(html).toContain(
+      'overlayText.textContent = "Submitting Answers...";',
+    );
     expect(html).toContain('scrollToTop();');
   });
+
+  test('includes plain Enter shortcut to advance or submit outside text entry', () => {
+    const html = renderInterviewPage('enter-test', 'enter-test');
+
+    expect(html).toContain('function isTextEntryTarget(target)');
+    expect(html).toContain('function isShortcutBlockedTarget(target)');
+    expect(html).toContain("if (e.key === 'Enter') {");
+    expect(html).toContain(
+      'const activeQ = questions[state.activeQuestionIndex];',
+    );
+    expect(html).toContain(
+      "const answer = (state.answers[activeQ.id] || '').trim();",
+    );
+    expect(html).toContain('if (e.repeat) {');
+    expect(html).toContain(
+      'if (state.data.isBusy || state.isSubmitting) return;',
+    );
+    expect(html).toContain('advanceToNextQuestion(activeQ.id);');
+    expect(html).toContain('if (submitBtn && !submitBtn.disabled) {');
+    expect(html).toContain('submitBtn.click();');
+  });
+
+  test('keeps Enter in textarea on normal multiline behavior', () => {
+    const html = renderInterviewPage(
+      'textarea-enter-test',
+      'textarea-enter-test',
+    );
+
+    expect(html).toContain('if (isTextEntryTarget(e.target)) return;');
+  });
+
+  test('guards against duplicate submit while answers are posting', () => {
+    const html = renderInterviewPage('submit-guard-test', 'submit-guard-test');
+
+    expect(html).toContain('isSubmitting: false');
+    expect(html).toContain('if (!state.data || state.isSubmitting) return;');
+    expect(html).toContain('state.isSubmitting = true;');
+    expect(html).toContain('state.isSubmitting = false;');
+  });
 });
 
 /** Discover a free port by briefly binding to port 0, then closing. */
@@ -1657,7 +1698,13 @@ describe('interview server port configuration', () => {
           isBusy: false,
         }) as any,
     ),
+    listInterviewFiles: mock(async () => []),
+    listInterviews: mock(() => []),
     submitAnswers: mock(async (_id: string, _answers: InterviewAnswer[]) => {}),
+    handleNudgeAction: mock(
+      async (_id: string, _action: 'more-questions' | 'confirm-complete') => {},
+    ),
+    outputFolder: 'interview',
   };
 
   test('server starts on a specific port when port is non-zero', async () => {

+ 63 - 4
src/interview/ui.ts

@@ -897,7 +897,14 @@ export function renderInterviewPage(
       ${clipboardHelperJs()}
       const interviewId = ${JSON.stringify(interviewId).replace(/</g, '\\u003c')};
       const resumeSlug = ${JSON.stringify(resumeSlug).replace(/</g, '\\u003c')};
-      const state = { data: null, answers: {}, activeQuestionIndex: 0, lastSig: null, customMode: {} };
+      const state = {
+        data: null,
+        answers: {},
+        activeQuestionIndex: 0,
+        lastSig: null,
+        customMode: {},
+        isSubmitting: false,
+      };
 
       function updateSubmitButton() {
         const button = document.getElementById('submitButton');
@@ -910,7 +917,11 @@ export function renderInterviewPage(
         const allAnswered = questions.every((question) =>
           (state.answers[question.id] || '').trim().length > 0,
         );
-        button.disabled = state.data.isBusy || !questions.length || !allAnswered;
+        button.disabled =
+          state.data.isBusy ||
+          state.isSubmitting ||
+          !questions.length ||
+          !allAnswered;
         const hideSubmit = ['completed', 'session-disconnected'];
         button.style.display = hideSubmit.includes(state.data.mode) ? 'none' : '';
         
@@ -1061,6 +1072,20 @@ export function renderInterviewPage(
          });
       }
 
+      function isTextEntryTarget(target) {
+        return target &&
+          (target.tagName === 'TEXTAREA' ||
+            target.tagName === 'INPUT' ||
+            target.isContentEditable);
+      }
+
+      function isShortcutBlockedTarget(target) {
+        if (!target) return false;
+        return !!target.closest(
+          'button, a, select, summary, textarea, input, [contenteditable="true"]',
+        );
+      }
+
       document.addEventListener('keydown', (e) => {
         const isSubmitShortcut =
           (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) ||
@@ -1074,12 +1099,41 @@ export function renderInterviewPage(
           return;
         }
 
-        if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
+        if (isTextEntryTarget(e.target)) return;
         if (e.ctrlKey || e.metaKey || e.altKey) return;
 
         const questions = state.data?.questions || [];
         if (!questions.length) return;
 
+        if (e.key === 'Enter') {
+          if (e.repeat) {
+            e.preventDefault();
+            return;
+          }
+          if (isShortcutBlockedTarget(e.target)) return;
+          if (state.data.isBusy || state.isSubmitting) return;
+
+          const activeQ = questions[state.activeQuestionIndex];
+          if (!activeQ) return;
+
+          const answer = (state.answers[activeQ.id] || '').trim();
+          if (!answer) return;
+
+          const isLastQuestion =
+            state.activeQuestionIndex === questions.length - 1;
+          if (isLastQuestion) {
+            const submitBtn = document.getElementById('submitButton');
+            if (submitBtn && !submitBtn.disabled) {
+              submitBtn.click();
+            }
+          } else {
+            advanceToNextQuestion(activeQ.id);
+          }
+
+          e.preventDefault();
+          return;
+        }
+
          const num = parseInt(e.key, 10);
          if (num >= 1 && num <= 9) {
           const activeQ = questions[state.activeQuestionIndex];
@@ -1395,7 +1449,7 @@ export function renderInterviewPage(
       }
 
       document.getElementById('submitButton').addEventListener('click', async () => {
-        if (!state.data) return;
+        if (!state.data || state.isSubmitting) return;
         const answers = (state.data.questions || []).map((question) => {
           return {
             questionId: question.id,
@@ -1403,6 +1457,9 @@ export function renderInterviewPage(
           };
         });
 
+        state.isSubmitting = true;
+        updateSubmitButton();
+
         const overlay = document.getElementById('loadingOverlay');
         const overlayText = document.getElementById('loadingText');
         overlay.classList.add('active');
@@ -1420,6 +1477,8 @@ export function renderInterviewPage(
         } catch (err) {
           document.getElementById('submitStatus').textContent = 'Error submitting answers.';
         }
+        state.isSubmitting = false;
+        updateSubmitButton();
         try {
           await refresh();
         } catch (_error) {

+ 1 - 1
src/utils/system-collapse.test.ts

@@ -1,4 +1,4 @@
-import { expect, describe, test } from 'bun:test';
+import { describe, expect, test } from 'bun:test';
 import { collapseSystemInPlace } from './system-collapse';
 
 /**