diff --git a/src/app/virtual-lab/lab/[virtualLabId]/(lab)/lab/page.tsx b/src/app/virtual-lab/lab/[virtualLabId]/(lab)/lab/page.tsx index a78830671..74e7e6e0a 100644 --- a/src/app/virtual-lab/lab/[virtualLabId]/(lab)/lab/page.tsx +++ b/src/app/virtual-lab/lab/[virtualLabId]/(lab)/lab/page.tsx @@ -1,5 +1,14 @@ +'use client'; + import VirtualLabHomePage from '@/components/VirtualLab/VirtualLabHomePage'; -export default function VirtualLabSettingsPage() { - return ; +interface ServerSideComponentProp { + params: Params; +} + +export default function VirtualLabSettingsPage({ + params, +}: ServerSideComponentProp<{ virtualLabId: string }>) { + const { virtualLabId } = params; + return ; } diff --git a/src/components/VirtualLab/VirtualLabHomePage/index.tsx b/src/components/VirtualLab/VirtualLabHomePage/index.tsx index 5c4d9ce03..13519cc7b 100644 --- a/src/components/VirtualLab/VirtualLabHomePage/index.tsx +++ b/src/components/VirtualLab/VirtualLabHomePage/index.tsx @@ -1,5 +1,7 @@ 'use client'; +import { loadable } from 'jotai/utils'; +import { useAtomValue } from 'jotai'; import { CalendarOutlined, EditOutlined, UserOutlined } from '@ant-design/icons'; import VirtualLabStatistic from '../VirtualLabStatistic'; @@ -13,13 +15,27 @@ import Brain from '@/components/icons/Brain'; import { mockMembers } from '@/components/VirtualLab/mockData/members'; import { mockProjects } from '@/components/VirtualLab/mockData/projects'; import { mockVirtualLab } from '@/components/VirtualLab/mockData/lab'; +import { virtualLabDetailAtomFamily } from '@/state/virtual-lab/lab'; +import Spinner from '@/components/Spinner'; import Styles from './home-page.module.css'; -export default function VirtualLabHomePage() { +type Props = { + id: string; +}; + +export default function VirtualLabHomePage({ id }: Props) { const iconStyle = { color: '#69C0FF' }; const virtualLab = mockVirtualLab; const projects = mockProjects; + const virtualLabDetail = useAtomValue(loadable(virtualLabDetailAtomFamily(id))); + console.log(virtualLabDetail); + if (virtualLabDetail.state === 'loading') { + return ; + } + if (virtualLabDetail.state === 'hasError') { + return
Something went wrong
; + } return (
@@ -33,7 +49,7 @@ export default function VirtualLabHomePage() {
Virtual Lab name
-

{virtualLab.title}

+

{virtualLabDetail.data?.name}

{virtualLab.description}
@@ -42,25 +58,21 @@ export default function VirtualLabHomePage() {
- } - title="Builds" - detail={virtualLab.builds} - /> + } title="Builds" detail={10} /> } title="Simulation experiments" - detail={virtualLab.simulationExperiments} + detail={10} /> } title="Members" - detail={virtualLab.members} + detail={10} /> } title="Admin" - detail={virtualLab.admin} + detail="Julian Budd" /> } @@ -69,11 +81,7 @@ export default function VirtualLabHomePage() { />
- +
Discover OBP
diff --git a/src/components/VirtualLab/VirtualLabSettingsComponent/VirtualLabSettingsComponent.spec.tsx b/src/components/VirtualLab/VirtualLabSettingsComponent/VirtualLabSettingsComponent.spec.tsx deleted file mode 100644 index 1c83f6af3..000000000 --- a/src/components/VirtualLab/VirtualLabSettingsComponent/VirtualLabSettingsComponent.spec.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import { Provider } from 'jotai'; -import { useHydrateAtoms } from 'jotai/utils'; -import userEvent, { UserEvent } from '@testing-library/user-event'; -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; - -import { Session } from 'next-auth'; -import { Plan } from './PlanPanel'; -import VirtualLabSettingsComponent from '.'; -import sessionAtom from '@/state/session'; -import * as MockVirtualLabModule from '@/services/virtual-lab'; -import { VirtualLab } from '@/services/virtual-lab/types'; -import { createMockVirtualLab } from '__tests__/__utils__/VirtualLab'; -import { getButton } from '__tests__/__utils__/utils'; - -jest.mock('@/services/virtual-lab/virtual-lab-service'); - -jest.mock('next/navigation', () => ({ - __esModule: true, - useParams: jest.fn(), - useRouter: jest.fn(), -})); - -describe('VirtualLabSettingsComponent', () => { - jest.setTimeout(10_000); - - beforeEach(() => { - // Clear all instances and calls to constructor and all methods: - virtualLabServiceMock().mockClear(); - getComputeTimeMock().mockClear(); - editVirtualLabMock().mockClear(); - }); - - it('shows error when loading compute time fails', async () => { - getComputeTimeMock().mockRejectedValueOnce(new Error('Error fetching compute time')); - renderComponentWithLab('test-lab'); - - await screen.findByText('There was an error while retrieving compute time.'); - }); - - it('shows collapsible panels for virtual lab information, members and plan', () => { - renderComponentWithLab('test-lab'); - - screen.getByText('Information'); - screen.getByText('Members'); - screen.getByText('Plan'); - }); - - it('shows lab name and reference when information panel is expanded', async () => { - const { virtualLab, user } = renderComponentWithLab('test-lab'); - await openInformationPanel(user); - - const teamName = screen.getByLabelText('Lab Name') as HTMLInputElement; - expect(teamName.value).toEqual(virtualLab.name); - const referenceEMail = screen.getByLabelText('Reference Contact') as HTMLInputElement; - expect(referenceEMail.value).toEqual(virtualLab.referenceEMail); - }); - - it('does not show edit features if user is not admin', async () => { - const { user } = renderComponentWithLab('test-lab', false); - await openInformationPanel(user); - - // Verify that all inputs are readonly - const allInputs = Array.from(document.querySelectorAll('input')) as HTMLInputElement[]; - const readOnlyInputs = allInputs.filter((input) => input.readOnly); - expect(allInputs.length).toEqual(readOnlyInputs.length); - - // No edit buttons are visible - const editButtons = screen.queryAllByTitle('Edit virtual lab information'); - expect(editButtons.length).toEqual(0); - }); - - it('shows edit features if user is admin', async () => { - const { user } = renderComponentWithLab('test-lab', true); - await openInformationPanel(user); - - const allInputs = Array.from(document.querySelectorAll('input')) as HTMLInputElement[]; - const readOnlyInputs = allInputs.filter((input) => input.readOnly); - expect(allInputs.length).toEqual(readOnlyInputs.length); - - const editButtons = screen.queryAllByTitle('Edit virtual lab information'); - expect(editButtons.length).toEqual(3); - }); - - it('shows edittable inputs if any edit button is clicked', async () => { - const { user } = renderComponentWithLab('test-lab', true); - await openInformationPanel(user); - - const editButtons = screen.queryAllByTitle('Edit virtual lab information'); - - for await (const editButton of editButtons) { - fireEvent.click(editButton); - const allInputs = Array.from(document.querySelectorAll('input')) as HTMLInputElement[]; - const readOnlyInputs = allInputs.filter((input) => input.readOnly); - expect(readOnlyInputs.length).toEqual(0); - } - }); - - it('shows udpated information when user clicks save button', async () => { - const { user } = renderComponentWithLab('test-lab', true); - await enableEditModeInInformation(user); - - changeInputValue('Lab Name', 'New team name'); - - await saveInformation(user); - - const teamNameInput = (await screen.findByLabelText('Lab Name')) as HTMLInputElement; - expect(teamNameInput.readOnly).toEqual(true); - expect(teamNameInput.value).toEqual('New team name'); - }); - - it('disables save button if user enters invalid virtual lab name', async () => { - const { user } = renderComponentWithLab('test-lab', true); - await enableEditModeInInformation(user); - - changeInputValue('Lab Name', ''); - - const save = screen.getByText('Save').closest('button') as HTMLButtonElement; - await waitFor(() => expect(save.disabled).toEqual(true)); - - changeInputValue('Lab Name', 'A valid name'); - await waitFor(() => expect(save.disabled).toEqual(false)); - }); - - it('disables save button if user enters invalid reference email', async () => { - const { user } = renderComponentWithLab('test-lab', true); - - await enableEditModeInInformation(user); - - changeInputValue('Reference Contact', 'not_an_email'); - - const save = screen.getByText('Save').closest('button') as HTMLButtonElement; - await waitFor(() => expect(save.disabled).toEqual(true)); - - changeInputValue('Reference Contact', 'such@valid.email'); - await waitFor(() => expect(save.disabled).toEqual(false)); - }); - - it('shows error message when saving information fails', async () => { - editVirtualLabMock().mockRejectedValueOnce(new Error('Mock error')); - - const { virtualLab, user } = renderComponentWithLab('test-lab', true); - await enableEditModeInInformation(user); - - changeInputValue('Lab Name', 'New valid team name'); - - await saveInformation(user); - - await screen.findByText('There was an error in saving information.'); - - const teamName = screen.getByLabelText('Lab Name') as HTMLInputElement; - expect(teamName.value).toEqual(virtualLab.name); - }); - - it('removes error message when saving information passes eventually', async () => { - editVirtualLabMock().mockRejectedValueOnce(new Error('Mock error')); - - const { user } = renderComponentWithLab('test-lab', true); - - await enableEditModeInInformation(user); - - changeInputValue('Lab Name', 'This should fail'); - await saveInformation(user); - - await screen.findByText(informationPanelError); - - await enableEditModeInInformation(user, false); - - changeInputValue('Lab Name', 'This should pass'); - await saveInformation(user); - - const teamNameInputAfter = (await screen.findByLabelText('Lab Name')) as HTMLInputElement; - expect(teamNameInputAfter.value).toEqual('This should pass'); - - const errorMessage = screen.queryByText(informationPanelError); - expect(errorMessage).not.toBeInTheDocument(); - }); - - it('highlights currently selected plan when user expands Plan section', async () => { - const { virtualLab, user } = renderComponentWithLab('test-lab', true); - await user.click(screen.getByText('Plan')); - const planElement = getElementForPlanType(virtualLab.plan!); - within(planElement!).getByText('Current Selection'); - const currentSelection = screen.getAllByText('Current Selection'); - expect(currentSelection).toHaveLength(1); - }); - - it('does not show Select plan buttons if user is not admin', async () => { - const { user } = renderComponentWithLab('test-lab', false); - await user.click(screen.getByText('Plan')); - - const planCollapseContent = screen.getByTestId('plans-collapsible-content'); - const selectPlanButtons = within(planCollapseContent).queryAllByText('Select', { - selector: 'button span', - }); - expect(selectPlanButtons).toHaveLength(0); - }); - - it('changes plan to entry level without asking for billing info', async () => { - const { user } = renderComponentWithLab('test-lab', true); - await user.click(screen.getByText('Plan')); - - const entryPlanElement = getElementForPlanType('entry'); - const selectPlanButton = within(entryPlanElement).getByText('Select', { - selector: 'button span', - }); - - await user.click(selectPlanButton); - await user.click(screen.getByText('Confirm')); - screen.getByTestId('Saving changes'); - - const entryPlanElementAfterChange = await findElementForPlanType('entry'); - within(entryPlanElementAfterChange).getByText(/Current Selection/i); - }); - - it('changes plan to new type after asking for billing info', async () => { - const { virtualLab, user } = renderComponentWithLab('test-lab', true); - await user.click(screen.getByText('Plan')); - expect(virtualLab.plan).not.toEqual('advanced'); - - const advancedPlanElement = getElementForPlanType('advanced'); - const selectPlanButton = within(advancedPlanElement).getByText('Select', { - selector: 'button span', - }); - - await user.click(selectPlanButton); - screen.getByTestId('billing-form'); - changeInputValue('Address', 'My new fake address'); - await user.click(screen.getByText('Submit')); - await user.click(screen.getByText('Confirm')); - - const advancedPlanElementAfterChange = await findElementForPlanType('advanced'); - within(advancedPlanElementAfterChange).getByText(/Current Selection/i); - expect(screen.queryByTestId('billing-form')).not.toBeInTheDocument(); - }); - - it('non admins do not see danger zone panel', () => { - renderComponentWithLab('test-lab', false); - expect(screen.queryByText(/Danger Zone/i)).not.toBeInTheDocument(); - }); - - it('admins see danger zone panel', () => { - renderComponentWithLab('test-lab', true); - fireEvent.click(screen.getByText(/Danger Zone/i)); - expect(getButton('Delete Virtual Lab')).toBeVisible(); - }); - - const getElementForPlanType = (plan: Plan) => { - const planElement = screen - .getByText(new RegExp(plan, 'i')) - .closest('[data-testid="plan-details"]') as HTMLElement; - expect(planElement).toBeVisible(); - return planElement; - }; - - const findElementForPlanType = async (plan: Plan) => { - const planElement = ( - await screen.findByText(new RegExp(plan, 'i'), { selector: 'h2' }) - ).closest('[data-testid="plan-details"]') as HTMLElement; - expect(planElement).toBeVisible(); - return planElement; - }; - - const renderComponentWithLab = (name: string, adminMode?: boolean) => { - cleanup(); - - const user = userEvent.setup(); - - const virtualLab = createMockVirtualLab(name); - render(VirtualLabSettingsComponentProvider(virtualLab, adminMode)); - - return { virtualLab, user }; - }; - - const openInformationPanel = async (user: UserEvent) => { - const informationPanelHeader = screen.getByText('Information'); - await user.click(informationPanelHeader); - }; - - const enableEditModeInInformation = async (user: UserEvent, openPanel: boolean = true) => { - if (openPanel) { - await openInformationPanel(user); - } - const editButton = screen.getAllByTitle('Edit virtual lab information')[0]; - fireEvent.click(editButton); - }; - - const saveInformation = async (user: UserEvent) => { - const save = screen.getByText('Save').closest('button') as HTMLButtonElement; - await user.click(save); - }; - - const changeInputValue = (label: string, value: string) => { - const inputElement = screen.getByLabelText(label) as HTMLInputElement; - fireEvent.change(inputElement, { target: { value } }); - }; - - const informationPanelError = 'There was an error in saving information.'; - - const HydrateAtoms = ({ initialValues, children }: any) => { - useHydrateAtoms(initialValues); - return children; - }; - - function TestProvider({ initialValues, children }: any) { - return ( - - {children} - - ); - } - - function VirtualLabSettingsComponentProvider(virtualLab: VirtualLab, adminMode?: boolean) { - if (adminMode) { - // eslint-disable-next-line react/destructuring-assignment - const anAdmin = virtualLab.members.find((member) => member.role === 'admin')!; - expect(anAdmin).toBeTruthy(); - const user: Session['user'] = { - username: anAdmin.email, - email: anAdmin.email, - name: anAdmin.name, - }; - return ( - - - - ); - } - - return ( - - - - ); - } - - const virtualLabServiceMock = () => (MockVirtualLabModule as any).default as jest.Mock; - const getComputeTimeMock = () => (MockVirtualLabModule as any).getComputeTimeMock as jest.Mock; - const editVirtualLabMock = () => (MockVirtualLabModule as any).editVirtualLabMock as jest.Mock; -}); diff --git a/src/components/VirtualLab/mockData/lab.ts b/src/components/VirtualLab/mockData/lab.ts index df76178b9..a1f4d101d 100644 --- a/src/components/VirtualLab/mockData/lab.ts +++ b/src/components/VirtualLab/mockData/lab.ts @@ -1,17 +1,10 @@ -import { MockVirtualLab } from '@/types/virtual-lab/lab'; +import { VirtualLab } from '@/types/virtual-lab/lab'; -export const mockVirtualLab: MockVirtualLab = { - title: 'Institute of Neuroscience', +export const mockVirtualLab: VirtualLab = { + id: 'test', + name: 'Institute of Neuroscience', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque tempor enim nec condimentum varius. Suspendisse quis sem efficitur, lacinia enim eu, facilisis leo. Aliquam ex arcu, aliquet et sagittis ac, imperdiet a diam. Fusce sodales, sapien ut mollis faucibus, nisi ex fringilla tellus, eu sagittis ipsum neque eu justo. Suspendisse potenti. Mauris a pellentesque arcu. Ut accumsan viverra nibh, vel condimentum ipsum semper quis. In venenatis vel nulla ut tempor. Mauris libero mi, mattis eget iaculis ac, vulputate id augue. Sed ullamcorper, erat ut euismod congue, lorem diam volutpat lectus, id tempus mi diam nec est. Aenean eu libero a.', - builds: 278, - simulationExperiments: 15, - members: 9, - admin: 'Julian Budd', creationDate: '12.02.2023', - budget: { - total: 1650, - totalSpent: 1300, - remaining: 350, - }, + budget: 1650, }; diff --git a/src/services/virtual-lab/labs/index.ts b/src/services/virtual-lab/labs/index.ts index e69de29bb..b710bfd15 100644 --- a/src/services/virtual-lab/labs/index.ts +++ b/src/services/virtual-lab/labs/index.ts @@ -0,0 +1,10 @@ +const accessToken = + 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJza2hnaTdjRWxFbEJzRFpnZXh1NGlvSzBNV081eGtQbWlXWENYang4eHVrIn0.eyJleHAiOjE3OTg2NDExNTcsImlhdCI6MTcxMjMyNzU1NywianRpIjoiNTZhZmU4OTAtNjAwZC00Y2QxLTk1NWItYWE0YWM0NWFiN2FmIiwiaXNzIjoiaHR0cDovL2tleWNsb2FrOjgwODAvcmVhbG1zL29icC1yZWFsbSIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI5NjM4NjM2Ni0yNTE1LTQ1NjctYmQ5Zi1jNzNlMmYzNDNhYmUiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJvYnBhcHAiLCJzZXNzaW9uX3N0YXRlIjoiMDQxODYzOTUtYTYxOS00NWY5LTk0ZjEtMDQzN2I1YWNjNGViIiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInNpZCI6IjA0MTg2Mzk1LWE2MTktNDVmOS05NGYxLTA0MzdiNWFjYzRlYiIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoidGVzdCB0ZXN0IiwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdCIsImdpdmVuX25hbWUiOiJ0ZXN0IiwiZmFtaWx5X25hbWUiOiJ0ZXN0IiwiZW1haWwiOiJ0ZXN0QHRlc3QuY29tIn0.2wZ5757mgL65fodoAi50svwpDvQPwVWhnhYvE3EYOrSL0RcmImN8GPpn-kO11QHxfCh9zp6cuKyOWvsF5H1JI1RfC38oAVbqMO9Wn6gU8So2PBtd1QpYxksaGyDGXNxHzUZcsNN2UFU48jtciowaW5Rtoi1-t3N4WfjokunUBNsEL8OMdWuGzFlkeuA52v4Jb7aDEfUOpaxEW5FisIOt3tEfXRnjXWR6DnuQUqam-Zu6UACdnImJN_BTXCTPntjvi3wIMeN4NP9ryLk5G7tXCM942RTdR8tojIa1TPVQFsGx3BNnKBsF9Rv7D_tIpjCE5KfyEXc2IaPE8IEtBtv5lg'; + +export default async function getVirtualLabDetail(id: string) { + const response = await fetch(`http://localhost:8000/virtual-labs/${id}`, { + method: 'GET', + headers: { Authorization: accessToken }, + }); + return response.json(); +} diff --git a/src/services/virtual-lab/types.ts b/src/services/virtual-lab/types.ts deleted file mode 100644 index 6090bc2f8..000000000 --- a/src/services/virtual-lab/types.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { TypeDef, assertType } from '@/util/type-guards'; - -export interface VirtualLab { - id: string; - name: string; - description: string; - referenceEMail: string; - members: VirtualLabMember[]; - plan?: VirtualLabPlanType; - billing: { - firstname: string; - lastname: string; - address: string; - city: string; - postalCode: string; - country: string; - }; -} - -export type VirtualLabPlanType = 'entry' | 'beginner' | 'intermediate' | 'advanced'; - -const VirtualLabMemberTypeDef: TypeDef = { - name: 'string', - email: 'string', - role: ['literal', 'admin', 'user'], -}; - -export interface VirtualLabMember { - name: string; - email: string; - role: 'admin' | 'user'; - lastActive?: number; -} - -export type NewMember = Pick; - -export interface ComputeTime { - labId: string; - usedTimeInHours: number; - totalTimeInHours: number; -} - -const VirtualLabTypeDef: TypeDef = { - id: 'string', - name: 'string', - description: 'string', - referenceEMail: 'string', - members: ['array', VirtualLabMemberTypeDef], - plan: ['?', 'string'], - billing: { - firstname: 'string', - lastname: 'string', - address: 'string', - city: 'string', - postalCode: 'string', - country: 'string', - }, -}; - -export function assertVirtualLabArray(data: unknown): asserts data is VirtualLab[] { - assertType(data, ['array', VirtualLabTypeDef]); -} diff --git a/src/state/virtual-lab/lab.ts b/src/state/virtual-lab/lab.ts index a7e2da469..f9bc8ed19 100644 --- a/src/state/virtual-lab/lab.ts +++ b/src/state/virtual-lab/lab.ts @@ -1,33 +1,12 @@ import { atom } from 'jotai'; -import sessionAtom from '../session'; -import { VirtualLab } from '@/services/virtual-lab/types'; -import VirtualLabService from '@/services/virtual-lab'; +import { atomFamily } from 'jotai/utils'; -export const currentVirtualLabIdAtom = atom(null); +import getVirtualLabDetail from '@/services/virtual-lab/labs'; +import { VirtualLab } from '@/types/virtual-lab/lab'; -export const virtualLabsForUserAtom = () => - atom>(async (get) => { - const session = get(sessionAtom); - if (!session) return []; - - const allLabs = await new VirtualLabService().listAll(session.user); - return allLabs; - }); - -export const getVirtualLabAtom = (labId: string) => - atom>(async (get) => { - const session = get(sessionAtom); - if (!session) return null; - - const service = new VirtualLabService(); - return service.get(session.user, labId); - }); - -export const getComputeTimeAtom = (labId: string) => - atom(async (get) => { - const session = get(sessionAtom); - if (!session) return null; - - const service = new VirtualLabService(); - return service.getComputeTime(labId); - }); +export const virtualLabDetailAtomFamily = atomFamily((virtualLabId: string) => + atom>(() => { + console.log(virtualLabId); + return getVirtualLabDetail(virtualLabId); + }) +); diff --git a/src/types/virtual-lab/lab.ts b/src/types/virtual-lab/lab.ts index 2095f434d..62e464179 100644 --- a/src/types/virtual-lab/lab.ts +++ b/src/types/virtual-lab/lab.ts @@ -1,16 +1,8 @@ -type MockBudget = { - total: number; - totalSpent: number; - remaining: number; -}; -export type MockVirtualLab = { - title: string; +export type VirtualLab = { + id: string; + name: string; description: string; - builds: number; - simulationExperiments: number; - members: number; - admin: string; creationDate: string; - budget: MockBudget; + budget: number; };