Skip to content

Commit fd012e7

Browse files
committed
Add Field component system and migrate UI
1 parent 1ecb332 commit fd012e7

File tree

71 files changed

+1023
-741
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+1023
-741
lines changed

e2e/auth.spec.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,7 @@ test('shows error for invalid login credentials', async ({ page }) => {
192192
await page.getByLabel('Password').fill('wrongpassword123');
193193
await page.getByRole('button', { name: 'Log in' }).click();
194194

195-
await expect(
196-
page.getByText('These credentials do not match our records.')
197-
).toBeVisible();
195+
await expect(page.getByText('These credentials do not match our records.')).toBeVisible();
198196
});
199197

200198
test('shows error when registering with existing email', async ({ page }) => {

e2e/profile.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,28 @@ test('test that user can create an API key', async ({ page }) => {
4444
await createNewApiToken(page);
4545
});
4646

47+
test('test that creating an API key with empty name shows validation error', async ({ page }) => {
48+
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
49+
50+
// Wait for the API Key Name input to be visible before interacting
51+
const nameInput = page.getByLabel('API Key Name');
52+
await expect(nameInput).toBeVisible();
53+
54+
// Ensure the API Key Name input is empty
55+
await nameInput.fill('');
56+
57+
// Click the create button and wait for the 422 response
58+
const [response] = await Promise.all([
59+
page.waitForResponse('**/users/me/api-tokens'),
60+
page.getByRole('button', { name: 'Create API Key' }).click(),
61+
]);
62+
63+
expect(response.status()).toBe(422);
64+
65+
// Verify that an error notification is shown with validation message about the name field
66+
await expect(page.getByText('name field is required')).toBeVisible({ timeout: 5000 });
67+
});
68+
4769
test('test that user can delete an API key', async ({ page }) => {
4870
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
4971
await createNewApiToken(page);

e2e/reporting.spec.ts

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,9 @@ test('test that deselecting a project removes the filter', async ({ page, ctx })
158158
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
159159
).toBeVisible();
160160

161-
// Deselect project
161+
// Deselect project (no network request expected — TanStack Query serves cached unfiltered data)
162162
await page.getByRole('button', { name: 'Projects' }).first().click();
163-
await Promise.all([
164-
page.getByRole('option').filter({ hasText: project1Name }).click(),
165-
waitForReportingUpdate(page),
166-
]);
163+
await page.getByRole('option').filter({ hasText: project1Name }).click();
167164
await page.keyboard.press('Escape');
168165

169166
// 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 })
281278
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
282279
).toBeVisible();
283280

284-
// Deselect client
281+
// Deselect client (no network request expected — TanStack Query serves cached unfiltered data)
285282
await page.getByRole('button', { name: 'Clients' }).first().click();
286-
await Promise.all([
287-
page.getByRole('option').filter({ hasText: client1Name }).click(),
288-
waitForReportingUpdate(page),
289-
]);
283+
await page.getByRole('option').filter({ hasText: client1Name }).click();
290284
await page.keyboard.press('Escape');
291285

292286
await expect(
@@ -445,12 +439,9 @@ test('test that deselecting a member removes the filter', async ({ page, ctx })
445439
page.getByRole('button', { name: 'Members' }).first().getByText('1')
446440
).toBeVisible();
447441

448-
// Deselect member
442+
// Deselect member (no network request expected — TanStack Query serves cached unfiltered data)
449443
await page.getByRole('button', { name: 'Members' }).first().click();
450-
await Promise.all([
451-
page.getByRole('option').filter({ hasText: 'John Doe' }).click(),
452-
waitForReportingUpdate(page),
453-
]);
444+
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
454445
await page.keyboard.press('Escape');
455446

456447
// Verify badge count is gone
@@ -544,12 +535,9 @@ test('test that deselecting a tag removes the filter', async ({ page, ctx }) =>
544535

545536
await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();
546537

547-
// Deselect tag
538+
// Deselect tag (no network request expected — TanStack Query serves cached unfiltered data)
548539
await page.getByRole('button', { name: 'Tags' }).click();
549-
await Promise.all([
550-
page.getByRole('option').filter({ hasText: tag1Name }).click(),
551-
waitForReportingUpdate(page),
552-
]);
540+
await page.getByRole('option').filter({ hasText: tag1Name }).click();
553541
await page.keyboard.press('Escape');
554542

555543
await expect(page.getByRole('button', { name: 'Tags' }).getByText(/^\d+$/)).not.toBeVisible();

e2e/utils/api.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -417,10 +417,9 @@ export async function updateOrganizationCurrencyViaWeb(
417417
currency: string,
418418
name: string = 'Test Organization'
419419
) {
420-
const response = await ctx.request.put(
421-
`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`,
422-
{ data: { name, currency } }
423-
);
420+
const response = await ctx.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {
421+
data: { name, currency },
422+
});
424423
expect(response.status()).toBe(200);
425424
}
426425

e2e/utils/mailpit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,4 @@ export async function getPasswordResetUrl(
7878
expect(resetUrlMatch).toBeTruthy();
7979

8080
return resetUrlMatch![1].replace(/&/g, '&');
81-
}
81+
}

resources/js/Components/Common/Client/ClientCreateModal.vue

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { CreateClientBody } from '@/packages/api/src';
77
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
88
import { useFocus } from '@vueuse/core';
99
import { useClientsStore } from '@/utils/useClients';
10-
import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue';
10+
import { Field, FieldLabel } from '@/packages/ui/src/field';
1111
1212
const { createClient } = useClientsStore();
1313
const show = defineModel('show', { default: false });
@@ -37,19 +37,19 @@ useFocus(clientNameInput, { initialValue: true });
3737

3838
<template #content>
3939
<div class="flex items-center space-x-4">
40-
<div class="col-span-6 sm:col-span-4 flex-1">
41-
<InputLabel for="clientName" value="Client Name" />
40+
<Field class="col-span-6 sm:col-span-4 flex-1">
41+
<FieldLabel for="clientName">Client Name</FieldLabel>
4242
<TextInput
4343
id="clientName"
4444
ref="clientNameInput"
4545
v-model="client.name"
4646
type="text"
4747
placeholder="Client Name"
48-
class="mt-1 block w-full"
48+
class="block w-full"
4949
required
5050
autocomplete="clientName"
5151
@keydown.enter="submit" />
52-
</div>
52+
</Field>
5353
</div>
5454
</template>
5555
<template #footer>

resources/js/Components/Common/Member/MemberCombobox.vue

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
ComboboxViewport,
1515
} from 'radix-vue';
1616
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
17-
import { Button } from '@/Components/ui/button';
17+
import { Button } from '@/packages/ui/src/Buttons';
1818
1919
const { members } = useMembersQuery();
2020
@@ -77,7 +77,6 @@ function selectMember(member: Member) {
7777
:disabled="disabled"
7878
type="button"
7979
variant="input"
80-
size="input"
8180
class="w-full justify-between text-start font-normal">
8281
<div class="flex items-center gap-3 truncate">
8382
<UserIcon class="w-4 text-text-secondary shrink-0" />
@@ -92,7 +91,7 @@ function selectMember(member: Member) {
9291
<template #content>
9392
<ComboboxRoot
9493
v-model:search-term="searchValue"
95-
:open="open"
94+
v-model:open="open"
9695
class="relative"
9796
:filter-function="(val: string[]) => val">
9897
<ComboboxAnchor>

resources/js/Components/Common/Member/MemberDeleteModal.vue

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
99
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
1010
import { useNotificationsStore } from '@/utils/notification';
1111
import { getCurrentOrganizationId } from '@/utils/useUser';
12-
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
13-
import InputError from '@/packages/ui/src/Input/InputError.vue';
12+
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
1413
1514
const props = defineProps<{
1615
show: boolean;
@@ -113,25 +112,21 @@ const close = () => {
113112
},
114113
}">
115114
<template #default="{ field }">
116-
<div class="flex flex-col">
117-
<div class="flex items-center space-x-3 text-sm">
118-
<Checkbox
119-
:id="field.name"
120-
:name="field.name"
121-
:checked="field.state.value"
122-
@update:checked="field.handleChange"
123-
@blur="field.handleBlur" />
124-
<InputLabel
125-
:for="field.name"
126-
class="font-medium text-text-primary">
127-
I understand that this will permanently delete all data
128-
related to this member
129-
</InputLabel>
130-
</div>
131-
<InputError
132-
class="pl-7 pt-2"
133-
:message="field.state.meta.errors[0]" />
134-
</div>
115+
<Field orientation="horizontal">
116+
<Checkbox
117+
:id="field.name"
118+
:name="field.name"
119+
:checked="field.state.value"
120+
@update:checked="field.handleChange"
121+
@blur="field.handleBlur" />
122+
<FieldLabel :for="field.name" class="font-medium text-text-primary">
123+
I understand that this will permanently delete all data related
124+
to this member
125+
</FieldLabel>
126+
<FieldError v-if="field.state.meta.errors[0]" class="pl-7 pt-2">
127+
{{ field.state.meta.errors[0] }}
128+
</FieldError>
129+
</Field>
135130
</template>
136131
</form.Field>
137132
</div>

resources/js/Components/Common/Member/MemberEditModal.vue

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Member, UpdateMemberBody } from '@/packages/api/src';
66
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
77
import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
88
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
9-
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
9+
import { Field, FieldLabel } from '@/packages/ui/src/field';
1010
import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';
1111
import MemberBillableSelect from '@/Components/Common/Member/MemberBillableSelect.vue';
1212
import { onMounted, watch } from 'vue';
@@ -121,36 +121,32 @@ const roleDescription = computed(() => {
121121
<template #content>
122122
<div class="pb-5 pt-2 divide-y divide-border-secondary">
123123
<div class="pb-5 flex space-x-6">
124-
<div>
125-
<InputLabel for="role" value="Role" />
126-
<MemberRoleSelect
127-
v-model="memberBody.role"
128-
class="mt-2"
129-
name="role"></MemberRoleSelect>
130-
</div>
124+
<Field>
125+
<FieldLabel for="role">Role</FieldLabel>
126+
<MemberRoleSelect v-model="memberBody.role" name="role"></MemberRoleSelect>
127+
</Field>
131128
<div class="flex-1 text-xs flex items-center pt-6">
132129
<p>{{ roleDescription }}</p>
133130
</div>
134131
</div>
135132
<div class="flex items-center space-x-4 pt-5">
136133
<div class="col-span-6 sm:col-span-4 flex-1 flex space-x-5">
137-
<div>
138-
<InputLabel for="billableType" value="Billable" />
134+
<Field>
135+
<FieldLabel for="billableType">Billable</FieldLabel>
139136
<MemberBillableSelect
140137
v-model="billableRateSelect"
141-
class="mt-2"
142138
name="billableType"></MemberBillableSelect>
143-
</div>
144-
<div v-if="billableRateSelect === 'custom-rate'" class="flex-1">
145-
<InputLabel for="memberBillableRate" value="Billable Rate" />
139+
</Field>
140+
<Field v-if="billableRateSelect === 'custom-rate'" class="flex-1">
141+
<FieldLabel for="memberBillableRate">Billable Rate</FieldLabel>
146142
<BillableRateInput
147143
v-model="memberBody.billable_rate"
148144
focus
149145
class="w-full"
150146
:currency="getOrganizationCurrencyString()"
151147
name="memberBillableRate"
152148
@keydown.enter="saveWithChecks()"></BillableRateInput>
153-
</div>
149+
</Field>
154150
</div>
155151
</div>
156152
</div>

resources/js/Components/Common/Member/MemberInviteModal.vue

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
55
import { ref } from 'vue';
66
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
77
import { useFocus } from '@vueuse/core';
8-
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
9-
import InputError from '@/packages/ui/src/Input/InputError.vue';
8+
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
109
import type { Role } from '@/types/jetstream';
1110
import { Link, useForm } from '@inertiajs/vue3';
1211
import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -111,25 +110,25 @@ useFocus(clientNameInput, { initialValue: true });
111110
</div>
112111
</div>
113112
<div v-else class="space-y-4">
114-
<div class="col-span-6 sm:col-span-4 flex-1">
115-
<InputLabel for="email" value="Email" />
113+
<Field class="col-span-6 sm:col-span-4 flex-1">
114+
<FieldLabel for="email">Email</FieldLabel>
116115
<TextInput
117116
id="email"
118117
ref="memberEmailInput"
119118
v-model="addTeamMemberForm.email"
120119
name="email"
121120
type="text"
122121
placeholder="Member Email"
123-
class="mt-1 block w-full"
122+
class="block w-full"
124123
required
125124
autocomplete="memberName"
126125
@keydown.enter="submit" />
127-
<InputError :message="errors.email" class="mt-2" />
128-
</div>
126+
<FieldError v-if="errors.email">{{ errors.email }}</FieldError>
127+
</Field>
129128

130-
<div v-if="availableRoles.length > 0">
131-
<InputLabel for="roles" value="Role" />
132-
<InputError :message="errors.role" class="mt-2" />
129+
<Field v-if="availableRoles.length > 0">
130+
<FieldLabel for="roles">Role</FieldLabel>
131+
<FieldError v-if="errors.role">{{ errors.role }}</FieldError>
133132

134133
<div
135134
class="relative z-0 mt-1 border border-card-border rounded-lg bg-card-background cursor-pointer">
@@ -182,7 +181,7 @@ useFocus(clientNameInput, { initialValue: true });
182181
</div>
183182
</button>
184183
</div>
185-
</div>
184+
</Field>
186185
</div>
187186
</template>
188187
<template #footer>

0 commit comments

Comments
 (0)