Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
15 changes: 6 additions & 9 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ const _mswApp = initialize({
onUnhandledRequest: 'bypass',
})

const languageName = (lang: string) =>
new Intl.DisplayNames(lang, { type: 'language' }).of(lang)

const preview: Preview = {
parameters: {
controls: {
Expand Down Expand Up @@ -68,12 +65,12 @@ const preview: Preview = {
toolbar: {
icon: 'globe',
items: [
{ value: 'en', right: '🇺🇸', title: languageName('en') },
{ value: 'es', right: '🇪🇸', title: languageName('es') },
{ value: 'fr', right: '🇫🇷', title: languageName('fr') },
{ value: 'ja', right: '🇯🇵', title: languageName('ja') },
{ value: 'kr', right: '🇰🇷', title: languageName('kr') },
{ value: 'zh', right: '🇨🇳', title: languageName('zh') },
{ value: 'en', right: '🇺🇸', title: 'English' },
{ value: 'es', right: '🇪🇸', title: 'Español' },
{ value: 'fr', right: '🇫🇷', title: 'Français' },
{ value: 'ja', right: '🇯🇵', title: '日本語' },
{ value: 'kr', right: '🇰🇷', title: '한국어' },
{ value: 'zh', right: '🇨🇳', title: '中文' },
],
},
},
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 121 additions & 0 deletions components/nodes/AdminCreateNodeFormModal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { AdminCreateNodeFormModal } from './AdminCreateNodeFormModal'

const meta = {
title: 'Components/Nodes/AdminCreateNodeFormModal',
component: AdminCreateNodeFormModal,
parameters: {
layout: 'centered',
docs: {
description: {
component: `
The AdminCreateNodeFormModal is used by administrators to add unclaimed nodes to the registry.
It features a repository URL input at the top with a "Fetch Info" button that automatically
populates form fields from the pyproject.toml file in GitHub repositories.

## Features
- Repository URL input with auto-fetch functionality
- Form validation using Zod schema
- Duplicate node detection
- Integration with React Hook Form
- Toast notifications for success/error states
`,
},
},
},
decorators: [
(Story) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return (
<QueryClientProvider client={queryClient}>
<div style={{ minHeight: '600px' }}>
<Story />
</div>
</QueryClientProvider>
)
},
],
tags: ['autodocs'],
} satisfies Meta<typeof AdminCreateNodeFormModal>

export default meta
type Story = StoryObj<typeof meta>

// Story wrapper component to handle modal state
function ModalWrapper(
args: React.ComponentProps<typeof AdminCreateNodeFormModal>
) {
const [open, setOpen] = useState(true)

return (
<AdminCreateNodeFormModal
{...args}
open={open}
onClose={() => {
console.log('Modal closed')
setOpen(false)
// Reopen after a delay for demo purposes
setTimeout(() => setOpen(true), 1000)
}}
/>
)
}

export const Default: Story = {
render: (args) => <ModalWrapper {...args} />,
args: {
open: true,
onClose: () => console.log('onClose'),
},
}

export const WithGitHubRepo: Story = {
render: (args) => <ModalWrapper {...args} />,
args: {
open: true,
onClose: () => console.log('onClose'),
},
parameters: {
docs: {
description: {
story: `
This story demonstrates the modal with a GitHub repository URL pre-filled.
In a real scenario, clicking "Fetch Info" would populate the form fields
with data from the repository's pyproject.toml file.
`,
},
},
},
play: async ({ canvasElement }) => {
// You could add play interactions here to demonstrate the functionality
// For example, filling in the repository field and clicking fetch
},
}

export const Closed: Story = {
render: () => (
<AdminCreateNodeFormModal
open={false}
onClose={() => console.log('onClose')}
/>
),
args: {
open: false,
onClose: () => console.log('onClose'),
},
parameters: {
docs: {
description: {
story: 'The modal in its closed state.',
},
},
},
}
170 changes: 163 additions & 7 deletions components/nodes/AdminCreateNodeFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { useQueryClient } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { Button, Label, Modal, Textarea, TextInput } from 'flowbite-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { HiPlus } from 'react-icons/hi'
import { HiDownload, HiPlus } from 'react-icons/hi'
import { toast } from 'react-toastify'
import TOML from 'smol-toml'
import { customThemeTModal } from 'utils/comfyTheme'
import { z } from 'zod'
import {
Expand Down Expand Up @@ -47,6 +49,104 @@ const adminCreateNodeDefaultValues: Partial<
license: '{file="LICENSE"}',
}

interface PyProjectData {
name?: string
description?: string
author?: string
license?: string
}

Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

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

The fetchGitHubRepoInfo function is missing JSDoc documentation. As this is a key feature of the PR, it should be well-documented with parameter descriptions, return value explanation, and potential errors that can be thrown. Consider adding:

/**
 * Fetches project metadata from a GitHub repository's pyproject.toml file
 * @param repoUrl - The GitHub repository URL (e.g., https://github.com/owner/repo)
 * @returns Promise resolving to PyProjectData containing name, description, author, and license, or null if parsing fails
 * @throws Error if the URL format is invalid, pyproject.toml is not found, or GitHub API rate limit is exceeded
 */
async function fetchGitHubRepoInfo(
  repoUrl: string
): Promise<PyProjectData | null> {
Suggested change
/**
* Fetches project metadata from a GitHub repository's pyproject.toml file.
* @param repoUrl - The GitHub repository URL (e.g., https://github.com/owner/repo)
* @returns Promise resolving to PyProjectData containing name, description, author, and license, or null if parsing fails
* @throws Error if the URL format is invalid, pyproject.toml is not found, or GitHub API rate limit is exceeded
*/

Copilot uses AI. Check for mistakes.
async function fetchGitHubRepoInfo(
repoUrl: string
): Promise<PyProjectData | null> {
try {
// Parse GitHub URL to extract owner and repo
const urlMatch = repoUrl.match(
/github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/|$)/
)
if (!urlMatch) {
throw new Error('Invalid GitHub URL format')
}

const [, owner, repo] = urlMatch
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/pyproject.toml`

const response = await fetch(apiUrl)
if (!response.ok) {
if (response.status === 404) {
throw new Error('pyproject.toml not found in repository')
}
Comment thread
snomiao marked this conversation as resolved.
throw new Error(`GitHub API error: ${response.statusText}`)
}

const data = await response.json()
// Validate encoding and base64 content
if (data.encoding !== 'base64') {
throw new Error(
`Unexpected encoding for pyproject.toml: ${data.encoding}`
)
}
// Basic base64 validation regex (allows padding)
const base64Regex =
/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/
if (!base64Regex.test(data.content)) {
throw new Error('Invalid base64 content in pyproject.toml')
Comment on lines +100 to +106
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

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

[nitpick] The base64 validation regex doesn't account for URL-safe base64 encoding (which uses - and _ instead of + and /). While GitHub API typically returns standard base64, consider using a more robust validation or simply attempting to decode without pre-validation, letting the decode operation handle invalid input:

// Remove validation or use a more permissive check
const cleanedContent = data.content.replace(/\s/g, '')
if (!cleanedContent) {
  throw new Error('Empty content in pyproject.toml')
}

The decoding operation itself will throw an error if the content is invalid.

Suggested change
// Strip whitespace from base64 content before validation
const cleanedContent = data.content.replace(/\s/g, '')
// Basic base64 validation regex (allows padding)
const base64Regex =
/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/
if (!base64Regex.test(cleanedContent)) {
throw new Error('Invalid base64 content in pyproject.toml')
// Strip whitespace from base64 content before decoding
const cleanedContent = data.content.replace(/\s/g, '')
if (!cleanedContent) {
throw new Error('Empty content in pyproject.toml')

Copilot uses AI. Check for mistakes.
}
Comment thread
snomiao marked this conversation as resolved.
const content = atob(data.content)
Comment thread
snomiao marked this conversation as resolved.
Outdated

// Parse TOML using proper TOML library
const parsed = TOML.parse(content)

const result: PyProjectData = {}

// Extract project metadata
if (parsed.project && typeof parsed.project === 'object') {
const project = parsed.project as any

// Extract name
if (typeof project.name === 'string') {
result.name = project.name
}

// Extract description
if (typeof project.description === 'string') {
result.description = project.description
}

// Extract author (from authors array or single author field)
if (Array.isArray(project.authors) && project.authors.length > 0) {
const firstAuthor = project.authors[0]
if (
typeof firstAuthor === 'object' &&
typeof firstAuthor.name === 'string'
) {
result.author = firstAuthor.name
}
} else if (typeof project.author === 'string') {
result.author = project.author
}

// Extract license
if (typeof project.license === 'string') {
result.license = project.license
} else if (
typeof project.license === 'object' &&
project.license !== null
) {
const license = project.license as any
if (typeof license.text === 'string') {
result.license = license.text
}
}
Comment on lines +153 to +154
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

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

The license extraction logic handles license.text but according to PEP 621 specification, the license field can also have a file property (e.g., license = {file = "LICENSE"}). Consider adding support for this format:

} else if (
  typeof project.license === 'object' &&
  project.license !== null
) {
  const license = project.license as any
  if (typeof license.text === 'string') {
    result.license = license.text
  } else if (typeof license.file === 'string') {
    result.license = `{file="${license.file}"}`
  }
}

This would maintain consistency with the default value {file="LICENSE"} defined in the form.

Suggested change
}
}
} else if (typeof license.file === 'string') {
result.license = `{file="${license.file}"}`
}

Copilot uses AI. Check for mistakes.
}

Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

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

The function should handle the edge case where pyproject.toml exists but doesn't contain a [project] section. Currently, if the file is parsed successfully but has no [project] section, the function returns an empty object and shows a success toast, which could be misleading to users.

Consider adding a check after parsing to ensure at least one field was extracted, or provide a more informative message when no project metadata is found:

if (Object.keys(result).length === 0) {
  throw new Error('No project metadata found in pyproject.toml')
}
Suggested change
if (Object.keys(result).length === 0) {
throw new Error('No project metadata found in pyproject.toml')
}

Copilot uses AI. Check for mistakes.
return result
} catch (error) {
console.error('Error fetching GitHub repo info:', error)
throw error
}
}

export function AdminCreateNodeFormModal({
open,
onClose,
Expand All @@ -56,6 +156,8 @@ export function AdminCreateNodeFormModal({
}) {
const { t } = useNextTranslation()
const qc = useQueryClient()
const [isFetching, setIsFetching] = useState(false)

const mutation = useAdminCreateNode({
mutation: {
onError: (error) => {
Expand Down Expand Up @@ -83,6 +185,7 @@ export function AdminCreateNodeFormModal({
formState: { errors },
watch,
reset,
setValue,
} = useForm<Node>({
resolver: zodResolver(adminCreateNodeSchema) as any,
defaultValues: adminCreateNodeDefaultValues,
Expand All @@ -106,6 +209,37 @@ export function AdminCreateNodeFormModal({
})
})

const handleFetchRepoInfo = async () => {
const repository = watch('repository')
if (!repository) {
toast.error(t('Please enter a repository URL first'))
return
}

setIsFetching(true)
try {
const repoData = await fetchGitHubRepoInfo(repository)
if (repoData) {
if (repoData.name) setValue('name', repoData.name)
if (repoData.description) setValue('description', repoData.description)
if (repoData.author) setValue('author', repoData.author)
if (repoData.license) setValue('license', repoData.license)

toast.success(t('Repository information fetched successfully'))
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error'
toast.error(
t('Failed to fetch repository information: {{error}}', {
error: errorMessage,
})
)
} finally {
setIsFetching(false)
}
}

const { data: allPublishers } = useListPublishers({
query: { enabled: false },
}) // list publishers for unclaimed user
Expand Down Expand Up @@ -146,6 +280,34 @@ export function AdminCreateNodeFormModal({
>
<p className="text-white">{t('Add unclaimed node')}</p>

<div>
<Label htmlFor="repository">{t('Repository URL')}</Label>
<div className="flex gap-2">
<TextInput
id="repository"
{...register('repository')}
placeholder="https://github.com/user/repo"
className="flex-1"
/>
<Button
type="button"
size="sm"
onClick={handleFetchRepoInfo}
disabled={isFetching}
className="whitespace-nowrap"
>
<HiDownload className="mr-2 h-4 w-4" />
{isFetching ? t('Fetching...') : t('Fetch Info')}
</Button>
</div>
<span className="text-error">{errors.repository?.message}</span>
<p className="text-xs text-gray-400 mt-1">
{t(
'Enter a GitHub repository URL and click "Fetch Info" to automatically fill in details from pyproject.toml'
)}
</p>
</div>

<div>
<Label htmlFor="id">{t('ID')}</Label>
<TextInput id="id" {...register('id')} />
Expand Down Expand Up @@ -204,12 +366,6 @@ export function AdminCreateNodeFormModal({
<span className="text-error">{errors.author?.message}</span>
</div>

<div>
<Label htmlFor="repository">{t('Repository')}</Label>
<TextInput id="repository" {...register('repository')} />
<span className="text-error">{errors.repository?.message}</span>
</div>

<div>
<Label htmlFor="license">{t('License')}</Label>
<TextInput id="license" {...register('license')} />
Expand Down
Loading
Loading