Remove problems UI (#94)

This commit is contained in:
Brian Hackett 2025-04-03 10:48:34 -07:00 committed by GitHub
parent f39baaa5a9
commit 9f91b9fdde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 51 additions and 963 deletions

View File

@ -33,7 +33,7 @@ import { assert, generateRandomId } from '~/lib/replay/ReplayProtocolClient';
import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '~/lib/persistence/message';
import { useAuthStatus } from '~/lib/stores/auth';
import { debounce } from '~/utils/debounce';
import { supabaseSubmitFeedback } from '~/lib/supabase/problems';
import { supabaseSubmitFeedback } from '~/lib/supabase/feedback';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',

View File

@ -1,113 +0,0 @@
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import { logStore } from '~/lib/stores/logs';
import { assert } from '~/lib/replay/ReplayProtocolClient';
import type { NutProblem } from '~/lib/replay/Problems';
import { getProblem } from '~/lib/replay/Problems';
import { createMessagesForRepository, type Message } from '~/lib/persistence/message';
interface LoadProblemButtonProps {
className?: string;
importChat?: (description: string, messages: Message[]) => Promise<void>;
}
export function setLastLoadedProblem(problem: NutProblem) {
localStorage.setItem('loadedProblemId', problem.problemId);
}
export async function getOrFetchLastLoadedProblem(): Promise<NutProblem | null> {
let problem: NutProblem | null = null;
const problemId = localStorage.getItem('loadedProblemId');
if (!problemId) {
return null;
}
problem = await getProblem(problemId);
if (!problem) {
return null;
}
return problem;
}
export async function loadProblem(
problemId: string,
importChat: (description: string, messages: Message[]) => Promise<void>,
) {
const problem = await getProblem(problemId);
if (!problem) {
return;
}
setLastLoadedProblem(problem);
const { repositoryId, title: problemTitle } = problem;
try {
const messages = createMessagesForRepository(`Problem: ${problemTitle}`, repositoryId);
await importChat(`Problem: ${problemTitle}`, messages);
logStore.logSystem('Problem loaded successfully', {
problemId,
});
toast.success('Problem loaded successfully');
} catch (error) {
logStore.logError('Failed to load problem', error);
console.error('Failed to load problem:', error);
toast.error('Failed to load problem');
}
}
export const LoadProblemButton: React.FC<LoadProblemButtonProps> = ({ className, importChat }) => {
const [isLoading, setIsLoading] = useState(false);
const [isInputOpen, setIsInputOpen] = useState(false);
const handleSubmit = async (_e: React.ChangeEvent<HTMLInputElement>) => {
setIsLoading(true);
setIsInputOpen(false);
const problemId = (document.getElementById('problem-input') as HTMLInputElement)?.value;
assert(importChat, 'importChat is required');
await loadProblem(problemId, importChat);
setIsLoading(false);
};
return (
<>
{isInputOpen && (
<input
id="problem-input"
type="text"
webkitdirectory=""
directory=""
onChange={(_e) => {
/* Input change handled by onKeyDown */
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSubmit(e as any);
}
}}
className="border border-gray-300 rounded px-2 py-1"
{...({} as any)}
/>
)}
{!isInputOpen && (
<button
onClick={() => {
setIsInputOpen(true);
}}
className={className}
disabled={isLoading}
>
<div className="i-ph:globe" />
{isLoading ? 'Loading...' : 'Load Problem'}
</button>
)}
</>
);
};

View File

@ -1,7 +1,7 @@
import { toast } from 'react-toastify';
import ReactModal from 'react-modal';
import { useState } from 'react';
import { supabaseSubmitFeedback } from '~/lib/supabase/problems';
import { supabaseSubmitFeedback } from '~/lib/supabase/feedback';
import { getLastChatMessages } from '~/components/chat/Chat.client';
ReactModal.setAppElement('#root');

View File

@ -25,6 +25,9 @@ export function Header() {
<img src="/logo-styled.svg" alt="logo" className="w-[40px] inline-block rotate-90" />
</a>
<Feedback />
<a href="https://www.replay.io/discord" className="text-bolt-elements-accent underline hover:no-underline" target="_blank" rel="noopener noreferrer">
<div className="i-ph:discord-logo-fill text-[1.3em]" />
</a>
</div>
<div className="flex-1 flex items-center ">

View File

@ -121,12 +121,6 @@ export const Menu = () => {
<div className="h-[55px]" /> {/* Spacer for top margin */}
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
<div className="flex gap-2 px-2 mb-0 ml-2.5">
<a
href="/problems"
className="flex gap-2 bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
>
Problems
</a>
<a
href="/about"
className="flex gap-2 bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"

View File

@ -4,7 +4,6 @@ import { toast } from 'react-toastify';
import { logStore } from '~/lib/stores/logs'; // Import logStore
import { chatStore } from '~/lib/stores/chat';
import { database } from './db';
import { loadProblem } from '~/components/chat/LoadProblemButton';
import { createMessagesForRepository, type Message } from './message';
import { debounce } from '~/utils/debounce';
@ -14,15 +13,11 @@ export interface ResumeChatInfo {
}
export function useChatHistory() {
const {
id: mixedId,
problemId,
repositoryId,
} = useLoaderData<{ id?: string; problemId?: string; repositoryId?: string }>() ?? {};
const { id: mixedId, repositoryId } = useLoaderData<{ id?: string; repositoryId?: string }>() ?? {};
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
const [resumeChat, setResumeChat] = useState<ResumeChatInfo | undefined>(undefined);
const [ready, setReady] = useState<boolean>(!mixedId && !problemId && !repositoryId);
const [ready, setReady] = useState<boolean>(!mixedId && !repositoryId);
const importChat = async (title: string, messages: Message[]) => {
try {
@ -73,9 +68,6 @@ export function useChatHistory() {
const publicData = await database.getChatPublicData(mixedId);
const messages = createMessagesForRepository(publicData.title, publicData.repositoryId);
await importChat(publicData.title, messages);
} else if (problemId) {
await loadProblem(problemId, importChat);
setReady(true);
} else if (repositoryId) {
await loadRepository(repositoryId);
setReady(true);

View File

@ -1,135 +0,0 @@
// Accessors for the API to access saved problems.
import { toast } from 'react-toastify';
import { assert, sendCommandDedicatedClient } from './ReplayProtocolClient';
import type { Message } from '~/lib/persistence/message';
// Add global declaration for the problem property
declare global {
interface Window {
__currentProblem__?: NutProblem;
}
}
export interface NutProblemComment {
id?: string;
username?: string;
content: string;
timestamp: number;
}
export interface NutProblemSolution {
simulationData: any;
messages: Message[];
evaluator?: string;
}
export enum NutProblemStatus {
// Problem has been submitted but not yet reviewed.
Pending = 'Pending',
// Problem has been reviewed and has not been solved yet.
Unsolved = 'Unsolved',
// Nut automatically produces a suitable explanation for solving the problem.
Solved = 'Solved',
}
// Information about each problem stored in the index file.
export interface NutProblemDescription {
version: number;
problemId: string;
timestamp: number;
title: string;
description: string;
status?: NutProblemStatus;
keywords?: string[];
}
export interface NutProblem extends NutProblemDescription {
username?: string;
user_id?: string;
repositoryId: string;
comments?: NutProblemComment[];
solution?: NutProblemSolution;
}
export type NutProblemInput = Omit<NutProblem, 'problemId' | 'timestamp'>;
export async function listAllProblems(): Promise<NutProblemDescription[]> {
let problems: NutProblemDescription[] = [];
try {
const rv = await sendCommandDedicatedClient({
method: 'Recording.globalExperimentalCommand',
params: {
name: 'listBoltProblems',
},
});
console.log('ListProblemsRval', rv);
problems = (rv as any).rval.problems.reverse();
const filteredProblems = problems.filter((problem) => {
// if ?showAll=true is not in the url, filter out [test] problems
if (window.location.search.includes('showAll=true')) {
return true;
}
return !problem.title.includes('[test]');
});
return filteredProblems;
} catch (error) {
console.error('Error fetching problems', error);
toast.error('Failed to fetch problems');
return [];
}
}
export async function getProblem(problemId: string): Promise<NutProblem | null> {
let problem: NutProblem | null = null;
try {
if (!problemId) {
toast.error('Invalid problem ID');
return null;
}
const rv = await sendCommandDedicatedClient({
method: 'Recording.globalExperimentalCommand',
params: {
name: 'fetchBoltProblem',
params: { problemId },
},
});
problem = (rv as { rval: { problem: NutProblem } }).rval.problem;
if (!problem) {
toast.error('Problem not found');
return null;
}
assert(problem.repositoryId, 'Problem probably has outdated data format. Must have a repositoryId.');
} catch (error) {
console.error('Error fetching problem', error);
// Check for specific protocol error
if (error instanceof Error && error.message.includes('Unknown problem ID')) {
toast.error('Problem not found');
} else {
toast.error('Failed to fetch problem');
}
}
/*
* Only used for testing
*/
if (problem) {
window.__currentProblem__ = problem;
}
return problem;
}

View File

@ -0,0 +1,30 @@
import { getSupabase } from './client';
import { toast } from 'react-toastify';
export async function supabaseSubmitFeedback(feedback: any) {
const supabase = getSupabase();
// Get the current user ID if available
const {
data: { user },
} = await supabase.auth.getUser();
const userId = user?.id || null;
// Insert feedback into the feedback table
const { data, error } = await supabase.from('feedback').insert({
user_id: userId,
description: feedback.description || feedback.text || JSON.stringify(feedback),
metadata: feedback,
});
if (error) {
console.error('Error submitting feedback to Supabase:', error);
toast.error('Failed to submit feedback');
return false;
}
console.log('Feedback submitted successfully:', data);
return true;
}

View File

@ -1,278 +0,0 @@
// Supabase implementation of problem management functions
import { toast } from 'react-toastify';
import { getSupabase, type Database } from './client';
import type { NutProblem, NutProblemDescription, NutProblemInput, NutProblemStatus } from '~/lib/replay/Problems';
import { getNutIsAdmin } from '~/lib/supabase/client';
async function downloadBlob(bucket: string, path: string) {
const supabase = getSupabase();
const { data, error } = await supabase.storage.from(bucket).download(path);
if (error) {
console.error('Error downloading blob:', error);
return null;
}
return data.text();
}
export async function supabaseListAllProblems(): Promise<NutProblemDescription[]> {
try {
const { data, error } = await getSupabase()
.from('problems')
.select('id, created_at, updated_at, title, description, status, keywords, user_id')
.order('created_at', { ascending: false });
if (error) {
throw error;
}
const problems: NutProblemDescription[] = data.map((problem) => ({
version: 1,
problemId: problem.id,
timestamp: new Date(problem.created_at).getTime(),
title: problem.title,
description: problem.description,
status: problem.status,
keywords: problem.keywords,
}));
return problems;
} catch (error) {
console.error('Error fetching problems', error);
toast.error('Failed to fetch problems');
return [];
}
}
export async function supabaseGetProblem(problemId: string): Promise<NutProblem | null> {
try {
if (!problemId) {
toast.error('Invalid problem ID');
return null;
}
const { data, error } = await getSupabase()
.from('problems')
.select(
`
*,
problem_comments (
*
)
`,
)
.eq('id', problemId)
.single();
if (error) {
// More specific error message based on error code
if (error.code === 'PGRST116') {
toast.error('Problem not found');
} else {
toast.error(`Failed to fetch problem: ${error.message}`);
}
throw error;
}
if (!data) {
toast.error('Problem not found');
return null;
}
// Fetch blob data from storage if paths are available
let solution = data.solution;
const prompt = data.prompt;
// Create a supabase instance for storage operations
const supabase = getSupabase();
if (data.solution_path) {
solution = JSON.parse((await downloadBlob('solutions', data.solution_path)) || '{}');
}
// If the problem has a user_id, fetch the profile information
let username = null;
if (data.user_id) {
const { data: profileData, error: profileError } = await supabase
.from('profiles')
.select('username')
.eq('id', data.user_id)
.single();
if (!profileError && profileData) {
username = profileData.username;
}
}
return {
problemId: data.id,
version: 1,
timestamp: new Date(data.created_at).getTime(),
title: data.title,
description: data.description,
status: data.status as NutProblemStatus,
keywords: data.keywords,
repositoryId: data.repository_id,
username,
solution: solution || prompt,
comments: data.problem_comments.map((comment: any) => ({
id: comment.id,
timestamp: comment.created_at,
problemId: comment.problem_id,
content: comment.content,
username: comment.username,
})),
};
} catch (error) {
console.error('Error fetching problem:', error);
// Don't show duplicate toast if we already showed one above
if (!(error as any)?.code) {
toast.error('Failed to fetch problem');
}
}
return null;
}
export async function supabaseSubmitProblem(problem: NutProblemInput): Promise<string | null> {
try {
const supabaseProblem = {
id: undefined as any, // This will be set by Supabase
title: problem.title,
description: problem.description,
status: problem.status as NutProblemStatus,
keywords: problem.keywords || [],
repository_id: problem.repositoryId,
user_id: problem.user_id,
};
const { data, error } = await getSupabase().from('problems').insert(supabaseProblem).select().single();
if (error) {
throw error;
}
return data.id;
} catch (error) {
console.error('Error submitting problem', error);
toast.error('Failed to submit problem');
return null;
}
}
export async function supabaseDeleteProblem(problemId: string): Promise<void | undefined> {
try {
const { error: deleteError } = await getSupabase().from('problems').delete().eq('id', problemId);
if (deleteError) {
throw deleteError;
}
return undefined;
} catch (error) {
console.error('Error deleting problem', error);
return undefined;
}
}
export async function supabaseUpdateProblem(problemId: string, problem: NutProblemInput): Promise<void> {
try {
if (!getNutIsAdmin()) {
toast.error('Admin user required');
return undefined;
}
// Convert to Supabase format
const updates: Database['public']['Tables']['problems']['Update'] = {
title: problem.title,
description: problem.description,
status: problem.status,
keywords: problem.keywords || [],
repository_id: problem.repositoryId,
solution_path: problem.solution ? `solutions/${problemId}.json` : undefined,
};
// Update the problem
const { error: updateError } = await getSupabase().from('problems').update(updates).eq('id', problemId);
if (updateError) {
throw updateError;
}
if (updates.solution_path) {
const { error: solutionError } = await getSupabase()
.storage.from('solutions')
.upload(updates.solution_path, JSON.stringify(problem.solution), { upsert: true });
if (solutionError) {
throw solutionError;
}
}
// Handle comments if they exist
if (problem.comments && problem.comments.length > 0) {
const commentInserts = problem.comments
.filter((comment) => !comment.id)
.map((comment) => {
return {
problem_id: problemId,
content: comment.content,
username: comment.username || 'Anonymous',
};
});
/**
* Use upsert with onConflict to avoid duplicates.
* This will insert new comments and ignore existing ones based on created_at.
*/
const { error: commentsError } = await getSupabase().from('problem_comments').insert(commentInserts);
if (commentsError) {
throw commentsError;
}
}
return undefined;
} catch (error) {
console.error('Error updating problem', error);
toast.error('Failed to update problem');
return undefined;
}
}
export async function supabaseSubmitFeedback(feedback: any) {
const supabase = getSupabase();
// Get the current user ID if available
const {
data: { user },
} = await supabase.auth.getUser();
const userId = user?.id || null;
// Insert feedback into the feedback table
const { data, error } = await supabase.from('feedback').insert({
user_id: userId,
description: feedback.description || feedback.text || JSON.stringify(feedback),
metadata: feedback,
});
if (error) {
console.error('Error submitting feedback to Supabase:', error);
toast.error('Failed to submit feedback');
return false;
}
console.log('Feedback submitted successfully:', data);
return true;
}

View File

@ -15,31 +15,14 @@ function AboutPage() {
<h1 className="text-4xl font-bold mb-8 text-gray-900 dark:text-gray-200">About Nut</h1>
<p className="mb-6">
Nut is an{' '}
<a
href="https://github.com/replayio/bolt"
className="text-bolt-elements-accent underline hover:no-underline"
target="_blank"
rel="noopener noreferrer"
>
open source fork
</a>{' '}
of{' '}
<a
href="https://bolt.new"
className="text-bolt-elements-accent underline hover:no-underline"
target="_blank"
rel="noopener noreferrer"
>
Bolt.new
</a>{' '}
for helping you develop full stack apps using AI. AI developers frequently struggle with fixing even simple
bugs when they don't know the cause, and get stuck making ineffective changes over and over. We want to
crack these tough nuts, so to speak, so you can get back to building.
Nut is an agentic app builder for reliably developing full stack apps using AI.
When you ask Nut to build or change an app, it will do its best to get the code
changes right the first time. Afterwards it will check the app to make sure it's
working as expected, writing tests and fixing problems those tests uncover.
</p>
<p className="mb-6">
When you ask Nut to fix a bug, it creates a{' '}
You can also ask Nut to fix bugs. Nut will create a{' '}
<a
href="https://replay.io"
className="text-bolt-elements-accent underline hover:no-underline"
@ -54,13 +37,6 @@ function AboutPage() {
</p>
<p className="mb-6">
Nut.new is already pretty good at fixing problems, and we're working to make it better. We want it to
reliably fix anything you're seeing, as long as it has a clear explanation and the problem isn't too
complicated (AIs aren't magic). If it's doing poorly, let us know! Use the UI to leave us some private
feedback or save your project to our public set of problems where AIs struggle.
</p>
<p>
Nut is being developed by the{' '}
<a
href="https://replay.io"
@ -69,22 +45,21 @@ function AboutPage() {
rel="noopener noreferrer"
>
Replay.io
</a>{' '}
team. We're offering unlimited free access to Nut.new for early adopters who can give us feedback we'll use
to improve Nut. Reach us at{' '}
<a href="mailto:hi@replay.io" className="text-bolt-elements-accent underline hover:no-underline">
hi@replay.io
</a>{' '}
or fill out our{' '}
</a>{' '} team.
We'd love to hear from you! Leave us some feedback at the top of the page,
join our{' '}
<a
href="https://replay.io/contact"
href="https://www.replay.io/discord"
className="text-bolt-elements-accent underline hover:no-underline"
target="_blank"
rel="noopener noreferrer"
>
contact form
Discord
</a>{' '}
to join our early adopter program.
or reach us at {' '}
<a href="mailto:hi@replay.io" className="text-bolt-elements-accent underline hover:no-underline">
hi@replay.io
</a>.
</p>
</div>
</div>

View File

@ -1,8 +0,0 @@
import { json, type LoaderFunctionArgs } from '~/lib/remix-types';
import { default as IndexRoute } from './_index';
export async function loader(args: LoaderFunctionArgs) {
return json({ problemId: args.params.id });
}
export default IndexRoute;

View File

@ -1,97 +0,0 @@
import { ClientOnly } from 'remix-utils/client-only';
import { Header } from '~/components/header/Header';
import { Menu } from '~/components/sidebar/Menu.client';
import BackgroundRays from '~/components/ui/BackgroundRays';
import { TooltipProvider } from '@radix-ui/react-tooltip';
import { ToastContainerWrapper, Status, Keywords } from './problems';
import { Suspense, useEffect, useState } from 'react';
import { useParams } from '@remix-run/react';
import { getProblem, NutProblemStatus } from '~/lib/replay/Problems';
import type { NutProblem, NutProblemComment } from '~/lib/replay/Problems';
function Comments({ comments }: { comments: NutProblemComment[] }) {
return (
<div className="space-y-4 mt-6">
{comments.map((comment, index) => (
<div
data-testid="problem-comment"
key={index}
className="bg-bolt-elements-background-depth-2 rounded-lg p-4 shadow-sm"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-bolt-text">{comment.username ?? 'Anonymous'}</span>
<span className="text-sm text-bolt-text-secondary">
{(() => {
const date = new Date(comment.timestamp);
return date && !isNaN(date.getTime()) ? date.toLocaleString() : 'Unknown date';
})()}
</span>
</div>
<div className="text-bolt-text whitespace-pre-wrap">{comment.content}</div>
</div>
))}
</div>
);
}
function ProblemViewer({ problem }: { problem: NutProblem }) {
const { problemId, title, description, status = NutProblemStatus.Pending, keywords = [], comments = [] } = problem;
return (
<div className="benchmark">
<h1 className="text-xl4 font-semibold mb-2">{title}</h1>
<p>{description}</p>
<a
href={`/load-problem/${problemId}`}
className="load-button inline-block px-4 py-2 mt-3 mb-3 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors duration-200 font-medium"
>
Load Problem
</a>
<Status status={status} />
<Keywords keywords={keywords} />
<Comments comments={comments} />
</div>
);
}
const Nothing = () => null;
function ViewProblemPage() {
const params = useParams();
const problemId = params.id;
if (typeof problemId !== 'string') {
throw new Error('Problem ID is required');
}
const [problemData, setProblemData] = useState<NutProblem | null>(null);
useEffect(() => {
getProblem(problemId).then(setProblemData);
}, [problemId]);
return (
<Suspense fallback={<Nothing />}>
<TooltipProvider>
<div className="flex flex-col h-full min-h-screen w-full bg-bolt-elements-background-depth-1 text-gray-900 dark:text-gray-200">
<BackgroundRays />
<Header />
<ClientOnly>{() => <Menu />}</ClientOnly>
<div className="p-6">
{problemData === null ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
) : (
<ProblemViewer problem={problemData} />
)}
</div>
<ToastContainerWrapper />
</div>
</TooltipProvider>
</Suspense>
);
}
export default ViewProblemPage;

View File

@ -1,177 +0,0 @@
import { ClientOnly } from 'remix-utils/client-only';
import { Header } from '~/components/header/Header';
import { Menu } from '~/components/sidebar/Menu.client';
import BackgroundRays from '~/components/ui/BackgroundRays';
import { TooltipProvider } from '@radix-ui/react-tooltip';
import { cssTransition, ToastContainer } from 'react-toastify';
import { Suspense, useEffect, useState } from 'react';
import { listAllProblems, NutProblemStatus } from '~/lib/replay/Problems';
import type { NutProblemDescription } from '~/lib/replay/Problems';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
exit: 'animated fadeOutRight',
});
export function ToastContainerWrapper() {
return (
<ToastContainer
closeButton={({ closeToast }) => {
return (
<button className="Toastify__close-button" onClick={closeToast}>
<div className="i-ph:x text-lg" />
</button>
);
}}
icon={({ type }) => {
/**
* @todo Handle more types if we need them. This may require extra color palettes.
*/
switch (type) {
case 'success': {
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
}
case 'error': {
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
}
}
return undefined;
}}
position="bottom-right"
pauseOnFocusLoss
transition={toastAnimation}
/>
);
}
export function Status({ status }: { status: NutProblemStatus | undefined }) {
if (!status) {
status = NutProblemStatus.Pending;
}
const statusColors: Record<NutProblemStatus, string> = {
[NutProblemStatus.Pending]: 'bg-yellow-400 dark:text-yellow-400',
[NutProblemStatus.Unsolved]: 'bg-orange-500 dark:text-orange-500',
[NutProblemStatus.Solved]: 'bg-blue-500 dark:text-blue-500',
};
return (
<div className="flex items-center gap-2 my-2">
<span className="font-semibold">Status:</span>
<div
className={`inline-flex items-center px-3 py-1 rounded-full bg-opacity-10 dark:bg-opacity-20 ${statusColors[status]}`}
>
<span className={`w-2 h-2 rounded-full mr-2 ${statusColors[status]} bg-opacity-100`}></span>
<span className="font-medium">{status.charAt(0).toUpperCase() + status.slice(1)}</span>
</div>
</div>
);
}
export function Keywords({ keywords }: { keywords: string[] | undefined }) {
if (!keywords?.length) {
return null;
}
return (
<div className="flex flex-wrap gap-2 mb-3">
{keywords.map((keyword, index) => (
<span
key={index}
className="px-3 py-1 text-sm rounded-full border border-bolt-elements-border bg-bolt-elements-background-depth-2 text-bolt-content-primary"
>
{keyword}
</span>
))}
</div>
);
}
function getProblemStatus(problem: NutProblemDescription): NutProblemStatus {
return problem.status ?? NutProblemStatus.Pending;
}
const Nothing = () => null;
function ProblemsPage() {
const [problems, setProblems] = useState<NutProblemDescription[] | null>(null);
const [statusFilter, setStatusFilter] = useState<NutProblemStatus | 'all'>(NutProblemStatus.Solved);
useEffect(() => {
listAllProblems().then(setProblems);
}, []);
const filteredProblems = problems?.filter((problem) => {
return statusFilter === 'all' || getProblemStatus(problem) === statusFilter;
});
return (
<Suspense fallback={<Nothing />}>
<TooltipProvider>
<div className="flex flex-col min-h-fit min-h-screen w-full bg-bolt-elements-background-depth-1 dark:bg-black text-gray-900 dark:text-gray-200">
<BackgroundRays />
<Header />
<ClientOnly>{() => <Menu />}</ClientOnly>
<div className="p-6">
{problems && (
<div className="mb-4">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as NutProblemStatus | 'all')}
className="appearance-none w-48 px-4 py-2.5 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-border text-bolt-content-primary hover:border-bolt-elements-border-hover focus:outline-none focus:ring-2 focus:ring-bolt-accent-primary/20 focus:border-bolt-accent-primary cursor-pointer relative pr-10"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 12px center',
backgroundSize: '16px',
}}
>
<option value="all">{`All Problems (${problems?.length ?? 0})`}</option>
{Object.values(NutProblemStatus).map((status) => {
const count = problems?.filter((problem) => getProblemStatus(problem) === status).length ?? 0;
return (
<option key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1) + ` (${count})`}
</option>
);
})}
</select>
</div>
)}
{problems === null ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
) : problems.length === 0 ? (
<div className="text-center text-gray-600">No problems found</div>
) : (
<div className="grid gap-4">
{filteredProblems?.map((problem) => (
<a
href={`/problem/${problem.problemId}`}
key={problem.problemId}
className="p-4 rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-colors cursor-pointer"
>
<h2 className="text-xl font-semibold mb-2">{problem.title}</h2>
<p className="text-gray-700 dark:text-gray-200 mb-2">{problem.description}</p>
<Status status={problem.status} />
<Keywords keywords={problem.keywords} />
<p className="text-sm text-gray-600 dark:text-gray-200">
Time: {new Date(problem.timestamp).toLocaleString()}
</p>
</a>
))}
</div>
)}
</div>
<ToastContainerWrapper />
</div>
</TooltipProvider>
</Suspense>
);
}
export default ProblemsPage;

View File

@ -1,98 +0,0 @@
import { test, expect } from '@playwright/test';
import { login, setLoginKey, openSidebar } from './setup/test-utils';
test('Should be able to load a problem', async ({ page }) => {
await page.goto('/problems');
const combobox = page.getByRole('combobox');
await expect(combobox).toBeVisible({ timeout: 30000 });
await combobox.selectOption('all');
const problemLink = page.getByRole('link', { name: 'Contact book tiny search icon' }).first();
await expect(problemLink).toBeVisible({ timeout: 30000 });
await problemLink.click();
const loadProblemLink = page.getByRole('link', { name: 'Load Problem' });
await expect(loadProblemLink).toBeVisible({ timeout: 30000 });
await loadProblemLink.click();
await expect(page.getByText('Import the "problem" folder')).toBeVisible({ timeout: 30000 });
});
// TODO: Unskip this test once we can make sure we get a repro for a problem.
test.skip('Should be able to save a problem ', async ({ page }) => {
await page.goto('/problems');
await page.getByRole('link', { name: 'App goes blank getting' }).click();
await page.getByRole('link', { name: 'Load Problem' }).click();
await expect(page.getByText('Import the "problem" folder')).toBeVisible({ timeout: 30000 });
await login(page);
await expect(page.getByText('Import the "problem" folder')).toBeVisible({ timeout: 30000 });
await openSidebar(page);
await page.getByRole('button', { name: 'Save Problem' }).click();
await page.locator('input[name="title"]').click();
await page.locator('input[name="title"]').fill('[test] playwright');
await page.locator('input[name="description"]').click();
await page.locator('input[name="description"]').fill('...');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button', { name: 'Close' }).click();
});
test('Should be able to update a problem', async ({ page }) => {
await page.goto('/problems?showAll=true');
await page.getByRole('combobox').selectOption('all');
await page.getByRole('link', { name: '[test] playwright' }).first().click();
expect(await page.getByRole('textbox', { name: 'Set the title of the problem' })).not.toBeVisible();
await login(page);
const currentTime = new Date();
const hours = currentTime.getHours().toString().padStart(2, '0');
const minutes = currentTime.getMinutes().toString().padStart(2, '0');
const timeString = `${hours}:${minutes}`;
const title = `[test] playwright ${timeString}`;
await page.getByRole('textbox', { name: 'Set the title of the problem' }).click();
await page.getByRole('textbox', { name: 'Set the title of the problem' }).fill(title);
await page.getByRole('button', { name: 'Set Title' }).click();
await page.getByRole('heading', { name: title }).click();
await page.getByRole('combobox').selectOption('Solved');
await page.getByRole('button', { name: 'Set Status' }).click();
await page.locator('span').filter({ hasText: 'Solved' }).click();
await page.getByRole('combobox').selectOption('Pending');
await page.getByRole('button', { name: 'Set Status' }).click();
await page.locator('span').filter({ hasText: 'Pending' }).click();
});
test('Should be able to add a comment to a problem', async ({ page }) => {
await page.goto('/problems?showAll=true');
await page.getByRole('combobox').selectOption('all');
await page.getByRole('link', { name: '[test] playwright' }).first().click();
await login(page);
// Add a comment to the problem
const comment = `test comment ${Date.now().toString()}`;
await page.getByRole('textbox', { name: 'Add a comment...' }).click();
await page.getByRole('textbox', { name: 'Add a comment...' }).fill(comment);
await page.getByRole('button', { name: 'Add Comment' }).click();
await expect(page.locator('[data-testid="problem-comment"]').filter({ hasText: comment })).toBeVisible();
// Reload the page and check that the comment is still visible
await page.reload();
await expect(page.locator('[data-testid="problem-comment"]').filter({ hasText: comment })).toBeVisible();
});
test('Confirm that admins see the "Save Problem" button', async ({ page }) => {
await page.goto('/problems?showAll=true');
await login(page);
await openSidebar(page);
await expect(page.getByRole('button', { name: 'Save Problem' })).toBeVisible();
});