From a5e53a023b943a2ad7a0c7fcefe92617574103ba Mon Sep 17 00:00:00 2001 From: Jason Laster Date: Wed, 19 Mar 2025 15:03:58 -0700 Subject: [PATCH] Support updating problems --- .github/workflows/playwright.yaml | 6 +- app/components/settings/SettingsWindow.tsx | 2 +- .../settings/providers/APIKeysTab.tsx | 18 ++++- app/components/sidebar/Menu.client.tsx | 6 +- app/components/sidebar/SaveProblem.tsx | 6 +- app/components/ui/Dialog.tsx | 2 +- app/components/ui/IconButton.tsx | 4 + app/lib/replay/Problems.ts | 76 +++++++++++++------ app/lib/stores/user.ts | 54 +++++++++++++ app/lib/supabase/client.ts | 35 ++++++++- app/lib/supabase/problems.ts | 6 +- app/root.tsx | 6 ++ app/routes/problem.$id.tsx | 61 +++++++++++---- package.json | 2 + tests/e2e/problem.spec.ts | 42 ++++++++-- tests/e2e/setup/test-utils.ts | 25 ++++++ 16 files changed, 284 insertions(+), 67 deletions(-) create mode 100644 app/lib/stores/user.ts diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index cf685d74..3e688487 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -50,6 +50,8 @@ jobs: env: SUPABASE_TEST_USER_EMAIL: ${{ secrets.SUPABASE_TEST_USER_EMAIL }} SUPABASE_TEST_USER_PASSWORD: ${{ secrets.SUPABASE_TEST_USER_PASSWORD }} + NUT_LOGIN_KEY: ${{ secrets.NUT_LOGIN_KEY }} + NUT_PASSWORD: ${{ secrets.NUT_PASSWORD }} - name: Upload test results if: always() @@ -99,7 +101,9 @@ jobs: PLAYWRIGHT_TEST_BASE_URL: ${{ steps.deploy.outputs.WITH_SUPABASE_PREVIEW_URL }} SUPABASE_TEST_USER_EMAIL: ${{ secrets.SUPABASE_TEST_USER_EMAIL }} SUPABASE_TEST_USER_PASSWORD: ${{ secrets.SUPABASE_TEST_USER_PASSWORD }} - + NUT_LOGIN_KEY: ${{ secrets.NUT_LOGIN_KEY }} + NUT_PASSWORD: ${{ secrets.NUT_PASSWORD }} + - name: Upload test results if: always() uses: actions/upload-artifact@v4 diff --git a/app/components/settings/SettingsWindow.tsx b/app/components/settings/SettingsWindow.tsx index e9ca9178..9abe0cba 100644 --- a/app/components/settings/SettingsWindow.tsx +++ b/app/components/settings/SettingsWindow.tsx @@ -118,7 +118,7 @@ export const SettingsWindow = ({ open, onClose }: SettingsProps) => { - + diff --git a/app/components/settings/providers/APIKeysTab.tsx b/app/components/settings/providers/APIKeysTab.tsx index 6b97d8b3..9fc7c9cb 100644 --- a/app/components/settings/providers/APIKeysTab.tsx +++ b/app/components/settings/providers/APIKeysTab.tsx @@ -1,12 +1,13 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { toast } from 'react-toastify'; import Cookies from 'js-cookie'; import { anthropicNumFreeUsesCookieName, anthropicApiKeyCookieName, maxFreeUses } from '~/utils/freeUses'; -import { saveNutLoginKey, saveProblemsUsername, getNutLoginKey, getProblemsUsername } from '~/lib/replay/Problems'; +import { saveNutLoginKey, saveUsername, getNutLoginKey, getUsername } from '~/lib/replay/Problems'; +import { debounce } from '~/utils/debounce'; export default function ConnectionsTab() { const [apiKey, setApiKey] = useState(Cookies.get(anthropicApiKeyCookieName) || ''); - const [username, setUsername] = useState(getProblemsUsername() || ''); + const [username, setUsername] = useState(getUsername() || ''); const [loginKey, setLoginKey] = useState(getNutLoginKey() || ''); const numFreeUses = +(Cookies.get(anthropicNumFreeUsesCookieName) || 0); @@ -20,9 +21,16 @@ export default function ConnectionsTab() { setApiKey(key); }; + const saveUsernameWithToast = (username: string) => { + saveUsername(username); + toast.success('Username saved!'); + }; + + const debouncedSaveUsername = useCallback(debounce(saveUsernameWithToast, 1000), []); + const handleSaveUsername = async (username: string) => { - saveProblemsUsername(username); setUsername(username); + debouncedSaveUsername(username); }; const handleSaveLoginKey = async (key: string) => { @@ -61,6 +69,7 @@ export default function ConnectionsTab() {
handleSaveUsername(e.target.value)} className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50" @@ -72,6 +81,7 @@ export default function ConnectionsTab() {
handleSaveLoginKey(e.target.value)} className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50" diff --git a/app/components/sidebar/Menu.client.tsx b/app/components/sidebar/Menu.client.tsx index e5064e8c..5cf9ca20 100644 --- a/app/components/sidebar/Menu.client.tsx +++ b/app/components/sidebar/Menu.client.tsx @@ -1,6 +1,7 @@ import { motion, type Variants } from 'framer-motion'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; import { ThemeSwitch } from '~/components/ui/ThemeSwitch'; import { SettingsWindow } from '~/components/settings/SettingsWindow'; @@ -13,7 +14,7 @@ import { binDates } from './date-binning'; import { useSearchFilter } from '~/lib/hooks/useSearchFilter'; import { SaveProblem } from './SaveProblem'; import { SaveReproductionModal } from './SaveReproduction'; -import { getNutIsAdmin } from '~/lib/replay/Problems'; +import { isAdminStore } from '~/lib/stores/user'; const menuVariants = { closed: { @@ -46,6 +47,7 @@ export const Menu = () => { const [open, setOpen] = useState(false); const [dialogContent, setDialogContent] = useState(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const isAdmin = useStore(isAdminStore); const { filteredItems: filteredList, handleSearchChange } = useSearchFilter({ items: list, @@ -140,7 +142,7 @@ export const Menu = () => { Problems - {getNutIsAdmin() && } + {isAdmin && } ({ ...prev, username })); @@ -70,7 +70,7 @@ export function SaveProblem() { // Only save username to cookie if not using Supabase if (!shouldUseSupabase()) { - saveProblemsUsername(formData.username); + saveUsername(formData.username); } toast.info('Submitting problem...'); diff --git a/app/components/ui/Dialog.tsx b/app/components/ui/Dialog.tsx index a808c774..ee9903af 100644 --- a/app/components/ui/Dialog.tsx +++ b/app/components/ui/Dialog.tsx @@ -124,7 +124,7 @@ export const Dialog = memo(({ className, children, onBackdrop, onClose }: Dialog > {children} - + diff --git a/app/components/ui/IconButton.tsx b/app/components/ui/IconButton.tsx index dd8e75e4..30edb863 100644 --- a/app/components/ui/IconButton.tsx +++ b/app/components/ui/IconButton.tsx @@ -17,11 +17,13 @@ interface BaseIconButtonProps { type IconButtonWithoutChildrenProps = { icon: string; children?: undefined; + testId?: string; } & BaseIconButtonProps; type IconButtonWithChildrenProps = { icon?: undefined; children: string | JSX.Element | JSX.Element[]; + testId?: string; } & BaseIconButtonProps; type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps; @@ -37,6 +39,7 @@ export const IconButton = memo( iconClassName, disabledClassName, disabled = false, + testId, title, onClick, children, @@ -56,6 +59,7 @@ export const IconButton = memo( )} title={title} disabled={disabled} + data-testid={testId || 'icon-button'} onClick={(event) => { if (disabled) { return; diff --git a/app/lib/replay/Problems.ts b/app/lib/replay/Problems.ts index 4cb1ad9e..797b2601 100644 --- a/app/lib/replay/Problems.ts +++ b/app/lib/replay/Problems.ts @@ -13,6 +13,8 @@ import { supabaseSubmitFeedback, supabaseDeleteProblem, } from '~/lib/supabase/problems'; +import { getNutIsAdmin as getNutIsAdminFromSupabase } from '~/lib/supabase/client'; +import { updateIsAdmin, updateUsername } from '~/lib/stores/user'; export interface BoltProblemComment { username?: string; @@ -59,26 +61,41 @@ export interface BoltProblem extends BoltProblemDescription { export type BoltProblemInput = Omit; export async function listAllProblems(): Promise { + let problems: BoltProblemDescription[] = []; + if (shouldUseSupabase()) { - return supabaseListAllProblems(); + problems = await supabaseListAllProblems(); + } else { + 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 []; + } } - try { - const rv = await sendCommandDedicatedClient({ - method: 'Recording.globalExperimentalCommand', - params: { - name: 'listBoltProblems', - }, - }); - console.log('ListProblemsRval', rv); - - return (rv as any).rval.problems.reverse(); - } catch (error) { - console.error('Error fetching problems', error); - toast.error('Failed to fetch problems'); - - return []; - } + return problems; } export async function getProblem(problemId: string): Promise { @@ -192,13 +209,18 @@ export async function updateProblem(problemId: string, problem: BoltProblemInput const nutLoginKeyCookieName = 'nutLoginKey'; const nutIsAdminCookieName = 'nutIsAdmin'; +const nutUsernameCookieName = 'nutUsername'; export function getNutLoginKey(): string | undefined { const cookieValue = Cookies.get(nutLoginKeyCookieName); return cookieValue?.length ? cookieValue : undefined; } -export function getNutIsAdmin(): boolean { +export async function getNutIsAdmin(): Promise { + if (shouldUseSupabase()) { + return getNutIsAdminFromSupabase(); + } + return Cookies.get(nutIsAdminCookieName) === 'true'; } @@ -222,22 +244,26 @@ export async function saveNutLoginKey(key: string) { console.log('UserInfo', userInfo); Cookies.set(nutLoginKeyCookieName, key); - Cookies.set(nutIsAdminCookieName, userInfo.admin ? 'true' : 'false'); + setNutIsAdmin(userInfo.admin); } export function setNutIsAdmin(isAdmin: boolean) { Cookies.set(nutIsAdminCookieName, isAdmin ? 'true' : 'false'); + + // Update the store + updateIsAdmin(isAdmin); } -const nutProblemsUsernameCookieName = 'nutProblemsUsername'; - -export function getProblemsUsername(): string | undefined { - const cookieValue = Cookies.get(nutProblemsUsernameCookieName); +export function getUsername(): string | undefined { + const cookieValue = Cookies.get(nutUsernameCookieName); return cookieValue?.length ? cookieValue : undefined; } -export function saveProblemsUsername(username: string) { - Cookies.set(nutProblemsUsernameCookieName, username); +export function saveUsername(username: string) { + Cookies.set(nutUsernameCookieName, username); + + // Update the store + updateUsername(username); } export async function submitFeedback(feedback: any): Promise { diff --git a/app/lib/stores/user.ts b/app/lib/stores/user.ts new file mode 100644 index 00000000..48c336ad --- /dev/null +++ b/app/lib/stores/user.ts @@ -0,0 +1,54 @@ +import { atom } from 'nanostores'; +import { getNutIsAdmin, getUsername } from '~/lib/replay/Problems'; +import { userStore } from './auth'; + +// Store for admin status +export const isAdminStore = atom(false); + +// Store for username +export const usernameStore = atom(undefined); + +// Safe store updaters that check for browser environment +export function updateIsAdmin(value: boolean) { + if (typeof window !== 'undefined') { + isAdminStore.set(value); + } +} + +export function updateUsername(username: string | undefined) { + if (typeof window !== 'undefined') { + usernameStore.set(username); + } +} + +// Initialize the user stores +export async function initializeUserStores() { + try { + // Only run in browser environment + if (typeof window === 'undefined') { + return undefined; + } + + // Initialize with current values + const isAdmin = await getNutIsAdmin(); + isAdminStore.set(isAdmin); + + const username = getUsername(); + usernameStore.set(username); + + // Subscribe to user changes to update admin status + return userStore.subscribe(async (user) => { + if (user) { + // When user changes, recalculate admin status + const isAdmin = await getNutIsAdmin(); + isAdminStore.set(isAdmin); + } else { + // Reset when logged out + isAdminStore.set(false); + } + }); + } catch (error) { + console.error('Failed to initialize user stores', error); + return undefined; + } +} diff --git a/app/lib/supabase/client.ts b/app/lib/supabase/client.ts index b788443f..d3677e6b 100644 --- a/app/lib/supabase/client.ts +++ b/app/lib/supabase/client.ts @@ -54,6 +54,9 @@ export interface Database { let supabaseUrl = ''; let supabaseAnonKey = ''; +// Add a singleton client instance +let supabaseClientInstance: ReturnType> | null = null; + /** * Determines whether Supabase should be used based on URL parameters and environment variables. * URL parameters take precedence over environment variables. @@ -86,6 +89,27 @@ export async function getCurrentUser(): Promise { } } +export async function getNutIsAdmin(): Promise { + const user = await getCurrentUser(); + + if (!user) { + return false; + } + + const { data: profileData, error: profileError } = await getSupabase() + .from('profiles') + .select('is_admin') + .eq('id', user?.id) + .single(); + + if (profileError) { + console.error('Error fetching user profile:', profileError); + return false; + } + + return profileData?.is_admin || false; +} + /** * Checks if there is a currently authenticated user. */ @@ -95,6 +119,11 @@ export async function isAuthenticated(): Promise { } export function getSupabase() { + // If we already have an instance, return it + if (supabaseClientInstance) { + return supabaseClientInstance; + } + // Determine execution environment and get appropriate variables if (typeof window == 'object') { supabaseUrl = window.ENV.SUPABASE_URL || ''; @@ -115,13 +144,15 @@ export function getSupabase() { console.warn('Missing Supabase environment variables. Some features may not work properly.'); } - // Create and return the Supabase client - return createClient(supabaseUrl, supabaseAnonKey, { + // Create and cache the Supabase client + supabaseClientInstance = createClient(supabaseUrl, supabaseAnonKey, { auth: { persistSession: true, autoRefreshToken: true, }, }); + + return supabaseClientInstance; } // Helper function to check if Supabase is properly initialized diff --git a/app/lib/supabase/problems.ts b/app/lib/supabase/problems.ts index 590c0667..5b568be1 100644 --- a/app/lib/supabase/problems.ts +++ b/app/lib/supabase/problems.ts @@ -3,7 +3,7 @@ import { toast } from 'react-toastify'; import { getSupabase, type Database } from './client'; import type { BoltProblem, BoltProblemDescription, BoltProblemInput, BoltProblemStatus } from '~/lib/replay/Problems'; -import { getProblemsUsername, getNutIsAdmin } from '~/lib/replay/Problems'; +import { getUsername, getNutIsAdmin } from '~/lib/replay/Problems'; export async function supabaseListAllProblems(): Promise { try { @@ -12,8 +12,6 @@ export async function supabaseListAllProblems(): Promise ({ problem_id: problemId, content: comment.content, - username: comment.username || getProblemsUsername() || 'Anonymous', + username: comment.username || getUsername() || 'Anonymous', /** * Use timestamp as a unique identifier for the comment. diff --git a/app/root.tsx b/app/root.tsx index 71af7bbf..6b7ce86a 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -10,6 +10,7 @@ import { createHead } from 'remix-island'; import { useEffect, useState } from 'react'; import { logStore } from './lib/stores/logs'; import { initializeAuth, userStore, isLoadingStore } from './lib/stores/auth'; +import { initializeUserStores } from './lib/stores/user'; import { ToastContainer } from 'react-toastify'; import { Analytics } from '@vercel/analytics/remix'; import { AuthModal } from './components/auth/AuthModal'; @@ -115,9 +116,14 @@ function AuthProvider({ data }: { data: LoaderData }) { useEffect(() => { if (typeof window !== 'undefined') { window.ENV = data.ENV; + + // Initialize auth and user stores initializeAuth().catch((err: Error) => { logStore.logError('Failed to initialize auth', err); }); + initializeUserStores().catch((err: Error) => { + logStore.logError('Failed to initialize user stores', err); + }); } }, [data]); diff --git a/app/routes/problem.$id.tsx b/app/routes/problem.$id.tsx index 674acadb..51e0bbce 100644 --- a/app/routes/problem.$id.tsx +++ b/app/routes/problem.$id.tsx @@ -7,14 +7,14 @@ import { ToastContainerWrapper, Status, Keywords } from './problems'; import { toast } from 'react-toastify'; import { Suspense, useCallback, useEffect, useState } from 'react'; import { useParams } from '@remix-run/react'; +import { useStore } from '@nanostores/react'; import { getProblem, updateProblem as backendUpdateProblem, deleteProblem as backendDeleteProblem, - getProblemsUsername, BoltProblemStatus, - getNutIsAdmin, } from '~/lib/replay/Problems'; +import { isAdminStore, usernameStore } from '~/lib/stores/user'; import type { BoltProblem, BoltProblemComment } from '~/lib/replay/Problems'; function Comments({ comments }: { comments: BoltProblemComment[] }) { @@ -57,10 +57,12 @@ interface UpdateProblemFormProps { handleSubmit: (content: string) => void; updateText: string; placeholder: string; + inputType?: 'textarea' | 'select'; + options?: { value: string; label: string }[]; } function UpdateProblemForm(props: UpdateProblemFormProps) { - const { handleSubmit, updateText, placeholder } = props; + const { handleSubmit, updateText, placeholder, inputType = 'textarea', options = [] } = props; const [value, setValue] = useState(''); const onSubmitClicked = (e: React.FormEvent) => { @@ -74,14 +76,32 @@ function UpdateProblemForm(props: UpdateProblemFormProps) { return (
-