diff --git a/README.md b/README.md index a7fa6fac..bfe85a6e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ The Zodius HTTP client is generated using the following command: ```bash -npm run generate:zod +npm run zod:generate ``` ## Contributing diff --git a/app/Http/Controllers/Api/V1/ClientController.php b/app/Http/Controllers/Api/V1/ClientController.php index 78874c9c..17e34892 100644 --- a/app/Http/Controllers/Api/V1/ClientController.php +++ b/app/Http/Controllers/Api/V1/ClientController.php @@ -29,6 +29,8 @@ protected function checkPermission(Organization $organization, string $permissio * @return ClientCollection * * @throws AuthorizationException + * + * @operationId getClients */ public function index(Organization $organization): ClientCollection { @@ -46,6 +48,8 @@ public function index(Organization $organization): ClientCollection * Create client * * @throws AuthorizationException + * + * @operationId createClient */ public function store(Organization $organization, TagStoreRequest $request): ClientResource { @@ -63,6 +67,8 @@ public function store(Organization $organization, TagStoreRequest $request): Cli * Update client * * @throws AuthorizationException + * + * @operationId updateClient */ public function update(Organization $organization, Client $client, TagUpdateRequest $request): ClientResource { @@ -78,6 +84,8 @@ public function update(Organization $organization, Client $client, TagUpdateRequ * Delete client * * @throws AuthorizationException + * + * @operationId deleteClient */ public function destroy(Organization $organization, Client $client): JsonResponse { diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index d3b8f447..777cfd85 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -23,6 +23,8 @@ class MemberController extends Controller * @return MemberCollection> * * @throws AuthorizationException + * + * @operationId getMembers */ public function index(Organization $organization, MemberIndexRequest $request): MemberCollection { @@ -38,6 +40,8 @@ public function index(Organization $organization, MemberIndexRequest $request): * Invite a placeholder user to become a member of the organization * * @throws AuthorizationException|UserNotPlaceholderApiException + * + * @operationId invitePlaceholder */ public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse { diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index a7a6a994..73355e59 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; async function registerNewUser(page, email, password) { - //await page.getByRole('link', { name: 'Register' }).click(); await page.goto(PLAYWRIGHT_BASE_URL + '/register'); await page.getByLabel('Name').fill('John Doe'); await page.getByLabel('Email').fill(email); diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts new file mode 100644 index 00000000..5deee4ee --- /dev/null +++ b/e2e/clients.spec.ts @@ -0,0 +1,51 @@ +import { expect, Page } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; + +async function goToProjectsOverview(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/clients'); +} + +// Create new project via modal +test('test that creating and deleting a new client via the modal works', async ({ + page, +}) => { + const newClientName = + 'New Project ' + Math.floor(1 + Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Client' }).click(); + await page.getByPlaceholder('Client Name').fill(newClientName); + await Promise.all([ + page.getByRole('button', { name: 'Create Client' }).nth(1).click(), + page.waitForResponse( + async (response) => + response.url().includes('/clients') && + response.request().method() === 'POST' && + response.status() === 201 && + (await response.json()).data.id !== null && + (await response.json()).data.name === newClientName + ), + ]); + + await expect(page.getByTestId('client_table')).toContainText(newClientName); + const moreButton = page.locator( + "[aria-label='Actions for Client " + newClientName + "']" + ); + moreButton.click(); + const deleteButton = page.locator( + "[aria-label='Delete Client " + newClientName + "']" + ); + + await Promise.all([ + deleteButton.click(), + page.waitForResponse( + async (response) => + response.url().includes('/clients') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + ]); + await expect(page.getByTestId('client_table')).not.toContainText( + newClientName + ); +}); diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts new file mode 100644 index 00000000..8b2c2463 --- /dev/null +++ b/e2e/projects.spec.ts @@ -0,0 +1,65 @@ +import { expect, Page } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; + +async function goToProjectsOverview(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); +} + +// Create new project via modal +test('test that creating and deleting a new project via the modal works', async ({ + page, +}) => { + const newProjectName = + 'New Project ' + Math.floor(1 + Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByPlaceholder('Project Name').fill(newProjectName); + await Promise.all([ + page.getByRole('button', { name: 'Create Project' }).nth(1).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 && + (await response.json()).data.id !== null && + (await response.json()).data.color !== null && + (await response.json()).data.client_id === null && + (await response.json()).data.name === newProjectName + ), + ]); + + await expect(page.getByTestId('project_table')).toContainText( + newProjectName + ); + const moreButton = page.locator( + "[aria-label='Actions for Project " + newProjectName + "']" + ); + moreButton.click(); + const deleteButton = page.locator( + "[aria-label='Delete Project " + newProjectName + "']" + ); + + await Promise.all([ + deleteButton.click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + ]); + await expect(page.getByTestId('project_table')).not.toContainText( + newProjectName + ); +}); + +// Create new project with new Client + +// Create new project with existing Client + +// Delete project via More Options + +// Test that project task count is displayed correctly + +// Test that active / archive / all filter works (once implemented) diff --git a/e2e/tags.spec.ts b/e2e/tags.spec.ts new file mode 100644 index 00000000..8074e1c1 --- /dev/null +++ b/e2e/tags.spec.ts @@ -0,0 +1,48 @@ +import { expect, Page } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; + +async function goToTagsOverview(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/tags'); +} + +// Create new project via modal +test('test that creating and deleting a new client via the modal works', async ({ + page, +}) => { + const newTagName = 'New Tag ' + Math.floor(1 + Math.random() * 10000); + await goToTagsOverview(page); + await page.getByRole('button', { name: 'Create Tag' }).click(); + await page.getByPlaceholder('Tag Name').fill(newTagName); + await Promise.all([ + page.getByRole('button', { name: 'Create Tag' }).nth(1).click(), + page.waitForResponse( + async (response) => + response.url().includes('/tags') && + response.request().method() === 'POST' && + response.status() === 201 && + (await response.json()).data.id !== null && + (await response.json()).data.name === newTagName + ), + ]); + + await expect(page.getByTestId('tag_table')).toContainText(newTagName); + const moreButton = page.locator( + "[aria-label='Actions for Tag " + newTagName + "']" + ); + moreButton.click(); + const deleteButton = page.locator( + "[aria-label='Delete Tag " + newTagName + "']" + ); + + await Promise.all([ + deleteButton.click(), + page.waitForResponse( + async (response) => + response.url().includes('/tags') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + ]); + await expect(page.getByTestId('tag_table')).not.toContainText(newTagName); +}); diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index fa76f50b..e4e1b314 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -428,12 +428,15 @@ test('test that deleting a time entry from the overview works', async ({ test('test that load more works when the end of page is reached', async ({ page, }) => { - await goToTimeOverview(page); - await page.waitForResponse( - (response) => - response.url().includes('/time-entries') && - response.status() === 200 - ); + await Promise.all([ + goToTimeOverview(page), + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.status() === 200 + ), + ]); + await page.waitForTimeout(200); await Promise.all([ page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)), diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts index 7701a682..77276a1f 100644 --- a/e2e/timetracker.spec.ts +++ b/e2e/timetracker.spec.ts @@ -7,8 +7,10 @@ import { startOrStopTimerWithButton, stoppedTimeEntryResponse, } from './utils/currentTimeEntry'; +import { Page } from '@playwright/test'; +import { newTagResponse } from './utils/tags'; -async function goToDashboard(page) { +async function goToDashboard(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); } @@ -37,46 +39,16 @@ test('test that starting and stopping a timer with a description works', async ( .getByTestId('time_entry_description') .fill('New Time Entry Description'); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 201 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end === null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === - 'New Time Entry Description' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); + newTimeEntryResponse(page, { + description: 'New Time Entry Description', }), startOrStopTimerWithButton(page), ]); await assertThatTimerHasStarted(page); await page.waitForTimeout(1500); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 200 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end !== null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === - 'New Time Entry Description' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration !== null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); + stoppedTimeEntryResponse(page, { + description: 'New Time Entry Description', }), await startOrStopTimerWithButton(page), ]); @@ -89,23 +61,7 @@ test('test that starting and updating the description while running works', asyn await goToDashboard(page); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 201 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end === null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); - }), + newTimeEntryResponse(page), startOrStopTimerWithButton(page), ]); await assertThatTimerHasStarted(page); @@ -115,47 +71,18 @@ test('test that starting and updating the description while running works', asyn .fill('New Time Entry Description'); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 200 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end === null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === - 'New Time Entry Description' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); + newTimeEntryResponse(page, { + status: 200, + description: 'New Time Entry Description', }), page.getByTestId('time_entry_description').press('Tab'), ]); await page.waitForTimeout(500); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 200 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end !== null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === - 'New Time Entry Description' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration !== null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); + stoppedTimeEntryResponse(page, { + description: 'New Time Entry Description', }), - await startOrStopTimerWithButton(page), + startOrStopTimerWithButton(page), ]); await assertThatTimerIsStopped(page); }); @@ -165,23 +92,7 @@ test('test that starting and updating the time while running works', async ({ }) => { await goToDashboard(page); const [createResponse] = await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 201 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end === null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); - }), + newTimeEntryResponse(page), await startOrStopTimerWithButton(page), ]); await assertThatTimerHasStarted(page); @@ -214,71 +125,26 @@ test('test that starting and updating the time while running works', async ({ await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/); await page.waitForTimeout(500); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 200 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end !== null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration !== null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); - }), + stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page), ]); await assertThatTimerIsStopped(page); }); -test('test that entering a time starts the timer on blur', async ({ page }) => { +test('test that entering a human readable time starts the timer on blur', async ({ + page, +}) => { await goToDashboard(page); await page.getByTestId('time_entry_time').fill('20min'); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 201 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end === null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); - }), + newTimeEntryResponse(page), page.getByTestId('time_entry_time').press('Tab'), ]); + await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/); await assertThatTimerHasStarted(page); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 200 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end !== null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration !== null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); - }), + stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page), ]); await page.locator( @@ -286,50 +152,71 @@ test('test that entering a time starts the timer on blur', async ({ page }) => { ); }); +test('test that entering a number in the time range starts the timer on blur', async ({ + page, +}) => { + await goToDashboard(page); + await page.getByTestId('time_entry_time').fill('5'); + await Promise.all([ + newTimeEntryResponse(page), + page.getByTestId('time_entry_time').press('Tab'), + ]); + await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:05:/); + await assertThatTimerHasStarted(page); + + await Promise.all([ + stoppedTimeEntryResponse(page), + startOrStopTimerWithButton(page), + ]); + await page.locator( + '[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70' + ); +}); + +test('test that entering a value with the format hh:mm in the time range starts the timer on blur', async ({ + page, +}) => { + await goToDashboard(page); + await page.getByTestId('time_entry_time').fill('12:30'); + await Promise.all([ + newTimeEntryResponse(page), + page.getByTestId('time_entry_time').press('Tab'), + ]); + await expect(page.getByTestId('time_entry_time')).toHaveValue(/12:30:/); + await assertThatTimerHasStarted(page); + + await Promise.all([ + stoppedTimeEntryResponse(page), + startOrStopTimerWithButton(page), + ]); + await page.locator( + '[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70' + ); +}); + +test('test that entering a random value in the time range does not start the timer on blur', async ({ + page, +}) => { + await goToDashboard(page); + await page.getByTestId('time_entry_time').fill('asdasdasd'); + await page.getByTestId('time_entry_time').press('Tab'), + await page.locator( + '[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70' + ); +}); + test('test that entering a time starts the timer on enter', async ({ page, }) => { await goToDashboard(page); await page.getByTestId('time_entry_time').fill('20min'); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 201 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end === null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); - }), + newTimeEntryResponse(page), page.getByTestId('time_entry_time').press('Enter'), ]); await assertThatTimerHasStarted(page); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 200 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end !== null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration !== null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) - ); - }), + stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page), ]); await assertThatTimerIsStopped(page); @@ -342,14 +229,7 @@ test('test that adding a new tag works', async ({ page }) => { await page.getByTestId('tag_dropdown_search').fill(newTagName); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 201 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.name === newTagName - ); - }), + newTagResponse(page, { name: newTagName }), page.getByTestId('tag_dropdown_search').press('Enter'), ]); @@ -370,56 +250,18 @@ test('test that adding a new tag when the timer is running', async ({ await page.getByTestId('tag_dropdown').click(); await page.getByTestId('tag_dropdown_search').fill(newTagName); const [tagCreateResponse] = await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 201 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.name === newTagName - ); - }), + newTagResponse(page, { name: newTagName }), page.getByTestId('tag_dropdown_search').press('Enter'), ]); - await page.waitForResponse(async (response) => { - return ( - response.status() === 200 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end === null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration === null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([(await tagCreateResponse.json()).data.id]) - ); - }); + const tagId = (await tagCreateResponse.json()).data.id; + await newTimeEntryResponse(page, { status: 200, tags: [tagId] }); await expect(page.getByTestId('tag_dropdown_search')).toHaveValue(''); await expect(page.getByRole('option', { name: newTagName })).toBeVisible(); await page.getByTestId('tag_dropdown_search').press('Escape'); await page.waitForTimeout(1000); await Promise.all([ - page.waitForResponse(async (response) => { - return ( - response.status() === 200 && - (await response.headerValue('Content-Type')) === - 'application/json' && - (await response.json()).data.id !== null && - (await response.json()).data.start !== null && - (await response.json()).data.end !== null && - (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && - (await response.json()).data.task_id === null && - (await response.json()).data.duration !== null && - (await response.json()).data.user_id !== null && - JSON.stringify((await response.json()).data.tags) === - JSON.stringify([(await tagCreateResponse.json()).data.id]) - ); - }), + stoppedTimeEntryResponse(page, { tags: [tagId] }), startOrStopTimerWithButton(page), ]); await assertThatTimerIsStopped(page); diff --git a/e2e/utils/currentTimeEntry.ts b/e2e/utils/currentTimeEntry.ts index c64ccf99..a616552f 100644 --- a/e2e/utils/currentTimeEntry.ts +++ b/e2e/utils/currentTimeEntry.ts @@ -12,22 +12,25 @@ export async function assertThatTimerHasStarted(page: Page) { ); } -export function newTimeEntryResponse(page: Page) { +export function newTimeEntryResponse( + page: Page, + { description = '', status = 201, tags = [] } = {} +) { return page.waitForResponse(async (response) => { return ( - response.status() === 201 && + response.status() === status && (await response.headerValue('Content-Type')) === 'application/json' && (await response.json()).data.id !== null && (await response.json()).data.start !== null && (await response.json()).data.end === null && (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && + (await response.json()).data.description === description && (await response.json()).data.task_id === null && (await response.json()).data.duration === null && (await response.json()).data.user_id !== null && JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) + JSON.stringify(tags) ); }); } @@ -40,7 +43,10 @@ export async function assertThatTimerIsStopped(page: Page) { ).toHaveClass(/bg-accent-300\/50/); } -export async function stoppedTimeEntryResponse(page: Page) { +export async function stoppedTimeEntryResponse( + page: Page, + { description = '', tags = [] } = {} +) { return page.waitForResponse(async (response) => { return ( response.status() === 200 && @@ -50,12 +56,12 @@ export async function stoppedTimeEntryResponse(page: Page) { (await response.json()).data.start !== null && (await response.json()).data.end !== null && (await response.json()).data.project_id === null && - (await response.json()).data.description === '' && + (await response.json()).data.description === description && (await response.json()).data.task_id === null && (await response.json()).data.duration !== null && (await response.json()).data.user_id !== null && JSON.stringify((await response.json()).data.tags) === - JSON.stringify([]) + JSON.stringify(tags) ); }); } diff --git a/e2e/utils/tags.ts b/e2e/utils/tags.ts new file mode 100644 index 00000000..a0a3a701 --- /dev/null +++ b/e2e/utils/tags.ts @@ -0,0 +1,12 @@ +import { Page } from '@playwright/test'; + +export function newTagResponse(page: Page, { name = '' } = {}) { + return page.waitForResponse(async (response) => { + return ( + response.status() === 201 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.name === name + ); + }); +} diff --git a/openapi.json.client.ts b/openapi.json.client.ts index 154762a2..1d049e02 100644 --- a/openapi.json.client.ts +++ b/openapi.json.client.ts @@ -20,11 +20,23 @@ const MemberResource = z email: z.string(), role: z.string(), is_placeholder: z.boolean(), + billable_rate: z.union([z.number(), z.null()]), }) .passthrough(); const MemberCollection = z.array(MemberResource); const OrganizationResource = z - .object({ id: z.string(), name: z.string(), is_personal: z.string() }) + .object({ + id: z.string(), + name: z.string(), + is_personal: z.string(), + billable_rate: z.union([z.number(), z.null()]), + }) + .passthrough(); +const v1_organizations_update_Body = z + .object({ + name: z.string(), + billable_rate: z.union([z.number(), z.null()]).optional(), + }) .passthrough(); const ProjectResource = z .object({ @@ -32,16 +44,35 @@ const ProjectResource = z name: z.string(), color: z.string(), client_id: z.union([z.string(), z.null()]), + billable_rate: z.union([z.number(), z.null()]), }) .passthrough(); -const ProjectCollection = z.array(ProjectResource); const createProject_Body = z .object({ name: z.string(), color: z.string(), + billable_rate: z.union([z.number(), z.null()]).optional(), client_id: z.union([z.string(), z.null()]).optional(), }) .passthrough(); +const ProjectMemberResource = z + .object({ + id: z.string(), + billable_rate: z.union([z.number(), z.null()]), + user_id: z.string(), + project_id: z.string(), + }) + .passthrough(); +const createProjectMember_Body = z + .object({ + user_id: z.string().uuid(), + billable_rate: z.union([z.number(), z.null()]).optional(), + }) + .passthrough(); +const updateProjectMember_Body = z + .object({ billable_rate: z.union([z.number(), z.null()]) }) + .partial() + .passthrough(); const TagResource = z .object({ id: z.string(), @@ -51,6 +82,18 @@ const TagResource = z }) .passthrough(); const TagCollection = z.array(TagResource); +const TaskResource = z + .object({ + id: z.string(), + name: z.string(), + project_id: z.string(), + created_at: z.string(), + updated_at: z.string(), + }) + .passthrough(); +const createTask_Body = z + .object({ name: z.string(), project_id: z.string() }) + .passthrough(); const before = z.union([z.string(), z.null()]).optional(); const TimeEntryResource = z .object({ @@ -98,11 +141,16 @@ export const schemas = { MemberResource, MemberCollection, OrganizationResource, + v1_organizations_update_Body, ProjectResource, - ProjectCollection, createProject_Body, + ProjectMemberResource, + createProjectMember_Body, + updateProjectMember_Body, TagResource, TagCollection, + TaskResource, + createTask_Body, before, TimeEntryResource, TimeEntryCollection, @@ -146,7 +194,7 @@ const endpoints = makeApi([ { name: 'body', type: 'Body', - schema: z.object({ name: z.string() }).passthrough(), + schema: v1_organizations_update_Body, }, { name: 'organization', @@ -181,7 +229,7 @@ const endpoints = makeApi([ { method: 'get', path: '/v1/organizations/:organization/clients', - alias: 'v1.clients.index', + alias: 'getClients', requestFormat: 'json', parameters: [ { @@ -207,7 +255,7 @@ const endpoints = makeApi([ { method: 'post', path: '/v1/organizations/:organization/clients', - alias: 'v1.clients.store', + alias: 'createClient', requestFormat: 'json', parameters: [ { @@ -248,7 +296,7 @@ const endpoints = makeApi([ { method: 'put', path: '/v1/organizations/:organization/clients/:client', - alias: 'v1.clients.update', + alias: 'updateClient', requestFormat: 'json', parameters: [ { @@ -294,7 +342,7 @@ const endpoints = makeApi([ { method: 'delete', path: '/v1/organizations/:organization/clients/:client', - alias: 'v1.clients.destroy', + alias: 'deleteClient', requestFormat: 'json', parameters: [ { @@ -400,7 +448,7 @@ const endpoints = makeApi([ { method: 'get', path: '/v1/organizations/:organization/members', - alias: 'v1.users.index', + alias: 'getMembers', requestFormat: 'json', parameters: [ { @@ -436,7 +484,7 @@ const endpoints = makeApi([ { method: 'post', path: '/v1/organizations/:organization/members/:user/invite-placeholder', - alias: 'v1.users.invite-placeholder', + alias: 'invitePlaceholder', requestFormat: 'json', parameters: [ { @@ -480,6 +528,88 @@ const endpoints = makeApi([ }, ], }, + { + method: 'put', + path: '/v1/organizations/:organization/project-members/:projectMember', + alias: 'updateProjectMember', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: updateProjectMember_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'projectMember', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: ProjectMemberResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'delete', + path: '/v1/organizations/:organization/project-members/:projectMember', + alias: 'deleteProjectMember', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({}).partial().passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'projectMember', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.null(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, { method: 'get', path: '/v1/organizations/:organization/projects', @@ -492,7 +622,39 @@ const endpoints = makeApi([ schema: z.string().uuid(), }, ], - response: z.object({ data: ProjectCollection }).passthrough(), + response: z + .object({ + data: z.array(ProjectResource), + links: z + .object({ + first: z.union([z.string(), z.null()]), + last: z.union([z.string(), z.null()]), + prev: z.union([z.string(), z.null()]), + next: z.union([z.string(), z.null()]), + }) + .passthrough(), + meta: z + .object({ + current_page: z.number().int(), + from: z.union([z.number(), z.null()]), + last_page: z.number().int(), + links: z.array( + z + .object({ + url: z.union([z.string(), z.null()]), + label: z.string(), + active: z.boolean(), + }) + .passthrough() + ), + path: z.union([z.string(), z.null()]), + per_page: z.number().int(), + to: z.union([z.number(), z.null()]), + total: z.number().int(), + }) + .passthrough(), + }) + .passthrough(), errors: [ { status: 403, @@ -660,6 +822,115 @@ const endpoints = makeApi([ }, ], }, + { + method: 'get', + path: '/v1/organizations/:organization/projects/:project/project-members', + alias: 'getProjectMembers', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'project', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z + .object({ + data: z.array(ProjectMemberResource), + links: z + .object({ + first: z.union([z.string(), z.null()]), + last: z.union([z.string(), z.null()]), + prev: z.union([z.string(), z.null()]), + next: z.union([z.string(), z.null()]), + }) + .passthrough(), + meta: z + .object({ + current_page: z.number().int(), + from: z.union([z.number(), z.null()]), + last_page: z.number().int(), + links: z.array( + z + .object({ + url: z.union([z.string(), z.null()]), + label: z.string(), + active: z.boolean(), + }) + .passthrough() + ), + path: z.union([z.string(), z.null()]), + per_page: z.number().int(), + to: z.union([z.number(), z.null()]), + total: z.number().int(), + }) + .passthrough(), + }) + .passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, + { + method: 'post', + path: '/v1/organizations/:organization/projects/:project/project-members', + alias: 'createProjectMember', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: createProjectMember_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'project', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: ProjectMemberResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, { method: 'get', path: '/v1/organizations/:organization/tags', @@ -809,6 +1080,202 @@ const endpoints = makeApi([ }, ], }, + { + method: 'get', + path: '/v1/organizations/:organization/tasks', + alias: 'getTasks', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'project_id', + type: 'Query', + schema: z.string().uuid().optional(), + }, + ], + response: z + .object({ + data: z.array(TaskResource), + links: z + .object({ + first: z.union([z.string(), z.null()]), + last: z.union([z.string(), z.null()]), + prev: z.union([z.string(), z.null()]), + next: z.union([z.string(), z.null()]), + }) + .passthrough(), + meta: z + .object({ + current_page: z.number().int(), + from: z.union([z.number(), z.null()]), + last_page: z.number().int(), + links: z.array( + z + .object({ + url: z.union([z.string(), z.null()]), + label: z.string(), + active: z.boolean(), + }) + .passthrough() + ), + path: z.union([z.string(), z.null()]), + per_page: z.number().int(), + to: z.union([z.number(), z.null()]), + total: z.number().int(), + }) + .passthrough(), + }) + .passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'post', + path: '/v1/organizations/:organization/tasks', + alias: 'createTask', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: createTask_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: TaskResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'put', + path: '/v1/organizations/:organization/tasks/:task', + alias: 'updateTask', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({ name: z.string() }).passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'task', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: TaskResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'delete', + path: '/v1/organizations/:organization/tasks/:task', + alias: 'deleteTask', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({}).partial().passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'task', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.null(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, { method: 'get', path: '/v1/organizations/:organization/time-entries', @@ -848,7 +1315,7 @@ const endpoints = makeApi([ { name: 'only_full_dates', type: 'Query', - schema: z.boolean().optional(), + schema: z.enum(['true', 'false']).optional(), }, ], response: z.object({ data: TimeEntryCollection }).passthrough(), @@ -951,6 +1418,17 @@ const endpoints = makeApi([ ], response: z.object({ data: TimeEntryResource }).passthrough(), errors: [ + { + status: 400, + description: `API exception`, + schema: z + .object({ + error: z.boolean(), + key: z.string(), + message: z.string(), + }) + .passthrough(), + }, { status: 403, description: `Authorization error`, diff --git a/package-lock.json b/package-lock.json index 9be0dbff..4402dff0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,8 @@ "packages": { "": { "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/vue": "^1.0.6", "@heroicons/vue": "^2.1.1", "@rushstack/eslint-patch": "^1.7.0", "@tailwindcss/container-queries": "^0.1.1", diff --git a/package.json b/package.json index 9bad1783..3a45efa1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .", "type-check": "vue-tsc --noEmit", "test:e2e": "rm -rf test-results/.auth && npx playwright test", - "generate:zod": "npx openapi-zod-client http://localhost:80/docs/api.json --output openapi.json.client.ts --base-url /api" + "zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output openapi.json.client.ts --base-url /api" }, "devDependencies": { "@inertiajs/vue3": "^1.0.0", @@ -32,6 +32,8 @@ "vue-tsc": "^1.8.27" }, "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/vue": "^1.0.6", "@heroicons/vue": "^2.1.1", "@rushstack/eslint-patch": "^1.7.0", "@tailwindcss/container-queries": "^0.1.1", diff --git a/resources/css/app.css b/resources/css/app.css index 145ba691..6c90c173 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -6,6 +6,17 @@ :root{ --theme-color-icon-default: #42466C; --theme-color-card-background: #13152B; + --theme-color-card-background-active: #1C1E34; + --theme-color-card-border: #242940; + --theme-color-card-border-active: #2A3461; + --theme-color-tab-background: var(--theme-color-card-background); + --theme-color-tab-background-active: var(--theme-color-card-background-active); + --theme-color-tab-border: var(--theme-color-card-border); + --theme-color-row-separator-background: var(--theme-color-card-border); + --theme-color-row-heading-background: var(--theme-color-card-background); + --theme-color-row-border: var(--theme-color-card-border); + --theme-color-row-heading-border: var(--theme-color-card-border); + } *{ diff --git a/resources/js/Components/.DS_Store b/resources/js/Components/.DS_Store new file mode 100644 index 00000000..d3ad177c Binary files /dev/null and b/resources/js/Components/.DS_Store differ diff --git a/resources/js/Components/Common/Badge.vue b/resources/js/Components/Common/Badge.vue index 58a3b05b..5db9a046 100644 --- a/resources/js/Components/Common/Badge.vue +++ b/resources/js/Components/Common/Badge.vue @@ -4,7 +4,6 @@ import { computed } from 'vue'; const props = withDefaults( defineProps<{ - name: string; size: 'base' | 'large'; tag: string; class?: string; diff --git a/resources/js/Components/Common/BillableToggleButton.vue b/resources/js/Components/Common/BillableToggleButton.vue index 85792698..3a22ccfc 100644 --- a/resources/js/Components/Common/BillableToggleButton.vue +++ b/resources/js/Components/Common/BillableToggleButton.vue @@ -21,7 +21,7 @@ const iconColorClasses = computed(() => { :class=" twMerge( iconColorClasses, - 'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-seperator hover:bg-card-background-seperator rounded-full w-11 h-11 flex items-center justify-center' + 'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full w-11 h-11 flex items-center justify-center' ) "> +import TextInput from '@/Components/TextInput.vue'; +import SecondaryButton from '@/Components/SecondaryButton.vue'; +import DialogModal from '@/Components/DialogModal.vue'; +import { ref } from 'vue'; +import type { CreateClientBody } from '@/utils/api'; +import PrimaryButton from '@/Components/PrimaryButton.vue'; +import { useFocus } from '@vueuse/core'; +import { useClientsStore } from '@/utils/useClients'; + +const { createClient } = useClientsStore(); +const show = defineModel('show', { default: false }); +const saving = ref(false); + +const client = ref({ + name: '', +}); + +async function submit() { + await createClient(client.value); + show.value = false; +} + +const clientNameInput = ref(null); +useFocus(clientNameInput, { initialValue: true }); + + +
+ Create Client +
+ + + + + + + + diff --git a/resources/js/Components/Common/Client/ClientDropdown.vue b/resources/js/Components/Common/Client/ClientDropdown.vue new file mode 100644 index 00000000..4cf7e710 --- /dev/null +++ b/resources/js/Components/Common/Client/ClientDropdown.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/resources/js/Components/Common/Client/ClientDropdownItem.vue b/resources/js/Components/Common/Client/ClientDropdownItem.vue new file mode 100644 index 00000000..74d8a79f --- /dev/null +++ b/resources/js/Components/Common/Client/ClientDropdownItem.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/resources/js/Components/Common/Client/ClientMoreOptionsDropdown.vue b/resources/js/Components/Common/Client/ClientMoreOptionsDropdown.vue new file mode 100644 index 00000000..2ab8e438 --- /dev/null +++ b/resources/js/Components/Common/Client/ClientMoreOptionsDropdown.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/resources/js/Components/Common/Client/ClientTable.vue b/resources/js/Components/Common/Client/ClientTable.vue new file mode 100644 index 00000000..374d61d4 --- /dev/null +++ b/resources/js/Components/Common/Client/ClientTable.vue @@ -0,0 +1,45 @@ + + + diff --git a/resources/js/Components/Common/Client/ClientTableHeading.vue b/resources/js/Components/Common/Client/ClientTableHeading.vue new file mode 100644 index 00000000..7f343c7d --- /dev/null +++ b/resources/js/Components/Common/Client/ClientTableHeading.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/resources/js/Components/Common/Client/ClientTableRow.vue b/resources/js/Components/Common/Client/ClientTableRow.vue new file mode 100644 index 00000000..8b0fe700 --- /dev/null +++ b/resources/js/Components/Common/Client/ClientTableRow.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/resources/js/Components/Common/Member/MemberInviteModal.vue b/resources/js/Components/Common/Member/MemberInviteModal.vue new file mode 100644 index 00000000..46a2f078 --- /dev/null +++ b/resources/js/Components/Common/Member/MemberInviteModal.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/resources/js/Components/Common/Member/MemberMoreOptionsDropdown.vue b/resources/js/Components/Common/Member/MemberMoreOptionsDropdown.vue new file mode 100644 index 00000000..8e3d091b --- /dev/null +++ b/resources/js/Components/Common/Member/MemberMoreOptionsDropdown.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/resources/js/Components/Common/Member/MemberTable.vue b/resources/js/Components/Common/Member/MemberTable.vue new file mode 100644 index 00000000..17944131 --- /dev/null +++ b/resources/js/Components/Common/Member/MemberTable.vue @@ -0,0 +1,28 @@ + + + diff --git a/resources/js/Components/Common/Member/MemberTableHeading.vue b/resources/js/Components/Common/Member/MemberTableHeading.vue new file mode 100644 index 00000000..1008fd29 --- /dev/null +++ b/resources/js/Components/Common/Member/MemberTableHeading.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/resources/js/Components/Common/Member/MemberTableRow.vue b/resources/js/Components/Common/Member/MemberTableRow.vue new file mode 100644 index 00000000..7913931e --- /dev/null +++ b/resources/js/Components/Common/Member/MemberTableRow.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/resources/js/Components/Common/Project/ProjectCreateModal.vue b/resources/js/Components/Common/Project/ProjectCreateModal.vue new file mode 100644 index 00000000..79310468 --- /dev/null +++ b/resources/js/Components/Common/Project/ProjectCreateModal.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/resources/js/Components/Common/Project/ProjectDropdown.vue b/resources/js/Components/Common/Project/ProjectDropdown.vue index a0c0d003..f16518ad 100644 --- a/resources/js/Components/Common/Project/ProjectDropdown.vue +++ b/resources/js/Components/Common/Project/ProjectDropdown.vue @@ -116,7 +116,7 @@ function updateValue(project: Project) { :border tag="button" :name="selectedProjectName" - class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-seperator hover:bg-card-background-seperator"> + class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator">