Compare commits

..

No commits in common. "8dd014ead1476f6dae877145290a473fd935cf36" and "b26a46274c5577a72ca85f37bb1f1b52a6b082f6" have entirely different histories.

9 changed files with 189 additions and 251 deletions

12
cypress/e2e/auth-user.ts Normal file
View file

@ -0,0 +1,12 @@
export default function authUser() {
cy.visit('http://127.0.0.1:3000/login');
cy.getBySel('login-header').should('exist');
cy.getBySel('login-form').should('exist');
cy.getBySel('email-input').should('exist');
cy.getBySel('password-input').should('exist');
cy.getBySel('login-button').should('exist');
cy.getBySel('email-input').type('cypress@example.com');
cy.getBySel('password-input').type('Password123!');
cy.getBySel('login-button').click();
cy.url().should('include', '/home');
}

View file

@ -1,40 +1,9 @@
import authUser from './auth-user';
describe('event creation', () => { describe('event creation', () => {
it('loads', () => { it('loads', () => {
cy.login(); authUser();
cy.visit('http://127.0.0.1:3000/events/new'); // cy.visit('http://127.0.0.1:3000/events/new'); // TODO: Add event creation tests
cy.getBySel('event-form').should('exist');
cy.getBySel('event-form').within(() => {
cy.getBySel('event-name-input').should('exist');
cy.getBySel('event-start-time-picker').should('exist');
cy.getBySel('event-end-time-picker').should('exist');
cy.getBySel('event-location-input').should('exist');
cy.getBySel('event-description-input').should('exist');
cy.getBySel('event-save-button').should('exist');
});
});
it('creates an event', () => {
cy.login();
cy.visit(
'http://127.0.0.1:3000/events/new?start=2025-07-01T01:00:00.000Z&end=2025-07-01T04:30:00.000Z',
);
cy.getBySel('event-form').should('exist');
cy.getBySel('event-form').within(() => {
cy.getBySel('event-name-input').type('Cypress Test Event');
cy.getBySel('event-location-input').type('Cypress Park');
cy.getBySel('event-description-input').type(
'This is a test event created by Cypress.',
);
cy.getBySel('event-save-button').click();
});
cy.wait(1000);
cy.visit('http://127.0.0.1:3000/events');
cy.getBySel('event-list-entry').should('exist');
cy.getBySel('event-list-entry')
.contains('Cypress Test Event')
.should('exist');
cy.getBySel('event-list-entry').contains('Cypress Park').should('exist');
}); });
}); });

View file

@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-namespace */
/// <reference types="cypress" /> /// <reference types="cypress" />
// *********************************************** // ***********************************************
// This example commands.ts shows you how to // This example commands.ts shows you how to
@ -46,22 +44,6 @@ Cypress.Commands.add('getBySelLike', (selector, ...args) => {
return cy.get(`[data-cy*=${selector}]`, ...args); return cy.get(`[data-cy*=${selector}]`, ...args);
}); });
Cypress.Commands.add('login', () => {
cy.session('auth', () => {
cy.visit('http://127.0.0.1:3000/login');
cy.getBySel('login-header').should('exist');
cy.getBySel('login-form').should('exist');
cy.getBySel('email-input').should('exist');
cy.getBySel('password-input').should('exist');
cy.getBySel('login-button').should('exist');
cy.getBySel('email-input').type('cypress@example.com');
cy.getBySel('password-input').type('Password123!');
cy.getBySel('login-button').click();
cy.url().should('include', '/home');
cy.getBySel('header').should('exist');
});
});
declare global { declare global {
namespace Cypress { namespace Cypress {
interface Chainable { interface Chainable {
@ -73,7 +55,6 @@ declare global {
selector: string, selector: string,
...args: any[] ...args: any[]
): Chainable<JQuery<HTMLElement>>; ): Chainable<JQuery<HTMLElement>>;
login(): Chainable<void>;
} }
} }
} }

View file

@ -17,10 +17,7 @@ export default function Events() {
const events = eventsData?.data?.events || []; const events = eventsData?.data?.events || [];
return ( return (
<div <div className='relative h-full flex flex-col items-center'>
className='relative h-full flex flex-col items-center'
data-cy='events-page'
>
{/* Heading */} {/* Heading */}
<h1 className='text-3xl font-bold mt-8 mb-4 text-center z-10'> <h1 className='text-3xl font-bold mt-8 mb-4 text-center z-10'>
My Events My Events

View file

@ -43,10 +43,7 @@ export default function EventListEntry({
return ( return (
<Link href={`/events/${id}`} className='block'> <Link href={`/events/${id}`} className='block'>
<Card className='w-full'> <Card className='w-full'>
<div <div className='grid grid-cols-1 gap-2 mx-auto md:mx-4 md:grid-cols-[80px_1fr_250px]'>
className='grid grid-cols-1 gap-2 mx-auto md:mx-4 md:grid-cols-[80px_1fr_250px]'
data-cy='event-list-entry'
>
<div className='w-full items-center justify-center grid'> <div className='w-full items-center justify-center grid'>
<Logo colorType='monochrome' logoType='submark' width={50} /> <Logo colorType='monochrome' logoType='submark' width={50} />
</div> </div>

View file

@ -17,7 +17,6 @@ export default function LabeledInput({
variantSize = 'default', variantSize = 'default',
autocomplete, autocomplete,
error, error,
'data-cy': dataCy,
...rest ...rest
}: { }: {
label: string; label: string;
@ -31,7 +30,6 @@ export default function LabeledInput({
variantSize?: 'default' | 'big' | 'textarea'; variantSize?: 'default' | 'big' | 'textarea';
autocomplete?: string; autocomplete?: string;
error?: string; error?: string;
'data-cy'?: string;
} & React.InputHTMLAttributes<HTMLInputElement>) { } & React.InputHTMLAttributes<HTMLInputElement>) {
const [passwordVisible, setPasswordVisible] = React.useState(false); const [passwordVisible, setPasswordVisible] = React.useState(false);
const [inputValue, setInputValue] = React.useState( const [inputValue, setInputValue] = React.useState(
@ -66,7 +64,6 @@ export default function LabeledInput({
id={name} id={name}
name={name} name={name}
rows={3} rows={3}
data-cy={dataCy}
/> />
) : ( ) : (
<span className='relative'> <span className='relative'>
@ -85,7 +82,6 @@ export default function LabeledInput({
id={name} id={name}
name={name} name={name}
autoComplete={autocomplete} autoComplete={autocomplete}
data-cy={dataCy}
{...rest} {...rest}
onChange={handleInputChange} onChange={handleInputChange}
/> />

View file

@ -190,6 +190,11 @@ const EventForm: React.FC<EventFormProps> = (props) => {
router.back(); router.back();
} }
// Calculate values for organiser, created, and updated
const organiserValue = isLoading
? 'Loading...'
: data?.data.user?.name || 'Unknown User';
// Use DB values for created_at/updated_at in edit mode // Use DB values for created_at/updated_at in edit mode
const createdAtValue = const createdAtValue =
props.type === 'edit' && eventData?.data?.event?.created_at props.type === 'edit' && eventData?.data?.event?.created_at
@ -204,203 +209,188 @@ const EventForm: React.FC<EventFormProps> = (props) => {
const createdAtDisplay = new Date(createdAtValue).toLocaleDateString(); const createdAtDisplay = new Date(createdAtValue).toLocaleDateString();
const updatedAtDisplay = new Date(updatedAtValue).toLocaleDateString(); const updatedAtDisplay = new Date(updatedAtValue).toLocaleDateString();
const [isClient, setIsClient] = React.useState(false);
React.useEffect(() => {
setIsClient(true);
}, []);
if (props.type === 'edit' && isLoading) return <div>Loading...</div>; if (props.type === 'edit' && isLoading) return <div>Loading...</div>;
if (props.type === 'edit' && fetchError) if (props.type === 'edit' && fetchError)
return <div>Error loading event.</div>; return <div>Error loading event.</div>;
return ( return (
<Dialog open={calendarOpen} onOpenChange={setCalendarOpen}> <>
<form <Dialog open={calendarOpen} onOpenChange={setCalendarOpen}>
className='flex flex-col gap-5 w-full' <form className='flex flex-col gap-5 w-full' onSubmit={handleSubmit}>
onSubmit={handleSubmit} <div className='grid grid-row-start:auto gap-4 sm:gap-8 w-full'>
data-cy='event-form' <div className='h-full w-full mt-0 ml-2 mb-16 flex items-center max-sm:grid max-sm:grid-row-start:auto max-sm:mb-6 max-sm:mt-10 max-sm:ml-0'>
> <div className='w-[100px] max-sm:w-full max-sm:flex max-sm:justify-center'>
<div className='grid grid-row-start:auto gap-4 sm:gap-8 w-full'> <Logo colorType='monochrome' logoType='submark' width={50} />
<div className='h-full w-full mt-0 ml-2 mb-16 flex items-center max-sm:grid max-sm:grid-row-start:auto max-sm:mb-6 max-sm:mt-10 max-sm:ml-0'>
<div className='w-[100px] max-sm:w-full max-sm:flex max-sm:justify-center'>
<Logo colorType='monochrome' logoType='submark' width={50} />
</div>
<div className='items-center ml-auto mr-auto max-sm:mb-6 max-sm:w-full'>
<LabeledInput
type='text'
label='Event Name'
placeholder={
props.type === 'create' ? 'New Event' : 'Event Name'
}
name='eventName'
variantSize='big'
value={title}
onChange={(e) => setTitle(e.target.value)}
data-cy='event-name-input'
/>
</div>
<div className='w-0 sm:w-[50px]'></div>
</div>
<div className='grid grid-cols-4 gap-4 h-full w-full max-lg:grid-cols-2 max-sm:grid-cols-1'>
<div>
<TimePicker
dateLabel='start Time'
timeLabel='&nbsp;'
date={startDate}
setDate={setStartDate}
time={startTime}
setTime={setStartTime}
data-cy='event-start-time-picker'
/>
</div>
<div>
<TimePicker
dateLabel='end Time'
timeLabel='&nbsp;'
date={endDate}
setDate={setEndDate}
time={endTime}
setTime={setEndTime}
data-cy='event-end-time-picker'
/>
</div>
<div className='w-54'>
<LabeledInput
type='text'
label='Location'
placeholder='where is the event?'
name='eventLocation'
value={location}
onChange={(e) => setLocation(e.target.value)}
data-cy='event-location-input'
/>
</div>
<div className='flex flex-col gap-4'>
<div className='flex flex-row gap-2'>
<Label className='w-[70px]'>created:</Label>
<Label className='text-[var(--color-neutral-300)]'>
{createdAtDisplay}
</Label>
</div> </div>
<div className='flex flex-row gap-2'> <div className='items-center ml-auto mr-auto max-sm:mb-6 max-sm:w-full'>
<Label className='w-[70px]'>updated:</Label> <LabeledInput
<p className='text-[var(--color-neutral-300)]'> type='text'
{updatedAtDisplay} label='Event Name'
</p> placeholder={
props.type === 'create' ? 'New Event' : 'Event Name'
}
name='eventName'
variantSize='big'
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div> </div>
<div className='w-0 sm:w-[50px]'></div>
</div> </div>
</div> <div className='grid grid-cols-4 gap-4 h-full w-full max-lg:grid-cols-2 max-sm:grid-cols-1'>
<div className='h-full w-full grid grid-cols-2 gap-4 max-sm:grid-cols-1'> <div>
<div className='h-full w-full grid grid-flow-row gap-4'> <TimePicker
<div className='h-full w-full'> dateLabel='start Time'
timeLabel='&nbsp;'
date={startDate}
setDate={setStartDate}
time={startTime}
setTime={setStartTime}
/>
</div>
<div>
<TimePicker
dateLabel='end Time'
timeLabel='&nbsp;'
date={endDate}
setDate={setEndDate}
time={endTime}
setTime={setEndTime}
/>
</div>
<div className='w-54'>
<LabeledInput
type='text'
label='Location'
placeholder='where is the event?'
name='eventLocation'
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<div className='flex flex-col gap-4'>
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
<Label>Organiser:</Label> <Label className='w-[70px]'>created:</Label>
<Label className='text-[var(--color-neutral-300)]'>
{createdAtDisplay}
</Label>
</div>
<div className='flex flex-row gap-2'>
<Label className='w-[70px]'>updated:</Label>
<p className='text-[var(--color-neutral-300)]'> <p className='text-[var(--color-neutral-300)]'>
{!isClient || isLoading {updatedAtDisplay}
? 'Loading...'
: data?.data.user?.name || 'Unknown User'}
</p> </p>
</div> </div>
</div> </div>
</div>
<div className='h-full w-full grid grid-cols-2 gap-4 max-sm:grid-cols-1'>
<div className='h-full w-full grid grid-flow-row gap-4'>
<div className='h-full w-full'>
<div className='flex flex-row gap-2'>
<Label>Organiser:</Label>
<Label className='text-[var(--color-neutral-300)]'>
{organiserValue}
</Label>
</div>
</div>
<div className='h-full w-full'>
<LabeledInput
type='text'
label='Event Description'
placeholder='What is the event about?'
name='eventDescription'
variantSize='textarea'
value={description}
onChange={(e) => setDescription(e.target.value)}
></LabeledInput>
</div>
</div>
<div className='h-full w-full'> <div className='h-full w-full'>
<LabeledInput <Label>Participants</Label>
type='text' <UserSearchInput
label='Event Description' selectedUsers={selectedParticipants}
placeholder='What is the event about?' addUserAction={(user) => {
name='eventDescription' setSelectedParticipants((current) =>
variantSize='textarea' current.find((u) => u.id === user.id)
value={description} ? current
onChange={(e) => setDescription(e.target.value)} : [...current, user],
data-cy='event-description-input' );
></LabeledInput> }}
removeUserAction={(user) => {
setSelectedParticipants((current) =>
current.filter((u) => u.id !== user.id),
);
}}
/>
<DialogTrigger asChild>
<Button variant='primary'>Calendar</Button>
</DialogTrigger>
<div className='grid grid-cols-1 mt-3 sm:max-h-60 sm:grid-cols-2 sm:overflow-y-auto sm:mb-0'>
{selectedParticipants.map((user) => (
<ParticipantListEntry
key={user.id}
user={user}
status='PENDING'
/>
))}
</div>
</div> </div>
</div> </div>
<div className='h-full w-full'>
<Label>Participants</Label>
<UserSearchInput
selectedUsers={selectedParticipants}
addUserAction={(user) => {
setSelectedParticipants((current) =>
current.find((u) => u.id === user.id)
? current
: [...current, user],
);
}}
removeUserAction={(user) => {
setSelectedParticipants((current) =>
current.filter((u) => u.id !== user.id),
);
}}
/>
<DialogTrigger asChild>
<Button variant='primary'>Calendar</Button>
</DialogTrigger>
<div className='grid grid-cols-1 mt-3 sm:max-h-60 sm:grid-cols-2 sm:overflow-y-auto sm:mb-0'>
{selectedParticipants.map((user) => (
<ParticipantListEntry
key={user.id}
user={user}
status='PENDING'
/>
))}
</div>
</div>
</div>
<div className='flex flex-row gap-2 justify-end mt-4 mb-6'> <div className='flex flex-row gap-2 justify-end mt-4 mb-6'>
<div className='w-[20%] grid max-sm:w-[40%]'> <div className='w-[20%] grid max-sm:w-[40%]'>
<Button <Button
type='button' type='button'
variant='secondary' variant='secondary'
onClick={() => { onClick={() => {
router.back(); router.back();
console.log('user aborted - no change in database'); console.log('user aborted - no change in database');
}} }}
> >
cancel cancel
</Button> </Button>
</div> </div>
<div className='w-[20%] grid max-sm:w-[40%]'> <div className='w-[20%] grid max-sm:w-[40%]'>
<Button <Button
type='submit' type='submit'
variant='primary' variant='primary'
disabled={status === 'pending'} disabled={status === 'pending'}
data-cy='event-save-button' >
> {status === 'pending' ? 'Saving...' : 'save event'}
{status === 'pending' ? 'Saving...' : 'save event'} </Button>
</Button> </div>
</div> </div>
{isSuccess && <p>Event created!</p>}
{error && <p className='text-red-500'>Error: {error.message}</p>}
</div> </div>
{isSuccess && <p>Event created!</p>} </form>
{error && <p className='text-red-500'>Error: {error.message}</p>} <DialogContent className='sm:max-w-[750px]'>
</div> <DialogHeader>
</form> <DialogTitle>Calendar</DialogTitle>
<DialogContent className='sm:max-w-[750px]'> <DialogDescription>
<DialogHeader> Calendar for selected participants
<DialogTitle>Calendar</DialogTitle> </DialogDescription>
<DialogDescription> </DialogHeader>
Calendar for selected participants <DialogFooter className='max-w-[calc(100svw-70px)]'>
</DialogDescription> <Calendar
</DialogHeader> userId={selectedParticipants.map((u) => u.id)}
<DialogFooter className='max-w-[calc(100svw-70px)]'> additionalEvents={[
<Calendar {
userId={selectedParticipants.map((u) => u.id)} id: 'temp-event',
additionalEvents={[ title: title || 'New Event',
{ start: startDate ? new Date(startDate) : new Date(),
id: 'temp-event', end: endDate ? new Date(endDate) : new Date(),
title: title || 'New Event', type: 'event',
start: startDate ? new Date(startDate) : new Date(), userId: 'create-event',
end: endDate ? new Date(endDate) : new Date(), colorOverride: '#ff9800',
type: 'event', },
userId: 'create-event', ]}
colorOverride: '#ff9800', height='600px'
}, />
]} </DialogFooter>
height='600px' </DialogContent>
/> </Dialog>
</DialogFooter> </>
</DialogContent>
</Dialog>
); );
}; };

View file

@ -25,10 +25,7 @@ export default function Header({
}>) { }>) {
return ( return (
<div className='w-full grid grid-rows-[50px_1fr] h-screen'> <div className='w-full grid grid-rows-[50px_1fr] h-screen'>
<header <header className='border-b-1 grid-cols-[1fr_3fr_1fr] grid items-center px-2 shadow-md'>
className='border-b-1 grid-cols-[1fr_3fr_1fr] grid items-center px-2 shadow-md'
data-cy='header'
>
<span className='flex justify-start'> <span className='flex justify-start'>
<SidebarTrigger variant='outline_primary' size='icon' /> <SidebarTrigger variant='outline_primary' size='icon' />
</span> </span>

View file

@ -20,7 +20,6 @@ export default function TimePicker({
setDate, setDate,
time, time,
setTime, setTime,
...props
}: { }: {
dateLabel?: string; dateLabel?: string;
timeLabel?: string; timeLabel?: string;
@ -28,11 +27,11 @@ export default function TimePicker({
setDate?: (date: Date | undefined) => void; setDate?: (date: Date | undefined) => void;
time?: string; time?: string;
setTime?: (time: string) => void; setTime?: (time: string) => void;
} & React.HTMLAttributes<HTMLDivElement>) { }) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
return ( return (
<div className='flex gap-4' {...props}> <div className='flex gap-4'>
<div className='flex flex-col gap-3'> <div className='flex flex-col gap-3'>
<Label htmlFor='date' className='px-1'> <Label htmlFor='date' className='px-1'>
{dateLabel} {dateLabel}