Skip to content

Commit a69fb9c

Browse files
committed
make client deselectable for projects, fixes #333
1 parent 62b5730 commit a69fb9c

File tree

4 files changed

+102
-125
lines changed

4 files changed

+102
-125
lines changed

e2e/organization.spec.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ test('test that organization billable rate can be updated with all existing time
5252
await page
5353
.locator('form')
5454
.filter({ hasText: 'Organization Billable' })
55-
.getByRole('button')
55+
.getByRole('button', { name: 'Save' })
5656
.click();
5757

5858
await Promise.all([
@@ -179,7 +179,9 @@ test('test that format settings are reflected in the dashboard', async ({
179179
page,
180180
}) => {
181181
// check that 0h 00min is displayed
182-
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible();
182+
await expect(
183+
page.getByText('0h 00min', { exact: true }).nth(0)
184+
).toBeVisible();
183185

184186
// First set the format settings
185187
await goToOrganizationSettings(page);
@@ -230,11 +232,17 @@ test('test that format settings are reflected in the dashboard', async ({
230232
// check that 00:00 is displayed
231233
await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible();
232234
// check that 0h 00min is not displayed
233-
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).not.toBeVisible();
235+
await expect(
236+
page.getByText('0h 00min', { exact: true }).nth(0)
237+
).not.toBeVisible();
234238

235239
// check that the current date is displayed in the dd/mm/yyyy format on the time page
236240
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
237-
await expect(page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)).toBeVisible();
241+
await expect(
242+
page
243+
.getByText(new Date().toLocaleDateString('en-GB'), { exact: true })
244+
.nth(0)
245+
).toBeVisible();
238246
});
239247

240-
// TODO: Test 12-hour clock format
248+
// TODO: Test 12-hour clock format

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ const createClient = ref(false);
2626
<ClientTableHeading></ClientTableHeading>
2727
<div
2828
v-if="clients.length === 0"
29-
class="col-span-2 py-24 text-center">
29+
class="col-span-3 py-24 text-center">
3030
<UserCircleIcon
3131
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
32-
<h3 class="text-text-primary font-semibold">No clients found</h3>
32+
<h3 class="text-text-primary font-semibold">
33+
No clients found
34+
</h3>
3335
<p v-if="canCreateClients()" class="pb-5">
3436
Create your first client now!
3537
</p>

resources/js/Components/Common/Reporting/ReportingFilterBadge.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ const activeClass = computed(() => {
2828
activeClass
2929
)
3030
">
31-
<component :is="icon" class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
32-
<span> {{ title }} </span>
31+
<component
32+
:is="icon"
33+
class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
34+
<span class="text-nowrap"> {{ title }} </span>
3335
<div
3436
v-if="count"
3537
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">

resources/js/packages/ui/src/Client/ClientDropdown.vue

Lines changed: 81 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
<script setup lang="ts">
22
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
3-
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
4-
import { type Component, computed, nextTick, ref, watch } from 'vue';
3+
import { computed, nextTick, ref, watch } from 'vue';
54
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
65
import type { CreateClientBody, Client } from '@/packages/api/src';
6+
import {
7+
ComboboxAnchor,
8+
ComboboxContent,
9+
ComboboxInput,
10+
ComboboxItem,
11+
ComboboxRoot,
12+
ComboboxViewport,
13+
} from 'radix-vue';
14+
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
15+
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
716
817
const model = defineModel<string | null>({
918
default: null,
@@ -14,10 +23,8 @@ const props = defineProps<{
1423
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
1524
}>();
1625
17-
const searchInput = ref<HTMLInputElement | null>(null);
26+
const searchInput = ref<HTMLElement | null>(null);
1827
const open = ref(false);
19-
const dropdownViewport = ref<Component | null>(null);
20-
2128
const searchValue = ref('');
2229
2330
function isClientSelected(id: string) {
@@ -27,7 +34,8 @@ function isClientSelected(id: string) {
2734
watch(open, (isOpen) => {
2835
if (isOpen) {
2936
nextTick(() => {
30-
searchInput.value?.focus();
37+
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
38+
searchInput.value?.$el?.focus();
3139
});
3240
}
3341
});
@@ -48,132 +56,89 @@ async function addClientIfNoneExists() {
4856
if (newClient) {
4957
model.value = newClient.id;
5058
searchValue.value = '';
51-
}
52-
} else {
53-
if (highlightedItemId.value) {
54-
model.value = highlightedItemId.value;
59+
open.value = false;
5560
}
5661
}
5762
}
5863
59-
watch(filteredClients, () => {
60-
if (filteredClients.value.length > 0) {
61-
highlightedItemId.value = filteredClients.value[0].id;
62-
}
63-
});
64-
65-
function updateSearchValue(event: Event) {
66-
const newInput = (event.target as HTMLInputElement).value;
67-
if (newInput === ' ') {
68-
searchValue.value = '';
69-
const highlightedClientId = highlightedItemId.value;
70-
if (highlightedClientId) {
71-
const highlightedClient = props.clients.find(
72-
(client) => client.id === highlightedClientId
73-
);
74-
if (highlightedClient) {
75-
model.value = highlightedClient.id;
76-
}
64+
const currentClient = computed(() => {
65+
return (
66+
props.clients.find((client) => client.id === model.value) ?? {
67+
id: null,
68+
name: 'No Client',
7769
}
78-
} else {
79-
searchValue.value = newInput;
80-
}
81-
}
70+
);
71+
});
8272
8373
const emit = defineEmits(['update:modelValue', 'changed']);
8474
85-
function updateClient(newValue: string) {
86-
model.value = newValue;
87-
nextTick(() => {
88-
emit('changed');
89-
});
75+
function updateValue(client: { id: string | null; name: string }) {
76+
model.value = client.id;
77+
emit('changed');
9078
}
91-
92-
function moveHighlightUp() {
93-
if (highlightedItem.value) {
94-
const currentHightlightedIndex = filteredClients.value.indexOf(
95-
highlightedItem.value
96-
);
97-
if (currentHightlightedIndex === 0) {
98-
highlightedItemId.value =
99-
filteredClients.value[filteredClients.value.length - 1].id;
100-
} else {
101-
highlightedItemId.value =
102-
filteredClients.value[currentHightlightedIndex - 1].id;
103-
}
104-
}
105-
}
106-
107-
function moveHighlightDown() {
108-
if (highlightedItem.value) {
109-
const currentHightlightedIndex = filteredClients.value.indexOf(
110-
highlightedItem.value
111-
);
112-
if (currentHightlightedIndex === filteredClients.value.length - 1) {
113-
highlightedItemId.value = filteredClients.value[0].id;
114-
} else {
115-
highlightedItemId.value =
116-
filteredClients.value[currentHightlightedIndex + 1].id;
117-
}
118-
}
119-
}
120-
121-
const highlightedItemId = ref<string | null>(null);
122-
const highlightedItem = computed(() => {
123-
return props.clients.find(
124-
(client) => client.id === highlightedItemId.value
125-
);
126-
});
12779
</script>
12880

12981
<template>
130-
<Dropdown v-model="open" width="120" :close-on-content-click="true">
82+
<Dropdown v-model="open" align="start" width="60">
13183
<template #trigger>
13284
<slot name="trigger"></slot>
13385
</template>
13486
<template #content>
135-
<input
136-
ref="searchInput"
137-
:value="searchValue"
138-
data-testid="client_dropdown_search"
139-
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
140-
placeholder="Search for a client..."
141-
@input="updateSearchValue"
142-
@keydown.enter="addClientIfNoneExists"
143-
@keydown.up.prevent="moveHighlightUp"
144-
@keydown.down.prevent="moveHighlightDown" />
145-
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
146-
<div
147-
v-if="
148-
searchValue.length > 0 && filteredClients.length === 0
149-
"
150-
class="bg-card-background-active"
151-
@click="addClientIfNoneExists">
152-
<div
153-
class="flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator">
154-
<PlusCircleIcon
155-
class="w-5 flex-shrink-0"></PlusCircleIcon>
156-
<span>Add "{{ searchValue }}" as a new Client</span>
157-
</div>
158-
</div>
159-
<div v-else></div>
160-
<div
161-
v-for="client in filteredClients"
162-
:key="client.id"
163-
role="option"
164-
:value="client.id"
165-
:class="{
166-
'bg-card-background-active':
167-
client.id === highlightedItemId,
168-
}"
169-
data-testid="client_dropdown_entries"
170-
:data-client-id="client.id">
171-
<ClientDropdownItem
172-
:selected="isClientSelected(client.id)"
173-
:name="client.name"
174-
@click="updateClient(client.id)"></ClientDropdownItem>
175-
</div>
176-
</div>
87+
<UseFocusTrap
88+
v-if="open"
89+
:options="{ immediate: true, allowOutsideClick: true }">
90+
<ComboboxRoot
91+
v-model:search-term="searchValue"
92+
:open="open"
93+
:model-value="currentClient"
94+
class="relative"
95+
@update:model-value="updateValue">
96+
<ComboboxAnchor>
97+
<ComboboxInput
98+
ref="searchInput"
99+
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
100+
placeholder="Search for a client..." />
101+
</ComboboxAnchor>
102+
<ComboboxContent>
103+
<ComboboxViewport
104+
class="w-60 max-h-60 overflow-y-scroll">
105+
<ComboboxItem
106+
:value="{ id: null, name: 'No Client' }"
107+
class="data-[highlighted]:bg-card-background-active">
108+
<ClientDropdownItem
109+
:selected="model === null"
110+
name="No Client" />
111+
</ComboboxItem>
112+
<ComboboxItem
113+
v-for="client in filteredClients"
114+
:key="client.id"
115+
:value="client"
116+
class="data-[highlighted]:bg-card-background-active"
117+
:data-client-id="client.id">
118+
<ClientDropdownItem
119+
:selected="isClientSelected(client.id)"
120+
:name="client.name" />
121+
</ComboboxItem>
122+
<div
123+
v-if="
124+
searchValue.length > 0 &&
125+
filteredClients.length === 0
126+
"
127+
class="bg-card-background-active">
128+
<div
129+
class="flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator"
130+
@click="addClientIfNoneExists">
131+
<PlusCircleIcon class="w-5 flex-shrink-0" />
132+
<span
133+
>Add "{{ searchValue }}" as a new
134+
Client</span
135+
>
136+
</div>
137+
</div>
138+
</ComboboxViewport>
139+
</ComboboxContent>
140+
</ComboboxRoot>
141+
</UseFocusTrap>
177142
</template>
178143
</Dropdown>
179144
</template>

0 commit comments

Comments
 (0)