From 15e61e9789213663a78f18fed5652f0225c94a99 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 10 Feb 2026 12:22:53 +0100 Subject: [PATCH] Add Field component system and migrate UI --- e2e/auth.spec.ts | 4 +- e2e/profile.spec.ts | 22 +++ e2e/reporting.spec.ts | 28 +-- e2e/utils/api.ts | 7 +- e2e/utils/mailpit.ts | 2 +- .../Common/Client/ClientCreateModal.vue | 10 +- .../Common/Member/MemberCombobox.vue | 5 +- .../Common/Member/MemberDeleteModal.vue | 37 ++-- .../Common/Member/MemberEditModal.vue | 26 ++- .../Common/Member/MemberInviteModal.vue | 21 ++- .../Common/Project/ProjectDropdown.vue | 2 +- .../Common/Project/ProjectEditModal.vue | 32 ++-- .../ProjectMember/ProjectMemberEditModal.vue | 8 +- .../Common/Report/ReportCreateModal.vue | 46 ++--- .../Common/Report/ReportEditModal.vue | 40 +++-- .../Reporting/ReportingRoundingControls.vue | 27 ++- resources/js/Components/ConfirmsPassword.vue | 10 +- resources/js/Components/ui/button/Button.vue | 25 --- resources/js/Components/ui/button/index.ts | 36 ---- .../ui/calendar/CalendarDateInput.vue | 1 - resources/js/Components/ui/label/Label.vue | 24 +++ resources/js/Components/ui/label/index.ts | 1 + .../ui/number-field/NumberFieldInput.vue | 2 +- .../js/Components/ui/select/SelectTrigger.vue | 23 ++- .../js/Pages/API/Partials/ApiTokenManager.vue | 17 +- resources/js/Pages/Auth/ConfirmPassword.vue | 13 +- resources/js/Pages/Auth/ForgotPassword.vue | 13 +- resources/js/Pages/Auth/Login.vue | 23 ++- resources/js/Pages/Auth/Register.vue | 113 ++++++------ resources/js/Pages/Auth/ResetPassword.vue | 35 ++-- .../js/Pages/Auth/TwoFactorChallenge.vue | 25 +-- .../Pages/Profile/Partials/ApiTokensForm.vue | 26 +-- .../Pages/Profile/Partials/DeleteUserForm.vue | 12 +- .../LogoutOtherBrowserSessionsForm.vue | 12 +- .../js/Pages/Profile/Partials/ThemeForm.vue | 43 ++--- .../Partials/TwoFactorAuthenticationForm.vue | 15 +- .../Profile/Partials/UpdatePasswordForm.vue | 37 ++-- .../Partials/UpdateProfileInformationForm.vue | 47 +++-- .../Pages/Teams/Partials/CreateTeamForm.vue | 15 +- .../js/Pages/Teams/Partials/ImportData.vue | 10 +- .../Partials/OrganizationBillableRate.vue | 50 +++--- .../Partials/OrganizationFormatSettings.vue | 162 ++++++++---------- .../OrganizationTimeEntrySettings.vue | 38 ++-- .../Teams/Partials/TeamMemberManager.vue | 22 ++- .../Teams/Partials/UpdateTeamNameForm.vue | 25 ++- resources/js/packages/ui/src/Buttons/index.ts | 7 +- .../packages/ui/src/EstimatedTimeSection.vue | 10 +- .../ui/src/Input/MultiselectDropdown.vue | 4 +- .../js/packages/ui/src/Input/TextInput.vue | 2 +- .../ui/src/Input/TimePickerSimple.vue | 5 +- .../ui/src/Project/ProjectCreateModal.vue | 29 ++-- .../Project/ProjectEditBillableSection.vue | 20 +-- .../ui/src/TimeEntry/TimeEntryCreateModal.vue | 41 ++--- .../ui/src/TimeEntry/TimeEntryEditModal.vue | 36 ++-- .../src/TimeEntry/TimeEntryMassActionRow.vue | 11 +- .../TimeEntry/TimeEntryMassUpdateModal.vue | 71 ++++---- resources/js/packages/ui/src/field/Field.vue | 21 +++ .../js/packages/ui/src/field/FieldContent.vue | 16 ++ .../ui/src/field/FieldDescription.vue | 23 +++ .../js/packages/ui/src/field/FieldError.vue | 40 +++++ .../js/packages/ui/src/field/FieldGroup.vue | 21 +++ .../js/packages/ui/src/field/FieldLabel.vue | 24 +++ .../js/packages/ui/src/field/FieldLegend.vue | 25 +++ .../packages/ui/src/field/FieldSeparator.vue | 29 ++++ .../js/packages/ui/src/field/FieldSet.vue | 22 +++ .../js/packages/ui/src/field/FieldTitle.vue | 21 +++ resources/js/packages/ui/src/field/index.ts | 39 +++++ resources/js/packages/ui/src/index.ts | 27 +++ .../packages/ui/src/separator/Separator.vue | 26 +++ .../js/packages/ui/src/separator/index.ts | 1 + .../js/utils/useAggregatedTimeEntriesQuery.ts | 1 + 71 files changed, 1023 insertions(+), 741 deletions(-) delete mode 100644 resources/js/Components/ui/button/Button.vue delete mode 100644 resources/js/Components/ui/button/index.ts create mode 100644 resources/js/Components/ui/label/Label.vue create mode 100644 resources/js/Components/ui/label/index.ts create mode 100644 resources/js/packages/ui/src/field/Field.vue create mode 100644 resources/js/packages/ui/src/field/FieldContent.vue create mode 100644 resources/js/packages/ui/src/field/FieldDescription.vue create mode 100644 resources/js/packages/ui/src/field/FieldError.vue create mode 100644 resources/js/packages/ui/src/field/FieldGroup.vue create mode 100644 resources/js/packages/ui/src/field/FieldLabel.vue create mode 100644 resources/js/packages/ui/src/field/FieldLegend.vue create mode 100644 resources/js/packages/ui/src/field/FieldSeparator.vue create mode 100644 resources/js/packages/ui/src/field/FieldSet.vue create mode 100644 resources/js/packages/ui/src/field/FieldTitle.vue create mode 100644 resources/js/packages/ui/src/field/index.ts create mode 100644 resources/js/packages/ui/src/separator/Separator.vue create mode 100644 resources/js/packages/ui/src/separator/index.ts diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index ad2fee64..b0d7720d 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -192,9 +192,7 @@ test('shows error for invalid login credentials', async ({ page }) => { await page.getByLabel('Password').fill('wrongpassword123'); await page.getByRole('button', { name: 'Log in' }).click(); - await expect( - page.getByText('These credentials do not match our records.') - ).toBeVisible(); + await expect(page.getByText('These credentials do not match our records.')).toBeVisible(); }); test('shows error when registering with existing email', async ({ page }) => { diff --git a/e2e/profile.spec.ts b/e2e/profile.spec.ts index 81aed742..93d9248c 100644 --- a/e2e/profile.spec.ts +++ b/e2e/profile.spec.ts @@ -44,6 +44,28 @@ test('test that user can create an API key', async ({ page }) => { await createNewApiToken(page); }); +test('test that creating an API key with empty name shows validation error', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); + + // Wait for the API Key Name input to be visible before interacting + const nameInput = page.getByLabel('API Key Name'); + await expect(nameInput).toBeVisible(); + + // Ensure the API Key Name input is empty + await nameInput.fill(''); + + // Click the create button and wait for the 422 response + const [response] = await Promise.all([ + page.waitForResponse('**/users/me/api-tokens'), + page.getByRole('button', { name: 'Create API Key' }).click(), + ]); + + expect(response.status()).toBe(422); + + // Verify that an error notification is shown with validation message about the name field + await expect(page.getByText('name field is required')).toBeVisible({ timeout: 5000 }); +}); + test('test that user can delete an API key', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await createNewApiToken(page); diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index 2e635a5d..7537030f 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -158,12 +158,9 @@ test('test that deselecting a project removes the filter', async ({ page, ctx }) page.getByRole('button', { name: 'Projects' }).first().getByText('1') ).toBeVisible(); - // Deselect project + // Deselect project (no network request expected — TanStack Query serves cached unfiltered data) await page.getByRole('button', { name: 'Projects' }).first().click(); - await Promise.all([ - page.getByRole('option').filter({ hasText: project1Name }).click(), - waitForReportingUpdate(page), - ]); + await page.getByRole('option').filter({ hasText: project1Name }).click(); await page.keyboard.press('Escape'); // Verify badge count is gone (no count displayed when 0) @@ -281,12 +278,9 @@ test('test that deselecting a client removes the filter', async ({ page, ctx }) page.getByRole('button', { name: 'Clients' }).first().getByText('1') ).toBeVisible(); - // Deselect client + // Deselect client (no network request expected — TanStack Query serves cached unfiltered data) await page.getByRole('button', { name: 'Clients' }).first().click(); - await Promise.all([ - page.getByRole('option').filter({ hasText: client1Name }).click(), - waitForReportingUpdate(page), - ]); + await page.getByRole('option').filter({ hasText: client1Name }).click(); await page.keyboard.press('Escape'); await expect( @@ -445,12 +439,9 @@ test('test that deselecting a member removes the filter', async ({ page, ctx }) page.getByRole('button', { name: 'Members' }).first().getByText('1') ).toBeVisible(); - // Deselect member + // Deselect member (no network request expected — TanStack Query serves cached unfiltered data) await page.getByRole('button', { name: 'Members' }).first().click(); - await Promise.all([ - page.getByRole('option').filter({ hasText: 'John Doe' }).click(), - waitForReportingUpdate(page), - ]); + await page.getByRole('option').filter({ hasText: 'John Doe' }).click(); await page.keyboard.press('Escape'); // Verify badge count is gone @@ -544,12 +535,9 @@ test('test that deselecting a tag removes the filter', async ({ page, ctx }) => await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible(); - // Deselect tag + // Deselect tag (no network request expected — TanStack Query serves cached unfiltered data) await page.getByRole('button', { name: 'Tags' }).click(); - await Promise.all([ - page.getByRole('option').filter({ hasText: tag1Name }).click(), - waitForReportingUpdate(page), - ]); + await page.getByRole('option').filter({ hasText: tag1Name }).click(); await page.keyboard.press('Escape'); await expect(page.getByRole('button', { name: 'Tags' }).getByText(/^\d+$/)).not.toBeVisible(); diff --git a/e2e/utils/api.ts b/e2e/utils/api.ts index 6d9cdc0d..f8d9e02c 100644 --- a/e2e/utils/api.ts +++ b/e2e/utils/api.ts @@ -417,10 +417,9 @@ export async function updateOrganizationCurrencyViaWeb( currency: string, name: string = 'Test Organization' ) { - const response = await ctx.request.put( - `${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, - { data: { name, currency } } - ); + const response = await ctx.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, { + data: { name, currency }, + }); expect(response.status()).toBe(200); } diff --git a/e2e/utils/mailpit.ts b/e2e/utils/mailpit.ts index 2ece5802..8d5c807a 100644 --- a/e2e/utils/mailpit.ts +++ b/e2e/utils/mailpit.ts @@ -78,4 +78,4 @@ export async function getPasswordResetUrl( expect(resetUrlMatch).toBeTruthy(); return resetUrlMatch![1].replace(/&/g, '&'); -} \ No newline at end of file +} diff --git a/resources/js/Components/Common/Client/ClientCreateModal.vue b/resources/js/Components/Common/Client/ClientCreateModal.vue index f7a3a453..6ce70036 100644 --- a/resources/js/Components/Common/Client/ClientCreateModal.vue +++ b/resources/js/Components/Common/Client/ClientCreateModal.vue @@ -7,7 +7,7 @@ import type { CreateClientBody } from '@/packages/api/src'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import { useFocus } from '@vueuse/core'; import { useClientsStore } from '@/utils/useClients'; -import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue'; +import { Field, FieldLabel } from '@/packages/ui/src/field'; const { createClient } = useClientsStore(); const show = defineModel('show', { default: false }); @@ -37,19 +37,19 @@ useFocus(clientNameInput, { initialValue: true });