From bb1746a007770d65d5f84b107c1d2d924bbdf181 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Wed, 26 Nov 2025 03:33:03 -0800 Subject: [PATCH] feat: add convex-helpers dependency and implement module toggling in ProfileEditor for improved build configuration management --- bun.lock | 3 + convex/builds.ts | 313 +++++++++++-------------------- convex/profiles.ts | 169 +++++------------ convex/schema.ts | 51 ++++- package.json | 1 + src/components/ModuleToggle.tsx | 33 ++++ src/components/ProfileCard.tsx | 3 +- src/components/ProfileEditor.tsx | 106 ++++++----- src/components/ui/switch.tsx | 51 +++++ src/pages/Dashboard.tsx | 3 +- src/pages/ProfileDetail.tsx | 35 ++-- src/pages/ProfileFlash.tsx | 50 +++-- 12 files changed, 402 insertions(+), 416 deletions(-) create mode 100644 src/components/ModuleToggle.tsx create mode 100644 src/components/ui/switch.tsx diff --git a/bun.lock b/bun.lock index 5dd7c3d..a466fa9 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "convex": "^1.29.3", + "convex-helpers": "^0.1.106", "lucide-react": "^0.554.0", "next-themes": "^0.4.6", "react": "^18.2.0", @@ -529,6 +530,8 @@ "convex": ["convex@1.29.3", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-tg5TXzMjpNk9m50YRtdp6US+t7ckxE4E+7DNKUCjJ2MupQs2RBSPF/z5SNN4GUmQLSfg0eMILDySzdAvjTrhnw=="], + "convex-helpers": ["convex-helpers@0.1.106", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-hWRe3yDaAVHMe4CUYw1YoQLiPZ1KIx6Kbf0w6UcRDx1BXpJgMCl3GVIMiSeYiA0PkbwjnIwGWIvoUVKloG5Tyw=="], + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], diff --git a/convex/builds.ts b/convex/builds.ts index d418942..e3b909d 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -1,21 +1,44 @@ import { getAuthUserId } from '@convex-dev/auth/server' import { v } from 'convex/values' -import { api } from './_generated/api' +import { pick } from 'convex-helpers' +import { api, internal } from './_generated/api' import type { Id } from './_generated/dataModel' import { internalMutation, mutation, query } from './_generated/server' import { generateSignedDownloadUrl } from './lib/r2' -import modulesData from './modules.json' +import { type BuildFields, buildFields, type ProfileFields } from './schema' type BuildUpdateData = { status: string completedAt?: number } +export const get = query({ + args: { id: v.id('builds') }, + handler: async (ctx, args) => { + return await ctx.db.get(args.id) + }, +}) + +/** + * Computes flags string from profile config. + * Only excludes modules explicitly marked as excluded (config[id] === true). + */ +export function computeFlagsFromProfile( + config: ProfileFields['config'] +): string { + // Sort modules to ensure consistent order + return Object.keys(config.modulesExcluded) + .sort() + .filter((module) => config.modulesExcluded[module]) + .map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`) + .join(' ') +} + /** * Computes a stable SHA-256 hash from version, target, and flags. * This hash uniquely identifies a build configuration based on what is actually executed. */ -async function computeBuildHash( +export async function computeBuildHash( version: string, target: string, flags: string @@ -37,15 +60,27 @@ async function computeBuildHash( return hashHex } +/** + * Computes buildHash for a profile and target. + * This is the single source of truth for build hash computation. + */ +export async function computeBuildHashForProfile( + profile: ProfileFields, + target: string +): Promise<{ hash: string; flags: string }> { + const flags = computeFlagsFromProfile(profile.config) + const hash = await computeBuildHash(profile.version, target, flags) + return { hash, flags } +} + /** * Constructs the R2 artifact URL from a build. * Uses artifactPath if available, otherwise falls back to buildHash.uf2 * Or custom domain if R2_PUBLIC_URL is set. */ -export function getR2ArtifactUrl(build: { - buildHash: string - artifactPath?: string -}): string { +export function getR2ArtifactUrl( + build: Pick +): string { const r2PublicUrl = process.env.R2_PUBLIC_URL if (!r2PublicUrl) { throw new Error('R2_PUBLIC_URL is not set') @@ -56,12 +91,62 @@ export function getR2ArtifactUrl(build: { return `${r2PublicUrl}${normalizedPath}` } -export const triggerBuildViaProfile = mutation({ +// Internal mutation to upsert a build by buildHash +// This is the single source of truth for build creation +export const upsertBuild = internalMutation({ + args: { + ...pick(buildFields, ['buildHash', 'target', 'version', 'profileString']), + status: v.optional(v.string()), + flags: v.string(), + }, + + handler: async (ctx, args) => { + // Check if build already exists with this hash + const existingBuild = await ctx.db + .query('builds') + .filter((q) => q.eq(q.field('buildHash'), args.buildHash)) + .unique() + + const { status, buildHash, target, version, profileString, flags } = args + + if (existingBuild) { + await ctx.db.patch(existingBuild._id, { + status: status ?? existingBuild.status, + updatedAt: Date.now(), + }) + return existingBuild._id + } + + // Create new build + const buildId = await ctx.db.insert('builds', { + target, + status: 'queued', + startedAt: Date.now(), + buildHash, + updatedAt: Date.now(), + version, + profileString, + }) + + // Dispatch GitHub workflow if needed + await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, { + buildId, + target, + flags, + version, + buildHash, + }) + + return buildId + }, +}) + +export const ensureBuildForProfileTarget = mutation({ args: { profileId: v.id('profiles'), target: v.string(), }, - handler: async (ctx, args) => { + handler: async (ctx, args): Promise> => { const userId = await getAuthUserId(ctx) if (!userId) { throw new Error('Unauthorized') @@ -72,167 +157,23 @@ export const triggerBuildViaProfile = mutation({ throw new Error('Profile not found') } - // Convert config object to flags string - const flags: string[] = [] + // Compute build hash (single source of truth) + const { hash: buildHash, flags: flagsString } = + await computeBuildHashForProfile(profile, args.target) - // Handle Modules (Inverted Logic: Default Excluded) - for (const module of modulesData.modules) { - // If config[id] is NOT false (explicitly included), we exclude it. - if (profile.config[module.id] !== false) { - flags.push(`-D${module.id}=1`) - } - } - - const flagsString = flags.join(' ') - - // Compute build hash - const buildHash = await computeBuildHash( - profile.version, - args.target, - flagsString - ) - - // Check if build already exists with this hash - let existingBuild = await ctx.db - .query('builds') - .filter((q) => q.eq('buildHash', buildHash)) - .first() - - let buildId: Id<'builds'> - let shouldDispatch = false - - if (existingBuild) { - // Build already exists, use it - buildId = existingBuild._id - } else { - // Create new build and dispatch workflow - buildId = await ctx.db.insert('builds', { - target: args.target, - githubRunId: 0, - status: 'queued', - startedAt: Date.now(), - buildHash: buildHash, - }) - shouldDispatch = true - - // Handle race condition - existingBuild = await ctx.db - .query('builds') - .filter((q) => q.eq('buildHash', buildHash)) - .first() - - if (existingBuild && existingBuild._id !== buildId) { - await ctx.db.delete(buildId) - buildId = existingBuild._id - shouldDispatch = false - } - } - - // Create or update profileBuild record - // Check if a profileBuild already exists for this profile+target combination - const profileId = args.profileId as string - const profileBuilds = await ctx.db - .query('profileBuilds') - .filter((q) => q.eq('profileId', profileId)) - .collect() - - // Find existing profileBuild with matching target by checking the build - let foundExisting = null - for (const pb of profileBuilds) { - const build = await ctx.db.get(pb.buildId) - if (build?.target === args.target) { - foundExisting = pb - break - } - } - - if (foundExisting) { - await ctx.db.patch(foundExisting._id, { - buildId: buildId, - }) - } else { - await ctx.db.insert('profileBuilds', { - profileId: args.profileId, - buildId: buildId, - }) - } - - // Dispatch GitHub workflow if needed - if (shouldDispatch) { - await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, { - buildId: buildId, + // Upsert the build (single source of truth) + const buildId: Id<'builds'> = await ctx.runMutation( + internal.builds.upsertBuild, + { + buildHash, target: args.target, flags: flagsString, version: profile.version, - buildHash: buildHash, - }) - } - - return buildId - }, -}) - -export const listByProfile = query({ - args: { profileId: v.id('profiles') }, - handler: async (ctx, args) => { - // Query profileBuilds for this profile - const listProfileId = args.profileId as string - const profileBuilds = await ctx.db - .query('profileBuilds') - .filter((q) => q.eq('profileId', listProfileId)) - .collect() - - // Get builds for each profileBuild - const builds = await Promise.all( - profileBuilds.map(async (pb) => { - const build = await ctx.db.get(pb.buildId) - if (!build) return null - // Return build with computed artifactUrl - return { - ...build, - artifactUrl: getR2ArtifactUrl(build), - } - }) + profileString: JSON.stringify(profile), + } ) - // Filter out nulls and sort by startedAt descending - return builds - .filter((b): b is NonNullable => b !== null) - .sort((a, b) => b.startedAt - a.startedAt) - .slice(0, 10) - }, -}) - -export const get = query({ - args: { buildId: v.id('builds') }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx) - if (!userId) return null - - const build = await ctx.db.get(args.buildId) - if (!build) return null - - // Check if user has access via profileBuilds - // Get all profileBuilds for this build - const buildId = args.buildId as string - const profileBuilds = await ctx.db - .query('profileBuilds') - .filter((q) => q.eq('buildId', buildId)) - .collect() - - // Check if any of these profileBuilds link to a profile owned by the user - for (const pb of profileBuilds) { - const profile = await ctx.db.get(pb.profileId) - if (profile && profile.userId === userId) { - // Return build with computed artifactUrl - return { - ...build, - artifactUrl: getR2ArtifactUrl(build), - } - } - } - - return null + return buildId }, }) @@ -244,41 +185,6 @@ export const getInternal = internalMutation({ }, }) -export const deleteBuild = mutation({ - args: { - buildId: v.id('builds'), - profileId: v.id('profiles'), - }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx) - if (!userId) throw new Error('Unauthorized') - - // Verify profile belongs to user - const profile = await ctx.db.get(args.profileId) - if (!profile || profile.userId !== userId) { - throw new Error('Unauthorized') - } - - // Find profileBuild linking this profile to this build - const buildId = args.buildId as string - const profileBuilds = await ctx.db - .query('profileBuilds') - .filter((q) => q.eq('buildId', buildId)) - .collect() - - const profileBuild = profileBuilds.find( - (pb) => pb.profileId === args.profileId - ) - - if (!profileBuild) { - throw new Error('ProfileBuild not found') - } - - // Delete only the profileBuild record (not the underlying build) - await ctx.db.delete(profileBuild._id) - }, -}) - // Internal mutation to log errors from actions export const logBuildError = internalMutation({ args: { @@ -296,10 +202,13 @@ export const logBuildError = internalMutation({ // Internal mutation to update build status export const updateBuildStatus = internalMutation({ args: { + ...pick(buildFields, [ + 'status', + 'completedAt', + 'artifactPath', + 'githubRunId', + ]), buildId: v.id('builds'), - status: v.string(), // Accepts any status string value - artifactPath: v.optional(v.string()), - githubRunId: v.optional(v.number()), }, handler: async (ctx, args) => { const build = await ctx.db.get(args.buildId) diff --git a/convex/profiles.ts b/convex/profiles.ts index 1eb9e87..4d4846e 100644 --- a/convex/profiles.ts +++ b/convex/profiles.ts @@ -1,7 +1,13 @@ import { getAuthUserId } from '@convex-dev/auth/server' import { v } from 'convex/values' -import { mutation, query } from './_generated/server' -import { getR2ArtifactUrl } from './builds' +import { api, internal } from './_generated/api' +import type { Doc, Id } from './_generated/dataModel' +import { action, internalMutation, mutation, query } from './_generated/server' +import { + computeBuildHashForProfile, + computeFlagsFromProfile, + getR2ArtifactUrl, +} from './builds' export const list = query({ args: {}, @@ -44,73 +50,11 @@ export const get = query({ }, }) -export const getTargets = query({ - args: { profileId: v.id('profiles') }, +// Internal mutation to get a build by ID +export const getBuildById = internalMutation({ + args: { buildId: v.id('builds') }, handler: async (ctx, args) => { - const profileId = args.profileId as string - const profileBuilds = await ctx.db - .query('profileBuilds') - .filter((q) => q.eq('profileId', profileId)) - .collect() - // Get unique targets from builds - const builds = await Promise.all( - profileBuilds.map((pb) => ctx.db.get(pb.buildId)) - ) - const targets = new Set( - builds - .filter((b): b is NonNullable => b !== null) - .map((b) => b.target) - ) - return Array.from(targets) - }, -}) - -export const getProfileTarget = query({ - args: { - profileId: v.id('profiles'), - target: v.string(), - }, - handler: async (ctx, args) => { - // Get all profileBuilds for this profile - const profileBuilds = await ctx.db - .query('profileBuilds') - .filter((q) => q.eq(q.field('profileId'), args.profileId)) - .collect() - - // Find the profileBuild with matching target by checking the build - for (const profileBuild of profileBuilds) { - const build = await ctx.db.get(profileBuild.buildId) - if (build?.target === args.target) { - return { - profileBuild, - build: { - ...build, - artifactUrl: getR2ArtifactUrl(build), - }, - } - } - } - - return null - }, -}) - -export const getFlashCount = query({ - args: { profileId: v.id('profiles') }, - handler: async (ctx, args) => { - const profileBuilds = await ctx.db - .query('profileBuilds') - .filter((q) => q.eq(q.field('profileId'), args.profileId)) - .collect() - - let successCount = 0 - for (const profileBuild of profileBuilds) { - const build = await ctx.db.get(profileBuild.buildId) - if (build && build.status === 'success') { - successCount++ - } - } - return successCount + return await ctx.db.get(args.buildId) }, }) @@ -135,11 +79,11 @@ export const recordFlash = mutation({ }, }) -export const create = mutation({ +export const upsert = mutation({ args: { + id: v.optional(v.id('profiles')), name: v.string(), description: v.string(), - targets: v.optional(v.array(v.string())), config: v.any(), version: v.string(), isPublic: v.boolean(), @@ -148,56 +92,39 @@ export const create = mutation({ const userId = await getAuthUserId(ctx) if (!userId) throw new Error('Unauthorized') - const profileId = await ctx.db.insert('profiles', { - userId, - name: args.name, - description: args.description, - config: args.config, - version: args.version, - flashCount: 0, - updatedAt: Date.now(), - isPublic: args.isPublic ?? true, - }) + if (args.id) { + // Update existing profile + const profile = await ctx.db.get(args.id) + if (!profile || profile.userId !== userId) { + throw new Error('Unauthorized') + } - // Note: targets are now tracked via profileBuilds when builds are triggered - // No need to create profileTargets entries + await ctx.db.patch(args.id, { + name: args.name, + description: args.description, + config: args.config, + version: args.version, + isPublic: args.isPublic, + flashCount: profile.flashCount ?? 0, + updatedAt: Date.now(), + }) - return profileId - }, -}) + return args.id + } else { + // Create new profile + const profileId = await ctx.db.insert('profiles', { + userId, + name: args.name, + description: args.description, + config: args.config, + version: args.version, + flashCount: 0, + updatedAt: Date.now(), + isPublic: args.isPublic ?? true, + }) -export const update = mutation({ - args: { - id: v.id('profiles'), - name: v.string(), - description: v.string(), - targets: v.optional(v.array(v.string())), - config: v.any(), - version: v.optional(v.string()), - isPublic: v.boolean(), - }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx) - if (!userId) throw new Error('Unauthorized') - - const profile = await ctx.db.get(args.id) - if (!profile || profile.userId !== userId) { - throw new Error('Unauthorized') + return profileId } - - // Update profile - await ctx.db.patch(args.id, { - name: args.name, - description: args.description, - config: args.config, - version: args.version, - isPublic: args.isPublic, - flashCount: profile.flashCount ?? 0, - updatedAt: Date.now(), - }) - - // Note: targets are now tracked via profileBuilds when builds are triggered - // No need to sync profileTargets }, }) @@ -212,16 +139,6 @@ export const remove = mutation({ throw new Error('Unauthorized') } - // Delete associated profileBuilds - const profileBuilds = await ctx.db - .query('profileBuilds') - .filter((q) => q.eq(q.field('profileId'), args.id)) - .collect() - - for (const profileBuild of profileBuilds) { - await ctx.db.delete(profileBuild._id) - } - await ctx.db.delete(args.id) }, }) diff --git a/convex/schema.ts b/convex/schema.ts index 8ac5e87..99c3d8a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,14 +1,45 @@ import { authTables } from '@convex-dev/auth/server' -import { defineSchema } from 'convex/server' +import { defineSchema, defineTable } from 'convex/server' +import { type Infer, v } from 'convex/values' +import type { Doc } from './_generated/dataModel' -const schema = defineSchema( - { - ...authTables, - // your other tables... - }, - { - strictTableNameTypes: false, - } -) +export const profileFields = { + userId: v.id('users'), + name: v.string(), + description: v.string(), + version: v.string(), + config: v.object({ + modulesExcluded: v.record(v.string(), v.boolean()), + }), + isPublic: v.boolean(), + flashCount: v.number(), + updatedAt: v.number(), +} + +export const buildFields = { + buildHash: v.string(), + target: v.string(), + version: v.string(), + status: v.string(), + startedAt: v.number(), + updatedAt: v.number(), + profileString: v.string(), + + // Optional props + completedAt: v.optional(v.number()), + artifactPath: v.optional(v.string()), + githubRunId: v.optional(v.number()), +} + +export const schema = defineSchema({ + ...authTables, + profiles: defineTable(profileFields), + builds: defineTable(buildFields), +}) + +export type ProfilesDoc = Doc<'profiles'> +export type BuildsDoc = Doc<'builds'> +export type ProfileFields = Infer +export type BuildFields = Infer export default schema diff --git a/package.json b/package.json index 1beb9d3..d0f816d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "convex": "^1.29.3", + "convex-helpers": "^0.1.106", "lucide-react": "^0.554.0", "next-themes": "^0.4.6", "react": "^18.2.0", diff --git a/src/components/ModuleToggle.tsx b/src/components/ModuleToggle.tsx new file mode 100644 index 0000000..0989f25 --- /dev/null +++ b/src/components/ModuleToggle.tsx @@ -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 ( +
+
+

{name}

+

{description}

+
+
+ +
+
+ ) +} diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 37e001a..2b30a51 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -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) { diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx index 12f9caf..e27f2a8 100644 --- a/src/components/ProfileEditor.tsx +++ b/src/components/ProfileEditor.tsx @@ -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 - 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 // 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 => { + if (!config || !config.modulesExcluded) return {} + return { ...config.modulesExcluded } + } + + // Transform flat config back to nested structure for database + const getNestedConfig = ( + flatConfig: Record + ): ProfileFields['config'] => { + const modulesExcluded: Record = {} + 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({

Modules

- 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.

{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 ( - { - // 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) }} /> ) diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..acb7872 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -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 ( + + ) +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index fd3ec3c..3d19c0f 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -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}`) } diff --git a/src/pages/ProfileDetail.tsx b/src/pages/ProfileDetail.tsx index caea49a..45a86a1 100644 --- a/src/pages/ProfileDetail.tsx +++ b/src/pages/ProfileDetail.tsx @@ -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('') // Group targets by category @@ -79,7 +77,7 @@ export default function ProfileDetail() { return
Profile ID required
} - if (profile === undefined || flashCount === undefined) { + if (profile === undefined) { return (
Loading...
@@ -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() {

{profile.name}

- Flashed {flashCount} time{flashCount !== 1 ? 's' : ''} + Flashed {totalFlashes} time{totalFlashes !== 1 ? 's' : ''}

- {/* Enabled Modules */} + {/* Excluded Modules */}
-

Enabled Modules

+

Excluded Modules

- {enabledModules.length === 0 ? ( -

No modules enabled

+ {excludedModules.length === 0 ? ( +

+ No modules explicitly excluded. All modules supported by your + target will be included. +

) : (
- {enabledModules.map((module) => ( + {excludedModules.map((module) => (
() - 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 | 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 (
@@ -33,7 +49,7 @@ export default function ProfileFlash() { ) } - if (data === null || !data.build) { + if (!build) { return (
@@ -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() {
-

Included Modules

- {includedModules.length === 0 ? ( -

No modules included.

+

Excluded Modules

+ {excludedModules.length === 0 ? ( +

+ No modules explicitly excluded. All modules supported by this + target are included. +

) : (
- {includedModules.map((module) => ( + {excludedModules.map((module) => (

{module.name}

{module.description}

@@ -193,12 +211,12 @@ export default function ProfileFlash() { )} - {new Date(build.startedAt).toLocaleString()} + {new Date(build.updatedAt).toLocaleString()}
- {build.status === 'success' && build.artifactUrl && ( + {build.status === 'success' && build.artifactPath && (