mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 04:32:15 +01:00
make client deselectable for projects, fixes #333
This commit is contained in:
@@ -52,7 +52,7 @@ test('test that organization billable rate can be updated with all existing time
|
|||||||
await page
|
await page
|
||||||
.locator('form')
|
.locator('form')
|
||||||
.filter({ hasText: 'Organization Billable' })
|
.filter({ hasText: 'Organization Billable' })
|
||||||
.getByRole('button')
|
.getByRole('button', { name: 'Save' })
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -179,7 +179,9 @@ test('test that format settings are reflected in the dashboard', async ({
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// check that 0h 00min is displayed
|
// check that 0h 00min is displayed
|
||||||
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible();
|
await expect(
|
||||||
|
page.getByText('0h 00min', { exact: true }).nth(0)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// First set the format settings
|
// First set the format settings
|
||||||
await goToOrganizationSettings(page);
|
await goToOrganizationSettings(page);
|
||||||
@@ -230,11 +232,17 @@ test('test that format settings are reflected in the dashboard', async ({
|
|||||||
// check that 00:00 is displayed
|
// check that 00:00 is displayed
|
||||||
await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible();
|
await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible();
|
||||||
// check that 0h 00min is not displayed
|
// check that 0h 00min is not displayed
|
||||||
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).not.toBeVisible();
|
await expect(
|
||||||
|
page.getByText('0h 00min', { exact: true }).nth(0)
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
// check that the current date is displayed in the dd/mm/yyyy format on the time page
|
// check that the current date is displayed in the dd/mm/yyyy format on the time page
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
await expect(page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)).toBeVisible();
|
await expect(
|
||||||
|
page
|
||||||
|
.getByText(new Date().toLocaleDateString('en-GB'), { exact: true })
|
||||||
|
.nth(0)
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Test 12-hour clock format
|
// TODO: Test 12-hour clock format
|
||||||
@@ -26,10 +26,12 @@ const createClient = ref(false);
|
|||||||
<ClientTableHeading></ClientTableHeading>
|
<ClientTableHeading></ClientTableHeading>
|
||||||
<div
|
<div
|
||||||
v-if="clients.length === 0"
|
v-if="clients.length === 0"
|
||||||
class="col-span-2 py-24 text-center">
|
class="col-span-3 py-24 text-center">
|
||||||
<UserCircleIcon
|
<UserCircleIcon
|
||||||
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
||||||
<h3 class="text-text-primary font-semibold">No clients found</h3>
|
<h3 class="text-text-primary font-semibold">
|
||||||
|
No clients found
|
||||||
|
</h3>
|
||||||
<p v-if="canCreateClients()" class="pb-5">
|
<p v-if="canCreateClients()" class="pb-5">
|
||||||
Create your first client now!
|
Create your first client now!
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ const activeClass = computed(() => {
|
|||||||
activeClass
|
activeClass
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
<component :is="icon" class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
|
<component
|
||||||
<span> {{ title }} </span>
|
:is="icon"
|
||||||
|
class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
|
||||||
|
<span class="text-nowrap"> {{ title }} </span>
|
||||||
<div
|
<div
|
||||||
v-if="count"
|
v-if="count"
|
||||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
|
||||||
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
|
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
|
||||||
import type { CreateClientBody, Client } from '@/packages/api/src';
|
import type { CreateClientBody, Client } from '@/packages/api/src';
|
||||||
|
import {
|
||||||
|
ComboboxAnchor,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxRoot,
|
||||||
|
ComboboxViewport,
|
||||||
|
} from 'radix-vue';
|
||||||
|
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||||
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
|
|
||||||
const model = defineModel<string | null>({
|
const model = defineModel<string | null>({
|
||||||
default: null,
|
default: null,
|
||||||
@@ -14,10 +23,8 @@ const props = defineProps<{
|
|||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const searchInput = ref<HTMLInputElement | null>(null);
|
const searchInput = ref<HTMLElement | null>(null);
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const dropdownViewport = ref<Component | null>(null);
|
|
||||||
|
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
|
|
||||||
function isClientSelected(id: string) {
|
function isClientSelected(id: string) {
|
||||||
@@ -27,7 +34,8 @@ function isClientSelected(id: string) {
|
|||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
searchInput.value?.focus();
|
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
|
||||||
|
searchInput.value?.$el?.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -48,132 +56,89 @@ async function addClientIfNoneExists() {
|
|||||||
if (newClient) {
|
if (newClient) {
|
||||||
model.value = newClient.id;
|
model.value = newClient.id;
|
||||||
searchValue.value = '';
|
searchValue.value = '';
|
||||||
}
|
open.value = false;
|
||||||
} else {
|
|
||||||
if (highlightedItemId.value) {
|
|
||||||
model.value = highlightedItemId.value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(filteredClients, () => {
|
const currentClient = computed(() => {
|
||||||
if (filteredClients.value.length > 0) {
|
return (
|
||||||
highlightedItemId.value = filteredClients.value[0].id;
|
props.clients.find((client) => client.id === model.value) ?? {
|
||||||
}
|
id: null,
|
||||||
|
name: 'No Client',
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateSearchValue(event: Event) {
|
|
||||||
const newInput = (event.target as HTMLInputElement).value;
|
|
||||||
if (newInput === ' ') {
|
|
||||||
searchValue.value = '';
|
|
||||||
const highlightedClientId = highlightedItemId.value;
|
|
||||||
if (highlightedClientId) {
|
|
||||||
const highlightedClient = props.clients.find(
|
|
||||||
(client) => client.id === highlightedClientId
|
|
||||||
);
|
|
||||||
if (highlightedClient) {
|
|
||||||
model.value = highlightedClient.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
searchValue.value = newInput;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||||
|
|
||||||
function updateClient(newValue: string) {
|
function updateValue(client: { id: string | null; name: string }) {
|
||||||
model.value = newValue;
|
model.value = client.id;
|
||||||
nextTick(() => {
|
emit('changed');
|
||||||
emit('changed');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveHighlightUp() {
|
|
||||||
if (highlightedItem.value) {
|
|
||||||
const currentHightlightedIndex = filteredClients.value.indexOf(
|
|
||||||
highlightedItem.value
|
|
||||||
);
|
|
||||||
if (currentHightlightedIndex === 0) {
|
|
||||||
highlightedItemId.value =
|
|
||||||
filteredClients.value[filteredClients.value.length - 1].id;
|
|
||||||
} else {
|
|
||||||
highlightedItemId.value =
|
|
||||||
filteredClients.value[currentHightlightedIndex - 1].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveHighlightDown() {
|
|
||||||
if (highlightedItem.value) {
|
|
||||||
const currentHightlightedIndex = filteredClients.value.indexOf(
|
|
||||||
highlightedItem.value
|
|
||||||
);
|
|
||||||
if (currentHightlightedIndex === filteredClients.value.length - 1) {
|
|
||||||
highlightedItemId.value = filteredClients.value[0].id;
|
|
||||||
} else {
|
|
||||||
highlightedItemId.value =
|
|
||||||
filteredClients.value[currentHightlightedIndex + 1].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightedItemId = ref<string | null>(null);
|
|
||||||
const highlightedItem = computed(() => {
|
|
||||||
return props.clients.find(
|
|
||||||
(client) => client.id === highlightedItemId.value
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dropdown v-model="open" width="120" :close-on-content-click="true">
|
<Dropdown v-model="open" align="start" width="60">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<input
|
<UseFocusTrap
|
||||||
ref="searchInput"
|
v-if="open"
|
||||||
:value="searchValue"
|
:options="{ immediate: true, allowOutsideClick: true }">
|
||||||
data-testid="client_dropdown_search"
|
<ComboboxRoot
|
||||||
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"
|
v-model:search-term="searchValue"
|
||||||
placeholder="Search for a client..."
|
:open="open"
|
||||||
@input="updateSearchValue"
|
:model-value="currentClient"
|
||||||
@keydown.enter="addClientIfNoneExists"
|
class="relative"
|
||||||
@keydown.up.prevent="moveHighlightUp"
|
@update:model-value="updateValue">
|
||||||
@keydown.down.prevent="moveHighlightDown" />
|
<ComboboxAnchor>
|
||||||
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
|
<ComboboxInput
|
||||||
<div
|
ref="searchInput"
|
||||||
v-if="
|
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"
|
||||||
searchValue.length > 0 && filteredClients.length === 0
|
placeholder="Search for a client..." />
|
||||||
"
|
</ComboboxAnchor>
|
||||||
class="bg-card-background-active"
|
<ComboboxContent>
|
||||||
@click="addClientIfNoneExists">
|
<ComboboxViewport
|
||||||
<div
|
class="w-60 max-h-60 overflow-y-scroll">
|
||||||
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">
|
<ComboboxItem
|
||||||
<PlusCircleIcon
|
:value="{ id: null, name: 'No Client' }"
|
||||||
class="w-5 flex-shrink-0"></PlusCircleIcon>
|
class="data-[highlighted]:bg-card-background-active">
|
||||||
<span>Add "{{ searchValue }}" as a new Client</span>
|
<ClientDropdownItem
|
||||||
</div>
|
:selected="model === null"
|
||||||
</div>
|
name="No Client" />
|
||||||
<div v-else></div>
|
</ComboboxItem>
|
||||||
<div
|
<ComboboxItem
|
||||||
v-for="client in filteredClients"
|
v-for="client in filteredClients"
|
||||||
:key="client.id"
|
:key="client.id"
|
||||||
role="option"
|
:value="client"
|
||||||
:value="client.id"
|
class="data-[highlighted]:bg-card-background-active"
|
||||||
:class="{
|
:data-client-id="client.id">
|
||||||
'bg-card-background-active':
|
<ClientDropdownItem
|
||||||
client.id === highlightedItemId,
|
:selected="isClientSelected(client.id)"
|
||||||
}"
|
:name="client.name" />
|
||||||
data-testid="client_dropdown_entries"
|
</ComboboxItem>
|
||||||
:data-client-id="client.id">
|
<div
|
||||||
<ClientDropdownItem
|
v-if="
|
||||||
:selected="isClientSelected(client.id)"
|
searchValue.length > 0 &&
|
||||||
:name="client.name"
|
filteredClients.length === 0
|
||||||
@click="updateClient(client.id)"></ClientDropdownItem>
|
"
|
||||||
</div>
|
class="bg-card-background-active">
|
||||||
</div>
|
<div
|
||||||
|
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"
|
||||||
|
@click="addClientIfNoneExists">
|
||||||
|
<PlusCircleIcon class="w-5 flex-shrink-0" />
|
||||||
|
<span
|
||||||
|
>Add "{{ searchValue }}" as a new
|
||||||
|
Client</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ComboboxViewport>
|
||||||
|
</ComboboxContent>
|
||||||
|
</ComboboxRoot>
|
||||||
|
</UseFocusTrap>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user