Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
19d77f3
ref: added dummy exam results to `KnowCMenu`
Marty-Byrde Mar 11, 2026
b8e45ee
style: lowered max-w size of practice-q container
Marty-Byrde Mar 12, 2026
ea18cc8
Merge branch 'canary' into ref/minor-improvements
Marty-Byrde Mar 25, 2026
d4e3df6
chore: added translation synchronization to `pre-commit`
Marty-Byrde Mar 25, 2026
54c9de9
ref: added `edit/[id]` layout to render specific `Breadcrumbs`
Marty-Byrde Mar 25, 2026
2b33167
Merge branch 'canary' into ref/minor-improvements
Marty-Byrde Mar 25, 2026
d96ff4d
ref: added minimum drag-drop answer constraint
Marty-Byrde Mar 25, 2026
4a3d5ed
ref: enabled `showIcon` in `FormFieldError` usages
Marty-Byrde Mar 25, 2026
1b4d162
ref: improved tooltip accessibility by allowing toggling
Marty-Byrde Mar 25, 2026
ff90fc8
ref: added textarea support to `Field` component
Marty-Byrde Mar 27, 2026
601b3da
ref: applied `Field` variant `textarea` for descriptions
Marty-Byrde Mar 27, 2026
66b7468
chore: resolved stripping of array effects in `stripDefaultValues`
Marty-Byrde Mar 27, 2026
58d59f9
ref: removed log statement in `schemaDefaults`
Marty-Byrde Mar 27, 2026
63da904
chore: added development build script
Marty-Byrde Mar 27, 2026
97676fa
Merge branch 'canary' into ref/minor-improvements
Marty-Byrde Mar 28, 2026
4b36885
ref: localized `PracticeBreadcrumbs` texts
Marty-Byrde Mar 28, 2026
ea02cfe
ref: localized `StopWatch` duration format based on locale
Marty-Byrde Mar 28, 2026
9a3c110
ref: extended `PracticeBreadcrumbs` to start from root
Marty-Byrde Mar 28, 2026
4b5e470
ref: localized `GenericBreadcrumb` page segments
Marty-Byrde Mar 28, 2026
3dd0674
ref: localized `PracticeResultsBreadcrumbs` texts
Marty-Byrde Mar 28, 2026
eccc8a3
fix: resolved `jumpBackButton` stage indecies
Marty-Byrde Mar 28, 2026
347b178
Merge branch 'canary' into ref/minor-improvements
Marty-Byrde Apr 2, 2026
7a39842
ref: localized `ContentPageBreadcrumbs` texts
Marty-Byrde Apr 2, 2026
cd68b4a
ref: improved DOM structure of `RichTextEditor`
Marty-Byrde Apr 2, 2026
1ac22cb
ref: added contents and categories to `PracticeStore`
Marty-Byrde Apr 5, 2026
4490572
feat: added help-content button during practicing
Marty-Byrde Apr 5, 2026
6a3899f
ref: wrapped show contents button in Tooltip
Marty-Byrde Apr 5, 2026
4d76e01
feat: created PracticeContentDrawer component
Marty-Byrde Apr 5, 2026
4efdeb8
ref: wrapped practice content button with new Drawer
Marty-Byrde Apr 5, 2026
08b9173
ref: simplified "contents"-help button on small screens
Marty-Byrde Apr 5, 2026
47ced7f
ref: localized `RenderPracticeQuestion` static texts
Marty-Byrde Apr 5, 2026
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
2 changes: 2 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

yarn sync-translations
git add src/i18n/locales/*.ts
Comment on lines +4 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing *.json files in git add pattern.

The sync-translations script can modify both .ts and .json files when base keys are missing (the 'update-both' mode in prepare-i18n_ally-locales.ts). The current git add only stages *.ts files, leaving modified .json files unstaged.

Proposed fix
 yarn sync-translations 
-git add src/i18n/locales/*.ts
+git add src/i18n/locales/*.ts src/i18n/locales/*.json
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
yarn sync-translations
git add src/i18n/locales/*.ts
yarn sync-translations
git add src/i18n/locales/*.ts src/i18n/locales/*.json
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.husky/pre-commit around lines 4 - 5, After running yarn sync-translations
the hook only stages TypeScript locale files (git add src/i18n/locales/*.ts) but
not JSON files; update the pre-commit hook so the git add command also stages
JSON locale files (add src/i18n/locales/*.json or use a combined glob for .ts
and .json) ensuring any .json changes produced by sync-translations are included
before commit.

npx lint-staged --allow-empty
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"semantic-release": "semantic-release",
"predev": "yarn de-instrument-code",
"sync-translations": "yarn tsx --conditions=react-server src/i18n/scripts/prepare-i18n_ally-locales.ts",
"dev": "concurrently 'NEXT_PUBLIC_MODE=development next dev --turbopack' 'nodemon --exec \"yarn tsx --conditions=react-server src/i18n/scripts/prepare-i18n_ally-locales.ts\" --watch src/i18n/locales/*.json'",
"-------- build & start --------": "--------",
"instrument-code": "cp ./config/.babelrc.json .babelrc.json",
Expand All @@ -15,6 +16,7 @@
"build:instrumented": "next build",
"postbuild:instrumented": "yarn postbuild && yarn de-instrument-code",
" ": "",
"build:dev": "__NEXT_TEST_MODE=true next build",
"build": "next build",
"postbuild": "cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/",
" ": "",
Expand Down
15 changes: 12 additions & 3 deletions src/app/[locale]/courses/[share_token]/practice/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,17 @@ export default async function PracticePage({ params, searchParams }: { params: P
}

return (
<PracticeStoreProvider initialStoreProps={{ questions: unfilteredQuestions, practiceQuestions, courseId: course.id }} key={category}>
<PracticeBreadcrumbs className={cn('mb-2', categories.length === 1 && 'hidden')} share_token={share_token} categories={categories} selectedCategory={category} />
<PracticeStoreProvider
initialStoreProps={{ questions: unfilteredQuestions, practiceQuestions, courseId: course.id, contents: course.contents, categories: course.questionCategories }}
key={category}>
<PracticeBreadcrumbs
courseId={course.id}
courseName={course.name}
className={cn('mb-2', categories.length === 1 && 'hidden')}
share_token={share_token}
categories={categories}
selectedCategory={category}
/>

<PageHeading title='Practice' />

Expand All @@ -74,7 +83,7 @@ export default async function PracticePage({ params, searchParams }: { params: P
<PracticeQuestionNavigation />
</div>
<div className='flex justify-center @[60rem]:order-first'>
<div className='flex max-w-11/12 flex-1 flex-col 2xl:max-w-4/5'>
<div className='flex max-w-11/12 flex-1 flex-col 2xl:max-w-[50vw]'>
<PracticeProgress />
<RenderPracticeQuestion />
<div className={cn('flex justify-between', practiceQuestions.length <= 1 && 'hidden')}>
Expand Down
36 changes: 36 additions & 0 deletions src/app/[locale]/courses/edit/[id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getCourseById } from '@/database/course/select'
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/src/components/shadcn/breadcrumb'

export default async function EditLayout({ children, params }: { children: React.ReactNode; params: Promise<{ id: string }> }) {
const { id } = await params
const course = await getCourseById(id)

if (!course) notFound()

return (
<>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbLink asChild>
<Link href={`/`}>Home</Link>
</BreadcrumbLink>
<BreadcrumbSeparator />

<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/courses`}>Courses</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>Edit</BreadcrumbItem>

<BreadcrumbSeparator />
<BreadcrumbPage>{course.name}</BreadcrumbPage>
</BreadcrumbList>
</Breadcrumb>
{children}
</>
)
}
14 changes: 8 additions & 6 deletions src/components/Shared/Breadcrumb/GenericBreadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Fragment } from 'react/jsx-runtime'
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/src/components/shadcn/breadcrumb'
import { useCurrentLocale } from '@/src/i18n/client-localization'
import { useCurrentLocale, useScopedI18n } from '@/src/i18n/client-localization'
import { cn } from '@/src/lib/Shared/utils'
import { Any } from '@/types'

export function GenericBreadcrumb({ show = true }: { show?: boolean }) {
const [breadcrumbExists, setBreadcrumbExists] = useState(false)
const t = useScopedI18n('Shared.Breadcrumbs')
const locale = useCurrentLocale()
const pathname = usePathname()
const pages = pathname.split('?').at(0)!.split('/')!
Expand All @@ -34,20 +36,20 @@ export function GenericBreadcrumb({ show = true }: { show?: boolean }) {
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={'/'} className='capitalize'>
Home
{t('root')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>

{pages?.map((p, i) => (
<Fragment key={p + i}>
{pages?.map((segment, i) => (
<Fragment key={segment + i}>
<BreadcrumbSeparator />
<BreadcrumbItem className='capitalize'>
{isCurrentPage(i) ? (
<BreadcrumbPage>{p}</BreadcrumbPage>
<BreadcrumbPage>{t(segment as Any)}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={'/' + pages.slice(0, i + 1).join('/')}>{p}</Link>
<Link href={'/' + pages.slice(0, i + 1).join('/')}>{t(segment as Any)}</Link>
</BreadcrumbLink>
Comment on lines +44 to 53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/components/Shared/Breadcrumb/GenericBreadcrumb.tsx

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 2679


🏁 Script executed:

# Search for i18n configuration files
find . -type f \( -name "i18n*" -o -name "*i18n*" -o -name "*.config*" -o -name "next-i18next*" \) 2>/dev/null | head -20

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 837


🏁 Script executed:

# Look for the i18n setup or locale files
fd -type f -name "*.json" | xargs grep -l "courses\|discover\|practice" 2>/dev/null | head -10

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 250


🏁 Script executed:

# Check for i18n usage patterns in the project
rg "useTranslation|t\(" src/ -A 2 -B 2 --max-count=5

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 50393


🏁 Script executed:

cat -n src/i18n/i18nConfig.ts

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 557


🏁 Script executed:

# Find breadcrumb translation files
find . -path ./node_modules -prune -o -type f \( -name "*.json" -o -name "*.ts" -o -name "*.js" \) -print | xargs grep -l "Shared.Breadcrumbs\|Breadcrumbs" 2>/dev/null | head -20

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 183


🏁 Script executed:

# Check client-localization implementation
cat -n src/i18n/client-localization.ts | head -100

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 617


🏁 Script executed:

# Look for translation file directories
find . -path ./node_modules -prune -o -type d -name "locales" -o -name "translations" -o -name "messages" -print 2>/dev/null

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 62


🏁 Script executed:

cat -n src/i18n/locales/en.json | grep -A 50 "Breadcrumb\|breadcrumb" -i

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 2172


🏁 Script executed:

# Check en.ts file for breadcrumb translations
cat -n src/i18n/locales/en.ts | head -200

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 9166


🏁 Script executed:

# Look for the actual structure of breadcrumb keys
rg "Shared.*Breadcrumb" src/i18n/locales/ -A 5 -B 5

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 62


🏁 Script executed:

# Search for usages of GenericBreadcrumb to see what paths contain
rg "GenericBreadcrumb|breadcrumb" src/ -A 3 -B 3 --type tsx --type ts | head -100

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 107


🏁 Script executed:

# Look for usages of breadcrumb and what paths might contain
rg "GenericBreadcrumb" src/ -l

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 209


🏁 Script executed:

# Check what pages might have IDs or slugs in their breadcrumbs
rg "courses/\|practice/\|edit/" src/app src/components --type ts --type js -A 2 -B 2 | head -80

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 62


🏁 Script executed:

# Look at next-international behavior for missing keys
rg "next-international\|useScopedI18n" src/ -A 3 -B 1 | head -150

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 62


🏁 Script executed:

# Search for examples of dynamic segments in breadcrumbs (like IDs, slugs)
find src/app -type f -name "page.tsx" -o -name "layout.tsx" | xargs grep -l "courses\|practice" 2>/dev/null | head -10

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 581


🏁 Script executed:

# Check the actual routes to understand what segments appear
cat -n src/app/[locale]/courses/edit/[id]/page.tsx | head -30

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 981


🏁 Script executed:

# Check one of the share_token pages
cat -n src/app/[locale]/courses/[share_token]/page.tsx | head -30

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 1981


🏁 Script executed:

# Look at how next-international handles missing keys - check documentation or implementation
find . -path ./node_modules/next-international -prune -o -type f -name "*.ts" -o -name "*.js" | xargs grep -l "missing.*key\|defaultValue" 2>/dev/null | grep -v node_modules | head -5

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 328


🏁 Script executed:

# Check if there's a fallback mechanism or if missing keys are logged
rg "defaultValue\|fallback\|missing" src/i18n/ -A 2 -B 2

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 62


🏁 Script executed:

# Look for how next-international handles missing translation keys
rg "key" node_modules/next-international -A 2 -B 2 --max-count=3 2>/dev/null | head -40

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 12198


🏁 Script executed:

# Search for any custom i18n setup or handling
find src/i18n -type f -name "*.ts" -o -name "*.js" | xargs cat -n 2>/dev/null

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 50392


🏁 Script executed:

# Check if there are any error boundaries or fallback handlers for translations
rg "Breadcrumb\|translation\|fallback" src/components/Shared/Breadcrumb/ -A 5 -B 5

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 62


🏁 Script executed:

# Look at actual test or usage to see what happens with dynamic segments
rg "GenericBreadcrumb" src/ -A 10 -B 5

Repository: Master-Thesis-188199/KnowledgeCheckr

Length of output: 5052


Translate only known breadcrumb segments; fall back to decoded segment text for dynamic values.

On lines 49 and 52, all path segments are unconditionally translated via t(segment as Any). Since routes contain dynamic segments (e.g., /courses/[share_token] or /edit/[id]), this causes missing translation keys for UUIDs and tokens. Without a configured fallback locale, these unknown segments will display as missing-key strings rather than human-readable text.

Maintain a set of known static breadcrumb keys and fall back to decodeURIComponent(segment) for any unknown segment.

💡 Proposed fix
-        {pages?.map((segment, i) => (
+        {pages?.map((segment, i) => (
           <Fragment key={segment + i}>
             <BreadcrumbSeparator />
             <BreadcrumbItem className='capitalize'>
+              {/** translate only known breadcrumb keys */}
+              {(() => {
+                const knownKeys = new Set([
+                  'courses',
+                  'discover',
+                  'practice',
+                  'edit',
+                  'create',
+                  'account',
+                  'start',
+                  'results',
+                  'contents',
+                  'not-allowed',
+                  'attempt-limit',
+                  'attempt-not-possible',
+                ])
+                const label = knownKeys.has(segment) ? t(segment as Any) : decodeURIComponent(segment)
+
+                return isCurrentPage(i) ? (
+                  <BreadcrumbPage>{label}</BreadcrumbPage>
+                ) : (
+                  <BreadcrumbLink asChild>
+                    <Link href={'/' + pages.slice(0, i + 1).join('/')}>{label}</Link>
+                  </BreadcrumbLink>
+                )
+              })()}
-              {isCurrentPage(i) ? (
-                <BreadcrumbPage>{t(segment as Any)}</BreadcrumbPage>
-              ) : (
-                <BreadcrumbLink asChild>
-                  <Link href={'/' + pages.slice(0, i + 1).join('/')}>{t(segment as Any)}</Link>
-                </BreadcrumbLink>
-              )}
             </BreadcrumbItem>
           </Fragment>
         ))}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{pages?.map((segment, i) => (
<Fragment key={segment + i}>
<BreadcrumbSeparator />
<BreadcrumbItem className='capitalize'>
{isCurrentPage(i) ? (
<BreadcrumbPage>{p}</BreadcrumbPage>
<BreadcrumbPage>{t(segment as Any)}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={'/' + pages.slice(0, i + 1).join('/')}>{p}</Link>
<Link href={'/' + pages.slice(0, i + 1).join('/')}>{t(segment as Any)}</Link>
</BreadcrumbLink>
{pages?.map((segment, i) => (
<Fragment key={segment + i}>
<BreadcrumbSeparator />
<BreadcrumbItem className='capitalize'>
{/** translate only known breadcrumb keys */}
{(() => {
const knownKeys = new Set([
'courses',
'discover',
'practice',
'edit',
'create',
'account',
'start',
'results',
'contents',
'not-allowed',
'attempt-limit',
'attempt-not-possible',
])
const label = knownKeys.has(segment) ? t(segment as Any) : decodeURIComponent(segment)
return isCurrentPage(i) ? (
<BreadcrumbPage>{label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={'/' + pages.slice(0, i + 1).join('/')}>{label}</Link>
</BreadcrumbLink>
)
})()}
</BreadcrumbItem>
</Fragment>
))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Shared/Breadcrumb/GenericBreadcrumb.tsx` around lines 44 - 53,
The breadcrumb renders every path segment through the i18n function t (in
GenericBreadcrumb.tsx inside the pages?.map loop using isCurrentPage,
BreadcrumbPage and BreadcrumbLink), which yields missing-key text for dynamic
segments; fix by defining a set/array of known static segment keys (e.g.,
knownBreadcrumbKeys) and, inside the map where t(segment as Any) is used, render
t(segment) only if segment is in knownBreadcrumbKeys, otherwise render
decodeURIComponent(segment) (optionally apply the existing 'capitalize' class)
so dynamic tokens/IDs show readable text instead of missing translation keys.

)}
</BreadcrumbItem>
Expand Down
10 changes: 8 additions & 2 deletions src/components/Shared/StopwatchTime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useCallback, useEffect, useState } from 'react'
import { differenceInMilliseconds, formatDuration } from 'date-fns'
import { de, enUS } from 'date-fns/locale'
import { useCurrentLocale } from '@/src/i18n/client-localization'

/**
* This component essentially returns / displays the time that a user e.g. spents on a page.
Expand All @@ -10,6 +12,7 @@ import { differenceInMilliseconds, formatDuration } from 'date-fns'
* @returns The time that has passed since the `start`
*/
export function StopwatchTime({ start: rawStartDate, delimiter = ' and ' }: { start: Date; delimiter?: string }) {
const currentLocale = useCurrentLocale()
const [timeSpent, setTimeSpent] = useState<string | null>(null)
const [startDate] = useState(new Date(Date.parse(rawStartDate.toString()))) //* ensure date-object even if stringified

Expand All @@ -18,9 +21,12 @@ export function StopwatchTime({ start: rawStartDate, delimiter = ' and ' }: { st
const differenceDate = new Date(difference)

setTimeSpent(
formatDuration({ seconds: differenceDate.getSeconds(), minutes: differenceDate.getMinutes() || undefined, hours: differenceDate.getHours() - 1 || undefined }, { zero: true, delimiter }),
formatDuration(
{ seconds: differenceDate.getSeconds(), minutes: differenceDate.getMinutes() || undefined, hours: differenceDate.getHours() - 1 || undefined },
{ zero: true, delimiter, locale: currentLocale === 'de' ? de : enUS },
),
)
}, [startDate, setTimeSpent, delimiter])
}, [startDate, setTimeSpent, delimiter, currentLocale])

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
Expand Down
25 changes: 23 additions & 2 deletions src/components/Shared/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { Tooltip as ShadcnTooltip, TooltipContent, TooltipTrigger } from '@/src/components/shadcn/tooltip'
import { cn } from '@/src/lib/Shared/utils'
Expand All @@ -12,9 +13,29 @@ export type TooltipProps = Omit<React.ComponentProps<typeof TooltipPrimitive.Con
}

export default function Tooltip({ disabled, config = {}, delay = 250, variant = 'normal', content, children, ...props }: TooltipProps) {
const [isHovered, setIsHovered] = useState(false)
const [isPinned, setPinned] = useState(config.open ?? false)

useEffect(() => {
if (config.open === undefined || config.open === isPinned) return

// eslint-disable-next-line react-hooks/set-state-in-effect
setPinned(config.open)
}, [config.open])

return (
<ShadcnTooltip delayDuration={delay} {...config} open={disabled === true ? false : config.open}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<ShadcnTooltip
delayDuration={delay}
{...config}
open={disabled === true ? false : isHovered || isPinned}
onOpenChange={(isOpen) => {
if (!isPinned) setIsHovered(isOpen)

config.onOpenChange?.(isOpen)
}}>
<TooltipTrigger asChild onClick={() => setPinned((prev) => !prev)} aria-label='toggle tooltip'>
{children}
</TooltipTrigger>
<TooltipContent
{...props}
className={cn(
Expand Down
71 changes: 43 additions & 28 deletions src/components/Shared/form/Field.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,45 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react'
import { ChangeEvent, ComponentProps, JSX, useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { InfoIcon, TriangleAlertIcon } from 'lucide-react'
import { FieldValues, UseFormReturn } from 'react-hook-form'
import { FormControl, FormField, FormLabel, FormMessage } from '@/src/components/shadcn/form'
import { Input as ShadcnInput } from '@/src/components/shadcn/input'
import { Textarea } from '@/src/components/shadcn/textarea'
import Tooltip from '@/src/components/Shared/Tooltip'
import { cn } from '@/src/lib/Shared/utils'
import { DescriptionMap, getDescriptionForRhfName } from '@/src/schemas/utils/extractDescriptions'
import { Any } from '@/types'
type BaseFieldProps<Values extends FieldValues> = {
form: UseFormReturn<Values>
name: Parameters<typeof FormField<Values>>['0']['name']
label?: string
descriptions?: DescriptionMap
showLabel?: boolean
labelClassname?: string
children?: React.ReactNode
containerClassname?: string
modifyValue?: (value: Any) => Any
}

type InputFieldProps = {
variant?: 'input'
onChange?: (values: ChangeEvent<HTMLInputElement>['target']) => unknown
} & Omit<ComponentProps<'input'>, 'onChange' | 'name' | 'form'>

type TextareaFieldProps = {
variant: 'textarea'
onChange?: (values: ChangeEvent<HTMLTextAreaElement>['target']) => unknown
} & Omit<ComponentProps<typeof Textarea>, 'onChange' | 'name' | 'form' | 'children' | 'type'>

type FieldProps<Values extends FieldValues> = BaseFieldProps<Values> & (InputFieldProps | TextareaFieldProps)

export default function Field<Values extends FieldValues>(props: BaseFieldProps<Values> & InputFieldProps): JSX.Element
export default function Field<Values extends FieldValues>(props: BaseFieldProps<Values> & TextareaFieldProps): JSX.Element
export default function Field<Values extends FieldValues>({
form,
name,
onChange,
variant = 'input',
label,
descriptions,
showLabel = true,
Expand All @@ -21,18 +48,7 @@ export default function Field<Values extends FieldValues>({
children,
modifyValue,
...props
}: {
form: UseFormReturn<Values>
name: Parameters<typeof FormField<Values>>['0']['name']
label?: string
descriptions?: DescriptionMap
showLabel?: boolean
labelClassname?: string
children?: React.ReactNode
containerClassname?: string
onChange?: (values: ChangeEvent<HTMLInputElement>['target']) => unknown
modifyValue?: (value: Any) => Any
} & Omit<React.ComponentProps<'input'>, 'onChange' | 'name' | 'form'>) {
}: FieldProps<Values>) {
const [isFocused, setIsFocused] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const previousFocusState = useRef(false)
Expand All @@ -53,21 +69,23 @@ export default function Field<Values extends FieldValues>({
const showDescription = (isFocused && !hasError) || isHovered
const description = descriptions ? getDescriptionForRhfName(descriptions, field.name) : undefined

const ControlledComponent = variant === 'textarea' ? Textarea : ShadcnInput

// when true --> prevents layout shifts when switching between error / description by setting min-h to animation-container. Note this "feature" is only enabled when there is something to switch between (thus, when there is a description (&& !!description))
const keepAnimationContainerSize = previousFocusState.current && (showDescription || hasError) && !!description

const fieldOnChange = (e: ChangeEvent<HTMLInputElement>) => {
const fieldOnChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// use 'custom' onChange to override value
if (onChange) {
return field.onChange({
...e,
target: { value: onChange(e.target) },
target: { value: onChange(e.target as Any) },
})
}

// auto-support number inputs to use `valueAsNumber`
if (props.type === 'number') {
return field.onChange({ ...e, target: { ...e.target, value: e.target.valueAsNumber } })
if (variant === 'input' && (props as InputFieldProps).type === 'number') {
return field.onChange({ ...e, target: { ...e.target, value: (e as ChangeEvent<HTMLInputElement>).target.valueAsNumber } })
}

return field.onChange(e)
Expand All @@ -79,22 +97,19 @@ export default function Field<Values extends FieldValues>({

<div className={cn('relative grid', containerClassname)}>
<FormControl>
<ShadcnInput
autoComplete={field.name.includes('password') ? 'current-password' : field.name}
// 'pr-8' moves input indicators like 'number' | 'date' to the left to make room for the info / error icon and ensures text does not cover info icon
className={cn('peer hover:cursor-text', (description || hasError) && 'pr-8')}
<ControlledComponent
{...field}
{...(props as Any)}
value={modifyValue ? modifyValue(field.value) : field.value}
{...props}
disabled={field.disabled || props.disabled}
className={cn('peer hover:cursor-text', (description || hasError) && 'pr-8', props.className)}
onFocus={(e) => {
// this prevents the description from being shown when the checkbox is clicked --> thus has focus
if (props.type !== 'checkbox') setIsFocused(true)
props.onFocus?.(e)
setIsFocused(true)
props.onFocus?.(e as Any)
}}
onBlur={(e) => {
setIsFocused(false)
props.onBlur?.(e)
props.onBlur?.(e as Any)
field.onBlur()
}}
onChange={fieldOnChange}
Expand All @@ -120,7 +135,7 @@ export default function Field<Values extends FieldValues>({
// disabled state styles
'data-[disabled=true]:text-muted-foreground/60 data-[disabled=true]:hover:text-muted-foreground/70 dark:data-[disabled=true]:hover:text-muted-foreground',
// positions the icon next to the checkbox
props.type === 'checkbox' && 'inset-y-0 right-auto left-7 items-center',
variant === 'input' && (props as InputFieldProps).type === 'checkbox' && 'inset-y-0 right-auto left-7 items-center',
!description && 'hidden',
)}>
<InfoIcon className={cn('size-4')} />
Expand All @@ -137,7 +152,7 @@ export default function Field<Values extends FieldValues>({
className={cn(
'absolute inset-y-0 top-2.5 right-3 z-10 flex items-baseline text-destructive',
// positions the icon next to the checkbox
props.type === 'checkbox' && 'top-0.5 right-auto bottom-0 left-7 items-baseline',
variant === 'input' && (props as InputFieldProps).type === 'checkbox' && 'top-0.5 right-auto bottom-0 left-7 items-baseline',
)}>
<TriangleAlertIcon className={cn('size-4')} />
</motion.div>
Expand Down
6 changes: 5 additions & 1 deletion src/components/courses/(hamburger-menu)/CourseActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ export default function CourseActionMenu({ id, questions, share_key, owner_id, c
{t('clone_course.label')}
<CopyPlusIcon className='size-4 text-neutral-600 group-data-disabled:text-inherit dark:text-neutral-400 dark:group-data-disabled:text-inherit' />
</DropdownMenuItem>
<DropdownMenuItem disabled>{t('inspect_statistics.label')}</DropdownMenuItem>
<DropdownMenuItem>
<Link href={`/checks/${share_key}/results`} className='flex flex-1 justify-between'>
{t('inspect_statistics.label')}
Comment on lines +168 to +170

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard nullable share_key before building the statistics route.

On Line 169, share_key can be null, which builds /checks/null/results and leads to invalid navigation for courses without a share token.

💡 Proposed fix
-          <DropdownMenuItem>
-            <Link href={`/checks/${share_key}/results`} className='flex flex-1 justify-between'>
-              {t('inspect_statistics.label')}
-            </Link>
-          </DropdownMenuItem>
+          <DropdownMenuItem
+            disabled={share_key === null}
+            enableTooltip={share_key === null}
+            tooltipOptions={{ ...baseTooltipOptions, content: t('remove_share_token.tooltip') }}
+            onClick={() => {
+              if (!share_key) return
+              router.push(`/checks/${share_key}/results`)
+            }}>
+            {t('inspect_statistics.label')}
+          </DropdownMenuItem>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/courses/`(hamburger-menu)/CourseActionMenu.tsx around lines
168 - 170, The DropdownMenuItem builds a link using a nullable variable
share_key (in CourseActionMenu) which can produce "/checks/null/results"; fix by
guarding share_key before constructing the route—only render the
DropdownMenuItem (or set the Link href) when share_key is truthy, otherwise omit
the item or render a disabled/alternate UI; update the Link usage inside
CourseActionMenu so the href is computed conditionally from share_key (or set
href to undefined) to prevent invalid navigation.

</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />

Expand Down
Loading
Loading