diff --git a/.changeset/add-rtk-query-types.md b/.changeset/add-rtk-query-types.md new file mode 100644 index 00000000..c999719c --- /dev/null +++ b/.changeset/add-rtk-query-types.md @@ -0,0 +1,5 @@ +--- +'@bilbomd/ui': minor +--- + +Add comprehensive TypeScript types to RTK Query endpoints. Previously, 36 out of 69 RTK Query endpoints (52%) lacked explicit type parameters, causing result types to default to `any` and bypass TypeScript's type safety. This change adds proper generic type parameters `` to all untyped endpoints across authApiSlice, configsApiSlice, statsApiSlice, adminApiSlice, usersApiSlice, and jobsApiSlice. Benefits include compile-time type safety, better IDE autocomplete, and self-documenting API contracts. diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4874c33d..f03cb4e9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,13 @@ "Bash(gh pr checks:*)", "Bash(pnpm -F @bilbomd/worker test:*)", "Bash(gh pr view:*)", - "Bash(git fetch:*)" + "Bash(git fetch:*)", + "Bash(pnpm vitest:*)", + "Bash(grep:*)", + "Bash(done)", + "Bash(pnpm tsc:*)", + "Bash(git restore:*)", + "Bash(pnpm:*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 52f79030..5e491def 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,72 @@ Environment: copy `infra/.env.example` to `infra/.env.local`. - All modules use ESM (`"type": "module"`) - TypeScript target: ES2022, module: NodeNext +## Development Workflow + +**IMPORTANT**: When working on code changes, always follow this workflow: + +### 1. Create a New Git Branch + +Before starting any code changes, create a new branch using the appropriate naming convention: + +```bash +git checkout -b / +# Examples: +git checkout -b feature/add-user-roles +git checkout -b fix/job-timeout +git checkout -b refactor/add-rtk-query-types +``` + +See [Git Branch Naming Convention](#git-branch-naming-convention) for prefix guidelines. + +### 2. Verify All Checks Pass Before Completion + +**Before considering work complete**, ensure all of the following pass without errors: + +```bash +# 1. Linting - must pass with zero warnings/errors +pnpm lint + +# 2. Build - must complete successfully +pnpm build + +# 3. Tests - all tests must pass +pnpm test +``` + +**For package-specific work**, run the checks filtered to that package: + +```bash +# Example for UI package +pnpm -F @bilbomd/ui lint +pnpm -F @bilbomd/ui build +pnpm -F @bilbomd/ui test + +# Example for backend package +pnpm -F @bilbomd/backend lint +pnpm -F @bilbomd/backend build +pnpm -F @bilbomd/backend test +``` + +### 3. Fix Any Issues + +If any of the checks fail: +- **Linting errors**: Fix ESLint warnings and errors before committing +- **Build errors**: Resolve TypeScript errors and build issues +- **Test failures**: Fix failing tests or update tests if behavior changed intentionally + +**Do not commit or push code that fails any of these checks.** + +### 4. Commit and Push + +Once all checks pass: + +```bash +git add -A +git commit -m "descriptive commit message" +git push origin +``` + ## Versioning Uses **Changesets** for per-package versioning. After code changes: diff --git a/apps/ui/src/components/ConstInpForm/FormModel/validationSchemas.ts b/apps/ui/src/components/ConstInpForm/FormModel/validationSchemas.ts index 9eeacf82..1776a421 100644 --- a/apps/ui/src/components/ConstInpForm/FormModel/validationSchemas.ts +++ b/apps/ui/src/components/ConstInpForm/FormModel/validationSchemas.ts @@ -193,10 +193,10 @@ const validationSchemas = [ return false } const chainStart = ctx.from[2].value.chains.find( - (x) => x.id === ctx.parent.chainid + (x: Chain) => x.id === ctx.parent.chainid ).first_res const chainEnd = ctx.from[2].value.chains.find( - (x) => x.id === ctx.parent.chainid + (x: Chain) => x.id === ctx.parent.chainid ).last_res if (value >= chainStart && value <= chainEnd) { return true diff --git a/apps/ui/src/components/ErrorFallback.tsx b/apps/ui/src/components/ErrorFallback.tsx index b9a0fd8a..082e7be3 100644 --- a/apps/ui/src/components/ErrorFallback.tsx +++ b/apps/ui/src/components/ErrorFallback.tsx @@ -1,11 +1,16 @@ -const ErrorFallback = ({ error }) => { +import { FallbackProps } from 'react-error-boundary' + +const ErrorFallback = ({ error, resetErrorBoundary: _ }: FallbackProps) => { + const errorMessage = + error instanceof Error ? error.message : 'An unknown error occurred' + return (

Something went wrong. Please send a screenshot of the error message to Scott.

-
{error.message}
+
{errorMessage}
) } diff --git a/apps/ui/src/components/Home.tsx b/apps/ui/src/components/Home.tsx index bbca1238..480d3b4f 100644 --- a/apps/ui/src/components/Home.tsx +++ b/apps/ui/src/components/Home.tsx @@ -35,7 +35,7 @@ const Home = ({ title = 'BilboMD' }) => { useEffect(() => { const verifyRefreshToken = async () => { try { - await refresh({}) + await refresh(undefined) setTrueSuccess(true) } catch (error) { console.error('verifyRefreshToken error:', error) diff --git a/apps/ui/src/components/Loadable.tsx b/apps/ui/src/components/Loadable.tsx index 2cbab2e5..8f679f71 100644 --- a/apps/ui/src/components/Loadable.tsx +++ b/apps/ui/src/components/Loadable.tsx @@ -1,8 +1,8 @@ -import { Suspense } from 'react' +import { Suspense, ComponentType } from 'react' import Loader from './Loader' -const Loadable = (Component) => { - const LoadableComponent = (props) => ( +const Loadable =

(Component: ComponentType

) => { + const LoadableComponent = (props: P) => ( }> diff --git a/apps/ui/src/components/__tests__/ErrorFallback.test.tsx b/apps/ui/src/components/__tests__/ErrorFallback.test.tsx index 6771879c..fd2a9537 100644 --- a/apps/ui/src/components/__tests__/ErrorFallback.test.tsx +++ b/apps/ui/src/components/__tests__/ErrorFallback.test.tsx @@ -3,10 +3,16 @@ import ErrorFallback from '../ErrorFallback' test('renders error message and alert role', () => { // Mock error object - const error = { message: 'Test error message' } + const error = new Error('Test error message') + const resetErrorBoundary = vi.fn() // Render the component with the mock error - const { getByRole, getByText } = render() + const { getByRole, getByText } = render( + + ) // Check if the alert role is present expect(getByRole('alert')).toBeInTheDocument() diff --git a/apps/ui/src/features/admin/QueueDetailsPage.tsx b/apps/ui/src/features/admin/QueueDetailsPage.tsx index 9eeed108..c1ae41f3 100644 --- a/apps/ui/src/features/admin/QueueDetailsPage.tsx +++ b/apps/ui/src/features/admin/QueueDetailsPage.tsx @@ -101,7 +101,7 @@ const QueueDetailsPage = () => { setAnchorEl(null) setMenuJobId(null) } - const { data: queues, isLoading, error } = useGetQueuesQuery({}) + const { data: queues, isLoading, error } = useGetQueuesQuery(undefined) const { data: jobs, isLoading: jobsLoading, @@ -138,7 +138,8 @@ const QueueDetailsPage = () => { console.log('Jobs:', jobs) - const typedJobs: FrontendBullMQJob[] = jobs?.jobs ?? [] + const typedJobs: FrontendBullMQJob[] = (jobs?.jobs ?? + []) as unknown as FrontendBullMQJob[] const filteredJobs = typedJobs.filter((job) => { const matchesType = typeFilter === 'All' || job.data?.type === typeFilter diff --git a/apps/ui/src/features/admin/QueueOverviewPanel.tsx b/apps/ui/src/features/admin/QueueOverviewPanel.tsx index 95200964..06766aa5 100644 --- a/apps/ui/src/features/admin/QueueOverviewPanel.tsx +++ b/apps/ui/src/features/admin/QueueOverviewPanel.tsx @@ -32,7 +32,7 @@ const QueueOverviewPanel = () => { error, isFetching } = useGetQueuesQuery( - {}, + undefined, { pollingInterval: pollingEnabled ? 5000 : 0 } diff --git a/apps/ui/src/features/alphafoldjob/NewAlphaFoldJobForm.tsx b/apps/ui/src/features/alphafoldjob/NewAlphaFoldJobForm.tsx index 5f3c606a..9ffd6c2b 100644 --- a/apps/ui/src/features/alphafoldjob/NewAlphaFoldJobForm.tsx +++ b/apps/ui/src/features/alphafoldjob/NewAlphaFoldJobForm.tsx @@ -465,7 +465,27 @@ const NewAlphaFoldJob = ({ mode = 'authenticated' }: NewJobFormProps) => { const [addNewPublicJob, { isSuccess: isAnonSuccess, data: anonJobResponse }] = useAddNewPublicJobMutation() const isSuccess = mode === 'anonymous' ? isAnonSuccess : isAuthSuccess - const jobResponse = mode === 'anonymous' ? anonJobResponse : authJobResponse + + // Transform responses to expected shape + const publicJobResponse = + anonJobResponse && mode === 'anonymous' + ? { + resultUrl: anonJobResponse.resultUrl, + publicId: anonJobResponse.publicId, + md_engine: anonJobResponse.md_engine + } + : undefined + + const authSuccessResponse = + authJobResponse && mode === 'authenticated' + ? { + message: 'Job submitted successfully', + jobid: authJobResponse.id, + uuid: authJobResponse.mongo.uuid, + md_engine: authJobResponse.mongo.md_engine + } + : undefined + const [isPerlmutterUnavailable, setIsPerlmutterUnavailable] = useState(false) const handleStatusCheck = (isUnavailable: boolean) => { setIsPerlmutterUnavailable(isUnavailable) @@ -484,6 +504,8 @@ const NewAlphaFoldJob = ({ mode = 'authenticated' }: NewJobFormProps) => { if (configIsLoading) return if (configError) return Error loading configuration + if (!config) + return Configuration not available const useAlphaFold = config.enableBilboMdAlphaFold?.toLowerCase() === 'true' const useNersc = config.useNersc?.toLowerCase() === 'true' @@ -504,10 +526,7 @@ const NewAlphaFoldJob = ({ mode = 'authenticated' }: NewJobFormProps) => { md_engine: 'charmm' } - const onSubmit = async ( - values: NewAlphaFoldJobFormValues, - { setStatus }: { setStatus: (status: string) => void } - ) => { + const onSubmit = async (values: NewAlphaFoldJobFormValues) => { setSubmitError(null) const form = new FormData() form.append('title', values.title) @@ -526,11 +545,9 @@ const NewAlphaFoldJob = ({ mode = 'authenticated' }: NewJobFormProps) => { } try { - const newJob = - mode === 'anonymous' - ? await addNewPublicJob(form).unwrap() - : await addNewAlphaFoldJob(form).unwrap() - setStatus(newJob) + await (mode === 'anonymous' + ? addNewPublicJob(form).unwrap() + : addNewAlphaFoldJob(form).unwrap()) } catch (error) { console.error('rejected', error) setSubmitError( @@ -576,17 +593,17 @@ const NewAlphaFoldJob = ({ mode = 'authenticated' }: NewJobFormProps) => { ) : isSuccess ? ( - mode === 'anonymous' ? ( + mode === 'anonymous' && publicJobResponse ? ( - ) : ( + ) : authSuccessResponse ? ( - ) + ) : null ) : ( initialValues={initialValues} diff --git a/apps/ui/src/features/auth/LogOut.tsx b/apps/ui/src/features/auth/LogOut.tsx index a67c406a..3bbf723d 100644 --- a/apps/ui/src/features/auth/LogOut.tsx +++ b/apps/ui/src/features/auth/LogOut.tsx @@ -14,7 +14,7 @@ const LogOut = () => { const onClickLogout = async () => { setOpen(false) - await sendLogout({}) + await sendLogout(undefined) void navigate('/') } diff --git a/apps/ui/src/features/auth/MagickLinkAuth.tsx b/apps/ui/src/features/auth/MagickLinkAuth.tsx index a836fa06..1053e7f0 100644 --- a/apps/ui/src/features/auth/MagickLinkAuth.tsx +++ b/apps/ui/src/features/auth/MagickLinkAuth.tsx @@ -24,6 +24,10 @@ const MagickLinkAuth = () => { let timeoutId: NodeJS.Timeout const authenticateOTP = async () => { + if (!otp) { + setAuthErrorMsg('No OTP provided') + return + } try { const { accessToken } = await login({ otp }).unwrap() dispatch(setCredentials({ accessToken })) diff --git a/apps/ui/src/features/auth/OrcidConfirmation.tsx b/apps/ui/src/features/auth/OrcidConfirmation.tsx index 3529c616..cbf0986f 100644 --- a/apps/ui/src/features/auth/OrcidConfirmation.tsx +++ b/apps/ui/src/features/auth/OrcidConfirmation.tsx @@ -29,7 +29,7 @@ const validationSchema = Yup.object().shape({ }) export default function OrcidConfirmation() { - const { data: profile, isLoading, isError } = useGetOrcidSessionQuery({}) + const { data: profile, isLoading, isError } = useGetOrcidSessionQuery(undefined) const [finalizeOrcid] = useFinalizeOrcidMutation() const formik = useFormik({ diff --git a/apps/ui/src/features/auth/PersistLogin.tsx b/apps/ui/src/features/auth/PersistLogin.tsx index 30f06d23..c928a44f 100644 --- a/apps/ui/src/features/auth/PersistLogin.tsx +++ b/apps/ui/src/features/auth/PersistLogin.tsx @@ -19,7 +19,7 @@ const PersistLogin = () => { useEffect(() => { const verifyRefreshToken = async () => { try { - await refresh({}).unwrap() + await refresh(undefined).unwrap() setTrueSuccess(true) } catch (err) { console.error('Refresh failed:', err) diff --git a/apps/ui/src/features/auth/Welcome.tsx b/apps/ui/src/features/auth/Welcome.tsx index 1e11562a..87995002 100644 --- a/apps/ui/src/features/auth/Welcome.tsx +++ b/apps/ui/src/features/auth/Welcome.tsx @@ -61,6 +61,8 @@ const Welcome: React.FC = ({ mode }: WelcomeProps) => { Loading system configuration... ) : configError ? ( Failed to load system configuration. + ) : !config ? ( + Configuration not available. ) : ( diff --git a/apps/ui/src/features/autojob/NewAutoJobForm.tsx b/apps/ui/src/features/autojob/NewAutoJobForm.tsx index 9c1fd184..11411dc5 100644 --- a/apps/ui/src/features/autojob/NewAutoJobForm.tsx +++ b/apps/ui/src/features/autojob/NewAutoJobForm.tsx @@ -42,7 +42,27 @@ const NewAutoJobForm = ({ mode = 'authenticated' }: NewJobFormProps) => { const [addNewPublicJob, { isSuccess: isAnonSuccess, data: anonJobResponse }] = useAddNewPublicJobMutation() const isSuccess = mode === 'anonymous' ? isAnonSuccess : isAuthSuccess - const jobResponse = mode === 'anonymous' ? anonJobResponse : authJobResponse + + // Transform responses to expected shape + const publicJobResponse = + anonJobResponse && mode === 'anonymous' + ? { + resultUrl: anonJobResponse.resultUrl, + publicId: anonJobResponse.publicId, + md_engine: anonJobResponse.md_engine + } + : undefined + + const authSuccessResponse = + authJobResponse && mode === 'authenticated' + ? { + message: 'Job submitted successfully', + jobid: authJobResponse.id, + uuid: authJobResponse.mongo.uuid, + md_engine: authJobResponse.mongo.md_engine + } + : undefined + const [isPerlmutterUnavailable, setIsPerlmutterUnavailable] = useState(false) const handleStatusCheck = (isUnavailable: boolean) => { setIsPerlmutterUnavailable(isUnavailable) @@ -61,6 +81,8 @@ const NewAutoJobForm = ({ mode = 'authenticated' }: NewJobFormProps) => { if (configIsLoading) return if (configError) return Error loading configuration + if (!config) + return Configuration not available // Are we running on NERSC? const useNersc = config.useNersc?.toLowerCase() === 'true' @@ -74,10 +96,7 @@ const NewAutoJobForm = ({ mode = 'authenticated' }: NewJobFormProps) => { md_engine: 'charmm' } - const onSubmit = async ( - values: BilboMDAutoJobFormValues, - { setStatus }: { setStatus: (status: string) => void } - ) => { + const onSubmit = async (values: BilboMDAutoJobFormValues) => { setSubmitError(null) const form = new FormData() form.append('title', values.title) @@ -91,11 +110,9 @@ const NewAutoJobForm = ({ mode = 'authenticated' }: NewJobFormProps) => { } try { - const newJob = - mode === 'anonymous' - ? await addNewPublicJob(form).unwrap() - : await addNewJob(form).unwrap() - setStatus(newJob) + await (mode === 'anonymous' + ? addNewPublicJob(form).unwrap() + : addNewJob(form).unwrap()) } catch (error) { console.error('rejected', error) setSubmitError( @@ -138,17 +155,17 @@ const NewAutoJobForm = ({ mode = 'authenticated' }: NewJobFormProps) => { {isSuccess ? ( - mode === 'anonymous' ? ( + mode === 'anonymous' && publicJobResponse ? ( - ) : ( + ) : authSuccessResponse ? ( - ) + ) : null ) : ( { const theme = useTheme() const isDarkMode = theme.palette.mode === 'dark' const { id } = useParams() + const navigate = useNavigate() // State, RTK mutations and queries const [addNewAutoJob, { isSuccess }] = useAddNewAutoJobMutation() @@ -60,7 +61,7 @@ const ResubmitAutoJobForm = () => { data: jobdata, isLoading: jobIsLoading, isError: jobIsError - } = useGetJobByIdQuery(id, { + } = useGetJobByIdQuery(id!, { skip: !id }) @@ -70,7 +71,7 @@ const ResubmitAutoJobForm = () => { }) // Are we running on NERSC? - const useNersc = config.useNersc?.toLowerCase() === 'true' + const useNersc = config?.useNersc?.toLowerCase() === 'true' // Grouped early return for loading and error states { @@ -79,6 +80,7 @@ const ResubmitAutoJobForm = () => { configIsLoading || jobIsLoading || !jobdata || + !config || !fileCheckQuery || !fileCheckQuery.data ) { @@ -123,10 +125,7 @@ const ResubmitAutoJobForm = () => { (jobMongo.md_engine?.toLowerCase?.() as 'charmm' | 'openmm') ?? 'charmm' } - const onSubmit = async ( - values: BilboMDAutoJobFormValues, - { setStatus }: { setStatus: (status: string) => void } - ) => { + const onSubmit = async (values: BilboMDAutoJobFormValues) => { const form = new FormData() form.append('bilbomd_mode', values.bilbomd_mode) form.append('title', values.title) @@ -157,7 +156,8 @@ const ResubmitAutoJobForm = () => { try { const newJob = await addNewAutoJob(form).unwrap() - setStatus(newJob) + // Navigate to the new job page + void navigate(`/dashboard/jobs/${newJob.id}`) } catch (error) { console.error('rejected', error) } diff --git a/apps/ui/src/features/jobs/FoXSAnalysis.tsx b/apps/ui/src/features/jobs/FoXSAnalysis.tsx index d350cf74..6b6d37b2 100644 --- a/apps/ui/src/features/jobs/FoXSAnalysis.tsx +++ b/apps/ui/src/features/jobs/FoXSAnalysis.tsx @@ -120,11 +120,11 @@ const FoXSAnalysis = ({ active?: boolean }) => { // Conditionally use the appropriate query - const protectedQuery = useGetFoxsAnalysisByIdQuery(id, { + const protectedQuery = useGetFoxsAnalysisByIdQuery(id!, { pollingInterval: 0, refetchOnFocus: true, refetchOnMountOrArgChange: true, - skip: !active || isPublic // Skip if public or inactive + skip: !active || isPublic || !id // Skip if public, inactive, or no id }) const publicQuery = useGetPublicFoxsDataQuery(publicId || '', { skip: !active || !isPublic || !publicId // Skip if not public, inactive, or no publicId diff --git a/apps/ui/src/features/jobs/NewJobForm.tsx b/apps/ui/src/features/jobs/NewJobForm.tsx index 4f359a04..8385a4d5 100644 --- a/apps/ui/src/features/jobs/NewJobForm.tsx +++ b/apps/ui/src/features/jobs/NewJobForm.tsx @@ -59,7 +59,27 @@ const NewJobForm = ({ mode = 'authenticated' }: NewJobFormProps) => { useAddNewPublicJobMutation() const [calculateAutoRg, { isLoading }] = useCalculateAutoRgMutation() const isSuccess = mode === 'anonymous' ? isAnonSuccess : isAuthSuccess - const jobResponse = mode === 'anonymous' ? anonJobResponse : authJobResponse + + // Transform responses to expected shape + const publicJobResponse = + anonJobResponse && mode === 'anonymous' + ? { + resultUrl: anonJobResponse.resultUrl, + publicId: anonJobResponse.publicId, + md_engine: anonJobResponse.md_engine + } + : undefined + + const authSuccessResponse = + authJobResponse && mode === 'authenticated' + ? { + message: 'Job submitted successfully', + jobid: authJobResponse.id, + uuid: authJobResponse.mongo.uuid, + md_engine: authJobResponse.mongo.md_engine + } + : undefined + const [isPerlmutterUnavailable, setIsPerlmutterUnavailable] = useState(false) const handleStatusCheck = (isUnavailable: boolean) => { setIsPerlmutterUnavailable(isUnavailable) @@ -81,6 +101,8 @@ const NewJobForm = ({ mode = 'authenticated' }: NewJobFormProps) => { if (configIsLoading) return if (configError) return Error loading configuration + if (!config) + return Configuration not available const useNersc = config.useNersc?.toLowerCase() === 'true' @@ -99,10 +121,7 @@ const NewJobForm = ({ mode = 'authenticated' }: NewJobFormProps) => { md_engine: 'charmm' } - const onSubmit = async ( - values: BilboMDClassicJobFormValues, - { setStatus }: { setStatus: (status: string) => void } - ) => { + const onSubmit = async (values: BilboMDClassicJobFormValues) => { setSubmitError(null) const form = new FormData() form.append('bilbomd_mode', values.bilbomd_mode) @@ -125,11 +144,9 @@ const NewJobForm = ({ mode = 'authenticated' }: NewJobFormProps) => { } try { - const newJob = - mode === 'anonymous' - ? await addNewPublicJob(form).unwrap() - : await addNewJob(form).unwrap() - setStatus(newJob) + await (mode === 'anonymous' + ? addNewPublicJob(form).unwrap() + : addNewJob(form).unwrap()) } catch (error) { console.error('rejected', error) setSubmitError( @@ -219,21 +236,21 @@ const NewJobForm = ({ mode = 'authenticated' }: NewJobFormProps) => { {isSuccess ? ( - mode === 'anonymous' ? ( + mode === 'anonymous' && publicJobResponse ? ( - ) : ( + ) : authSuccessResponse ? ( - ) + ) : null ) : ( { const theme = useTheme() const isDarkMode = theme.palette.mode === 'dark' const { id } = useParams() + const navigate = useNavigate() // State, RTK mutations and queries const [addNewJob, { isSuccess }] = useAddNewJobMutation() @@ -75,7 +76,7 @@ const ResubmitJobForm = () => { data: jobdata, isLoading: jobIsLoading, isError: jobIsError - } = useGetJobByIdQuery(id, { + } = useGetJobByIdQuery(id!, { skip: !id }) @@ -85,7 +86,7 @@ const ResubmitJobForm = () => { }) // Are we running on NERSC? - const useNersc = config.useNersc?.toLowerCase() === 'true' + const useNersc = config?.useNersc?.toLowerCase() === 'true' // Grouped early return for loading and error states { @@ -94,6 +95,7 @@ const ResubmitJobForm = () => { configIsLoading || jobIsLoading || !jobdata || + !config || !fileCheckQuery || !fileCheckQuery.data ) { @@ -179,10 +181,7 @@ const ResubmitJobForm = () => { throw new Error(`Unsupported job type: ${job.mongo.jobType}`) } - const onSubmit = async ( - values: BilboMDClassicJobFormValues, - { setStatus }: { setStatus: (status: string) => void } - ) => { + const onSubmit = async (values: BilboMDClassicJobFormValues) => { const form = new FormData() form.append('bilbomd_mode', values.bilbomd_mode) form.append('title', values.title) @@ -227,7 +226,8 @@ const ResubmitJobForm = () => { try { const newJob = await addNewJob(form).unwrap() - setStatus(newJob) + // Navigate to the new job page + void navigate(`/dashboard/jobs/${newJob.id}`) } catch (error) { console.error('rejected', error) } diff --git a/apps/ui/src/features/jobs/SingleJobPage.tsx b/apps/ui/src/features/jobs/SingleJobPage.tsx index 41126927..0335c6da 100644 --- a/apps/ui/src/features/jobs/SingleJobPage.tsx +++ b/apps/ui/src/features/jobs/SingleJobPage.tsx @@ -80,6 +80,7 @@ const SingleJobPage = () => { const handleDeleteJob = async () => { // console.log('Deleting job with ID:', id) + if (!id) return try { await deleteJob({ id }) void navigate('/dashboard/jobs') diff --git a/apps/ui/src/features/nersc/NerscStatusChecker.tsx b/apps/ui/src/features/nersc/NerscStatusChecker.tsx index f5cebdc4..326d05b4 100644 --- a/apps/ui/src/features/nersc/NerscStatusChecker.tsx +++ b/apps/ui/src/features/nersc/NerscStatusChecker.tsx @@ -19,11 +19,20 @@ const NerscStatusChecker: React.FC = ({ isLoading: nerscStatIsLoading } = useGetNerscStatusQuery() - const isFetchBaseQueryError = (error): error is FetchBaseQueryError => - error && typeof error.status === 'number' && 'data' in error + const isFetchBaseQueryError = ( + error: unknown + ): error is FetchBaseQueryError => + typeof error === 'object' && + error !== null && + 'status' in error && + typeof (error as { status: unknown }).status === 'number' && + 'data' in error - const isSerializedError = (error): error is SerializedError => - error && typeof error.message === 'string' + const isSerializedError = (error: unknown): error is SerializedError => + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as { message: unknown }).message === 'string' // If NERSC status is successfully fetched, find the relevant system const systemStatus = nerscStatIsSuccess diff --git a/apps/ui/src/features/sansjob/NewSANSJobForm.tsx b/apps/ui/src/features/sansjob/NewSANSJobForm.tsx index 2d2c10a8..1b1293ce 100644 --- a/apps/ui/src/features/sansjob/NewSANSJobForm.tsx +++ b/apps/ui/src/features/sansjob/NewSANSJobForm.tsx @@ -75,7 +75,27 @@ const NewSANSJob = ({ mode = 'authenticated' }: NewJobFormProps) => { { isSuccess: isAnonSuccess, data: anonJobResponse } ] = useAddNewPublicSANSJobMutation() const isSuccess = mode === 'anonymous' ? isAnonSuccess : isAuthSuccess - const jobResponse = mode === 'anonymous' ? anonJobResponse : authJobResponse + + // Transform responses to expected shape + const publicJobResponse = + anonJobResponse && mode === 'anonymous' + ? { + resultUrl: anonJobResponse.resultUrl, + publicId: anonJobResponse.publicId, + md_engine: anonJobResponse.md_engine + } + : undefined + + const authSuccessResponse = + authJobResponse && mode === 'authenticated' + ? { + message: 'Job submitted successfully', + jobid: authJobResponse.id, + uuid: authJobResponse.mongo.uuid, + md_engine: authJobResponse.mongo.md_engine + } + : undefined + const [calculateAutoRg, { isLoading }] = useCalculateAutoRgMutation() const [isPerlmutterUnavailable, setIsPerlmutterUnavailable] = useState(false) const [chainIds, setChainIds] = useState([]) @@ -91,6 +111,8 @@ const NewSANSJob = ({ mode = 'authenticated' }: NewJobFormProps) => { if (configError) return Error loading configuration + if (!config) + return Configuration not available const useNersc = config.useNersc?.toLowerCase() === 'true' @@ -110,10 +132,7 @@ const NewSANSJob = ({ mode = 'authenticated' }: NewJobFormProps) => { md_engine: 'charmm' } - const onSubmit = async ( - values: NewSANSJobFormValues, - { setStatus }: { setStatus: (status: string | null) => void } - ) => { + const onSubmit = async (values: NewSANSJobFormValues) => { const form = new FormData() form.append('title', values.title) form.append('pdb_file', values.pdb_file) @@ -135,11 +154,9 @@ const NewSANSJob = ({ mode = 'authenticated' }: NewJobFormProps) => { }) try { - const newJob = - mode === 'anonymous' - ? await addNewPublicSANSJob(form).unwrap() - : await addNewSANSJob(form).unwrap() - setStatus(newJob) + await (mode === 'anonymous' + ? addNewPublicSANSJob(form).unwrap() + : addNewSANSJob(form).unwrap()) } catch (error) { console.error('rejected', error) } @@ -206,17 +223,17 @@ const NewSANSJob = ({ mode = 'authenticated' }: NewJobFormProps) => { {isSuccess ? ( - mode === 'anonymous' ? ( + mode === 'anonymous' && publicJobResponse ? ( - ) : ( + ) : authSuccessResponse ? ( - ) + ) : null ) : ( initialValues={initialValues} diff --git a/apps/ui/src/features/scoperjob/NewScoperJobForm.tsx b/apps/ui/src/features/scoperjob/NewScoperJobForm.tsx index db9a8bc9..cb170f69 100644 --- a/apps/ui/src/features/scoperjob/NewScoperJobForm.tsx +++ b/apps/ui/src/features/scoperjob/NewScoperJobForm.tsx @@ -46,7 +46,27 @@ const NewScoperJobForm = ({ const [addNewPublicJob, { isSuccess: isAnonSuccess, data: anonJobResponse }] = useAddNewPublicJobMutation() const isSuccess = mode === 'anonymous' ? isAnonSuccess : isAuthSuccess - const jobResponse = mode === 'anonymous' ? anonJobResponse : authJobResponse + + // Transform responses to expected shape + const publicJobResponse = + anonJobResponse && mode === 'anonymous' + ? { + resultUrl: anonJobResponse.resultUrl, + publicId: anonJobResponse.publicId, + md_engine: anonJobResponse.md_engine + } + : undefined + + const authSuccessResponse = + authJobResponse && mode === 'authenticated' + ? { + message: 'Job submitted successfully', + jobid: authJobResponse.id, + uuid: authJobResponse.mongo.uuid, + md_engine: authJobResponse.mongo.md_engine + } + : undefined + const [useExampleData, setUseExampleData] = useState(false) const [submitError, setSubmitError] = useState(null) @@ -176,17 +196,17 @@ const NewScoperJobForm = ({ {isSuccess ? ( - mode === 'anonymous' ? ( + mode === 'anonymous' && publicJobResponse ? ( - ) : ( + ) : authSuccessResponse ? ( - ) + ) : null ) : ( { refetchOnMountOrArgChange: true }) - const foxsData: FoxsData[] = data as FoxsData[] + // Memoize foxsData to prevent unnecessary re-renders + const foxsData = useMemo( + () => ((data ?? []) as FoxsData[]), + [data] + ) // Prepare original data to reduce the number of digits after the decimal point // and filter out negative values const origData = useMemo( - () => (foxsData ? prepData(foxsData[0].data) : []), + () => (foxsData.length > 0 ? prepData(foxsData[0].data) : []), [foxsData] ) const scopData = useMemo( - () => (foxsData ? prepData(foxsData[1].data) : []), + () => (foxsData.length > 1 ? prepData(foxsData[1].data) : []), [foxsData] ) // Calculate residual values for both datasets const origResiduals = useMemo( - () => (foxsData ? calculateResiduals(origData) : []), - [origData, foxsData] + () => (origData.length > 0 ? calculateResiduals(origData) : []), + [origData] ) const scopResiduals = useMemo( - () => (foxsData ? calculateResiduals(scopData) : []), - [scopData, foxsData] + () => (scopData.length > 0 ? calculateResiduals(scopData) : []), + [scopData] ) // Define a Memoized calculation for min and max Y axis values diff --git a/apps/ui/src/features/users/ApiTokenManagement.tsx b/apps/ui/src/features/users/ApiTokenManagement.tsx index fb43465b..e71f01e5 100644 --- a/apps/ui/src/features/users/ApiTokenManagement.tsx +++ b/apps/ui/src/features/users/ApiTokenManagement.tsx @@ -28,7 +28,7 @@ const APITokenManager = () => { const { username } = useAuth() const { enqueueSnackbar } = useSnackbar() const { data, refetch, isLoading, error } = useGetAPITokensQuery(username) - const tokens: IAPIToken[] = data?.tokens || [] + const tokens = (data?.tokens || []) as IAPIToken[] const [createToken] = useCreateAPITokenMutation() const [deleteToken] = useDeleteAPITokenMutation() const [newToken, setNewToken] = useState(null) @@ -123,7 +123,7 @@ const APITokenManager = () => { label, expiresAt: expiresAt.toISOString() }).unwrap() - setNewToken(res.token) + setNewToken(res.token ?? null) enqueueSnackbar(`${label} Token created.`, { variant: 'default' }) void refetch() } catch (err) { diff --git a/apps/ui/src/features/users/EditUserForm.tsx b/apps/ui/src/features/users/EditUserForm.tsx index 5a37b54e..494107a4 100644 --- a/apps/ui/src/features/users/EditUserForm.tsx +++ b/apps/ui/src/features/users/EditUserForm.tsx @@ -123,7 +123,6 @@ const EditUserForm = ({ user }: EditUserFormProps) => { // console.log(values) await updateUser({ id: user.id, - username: values.username, roles: values.roles, active: values.active, email: values.email diff --git a/apps/ui/src/hooks/__tests__/useLogout.test.tsx b/apps/ui/src/hooks/__tests__/useLogout.test.tsx index 780eea43..33139f29 100644 --- a/apps/ui/src/hooks/__tests__/useLogout.test.tsx +++ b/apps/ui/src/hooks/__tests__/useLogout.test.tsx @@ -37,7 +37,7 @@ describe('useLogout', () => { await result.current() }) - expect(mockSendLogout).toHaveBeenCalledWith({}) + expect(mockSendLogout).toHaveBeenCalledWith(undefined) expect(mockNavigate).toHaveBeenCalledWith('/') }) @@ -58,7 +58,7 @@ describe('useLogout', () => { }) // Assert: Verify sendLogout was called, but navigation did not occur - expect(mockSendLogout).toHaveBeenCalledWith({}) + expect(mockSendLogout).toHaveBeenCalledWith(undefined) expect(mockNavigate).not.toHaveBeenCalled() }) }) diff --git a/apps/ui/src/hooks/useLogout.ts b/apps/ui/src/hooks/useLogout.ts index 5f6d1b3c..55743f18 100644 --- a/apps/ui/src/hooks/useLogout.ts +++ b/apps/ui/src/hooks/useLogout.ts @@ -6,7 +6,7 @@ const useLogout = () => { const [sendLogout] = useSendLogoutMutation() const logout = async () => { - await sendLogout({}) + await sendLogout(undefined) void navigate("/") } diff --git a/apps/ui/src/layout/MainLayout/index.tsx b/apps/ui/src/layout/MainLayout/index.tsx index 2b917ff5..b0ed0d37 100644 --- a/apps/ui/src/layout/MainLayout/index.tsx +++ b/apps/ui/src/layout/MainLayout/index.tsx @@ -42,7 +42,6 @@ export default function ClippedDrawer() { const location = useLocation() const theme = useTheme() const isSettingsPage = location.pathname.startsWith('/settings') - const showBreadcrumbs = config?.showBreadcrumbs?.toLowerCase() === 'true' if (configIsLoading) return if (configError) @@ -50,6 +49,7 @@ export default function ClippedDrawer() { if (!config) return No configuration data available + const showBreadcrumbs = config.showBreadcrumbs?.toLowerCase() === 'true' const useNersc = config.useNersc?.toLowerCase() === 'true' const enableBilboMdSANS = config.enableBilboMdSANS?.toLowerCase() === 'true' const enableBilboMdMulti = config.enableBilboMdMulti?.toLowerCase() === 'true' diff --git a/apps/ui/src/slices/__tests__/adminApiSlice.test.ts b/apps/ui/src/slices/__tests__/adminApiSlice.test.ts index 77b434dc..8302bf50 100644 --- a/apps/ui/src/slices/__tests__/adminApiSlice.test.ts +++ b/apps/ui/src/slices/__tests__/adminApiSlice.test.ts @@ -93,7 +93,7 @@ describe('adminApiSlice', () => { describe('getQueues', () => { it('should fetch queues successfully', async () => { const result = await storeRef.store.dispatch( - adminApiSlice.endpoints.getQueues.initiate({}) + adminApiSlice.endpoints.getQueues.initiate(undefined) ) expect(result.data).toBeDefined() diff --git a/apps/ui/src/slices/__tests__/authApiSlice.test.ts b/apps/ui/src/slices/__tests__/authApiSlice.test.ts index b022b7ec..81e9596b 100644 --- a/apps/ui/src/slices/__tests__/authApiSlice.test.ts +++ b/apps/ui/src/slices/__tests__/authApiSlice.test.ts @@ -14,8 +14,10 @@ const mockRefreshResponse = { } const mockOrcidSessionResponse = { - orcidId: '0000-0000-0000-0000', - sessionId: 'session-123' + givenName: 'Test', + familyName: 'User', + email: 'test@example.com', + orcidId: '0000-0000-0000-0000' } describe('authApiSlice', () => { @@ -90,7 +92,7 @@ describe('authApiSlice', () => { describe('sendLogout', () => { it('should send logout request and clear state', async () => { const result = await storeRef.store.dispatch( - authApiSlice.endpoints.sendLogout.initiate({}) + authApiSlice.endpoints.sendLogout.initiate(undefined) ) expect(result.data).toBeDefined() @@ -105,7 +107,7 @@ describe('authApiSlice', () => { ) const result = await storeRef.store.dispatch( - authApiSlice.endpoints.sendLogout.initiate({}) + authApiSlice.endpoints.sendLogout.initiate(undefined) ) expect(result.error).toBeDefined() @@ -116,7 +118,7 @@ describe('authApiSlice', () => { describe('refresh', () => { it('should refresh access token', async () => { const result = await storeRef.store.dispatch( - authApiSlice.endpoints.refresh.initiate({}) + authApiSlice.endpoints.refresh.initiate(undefined) ) expect(result.data).toBeDefined() @@ -131,7 +133,7 @@ describe('authApiSlice', () => { ) const result = await storeRef.store.dispatch( - authApiSlice.endpoints.refresh.initiate({}) + authApiSlice.endpoints.refresh.initiate(undefined) ) expect(result.error).toBeDefined() @@ -142,7 +144,7 @@ describe('authApiSlice', () => { describe('getOrcidSession', () => { it('should fetch ORCID session information', async () => { const result = await storeRef.store.dispatch( - authApiSlice.endpoints.getOrcidSession.initiate({}) + authApiSlice.endpoints.getOrcidSession.initiate(undefined) ) expect(result.data).toBeDefined() diff --git a/apps/ui/src/slices/__tests__/configsApiSlice.test.ts b/apps/ui/src/slices/__tests__/configsApiSlice.test.ts index acb7d4be..5b5f8d93 100644 --- a/apps/ui/src/slices/__tests__/configsApiSlice.test.ts +++ b/apps/ui/src/slices/__tests__/configsApiSlice.test.ts @@ -36,7 +36,7 @@ describe('configApiSlice', () => { describe('getConfigs', () => { it('should fetch configs successfully', async () => { const result = await storeRef.store.dispatch( - configApiSlice.endpoints.getConfigs.initiate({}) + configApiSlice.endpoints.getConfigs.initiate(undefined) ) expect(result.data).toBeDefined() @@ -53,7 +53,7 @@ describe('configApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - configApiSlice.endpoints.getConfigs.initiate({}) + configApiSlice.endpoints.getConfigs.initiate(undefined) ) expect(result.error).toBeDefined() @@ -73,7 +73,7 @@ describe('configApiSlice', () => { try { await freshStoreRef.store.dispatch( - configApiSlice.endpoints.getConfigs.initiate({}) + configApiSlice.endpoints.getConfigs.initiate(undefined) ) expect.fail('Expected query to throw') } catch (error) { @@ -93,7 +93,7 @@ describe('configApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - configApiSlice.endpoints.getConfigs.initiate({}) + configApiSlice.endpoints.getConfigs.initiate(undefined) ) expect(result.error).toBeDefined() @@ -112,7 +112,7 @@ describe('configApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - configApiSlice.endpoints.getConfigs.initiate({}) + configApiSlice.endpoints.getConfigs.initiate(undefined) ) expect(result.data).toBeNull() diff --git a/apps/ui/src/slices/__tests__/jobsApiSlice.test.ts b/apps/ui/src/slices/__tests__/jobsApiSlice.test.ts index 85940111..ed192c8b 100644 --- a/apps/ui/src/slices/__tests__/jobsApiSlice.test.ts +++ b/apps/ui/src/slices/__tests__/jobsApiSlice.test.ts @@ -38,8 +38,6 @@ const mockJob: BilboMDJobDTO = { } } -const mockJobsResponse: BilboMDJobDTO[] = [mockJob] - const mockFoxsAnalysis = { chi_sq: 1.23, rg: 25.4, @@ -47,10 +45,6 @@ const mockFoxsAnalysis = { excluded_points: [] } -const mockMDMovies = { - movies: ['movie1.mp4', 'movie2.mp4'] -} - const mockFileCheckResult = { isValid: true, errors: [], @@ -61,34 +55,7 @@ describe('jobsApiSlice', () => { const storeRef = setupApiStore() beforeEach(() => { - server.use( - http.get('/api/v1/jobs', () => { - return HttpResponse.json(mockJobsResponse) - }), - http.get('/api/v1/jobs/:id', ({ params: _params }) => { - return HttpResponse.json(mockJob) - }), - http.get('/api/v1/jobs/:id/results/foxs', () => { - return HttpResponse.json(mockFoxsAnalysis) - }), - http.post('/api/v1/jobs', async ({ request }) => { - const body = (await request.json()) as Partial - return HttpResponse.json({ ...mockJob, ...body }) - }), - http.patch('/api/v1/jobs', async ({ request }) => { - const body = (await request.json()) as Partial - return HttpResponse.json({ ...mockJob, ...body }) - }), - http.delete('/api/v1/jobs/:id', () => { - return HttpResponse.json({ success: true }) - }), - http.get('/api/v1/jobs/:id/check', () => { - return HttpResponse.json(mockFileCheckResult) - }), - http.get('/api/v1/jobs/:id/movies', () => { - return HttpResponse.json(mockMDMovies) - }) - ) + // No additional handlers needed - using global handlers from test/handlers.ts }) afterEach(() => { @@ -98,7 +65,7 @@ describe('jobsApiSlice', () => { describe('getJobs', () => { it('should fetch jobs and transform them using entity adapter', async () => { const result = await storeRef.store.dispatch( - jobsApiSlice.endpoints.getJobs.initiate({}) + jobsApiSlice.endpoints.getJobs.initiate(undefined) ) expect(result.data?.entities).toBeDefined() @@ -123,7 +90,7 @@ describe('jobsApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - jobsApiSlice.endpoints.getJobs.initiate({}) + jobsApiSlice.endpoints.getJobs.initiate(undefined) ) expect(result.data?.ids).toHaveLength(0) @@ -197,14 +164,17 @@ describe('jobsApiSlice', () => { describe('addNewJob', () => { it('should create new job', async () => { - const newJobData = { title: 'New Test Job', jobType: 'auto' } + const newJobData = new FormData() + newJobData.append('title', 'New Test Job') + newJobData.append('jobType', 'auto') const promise = storeRef.store.dispatch( jobsApiSlice.endpoints.addNewJob.initiate(newJobData) ) const result = await promise - expect(result.data).toMatchObject(newJobData) + expect(result.data).toBeDefined() + expect(result.error).toBeUndefined() }) it('should invalidate job list tags', () => { @@ -216,7 +186,7 @@ describe('jobsApiSlice', () => { it('should handle validation errors', async () => { server.use( - http.post('http://localhost:3002/api/v1/jobs', () => { + http.post('http://localhost:3003/api/v1/jobs', () => { return HttpResponse.json( { error: 'Validation failed' }, { status: 400 } @@ -224,8 +194,11 @@ describe('jobsApiSlice', () => { }) ) + const invalidData = new FormData() + invalidData.append('invalidData', 'true') + const result = await storeRef.store.dispatch( - jobsApiSlice.endpoints.addNewJob.initiate({ invalidData: true }) + jobsApiSlice.endpoints.addNewJob.initiate(invalidData) ) // RTK Query returns errors in the result object, not as thrown exceptions @@ -340,7 +313,7 @@ describe('jobsApiSlice', () => { try { await storeRef.store.dispatch( - jobsApiSlice.endpoints.getJobs.initiate({}) + jobsApiSlice.endpoints.getJobs.initiate(undefined) ) expect.fail('Expected query to throw') } catch (error) { @@ -359,7 +332,7 @@ describe('jobsApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - jobsApiSlice.endpoints.getJobs.initiate({}) + jobsApiSlice.endpoints.getJobs.initiate(undefined) ) // RTK Query returns errors in the result object, not as thrown exceptions diff --git a/apps/ui/src/slices/__tests__/statsApiSlice.test.ts b/apps/ui/src/slices/__tests__/statsApiSlice.test.ts index 815a861e..66e1d27e 100644 --- a/apps/ui/src/slices/__tests__/statsApiSlice.test.ts +++ b/apps/ui/src/slices/__tests__/statsApiSlice.test.ts @@ -47,7 +47,7 @@ describe('statsApiSlice', () => { describe('getStats', () => { it('should fetch stats successfully', async () => { const result = await storeRef.store.dispatch( - statsApiSlice.endpoints.getStats.initiate({}) + statsApiSlice.endpoints.getStats.initiate(undefined) ) expect(result.data).toBeDefined() @@ -64,7 +64,7 @@ describe('statsApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - statsApiSlice.endpoints.getStats.initiate({}) + statsApiSlice.endpoints.getStats.initiate(undefined) ) expect(result.error).toBeDefined() @@ -83,7 +83,7 @@ describe('statsApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - statsApiSlice.endpoints.getStats.initiate({}) + statsApiSlice.endpoints.getStats.initiate(undefined) ) expect(result.error).toBeDefined() @@ -107,7 +107,7 @@ describe('statsApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - statsApiSlice.endpoints.getStats.initiate({}) + statsApiSlice.endpoints.getStats.initiate(undefined) ) expect(result.data).toBeNull() @@ -126,7 +126,7 @@ describe('statsApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - statsApiSlice.endpoints.getStats.initiate({}) + statsApiSlice.endpoints.getStats.initiate(undefined) ) expect(result.error).toBeDefined() @@ -150,7 +150,7 @@ describe('statsApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - statsApiSlice.endpoints.getStats.initiate({}) + statsApiSlice.endpoints.getStats.initiate(undefined) ) expect(result.data).toBeUndefined() @@ -177,7 +177,7 @@ describe('statsApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - statsApiSlice.endpoints.getStats.initiate({}) + statsApiSlice.endpoints.getStats.initiate(undefined) ) expect(result.data?.jobTypes).toEqual({}) diff --git a/apps/ui/src/slices/__tests__/usersApiSlice.test.ts b/apps/ui/src/slices/__tests__/usersApiSlice.test.ts index d2a31dce..d09d3534 100644 --- a/apps/ui/src/slices/__tests__/usersApiSlice.test.ts +++ b/apps/ui/src/slices/__tests__/usersApiSlice.test.ts @@ -3,7 +3,7 @@ import { setupApiStore } from '../../test/testUtils' import { usersApiSlice } from '../usersApiSlice' import { server } from '../../test/server' import { http, HttpResponse } from 'msw' -import type { UserDTO } from '@bilbomd/bilbomd-types' +import type { UserDTO, UserRole } from '@bilbomd/bilbomd-types' type MongoUser = { _id: string @@ -49,7 +49,7 @@ const mockUsersResponse = { const mockNewUserData = { username: 'newuser', email: 'newuser@example.com', - roles: ['User'], + roles: ['User'] as UserRole[], firstName: 'New', lastName: 'User', institution: 'New University' @@ -57,7 +57,7 @@ const mockNewUserData = { const mockUserUpdate = { id: 'user-123', - roles: ['Manager'] + roles: ['Manager'] as UserRole[] } describe('usersApiSlice', () => { @@ -89,7 +89,7 @@ describe('usersApiSlice', () => { describe('getUsers', () => { it('should fetch users and transform them using entity adapter', async () => { const result = await storeRef.store.dispatch( - usersApiSlice.endpoints.getUsers.initiate({}) + usersApiSlice.endpoints.getUsers.initiate(undefined) ) expect(result.data).toBeDefined() @@ -129,7 +129,7 @@ describe('usersApiSlice', () => { it('should handle different response statuses correctly', async () => { // Test successful response const result = await storeRef.store.dispatch( - usersApiSlice.endpoints.getUsers.initiate({}) + usersApiSlice.endpoints.getUsers.initiate(undefined) ) // Should handle the response without throwing errors @@ -153,7 +153,7 @@ describe('usersApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - usersApiSlice.endpoints.getUsers.initiate({}) + usersApiSlice.endpoints.getUsers.initiate(undefined) ) expect(result.data).toBeDefined() @@ -174,7 +174,7 @@ describe('usersApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - usersApiSlice.endpoints.getUsers.initiate({}) + usersApiSlice.endpoints.getUsers.initiate(undefined) ) expect(result.error).toBeDefined() @@ -193,7 +193,7 @@ describe('usersApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - usersApiSlice.endpoints.getUsers.initiate({}) + usersApiSlice.endpoints.getUsers.initiate(undefined) ) expect(result.error).toBeDefined() @@ -213,7 +213,7 @@ describe('usersApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - usersApiSlice.endpoints.getUsers.initiate({}) + usersApiSlice.endpoints.getUsers.initiate(undefined) ) expect(result.error).toBeDefined() @@ -401,7 +401,7 @@ describe('usersApiSlice', () => { try { await freshStoreRef.store.dispatch( - usersApiSlice.endpoints.getUsers.initiate({}) + usersApiSlice.endpoints.getUsers.initiate(undefined) ) expect.fail('Expected query to throw') } catch (error) { @@ -421,7 +421,7 @@ describe('usersApiSlice', () => { ) const result = await freshStoreRef.store.dispatch( - usersApiSlice.endpoints.getUsers.initiate({}) + usersApiSlice.endpoints.getUsers.initiate(undefined) ) expect(result.error).toBeDefined() diff --git a/apps/ui/src/slices/adminApiSlice.ts b/apps/ui/src/slices/adminApiSlice.ts index 92460c74..de475dc5 100644 --- a/apps/ui/src/slices/adminApiSlice.ts +++ b/apps/ui/src/slices/adminApiSlice.ts @@ -1,51 +1,76 @@ import { apiSlice } from 'app/api/apiSlice' +import type { FrontendBullMQJob } from 'types/bullmq' + +interface QueueInfo { + name: string + isPaused: boolean + jobCounts: { + active: number + completed: number + delayed: number + failed: number + paused: number + prioritized: number + waiting: number + ['waiting-children']: number + } +} + +interface QueueJobsResponse { + jobs: FrontendBullMQJob[] +} + +interface QueueMutationParams { + queueName: string + jobId: string +} export const adminApiSlice = apiSlice.injectEndpoints({ endpoints: (builder) => ({ - getQueues: builder.query({ + getQueues: builder.query({ query: () => '/admin/queues', providesTags: ['AdminQueue'] }), - pauseQueue: builder.mutation({ - query: (queueName: string) => ({ + pauseQueue: builder.mutation({ + query: (queueName) => ({ url: `/admin/queues/${queueName}/pause`, method: 'POST' }), invalidatesTags: ['AdminQueue'] }), - resumeQueue: builder.mutation({ - query: (queueName: string) => ({ + resumeQueue: builder.mutation({ + query: (queueName) => ({ url: `/admin/queues/${queueName}/resume`, method: 'POST' }), invalidatesTags: ['AdminQueue'] }), - getJobsByQueue: builder.query({ - query: (queueName: string) => `/admin/queues/${queueName}/jobs` + getJobsByQueue: builder.query({ + query: (queueName) => `/admin/queues/${queueName}/jobs` }), - retryQueueJob: builder.mutation({ - query: ({ queueName, jobId }: { queueName: string; jobId: string }) => ({ + retryQueueJob: builder.mutation({ + query: ({ queueName, jobId }) => ({ url: `/admin/queues/${queueName}/jobs/${jobId}/retry`, method: 'POST' }), invalidatesTags: ['AdminQueue'] }), - deleteQueueJob: builder.mutation({ - query: ({ queueName, jobId }: { queueName: string; jobId: string }) => ({ + deleteQueueJob: builder.mutation({ + query: ({ queueName, jobId }) => ({ url: `/admin/queues/${queueName}/jobs/${jobId}`, method: 'DELETE' }), invalidatesTags: ['AdminQueue'] }), - drainQueue: builder.mutation({ - query: (queueName: string) => ({ + drainQueue: builder.mutation({ + query: (queueName) => ({ url: `/admin/queues/${queueName}/drain`, method: 'POST' }), invalidatesTags: ['AdminQueue'] }), - failQueueJob: builder.mutation({ - query: ({ queueName, jobId }: { queueName: string; jobId: string }) => ({ + failQueueJob: builder.mutation({ + query: ({ queueName, jobId }) => ({ url: `/admin/queues/${queueName}/jobs/${jobId}/fail`, method: 'POST' }), diff --git a/apps/ui/src/slices/authApiSlice.ts b/apps/ui/src/slices/authApiSlice.ts index 09593110..2dde9c90 100644 --- a/apps/ui/src/slices/authApiSlice.ts +++ b/apps/ui/src/slices/authApiSlice.ts @@ -1,16 +1,41 @@ import { apiSlice } from 'app/api/apiSlice' import { logOut, setCredentials } from 'slices/authSlice' +interface LoginCredentials { + email?: string + otp: string +} + +interface AuthResponse { + accessToken: string +} + +interface OrcidSessionResponse { + givenName: string + familyName: string + email: string + orcidId: string +} + +interface OrcidFinalizeRequest { + givenName?: string + familyName?: string + email?: string + orcidId?: string + code?: string + state?: string +} + export const authApiSlice = apiSlice.injectEndpoints({ endpoints: (builder) => ({ - login: builder.mutation({ + login: builder.mutation({ query: (credentials) => ({ url: '/auth/otp', method: 'POST', body: { ...credentials } }) }), - sendLogout: builder.mutation({ + sendLogout: builder.mutation({ query: () => ({ url: '/auth/logout', method: 'POST' @@ -29,7 +54,7 @@ export const authApiSlice = apiSlice.injectEndpoints({ } } }), - refresh: builder.mutation({ + refresh: builder.mutation({ query: () => ({ url: '/auth/refresh', method: 'GET' @@ -45,13 +70,13 @@ export const authApiSlice = apiSlice.injectEndpoints({ } } }), - getOrcidSession: builder.query({ + getOrcidSession: builder.query({ query: () => ({ url: '/auth/orcid/confirmation', method: 'GET' }) }), - finalizeOrcid: builder.mutation({ + finalizeOrcid: builder.mutation({ query: (body) => ({ url: '/auth/orcid/finalize', method: 'POST', diff --git a/apps/ui/src/slices/configsApiSlice.ts b/apps/ui/src/slices/configsApiSlice.ts index 8992f485..ff18db2b 100644 --- a/apps/ui/src/slices/configsApiSlice.ts +++ b/apps/ui/src/slices/configsApiSlice.ts @@ -1,8 +1,14 @@ import { apiSlice } from 'app/api/apiSlice' +export interface ConfigResponse { + useNersc?: string + enableBilboMdAlphaFold?: string + [key: string]: string | undefined +} + export const configApiSlice = apiSlice.injectEndpoints({ endpoints: (builder) => ({ - getConfigs: builder.query({ + getConfigs: builder.query({ query: () => ({ url: '/configs', method: 'GET' diff --git a/apps/ui/src/slices/jobsApiSlice.ts b/apps/ui/src/slices/jobsApiSlice.ts index 16ea9842..a3c4e234 100644 --- a/apps/ui/src/slices/jobsApiSlice.ts +++ b/apps/ui/src/slices/jobsApiSlice.ts @@ -1,16 +1,43 @@ -import { createEntityAdapter, createSelector, EntityId } from '@reduxjs/toolkit' +import { + createEntityAdapter, + createSelector, + EntityId, + type EntityState +} from '@reduxjs/toolkit' import { apiSlice } from '../app/api/apiSlice' import type { BilboMDJobDTO, JobAssetsDTO } from '@bilbomd/bilbomd-types' import { FileCheckResult } from '../types/jobCheckResults' import { RootState } from '../app/store' +interface FoxsData { + [key: string]: unknown +} + +interface AutoRgResponse { + rg?: number + i0?: number + [key: string]: unknown +} + +interface Af2PaeResponse { + uuid: string + status: string + [key: string]: unknown +} + +interface Af2PaeStatusResponse { + status: string + progress?: number + [key: string]: unknown +} + const jobsAdapter = createEntityAdapter() const initialState = jobsAdapter.getInitialState() export const jobsApiSlice = apiSlice.injectEndpoints({ endpoints: (builder) => ({ - getJobs: builder.query({ + getJobs: builder.query, unknown>({ query: () => ({ url: '/jobs', method: 'GET' @@ -36,7 +63,7 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ ] : [{ type: 'Job', id: 'LIST' }] }), - getJobById: builder.query({ + getJobById: builder.query({ query: (id) => ({ url: `/jobs/${id}`, method: 'GET' @@ -46,14 +73,14 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ }, providesTags: (_, __, id) => [{ type: 'Job', id }] }), - getFoxsAnalysisById: builder.query({ + getFoxsAnalysisById: builder.query({ query: (id) => ({ url: `/jobs/${id}/results/foxs`, method: 'GET' }), providesTags: (_, __, id) => [{ type: 'FoxsAnalysis', id }] }), - addNewJob: builder.mutation({ + addNewJob: builder.mutation({ query: (newJob) => ({ url: '/jobs', method: 'POST', @@ -61,7 +88,10 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ }), invalidatesTags: [{ type: 'Job', id: 'LIST' }] }), - updateJob: builder.mutation({ + updateJob: builder.mutation< + BilboMDJobDTO, + Partial & { id: string } + >({ query: (initialJob) => ({ url: '/jobs', method: 'PATCH', @@ -71,7 +101,7 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ }), invalidatesTags: (_, __, arg) => [{ type: 'Job', id: arg.id }] }), - deleteJob: builder.mutation({ + deleteJob: builder.mutation({ query: ({ id }) => ({ url: `/jobs/${id}`, method: 'DELETE' @@ -102,14 +132,14 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ method: 'GET' }) }), - calculateAutoRg: builder.mutation({ - query: (formData: FormData) => ({ + calculateAutoRg: builder.mutation({ + query: (formData) => ({ url: '/autorg', method: 'POST', body: formData }) }), - addNewAutoJob: builder.mutation({ + addNewAutoJob: builder.mutation({ query: (newJob) => ({ url: '/jobs/bilbomd-auto', method: 'POST', @@ -117,7 +147,7 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ }), invalidatesTags: [{ type: 'Job', id: 'LIST' }] }), - addNewAlphaFoldJob: builder.mutation({ + addNewAlphaFoldJob: builder.mutation({ query: (newJob) => ({ url: '/jobs/bilbomd-alphafold', method: 'POST', @@ -125,7 +155,7 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ }), invalidatesTags: [{ type: 'Job', id: 'LIST' }] }), - addNewSANSJob: builder.mutation({ + addNewSANSJob: builder.mutation({ query: (newJob) => ({ url: '/jobs/bilbomd-sans', method: 'POST', @@ -133,7 +163,7 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ }), invalidatesTags: [{ type: 'Job', id: 'LIST' }] }), - addNewScoperJob: builder.mutation({ + addNewScoperJob: builder.mutation({ query: (newJob) => ({ url: '/jobs/bilbomd-scoper', method: 'POST', @@ -141,7 +171,7 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ }), invalidatesTags: [{ type: 'Job', id: 'LIST' }] }), - addNewMultiJob: builder.mutation({ + addNewMultiJob: builder.mutation({ query: (newJob) => ({ url: '/jobs/bilbomd-multi', method: 'POST', @@ -149,8 +179,8 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ }), invalidatesTags: [{ type: 'Job', id: 'LIST' }] }), - af2PaeJiffy: builder.mutation({ - query: (formData: FormData) => ({ + af2PaeJiffy: builder.mutation({ + query: (formData) => ({ url: '/af2pae', method: 'POST', body: formData @@ -166,8 +196,8 @@ export const jobsApiSlice = apiSlice.injectEndpoints({ return baseQueryReturnValue } }), - getAf2PaeStatus: builder.query({ - query: (uuid: string) => ({ + getAf2PaeStatus: builder.query({ + query: (uuid) => ({ url: `/af2pae/status?uuid=${uuid}`, method: 'GET' }) diff --git a/apps/ui/src/slices/statsApiSlice.ts b/apps/ui/src/slices/statsApiSlice.ts index 23d20857..cc6f8da8 100644 --- a/apps/ui/src/slices/statsApiSlice.ts +++ b/apps/ui/src/slices/statsApiSlice.ts @@ -9,7 +9,7 @@ interface Stats { export const statsApiSlice = apiSlice.injectEndpoints({ endpoints: (builder) => ({ - getStats: builder.query({ + getStats: builder.query({ query: () => ({ url: '/stats', method: 'GET' diff --git a/apps/ui/src/slices/usersApiSlice.ts b/apps/ui/src/slices/usersApiSlice.ts index 1fb708fc..9d61b81b 100644 --- a/apps/ui/src/slices/usersApiSlice.ts +++ b/apps/ui/src/slices/usersApiSlice.ts @@ -1,16 +1,45 @@ -import { createEntityAdapter } from '@reduxjs/toolkit' +import { createEntityAdapter, type EntityState } from '@reduxjs/toolkit' import { apiSlice } from 'app/api/apiSlice' -import type { UserDTO } from '@bilbomd/bilbomd-types' +import type { + UserDTO, + CreateUserDTO, + UpdateUserDTO +} from '@bilbomd/bilbomd-types' type NormalizedUser = UserDTO +interface APITokenInfo { + _id?: string + id?: string + tokenHash?: string + label: string + createdAt: string | Date + expiresAt?: string | Date + token?: string +} + +interface APITokensResponse { + tokens: APITokenInfo[] +} + +interface CreateAPITokenRequest { + username: string + label: string + expiresAt?: string +} + +interface DeleteAPITokenRequest { + username: string + id: string +} + const usersAdapter = createEntityAdapter() const initialState = usersAdapter.getInitialState() export const usersApiSlice = apiSlice.injectEndpoints({ endpoints: (builder) => ({ - getUsers: builder.query({ + getUsers: builder.query, unknown>({ query: () => ({ url: '/users', method: 'GET', @@ -69,7 +98,7 @@ export const usersApiSlice = apiSlice.injectEndpoints({ ] : [{ type: 'User', id: 'LIST' }] }), - addNewUser: builder.mutation({ + addNewUser: builder.mutation({ query: (initialUserData) => ({ url: '/users', method: 'POST', @@ -79,7 +108,7 @@ export const usersApiSlice = apiSlice.injectEndpoints({ }), invalidatesTags: [{ type: 'User', id: 'LIST' }] }), - updateUser: builder.mutation({ + updateUser: builder.mutation({ query: (initialUserData) => ({ url: '/users', method: 'PATCH', @@ -89,15 +118,15 @@ export const usersApiSlice = apiSlice.injectEndpoints({ }), invalidatesTags: (_result, _error, arg) => [{ type: 'User', id: arg.id }] }), - deleteUser: builder.mutation({ + deleteUser: builder.mutation({ query: ({ id }) => ({ url: `/users/${id}`, method: 'DELETE' }), invalidatesTags: (_result, _error, arg) => [{ type: 'User', id: arg.id }] }), - getAPITokens: builder.query({ - query: (username: string) => ({ + getAPITokens: builder.query({ + query: (username) => ({ url: `/users/${username}/tokens`, method: 'GET' }), @@ -105,7 +134,7 @@ export const usersApiSlice = apiSlice.injectEndpoints({ { type: 'Token', id: username } ] }), - createAPIToken: builder.mutation({ + createAPIToken: builder.mutation({ query: ({ username, label, expiresAt }) => ({ url: `/users/${username}/tokens`, method: 'POST', @@ -115,7 +144,7 @@ export const usersApiSlice = apiSlice.injectEndpoints({ { type: 'Token', id: username } ] }), - deleteAPIToken: builder.mutation({ + deleteAPIToken: builder.mutation({ query: ({ username, id }) => ({ url: `/users/${username}/tokens/${id}`, method: 'DELETE' diff --git a/apps/ui/src/test/handlers.ts b/apps/ui/src/test/handlers.ts index 1bbd9748..f8843c60 100644 --- a/apps/ui/src/test/handlers.ts +++ b/apps/ui/src/test/handlers.ts @@ -151,22 +151,44 @@ export const handlers = [ }), http.post('http://localhost:3003/api/v1/jobs', async ({ request }) => { - const body = (await request.json()) as Record - if (body?.invalidData) { - return new Response( - JSON.stringify({ - success: false, - error: 'Validation error' - }), - { - status: 400, - headers: { - 'Content-Type': 'application/json' + // Check content type to handle both JSON and FormData + const contentType = request.headers.get('content-type') || '' + + if (contentType.includes('application/json')) { + const body = (await request.json()) as Record + if (body?.invalidData) { + return new Response( + JSON.stringify({ + success: false, + error: 'Validation error' + }), + { + status: 400, + headers: { + 'Content-Type': 'application/json' + } } + ) + } + return new Response(JSON.stringify({ ...mockJob, ...body }), { + headers: { + 'Content-Type': 'application/json' } - ) + }) } - return new Response(JSON.stringify({ ...mockJob, ...body }), { + + // Handle FormData (from actual endpoint) + if (contentType.includes('multipart/form-data')) { + // Just return the mock job for FormData requests + return new Response(JSON.stringify(mockJob), { + headers: { + 'Content-Type': 'application/json' + } + }) + } + + // Default: return mockJob + return new Response(JSON.stringify(mockJob), { headers: { 'Content-Type': 'application/json' } diff --git a/apps/ui/tsconfig.json b/apps/ui/tsconfig.json index 999b89c1..5933018e 100644 --- a/apps/ui/tsconfig.json +++ b/apps/ui/tsconfig.json @@ -15,7 +15,7 @@ "noEmit": true, "jsx": "react-jsx", /*MUI suggestions*/ - "noImplicitAny": false, + "noImplicitAny": true, "noImplicitThis": true, "strictNullChecks": true, "allowSyntheticDefaultImports": true,