feat: add convex-helpers dependency and implement module toggling in ProfileEditor for improved build configuration management

This commit is contained in:
Ben Allfree
2025-11-26 03:33:03 -08:00
parent cfe053b68c
commit bb1746a007
12 changed files with 402 additions and 416 deletions
+33
View File
@@ -0,0 +1,33 @@
import { Switch } from '@/components/ui/switch'
interface ModuleToggleProps {
id: string
name: string
description: string
isExcluded: boolean
onToggle: (excluded: boolean) => void
}
export function ModuleToggle({
name,
description,
isExcluded,
onToggle,
}: ModuleToggleProps) {
return (
<div className="flex items-start gap-4 p-4 rounded-lg border-2 border-slate-700 bg-slate-900/50 hover:border-slate-600 transition-colors">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm mb-1">{name}</h4>
<p className="text-xs text-slate-400 leading-relaxed">{description}</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={isExcluded}
onCheckedChange={onToggle}
labelLeft="Default"
labelRight="Excluded"
/>
</div>
</div>
)
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Doc } from '../../convex/_generated/dataModel'
import type { ProfileFields } from '../../convex/schema'
export const profileCardClasses =
'border border-slate-800 rounded-lg p-6 bg-slate-900/50 flex flex-col gap-4'
@@ -30,7 +31,7 @@ export function ProfileStatisticPills({
}
interface ProfileCardContentProps {
profile: Doc<'profiles'>
profile: Doc<'profiles'> & ProfileFields
}
export function ProfileCardContent({ profile }: ProfileCardContentProps) {
+63 -43
View File
@@ -4,21 +4,21 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import modulesData from '../../convex/modules.json'
import type { ProfileFields, ProfilesDoc } from '../../convex/schema'
import { VERSIONS } from '../constants/versions'
import { ModuleCard } from './ModuleCard'
import { ModuleToggle } from './ModuleToggle'
interface ProfileFormValues {
name: string
description: string
config: Record<string, boolean>
version: string
isPublic: boolean
// Form values use flattened config for UI, but will be transformed to nested on submit
type ProfileFormValues = Omit<
ProfileFields,
'_id' | '_creationTime' | 'userId' | 'flashCount' | 'updatedAt' | 'config'
> & {
config: Record<string, boolean | undefined> // Flattened: moduleId -> boolean
}
interface ProfileEditorProps {
initialData?: Doc<'profiles'>
initialData?: ProfilesDoc
onSave: () => void
onCancel: () => void
}
@@ -28,8 +28,28 @@ export default function ProfileEditor({
onSave,
onCancel,
}: ProfileEditorProps) {
const createProfile = useMutation(api.profiles.create)
const updateProfile = useMutation(api.profiles.update)
const upsertProfile = useMutation(api.profiles.upsert)
// Flatten config for UI: transform config.modulesExcluded to flat object
const getFlattenedConfig = (
config: ProfileFields['config'] | undefined
): Record<string, boolean> => {
if (!config || !config.modulesExcluded) return {}
return { ...config.modulesExcluded }
}
// Transform flat config back to nested structure for database
const getNestedConfig = (
flatConfig: Record<string, boolean | undefined>
): ProfileFields['config'] => {
const modulesExcluded: Record<string, boolean> = {}
for (const [key, value] of Object.entries(flatConfig)) {
if (value === true) {
modulesExcluded[key] = true
}
}
return { modulesExcluded }
}
const {
register,
@@ -41,31 +61,23 @@ export default function ProfileEditor({
defaultValues: {
name: initialData?.name || '',
description: initialData?.description || '',
config: initialData?.config || {},
config: getFlattenedConfig(initialData?.config),
version: initialData?.version || VERSIONS[0],
isPublic: initialData?.isPublic ?? true,
},
})
const onSubmit = async (data: ProfileFormValues) => {
if (initialData?._id) {
await updateProfile({
id: initialData._id,
name: data.name,
description: data.description,
config: data.config,
version: data.version,
isPublic: data.isPublic,
})
} else {
await createProfile({
name: data.name,
description: data.description,
config: data.config,
version: data.version,
isPublic: data.isPublic,
})
}
// Transform flattened config back to nested structure
const nestedConfig = getNestedConfig(data.config)
await upsertProfile({
id: initialData?._id,
name: data.name,
description: data.description,
config: nestedConfig,
version: data.version,
isPublic: data.isPublic,
})
onSave()
}
@@ -151,28 +163,36 @@ export default function ProfileEditor({
<div className="mb-4">
<h3 className="text-lg font-medium">Modules</h3>
<p className="text-sm text-slate-400">
Select the modules to include in your build.
Modules are included by default if supported by your target.
Toggle to exclude modules you don't need.
</p>
</div>
<div className="flex flex-col gap-2">
{modulesData.modules.map((module) => {
// Inverted logic:
// config[id] === false -> Explicitly Included
// config[id] === true or undefined -> Excluded
const configValue = watch(`config.${module.id}`)
const isIncluded = configValue === false
// Flattened config: config[id] === true -> Explicitly Excluded
// config[id] === undefined/false -> Default (included if target supports)
const currentConfig = watch('config') as Record<
string,
boolean | undefined
>
const configValue = currentConfig[module.id]
const isExcluded = configValue === true
return (
<ModuleCard
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
selected={isIncluded}
onClick={() => {
// Toggle:
// If currently included (true), we want to exclude (set config to true)
// If currently excluded (false), we want to include (set config to false)
setValue(`config.${module.id}`, !!isIncluded)
isExcluded={isExcluded}
onToggle={(excluded) => {
const newConfig = { ...currentConfig }
if (excluded) {
newConfig[module.id] = true
} else {
delete newConfig[module.id]
}
setValue('config', newConfig)
}}
/>
)
+51
View File
@@ -0,0 +1,51 @@
import { cn } from '@/lib/utils'
interface SwitchProps {
checked: boolean
onCheckedChange: (checked: boolean) => void
disabled?: boolean
className?: string
labelLeft?: string
labelRight?: string
}
export function Switch({
checked,
onCheckedChange,
disabled = false,
className,
labelLeft,
labelRight,
}: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-8 w-24 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-red-600' : 'bg-slate-600',
className
)}
>
<span
className={cn(
'inline-block h-6 w-6 transform rounded-full bg-white transition-transform',
checked ? 'translate-x-[68px]' : 'translate-x-1'
)}
/>
{checked && labelRight && (
<span className="absolute left-2 text-xs font-medium text-white">
{labelRight}
</span>
)}
{!checked && labelLeft && (
<span className="absolute right-2 text-xs font-medium text-white">
{labelLeft}
</span>
)}
</button>
)
}
+2 -1
View File
@@ -11,6 +11,7 @@ import ProfileEditor from '@/components/ProfileEditor'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Doc, Id } from '../../convex/_generated/dataModel'
import type { ProfileFields } from '../../convex/schema'
export default function Dashboard() {
const navigate = useNavigate()
@@ -18,7 +19,7 @@ export default function Dashboard() {
const removeProfile = useMutation(api.profiles.remove)
const [isCreating, setIsCreating] = useState(false)
const handleEdit = (profile: Doc<'profiles'>) => {
const handleEdit = (profile: Doc<'profiles'> & ProfileFields) => {
navigate(`/dashboard/profiles/${profile._id}`)
}
+18 -17
View File
@@ -1,5 +1,5 @@
import { useAuthActions } from '@convex-dev/auth/react'
import { useConvexAuth, useMutation, useQuery } from 'convex/react'
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
import * as React from 'react'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
@@ -16,15 +16,13 @@ export default function ProfileDetail() {
const navigate = useNavigate()
const { isAuthenticated } = useConvexAuth()
const { signIn } = useAuthActions()
const triggerBuildViaProfile = useMutation(api.builds.triggerBuildViaProfile)
const ensureBuildForProfileTarget = useMutation(
api.builds.ensureBuildForProfileTarget
)
const profile = useQuery(
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
)
const flashCount = useQuery(
api.profiles.getFlashCount,
id ? { profileId: id as Id<'profiles'> } : 'skip'
)
const [selectedTarget, setSelectedTarget] = useState<string>('')
// Group targets by category
@@ -79,7 +77,7 @@ export default function ProfileDetail() {
return <div>Profile ID required</div>
}
if (profile === undefined || flashCount === undefined) {
if (profile === undefined) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
<div>Loading...</div>
@@ -95,9 +93,9 @@ export default function ProfileDetail() {
)
}
// Get enabled modules (inverted logic: config[id] === false means included)
const enabledModules = modulesData.modules.filter(
(module) => profile.config[module.id] === false
// Get excluded modules (new logic: config[id] === true means excluded)
const excludedModules = modulesData.modules.filter(
(module) => profile.config.modulesExcluded[module.id] === true
)
const handleFlash = async () => {
@@ -109,7 +107,7 @@ export default function ProfileDetail() {
}
try {
await triggerBuildViaProfile({
await ensureBuildForProfileTarget({
profileId: id as Id<'profiles'>,
target: selectedTarget,
})
@@ -130,7 +128,7 @@ export default function ProfileDetail() {
<div>
<h1 className="text-4xl font-bold mb-2">{profile.name}</h1>
<p className="text-slate-400">
Flashed {flashCount} time{flashCount !== 1 ? 's' : ''}
Flashed {totalFlashes} time{totalFlashes !== 1 ? 's' : ''}
</p>
</div>
<ProfileStatisticPills
@@ -140,15 +138,18 @@ export default function ProfileDetail() {
</div>
<div className="space-y-8">
{/* Enabled Modules */}
{/* Excluded Modules */}
<div>
<h2 className="text-2xl font-semibold mb-4">Enabled Modules</h2>
<h2 className="text-2xl font-semibold mb-4">Excluded Modules</h2>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
{enabledModules.length === 0 ? (
<p className="text-slate-400">No modules enabled</p>
{excludedModules.length === 0 ? (
<p className="text-slate-400">
No modules explicitly excluded. All modules supported by your
target will be included.
</p>
) : (
<div className="space-y-4">
{enabledModules.map((module) => (
{excludedModules.map((module) => (
<div
key={module.id}
className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0"
+34 -16
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery } from 'convex/react'
import { ArrowLeft, CheckCircle, Loader2, XCircle } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ProfileStatisticPills } from '@/components/ProfileCard'
import { Button } from '@/components/ui/button'
@@ -15,17 +16,32 @@ export default function ProfileFlash() {
target: string
}>()
const data = useQuery(
api.profiles.getProfileTarget,
id && target ? { profileId: id as Id<'profiles'>, target } : 'skip'
const ensureBuildForProfileTarget = useMutation(
api.builds.ensureBuildForProfileTarget
)
const [buildId, setBuildId] = useState<Id<'builds'> | null>(null)
const build = useQuery(
api.builds.get, // query you write that does ctx.db.get(id)
buildId ? { id: buildId } : 'skip'
)
const profile = useQuery(
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
)
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
if (data === undefined || profile === undefined) {
useEffect(() => {
if (id && target) {
ensureBuildForProfileTarget({ profileId: id as Id<'profiles'>, target })
.then(setBuildId)
.catch(() => setBuildId(null))
}
}, [id, target, ensureBuildForProfileTarget])
if (build === undefined || profile === undefined) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
@@ -33,7 +49,7 @@ export default function ProfileFlash() {
)
}
if (data === null || !data.build) {
if (!build) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
@@ -69,16 +85,15 @@ export default function ProfileFlash() {
)
}
const build = data.build
const targetMeta = target ? TARGETS[target] : undefined
const targetLabel = targetMeta?.name ?? target ?? 'Unknown Target'
const includedModules = modulesData.modules.filter(
(module) => profile.config?.[module.id] === false
const excludedModules = modulesData.modules.filter(
(module) => profile.config.modulesExcluded[module.id] === true
)
const totalFlashes = profile.flashCount ?? 0
const handleDownload = async () => {
if (!id || !build.artifactUrl) return
if (!id || !build.artifactPath) return
try {
const url = await generateDownloadUrl({
@@ -108,7 +123,7 @@ export default function ProfileFlash() {
}
const githubActionUrl =
build.githubRunId > 0
build.githubRunId && build.githubRunId > 0
? `https://github.com/MeshEnvy/configurable-web-flasher/actions/runs/${build.githubRunId}`
: null
@@ -142,12 +157,15 @@ export default function ProfileFlash() {
</div>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<h2 className="text-xl font-semibold mb-4">Included Modules</h2>
{includedModules.length === 0 ? (
<p className="text-slate-400 text-sm">No modules included.</p>
<h2 className="text-xl font-semibold mb-4">Excluded Modules</h2>
{excludedModules.length === 0 ? (
<p className="text-slate-400 text-sm">
No modules explicitly excluded. All modules supported by this
target are included.
</p>
) : (
<div className="space-y-3">
{includedModules.map((module) => (
{excludedModules.map((module) => (
<div key={module.id}>
<p className="font-medium text-sm">{module.name}</p>
<p className="text-slate-400 text-sm">{module.description}</p>
@@ -193,12 +211,12 @@ export default function ProfileFlash() {
</a>
)}
<span></span>
<span>{new Date(build.startedAt).toLocaleString()}</span>
<span>{new Date(build.updatedAt).toLocaleString()}</span>
</div>
</div>
</div>
{build.status === 'success' && build.artifactUrl && (
{build.status === 'success' && build.artifactPath && (
<div>
<Button
onClick={handleDownload}