mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
migrate select/multiselect components to Radix Vue primitives
This commit is contained in:
@@ -12,6 +12,10 @@ function getKeyFromItem(item: Client) {
|
||||
function getNameForItem(item: Client) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +23,9 @@ function getNameForItem(item: Client) {
|
||||
search-placeholder="Search for a Client..."
|
||||
:items="clients"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
:get-name-for-item="getNameForItem"
|
||||
no-item-label="No Client"
|
||||
@submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import type { BillableKey } from '@/types/projects';
|
||||
|
||||
const model = defineModel<BillableKey>({
|
||||
@@ -21,38 +25,26 @@ const options: Option[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function getKeyFromItem(item: Option) {
|
||||
return item.key;
|
||||
}
|
||||
|
||||
function getNameFromItem(item: Option) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
function getNameForKey(key: BillableKey | undefined) {
|
||||
const item = options.find((item) => getKeyFromItem(item) === key);
|
||||
const item = options.find((item) => item.key === key);
|
||||
if (item) {
|
||||
return getNameFromItem(item);
|
||||
return item.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectDropdown
|
||||
v-model="model"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameFromItem"
|
||||
:items="options">
|
||||
<template #trigger>
|
||||
<Badge size="xlarge" class="bg-input-background cursor-pointer">
|
||||
<span>
|
||||
{{ getNameForKey(model) }}
|
||||
</span>
|
||||
<ChevronDownIcon class="text-text-secondary w-5"></ChevronDownIcon>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
<Select v-model="model">
|
||||
<SelectTrigger>
|
||||
<SelectValue>{{ getNameForKey(model) }}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in options" :key="option.key" :value="option.key">
|
||||
{{ option.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { UserIcon } from '@heroicons/vue/24/solid';
|
||||
import { ChevronDown } from 'lucide-vue-next';
|
||||
import type { ProjectMember } from '@/packages/api/src';
|
||||
import { Badge, SelectDropdown } from '@/packages/ui/src';
|
||||
import type { Member } from '@/packages/api/src';
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
ComboboxViewport,
|
||||
} from 'radix-vue';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
|
||||
const { members } = useMembersQuery();
|
||||
|
||||
@@ -24,16 +33,24 @@ const props = withDefaults(
|
||||
}
|
||||
);
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const open = ref(false);
|
||||
const searchValue = ref('');
|
||||
const searchInput = ref<HTMLElement | null>(null);
|
||||
|
||||
useFocus(searchInput, { initialValue: true });
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
searchValue.value = '';
|
||||
nextTick(() => {
|
||||
// @ts-expect-error We need to access the actual HTML Element to focus
|
||||
searchInput.value?.$el?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const filteredMembers = computed<Member[]>(() => {
|
||||
return members.value.filter((member) => {
|
||||
return (
|
||||
member.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '') &&
|
||||
member.name.toLowerCase().includes(searchValue.value.toLowerCase().trim() || '') &&
|
||||
!props.hiddenMembers.some((hiddenMember) => hiddenMember.member_id === member.id) &&
|
||||
member.is_placeholder === false
|
||||
);
|
||||
@@ -44,29 +61,65 @@ const currentValue = computed(() => {
|
||||
if (model.value) {
|
||||
return members.value.find((member) => member.id === model.value)?.name;
|
||||
}
|
||||
return searchValue.value;
|
||||
return '';
|
||||
});
|
||||
|
||||
function selectMember(member: Member) {
|
||||
model.value = member.id;
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectDropdown
|
||||
v-model="model"
|
||||
:items="filteredMembers"
|
||||
:get-key-from-item="(member) => member.id"
|
||||
:get-name-for-item="(member) => member.name">
|
||||
<Dropdown v-model="open" align="start" :close-on-content-click="false">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
tag="button"
|
||||
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary bg-input-background font-normal cursor py-1.5">
|
||||
<UserIcon class="relative z-10 w-4 text-text-secondary"></UserIcon>
|
||||
<div v-if="currentValue" class="flex-1 truncate">
|
||||
{{ currentValue }}
|
||||
<Button
|
||||
:disabled="disabled"
|
||||
type="button"
|
||||
variant="input"
|
||||
size="input"
|
||||
class="w-full justify-between text-start font-normal">
|
||||
<div class="flex items-center gap-3 truncate">
|
||||
<UserIcon class="w-4 text-text-secondary shrink-0" />
|
||||
<span v-if="currentValue" class="truncate text-text-primary">{{
|
||||
currentValue
|
||||
}}</span>
|
||||
<span v-else class="text-muted-foreground">Select a member...</span>
|
||||
</div>
|
||||
<div v-else class="flex-1">Select a member...</div>
|
||||
<ChevronDownIcon class="w-4 text-text-secondary"></ChevronDownIcon>
|
||||
</Badge>
|
||||
<ChevronDown class="w-4 h-4 text-icon-default shrink-0" />
|
||||
</Button>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
<template #content>
|
||||
<ComboboxRoot
|
||||
v-model:search-term="searchValue"
|
||||
:open="open"
|
||||
class="relative"
|
||||
:filter-function="(val: string[]) => val">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a member..." />
|
||||
</ComboboxAnchor>
|
||||
<ComboboxContent
|
||||
:dismiss-able="false"
|
||||
position="inline"
|
||||
class="w-60 max-h-60 overflow-y-auto">
|
||||
<ComboboxViewport>
|
||||
<ComboboxItem
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.id"
|
||||
:value="member.id"
|
||||
class="flex items-center gap-3 px-3 py-2.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||
@select.prevent="selectMember(member)">
|
||||
<UserIcon class="w-4 text-text-secondary shrink-0" />
|
||||
<span class="truncate">{{ member.name }}</span>
|
||||
</ComboboxItem>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -7,9 +7,10 @@ import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';
|
||||
import { UserIcon, ArrowRightIcon } from '@heroicons/vue/24/solid';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import { useMutation } from '@tanstack/vue-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
const queryClient = useQueryClient();
|
||||
const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
@@ -50,6 +51,7 @@ async function submit() {
|
||||
'Members successfully merged!',
|
||||
'There was an error merging the members.',
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['members'] });
|
||||
show.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -12,6 +12,10 @@ function getKeyFromItem(item: Member) {
|
||||
function getNameForItem(item: Member) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +23,8 @@ function getNameForItem(item: Member) {
|
||||
search-placeholder="Search for a Member..."
|
||||
:items="members"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
:get-name-for-item="getNameForItem"
|
||||
@submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import type { Role } from '@/types/jetstream';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
|
||||
@@ -13,38 +17,26 @@ const page = usePage<{
|
||||
availableRoles: Role[];
|
||||
}>();
|
||||
|
||||
function getKeyFromItem(item: Role) {
|
||||
return item.key;
|
||||
}
|
||||
|
||||
function getNameFromItem(item: Role) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
function getNameForKey(key: string | undefined) {
|
||||
const item = page.props.availableRoles.find((item) => getKeyFromItem(item) === key);
|
||||
const item = page.props.availableRoles.find((item) => item.key === key);
|
||||
if (item) {
|
||||
return getNameFromItem(item);
|
||||
return item.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectDropdown
|
||||
v-model="model"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameFromItem"
|
||||
:items="page.props.availableRoles">
|
||||
<template #trigger>
|
||||
<Badge size="xlarge" class="bg-input-background cursor-pointer">
|
||||
<span>
|
||||
{{ getNameForKey(model) }}
|
||||
</span>
|
||||
<ChevronDownIcon class="text-text-secondary w-5"></ChevronDownIcon>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
<Select v-model="model">
|
||||
<SelectTrigger>
|
||||
<SelectValue>{{ getNameForKey(model) }}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="role in page.props.availableRoles" :key="role.key" :value="role.key">
|
||||
{{ role.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -12,6 +12,10 @@ function getKeyFromItem(item: Project) {
|
||||
function getNameForItem(item: Project) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +23,9 @@ function getNameForItem(item: Project) {
|
||||
search-placeholder="Search for a Project..."
|
||||
:items="projects"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
:get-name-for-item="getNameForItem"
|
||||
no-item-label="No Project"
|
||||
@submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
|
||||
151
resources/js/Components/Common/Reporting/ReportingFilterBar.vue
Normal file
151
resources/js/Components/Common/Reporting/ReportingFilterBar.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
||||
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
||||
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
|
||||
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
|
||||
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
|
||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||
|
||||
const selectedMembers = defineModel<string[]>('selectedMembers', { required: true });
|
||||
const selectedProjects = defineModel<string[]>('selectedProjects', { required: true });
|
||||
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
|
||||
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
|
||||
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
|
||||
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
|
||||
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
|
||||
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
|
||||
const roundingMinutes = defineModel<number>('roundingMinutes', { required: true });
|
||||
const startDate = defineModel<string>('startDate', { required: true });
|
||||
const endDate = defineModel<string>('endDate', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const { tags } = useTagsQuery();
|
||||
|
||||
async function createTag(name: string) {
|
||||
return await useTagsStore().createTag(name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-2.5 w-full border-b border-default-background-separator">
|
||||
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
|
||||
<div class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
|
||||
<div class="text-sm font-medium">Filters</div>
|
||||
<MemberMultiselectDropdown
|
||||
v-model="selectedMembers"
|
||||
@submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:count="selectedMembers.length"
|
||||
:active="selectedMembers.length > 0"
|
||||
title="Members"
|
||||
:icon="UserGroupIcon" />
|
||||
</template>
|
||||
</MemberMultiselectDropdown>
|
||||
<ProjectMultiselectDropdown
|
||||
v-model="selectedProjects"
|
||||
@submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:count="selectedProjects.length"
|
||||
:active="selectedProjects.length > 0"
|
||||
title="Projects"
|
||||
:icon="FolderIcon" />
|
||||
</template>
|
||||
</ProjectMultiselectDropdown>
|
||||
<TaskMultiselectDropdown
|
||||
v-model="selectedTasks"
|
||||
@submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:count="selectedTasks.length"
|
||||
:active="selectedTasks.length > 0"
|
||||
title="Tasks"
|
||||
:icon="CheckCircleIcon" />
|
||||
</template>
|
||||
</TaskMultiselectDropdown>
|
||||
<ClientMultiselectDropdown
|
||||
v-model="selectedClients"
|
||||
@submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:count="selectedClients.length"
|
||||
:active="selectedClients.length > 0"
|
||||
title="Clients"
|
||||
:icon="FolderIcon" />
|
||||
</template>
|
||||
</ClientMultiselectDropdown>
|
||||
<TagDropdown
|
||||
v-model="selectedTags"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
@submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<ReportingFilterBadge
|
||||
:count="selectedTags.length"
|
||||
:active="selectedTags.length > 0"
|
||||
title="Tags"
|
||||
:icon="TagIcon" />
|
||||
</template>
|
||||
</TagDropdown>
|
||||
|
||||
<Select v-model="billable" @update:model-value="emit('submit')">
|
||||
<SelectTrigger
|
||||
size="small"
|
||||
variant="outline"
|
||||
:active="billable !== null"
|
||||
:show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<BillableIcon
|
||||
class="h-4"
|
||||
:class="
|
||||
billable !== null
|
||||
? 'dark:text-accent-300/80 text-accent-400/80'
|
||||
: 'text-text-quaternary'
|
||||
" />
|
||||
<span class="text-text-secondary">{{
|
||||
billable === 'false' ? 'Non Billable' : 'Billable'
|
||||
}}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Both</SelectItem>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ReportingRoundingControls
|
||||
v-model:enabled="roundingEnabled"
|
||||
v-model:type="roundingType"
|
||||
v-model:minutes="roundingMinutes"
|
||||
@change="emit('submit')" />
|
||||
</div>
|
||||
<div>
|
||||
<DateRangePicker
|
||||
v-model:start="startDate"
|
||||
v-model:end="endDate"
|
||||
@submit="emit('submit')" />
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,6 +12,10 @@ function getKeyFromItem(item: Task) {
|
||||
function getNameForItem(item: Task) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +23,9 @@ function getNameForItem(item: Task) {
|
||||
search-placeholder="Search for a Task..."
|
||||
:items="tasks"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
:get-name-for-item="getNameForItem"
|
||||
no-item-label="No Task"
|
||||
@submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
|
||||
@@ -4,19 +4,43 @@ import { ChevronDown } from 'lucide-vue-next';
|
||||
import { SelectIcon, SelectTrigger, type SelectTriggerProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<
|
||||
SelectTriggerProps & { size?: 'small'; class?: HTMLAttributes['class'] }
|
||||
>();
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
SelectTriggerProps & {
|
||||
size?: 'small';
|
||||
class?: HTMLAttributes['class'];
|
||||
showChevron?: boolean;
|
||||
variant?: 'default' | 'outline';
|
||||
active?: boolean;
|
||||
}
|
||||
>(),
|
||||
{
|
||||
showChevron: true,
|
||||
variant: 'default',
|
||||
active: false,
|
||||
}
|
||||
);
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
const { class: _, showChevron: __, variant: ___, active: ____, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
return props.size === 'small' ? 'h-[34px] text-sm' : 'h-[42px]';
|
||||
return props.size === 'small' ? 'h-8 text-xs' : 'h-[42px] w-full text-sm';
|
||||
});
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
if (props.variant === 'outline') {
|
||||
if (props.active) {
|
||||
return 'border border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';
|
||||
}
|
||||
return 'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5';
|
||||
}
|
||||
return 'border border-input-border bg-input-background shadow-sm';
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -25,14 +49,15 @@ const sizeClasses = computed(() => {
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center justify-between whitespace-nowrap rounded-md border border-input-border bg-input-background px-3 py-2.5 shadow-sm data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
|
||||
'flex items-center justify-between gap-3 whitespace-nowrap rounded-md px-3 py-2.5 data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start font-medium transition-colors',
|
||||
sizeClasses,
|
||||
variantClasses,
|
||||
props.class
|
||||
)
|
||||
">
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="w-4 h-4 opacity-50 shrink-0" />
|
||||
<SelectIcon v-if="showChevron" as-child>
|
||||
<ChevronDown class="w-4 h-4 text-icon-default shrink-0" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
ComboboxViewport,
|
||||
} from 'radix-vue';
|
||||
|
||||
const NONE_ID = 'none';
|
||||
|
||||
const model = defineModel<string[]>({
|
||||
default: [],
|
||||
@@ -12,164 +22,98 @@ const props = defineProps<{
|
||||
searchPlaceholder: string;
|
||||
getKeyFromItem: (item: T) => string;
|
||||
getNameForItem: (item: T) => string;
|
||||
noItemLabel?: string;
|
||||
}>();
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const open = ref(false);
|
||||
const dropdownViewport = ref<Component | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
const sortedItems = ref<T[]>([]);
|
||||
|
||||
function isItemSelected(id: string) {
|
||||
return model.value.includes(id);
|
||||
}
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
searchValue.value = '';
|
||||
sortedItems.value = [...props.items].sort((a, b) => {
|
||||
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
|
||||
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
|
||||
return aSelected - bSelected;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function addOrRemoveItemFromSelection(id: string) {
|
||||
const filteredItems = computed(() => {
|
||||
const search = searchValue.value.toLowerCase().trim();
|
||||
if (!search) return sortedItems.value;
|
||||
return sortedItems.value.filter((item) => props.getNameForItem(item).toLowerCase().includes(search));
|
||||
});
|
||||
|
||||
const showNoItem = computed(() => {
|
||||
if (!props.noItemLabel) return false;
|
||||
const search = searchValue.value.toLowerCase().trim();
|
||||
if (!search) return true;
|
||||
return props.noItemLabel.toLowerCase().includes(search);
|
||||
});
|
||||
|
||||
function toggleItem(id: string) {
|
||||
if (model.value.includes(id)) {
|
||||
model.value = model.value.filter((itemId) => itemId !== id);
|
||||
} else {
|
||||
model.value.push(id);
|
||||
model.value = [...model.value, id];
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
|
||||
// sort tags alphabetically
|
||||
[...props.items].sort((a, b) => {
|
||||
const aIsSelected = model.value.includes(props.getKeyFromItem(a));
|
||||
const bIsSelected = model.value.includes(props.getKeyFromItem(b));
|
||||
if (aIsSelected === bIsSelected) {
|
||||
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
|
||||
}
|
||||
return model.value.includes(props.getKeyFromItem(a)) ? -1 : 1;
|
||||
});
|
||||
nextTick(() => {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const filteredItems = computed<T[]>(() => {
|
||||
return props.items.filter((item: T) => {
|
||||
return props
|
||||
.getNameForItem(item)
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
watch(filteredItems, () => {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
|
||||
function updateSearchValue(event: Event) {
|
||||
const newInput = (event.target as HTMLInputElement).value;
|
||||
if (newInput === ' ') {
|
||||
searchValue.value = '';
|
||||
const highlightedTagId = highlightedItemId.value;
|
||||
if (highlightedTagId) {
|
||||
const highlightedItem = props.items.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedTagId
|
||||
);
|
||||
if (highlightedItem) {
|
||||
addOrRemoveItemFromSelection(props.getKeyFromItem(highlightedItem));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
searchValue.value = newInput;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function toggleItem(newValue: string | null) {
|
||||
if (newValue !== null) {
|
||||
if (model.value.includes(newValue)) {
|
||||
model.value = [...model.value].filter((id) => id !== newValue);
|
||||
} else {
|
||||
model.value = [...model.value, newValue];
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[filteredItems.value.length - 1]
|
||||
);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return props.items.find((item) => props.getKeyFromItem(item) === highlightedItemId.value);
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-model="open" align="start" :close-on-content-click="false">
|
||||
<Dropdown v-model="open" align="start" :close-on-content-click="false" @submit="emit('submit')">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
:placeholder="searchPlaceholder"
|
||||
@input="updateSearchValue"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
@keydown.enter="toggleItem(highlightedItemId)" />
|
||||
<div ref="dropdownViewport" class="min-w-60 max-w-80 max-h-60 overflow-y-scroll">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="props.getKeyFromItem(item)"
|
||||
role="option"
|
||||
:value="props.getKeyFromItem(item)"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
props.getKeyFromItem(item) === highlightedItemId,
|
||||
}"
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<MultiselectDropdownItem
|
||||
:selected="isItemSelected(props.getKeyFromItem(item))"
|
||||
:name="props.getNameForItem(item)"
|
||||
@click="toggleItem(props.getKeyFromItem(item))"></MultiselectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
<ComboboxRoot
|
||||
v-model:search-term="searchValue"
|
||||
:open="open"
|
||||
class="p-2"
|
||||
:filter-function="(val: string[]) => val">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
|
||||
:placeholder="searchPlaceholder" />
|
||||
</ComboboxAnchor>
|
||||
<ComboboxContent
|
||||
:dismiss-able="false"
|
||||
position="inline"
|
||||
class="mt-2 min-w-60 max-w-80 max-h-60 overflow-y-auto">
|
||||
<ComboboxViewport>
|
||||
<ComboboxItem
|
||||
v-if="showNoItem"
|
||||
:value="NONE_ID"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||
@select.prevent="toggleItem(NONE_ID)">
|
||||
<Checkbox
|
||||
:checked="model.includes(NONE_ID)"
|
||||
aria-hidden="true"
|
||||
:tabindex="-1"
|
||||
class="pointer-events-none" />
|
||||
<span class="truncate">{{ noItemLabel }}</span>
|
||||
</ComboboxItem>
|
||||
<ComboboxItem
|
||||
v-for="item in filteredItems"
|
||||
:key="getKeyFromItem(item)"
|
||||
:value="getKeyFromItem(item)"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||
@select.prevent="toggleItem(getKeyFromItem(item))">
|
||||
<Checkbox
|
||||
:checked="model.includes(getKeyFromItem(item))"
|
||||
aria-hidden="true"
|
||||
:tabindex="-1"
|
||||
class="pointer-events-none" />
|
||||
<span class="truncate">{{ getNameForItem(item) }}</span>
|
||||
</ComboboxItem>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}>();
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
if (props.selected) {
|
||||
return 'text-input-select-active';
|
||||
} else {
|
||||
return 'text-text-quaternary opacity-50';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<CheckCircleIcon :class="twMerge(iconClasses, 'w-5')"></CheckCircleIcon>
|
||||
<span class="flex-1 min-w-0 overflow-ellipsis overflow-hidden">{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,188 +0,0 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const open = defineModel('open', {
|
||||
default: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[];
|
||||
getKeyFromItem: (item: T) => string | null;
|
||||
getNameForItem: (item: T) => string;
|
||||
align?: 'center' | 'end' | 'start';
|
||||
class?: string;
|
||||
}>(),
|
||||
{
|
||||
align: 'start',
|
||||
}
|
||||
);
|
||||
|
||||
const dropdownViewport = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
// DropdownMultiselect
|
||||
const filteredItems = computed<T[]>(() => {
|
||||
return props.items.filter((item: T) => {
|
||||
return props
|
||||
.getNameForItem(item)
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
const highlightedItemId = ref<string | null>(model.value);
|
||||
|
||||
watch(model, () => {
|
||||
if (model.value) {
|
||||
highlightedItemId.value = model.value;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!highlightedItemId.value) {
|
||||
resetHightlightedItem();
|
||||
}
|
||||
});
|
||||
|
||||
watch(filteredItems, () => {
|
||||
resetHightlightedItem();
|
||||
});
|
||||
|
||||
function resetHightlightedItem() {
|
||||
if (
|
||||
filteredItems.value.length > 0 &&
|
||||
filteredItems.value.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||
) === undefined
|
||||
) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
watch(highlightedItemId, () => {
|
||||
if (highlightedItemId.value) {
|
||||
const highlightedDomElement = dropdownViewport.value?.querySelector(
|
||||
`[data-select-id="${highlightedItemId.value}"]`
|
||||
) as HTMLElement;
|
||||
|
||||
highlightedDomElement?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function setItem(newValue: string | null) {
|
||||
model.value = newValue;
|
||||
emit('changed');
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[filteredItems.value.length - 1]
|
||||
);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItem = computed(() => {
|
||||
return props.items.find((item) => props.getKeyFromItem(item) === highlightedItemId.value);
|
||||
});
|
||||
|
||||
onKeyStroke('ArrowDown', (e) => {
|
||||
if (open.value === true) {
|
||||
moveHighlightDown();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke('ArrowUp', (e) => {
|
||||
if (open.value === true) {
|
||||
moveHighlightUp();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke('Enter', (e) => {
|
||||
if (open.value === true) {
|
||||
setItem(highlightedItemId.value);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
watch(open, () => {
|
||||
if (open.value === true) {
|
||||
nextTick(() => {
|
||||
const highlightedDomElement = dropdownViewport.value?.querySelector(
|
||||
`[data-select-id="${model.value}"]`
|
||||
) as HTMLElement;
|
||||
dropdownViewport.value?.scrollTo({
|
||||
top: highlightedDomElement?.offsetTop ?? 0,
|
||||
behavior: 'instant',
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-model="open" :align="align" :close-on-content-click="false">
|
||||
<template #trigger>
|
||||
<slot name="trigger"> </slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
ref="dropdownViewport"
|
||||
:class="twMerge('w-60 py-1.5 max-h-60 overflow-y-scroll', props.class)">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="props.getKeyFromItem(item) ?? 'none'"
|
||||
role="option"
|
||||
:data-select-id="props.getKeyFromItem(item)"
|
||||
:value="props.getKeyFromItem(item)"
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<SelectDropdownItem
|
||||
:highlighted="props.getKeyFromItem(item) === highlightedItemId"
|
||||
:selected="props.getKeyFromItem(item) === model"
|
||||
:name="props.getNameForItem(item)"
|
||||
@mouseenter="highlightedItemId = props.getKeyFromItem(item)"
|
||||
@click="setItem(props.getKeyFromItem(item))"></SelectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
selected: boolean;
|
||||
highlighted: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-1">
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'flex items-center space-x-3 w-full px-1.5 py-1.5 rounded text-start text-sm font-medium leading-5 text-text-primary focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
|
||||
props.highlighted && 'bg-card-background-active',
|
||||
props.selected ? 'bg-accent-300/20' : 'hover:bg-card-background-active'
|
||||
)
|
||||
">
|
||||
<span>{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,162 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { SelectDropdown, TextInput } from '@/packages/ui/src';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
// This has to be a localized timestamp, not UTC
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: 'base' | 'large';
|
||||
focus?: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
focus: false,
|
||||
}
|
||||
);
|
||||
|
||||
function updateTime(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value.trim();
|
||||
if (newValue.split(':').length === 2) {
|
||||
const [hours, minutes] = newValue.split(':');
|
||||
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
}
|
||||
}
|
||||
// check if input is only numbers
|
||||
else if (/^\d+$/.test(newValue)) {
|
||||
if (newValue.length === 4) {
|
||||
// parse 1300 to 13:00
|
||||
const [hours, minutes] = [newValue.slice(0, 2), newValue.slice(2, 4)];
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 3) {
|
||||
// parse 130 to 01:30
|
||||
const [hours, minutes] = [newValue.slice(0, 1), newValue.slice(1, 3)];
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 2) {
|
||||
// parse 13 to 13:00
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.set('minutes', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 1) {
|
||||
// parse 1 to 01:00
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.set('minutes', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
}
|
||||
}
|
||||
|
||||
inputValue.value = getLocalizedDayJs(model.value).format('HH:mm');
|
||||
}
|
||||
|
||||
watch(model, (value) => {
|
||||
inputValue.value = value ? getLocalizedDayJs(value).format('HH:mm') : null;
|
||||
});
|
||||
|
||||
const timeInput = ref<HTMLInputElement | null>(null);
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
useFocus(timeInput, { initialValue: props.focus });
|
||||
|
||||
type TimeOption = {
|
||||
timestamp: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const getStartOptions = computed<TimeOption[]>(() => {
|
||||
// options for the entire day in 15 minute intervals
|
||||
const options = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const timestamp = getLocalizedDayJs(model.value)
|
||||
.set('hour', hour)
|
||||
.set('minute', minute)
|
||||
.format();
|
||||
const name = getLocalizedDayJs(model.value)
|
||||
.set('hour', hour)
|
||||
.set('minute', minute)
|
||||
.format('HH:mm');
|
||||
options.push({ timestamp, name });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null);
|
||||
const open = ref(false);
|
||||
const closestValue = computed({
|
||||
get() {
|
||||
const target = getDayJsInstance()(model.value);
|
||||
let closestDiff: number | null = null;
|
||||
let closest = target;
|
||||
for (const option of getStartOptions.value) {
|
||||
const diff = Math.abs(getDayJsInstance()(option.timestamp).diff(target));
|
||||
if (closestDiff === null || diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closest = getDayJsInstance()(option.timestamp);
|
||||
}
|
||||
}
|
||||
return closest.format();
|
||||
},
|
||||
set(value: string) {
|
||||
model.value = value;
|
||||
emit('changed', model.value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center justify-center text-text-primary">
|
||||
<SelectDropdown
|
||||
v-model="closestValue"
|
||||
v-model:open="open"
|
||||
:class="twMerge('mine-w-0 w-24', size === 'large' && 'w-28')"
|
||||
:get-key-from-item="(item: TimeOption) => item.timestamp"
|
||||
:get-name-for-item="(item: TimeOption) => item.name"
|
||||
:items="getStartOptions">
|
||||
<template #trigger>
|
||||
<TextInput
|
||||
ref="timeInput"
|
||||
v-model="inputValue"
|
||||
:class="twMerge('text-center w-24 px-3 py-2', size === 'large' && 'w-28')"
|
||||
data-testid="time_picker_input"
|
||||
type="text"
|
||||
@blur="updateTime"
|
||||
@keydown.enter="
|
||||
updateTime($event);
|
||||
open = false;
|
||||
"
|
||||
@keydown.tab="open = false"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
@mouseup="($event.target as HTMLInputElement).select()"
|
||||
@click="($event.target as HTMLInputElement).select()"
|
||||
@pointerup="($event.target as HTMLInputElement).select()"
|
||||
@focusin="open = true" />
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown align="end">
|
||||
<template #trigger>
|
||||
<button
|
||||
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
|
||||
:aria-label="label">
|
||||
<svg
|
||||
class="h-8 w-8 p-1 rounded-full"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import type { BillableKey } from '@/types/projects';
|
||||
|
||||
const model = defineModel<BillableKey>({
|
||||
@@ -25,38 +29,26 @@ const options: Option[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function getKeyFromItem(item: Option) {
|
||||
return item.key;
|
||||
}
|
||||
|
||||
function getNameFromItem(item: Option) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
function getNameForKey(key: BillableKey | undefined) {
|
||||
const item = options.find((item) => getKeyFromItem(item) === key);
|
||||
const item = options.find((item) => item.key === key);
|
||||
if (item) {
|
||||
return getNameFromItem(item);
|
||||
return item.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectDropdown
|
||||
v-model="model"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameFromItem"
|
||||
:items="options">
|
||||
<template #trigger>
|
||||
<Badge tag="button" size="xlarge" class="bg-input-background cursor-pointer">
|
||||
<span>
|
||||
{{ getNameForKey(model) }}
|
||||
</span>
|
||||
<ChevronDownIcon class="text-text-secondary w-5"></ChevronDownIcon>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
<Select v-model="model">
|
||||
<SelectTrigger>
|
||||
<SelectValue>{{ getNameForKey(model) }}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in options" :key="option.key" :value="option.key">
|
||||
{{ option.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
|
||||
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
||||
import type { Tag } from '@/packages/api/src';
|
||||
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
ComboboxViewport,
|
||||
} from 'radix-vue';
|
||||
|
||||
const NONE_ID = 'none';
|
||||
const NO_TAG_LABEL = 'No Tag';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -22,17 +33,34 @@ const model = defineModel<string[]>({
|
||||
default: [],
|
||||
});
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const open = ref(false);
|
||||
const dropdownViewport = ref<Component | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
const sortedTags = ref<Tag[]>([]);
|
||||
|
||||
function isTagSelected(id: string) {
|
||||
return model.value.includes(id);
|
||||
}
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
searchValue.value = '';
|
||||
sortedTags.value = [...props.tags].sort((a, b) => {
|
||||
const aSelected = model.value.includes(a.id) ? 0 : 1;
|
||||
const bSelected = model.value.includes(b.id) ? 0 : 1;
|
||||
return aSelected - bSelected;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function addOrRemoveTagFromSelection(id: string) {
|
||||
const filteredTags = computed(() => {
|
||||
const search = searchValue.value.toLowerCase().trim();
|
||||
if (!search) return sortedTags.value;
|
||||
return sortedTags.value.filter((tag) => tag.name.toLowerCase().includes(search));
|
||||
});
|
||||
|
||||
const showNoTag = computed(() => {
|
||||
const search = searchValue.value.toLowerCase().trim();
|
||||
if (!search) return true;
|
||||
return NO_TAG_LABEL.toLowerCase().includes(search);
|
||||
});
|
||||
|
||||
function toggleTag(id: string) {
|
||||
if (model.value.includes(id)) {
|
||||
model.value = model.value.filter((tagId) => tagId !== id);
|
||||
} else {
|
||||
@@ -41,115 +69,20 @@ function addOrRemoveTagFromSelection(id: string) {
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
const sortedTags = ref(props.tags);
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
|
||||
// sort tags alphabetically
|
||||
sortedTags.value = [...props.tags].sort((a, b) => {
|
||||
const aIsSelected = model.value.includes(a.id);
|
||||
const bIsSelected = model.value.includes(b.id);
|
||||
if (aIsSelected === bIsSelected) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return model.value.includes(a.id) ? -1 : 1;
|
||||
});
|
||||
nextTick(() => {
|
||||
if (filteredTags.value.length > 0) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
return sortedTags.value.filter((tag: Tag) => {
|
||||
return tag.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
async function createAndAddTag(name: string) {
|
||||
const newTag = await props.createTag(name);
|
||||
if (newTag) {
|
||||
addOrRemoveTagFromSelection(newTag.id);
|
||||
toggleTag(newTag.id);
|
||||
}
|
||||
searchValue.value = '';
|
||||
return newTag;
|
||||
}
|
||||
|
||||
async function addTagIfNoneExists() {
|
||||
if (highlightedItemId.value) {
|
||||
addOrRemoveTagFromSelection(highlightedItemId.value);
|
||||
}
|
||||
}
|
||||
|
||||
watch(filteredTags, () => {
|
||||
if (filteredTags.value.length > 0) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
function updateSearchValue(event: Event) {
|
||||
const newInput = (event.target as HTMLInputElement).value;
|
||||
if (newInput === ' ') {
|
||||
searchValue.value = '';
|
||||
const highlightedTagId = highlightedItemId.value;
|
||||
if (highlightedTagId) {
|
||||
const highlightedTag = props.tags.find((tag) => tag.id === highlightedTagId);
|
||||
if (highlightedTag) {
|
||||
addOrRemoveTagFromSelection(highlightedTag.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
searchValue.value = newInput;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [];
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
function toggleTag(newValue: string) {
|
||||
if (model.value.includes(newValue)) {
|
||||
model.value = [...model.value].filter((id) => id !== newValue);
|
||||
} else {
|
||||
model.value = [...model.value, newValue];
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value = filteredTags.value[filteredTags.value.length - 1].id;
|
||||
} else {
|
||||
highlightedItemId.value = filteredTags.value[currentHightlightedIndex - 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === filteredTags.value.length - 1) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
} else {
|
||||
highlightedItemId.value = filteredTags.value[currentHightlightedIndex + 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return props.tags.find((tag) => tag.id === highlightedItemId.value);
|
||||
});
|
||||
|
||||
const showCreateTagModal = ref(false);
|
||||
</script>
|
||||
|
||||
@@ -166,50 +99,65 @@ const showCreateTagModal = ref(false);
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
data-testid="tag_dropdown_search"
|
||||
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a Tag..."
|
||||
@input="updateSearchValue"
|
||||
@keydown.esc.prevent="open = false"
|
||||
@keydown.enter="addTagIfNoneExists"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown" />
|
||||
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
|
||||
<div
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
role="option"
|
||||
:value="tag.id"
|
||||
:class="{
|
||||
'bg-card-background-active': tag.id === highlightedItemId,
|
||||
}"
|
||||
data-testid="tag_dropdown_entries"
|
||||
:data-tag-id="tag.id">
|
||||
<MultiselectDropdownItem
|
||||
:selected="isTagSelected(tag.id)"
|
||||
:name="tag.name"
|
||||
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hover:bg-card-background-active rounded-b-lg">
|
||||
<button
|
||||
class="text-text-primary w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
|
||||
<ComboboxRoot
|
||||
v-model:search-term="searchValue"
|
||||
:open="open"
|
||||
class="p-2"
|
||||
:filter-function="(val: string[]) => val">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
data-testid="tag_dropdown_search"
|
||||
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
|
||||
placeholder="Search for a Tag..." />
|
||||
</ComboboxAnchor>
|
||||
<ComboboxContent
|
||||
:dismiss-able="false"
|
||||
position="inline"
|
||||
class="mt-2 w-60 max-h-60 overflow-y-auto">
|
||||
<ComboboxViewport>
|
||||
<ComboboxItem
|
||||
v-if="showNoTag"
|
||||
:value="NONE_ID"
|
||||
data-testid="tag_dropdown_entries"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||
@select.prevent="toggleTag(NONE_ID)">
|
||||
<Checkbox
|
||||
:checked="model.includes(NONE_ID)"
|
||||
aria-hidden="true"
|
||||
:tabindex="-1"
|
||||
class="pointer-events-none" />
|
||||
<span class="truncate">{{ NO_TAG_LABEL }}</span>
|
||||
</ComboboxItem>
|
||||
<ComboboxItem
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
:value="tag.id"
|
||||
data-testid="tag_dropdown_entries"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||
@select.prevent="toggleTag(tag.id)">
|
||||
<Checkbox
|
||||
:checked="model.includes(tag.id)"
|
||||
aria-hidden="true"
|
||||
:tabindex="-1"
|
||||
class="pointer-events-none" />
|
||||
<span class="truncate">{{ tag.name }}</span>
|
||||
</ComboboxItem>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
<div class="mt-1 border-t border-card-background-separator pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2 px-2 py-1.5 text-sm text-text-primary"
|
||||
@click="
|
||||
open = false;
|
||||
showCreateTagModal = true;
|
||||
">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
||||
<PlusCircleIcon class="w-4 h-4 flex-shrink-0 text-icon-default" />
|
||||
<span>Create new Tag</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</UseFocusTrap>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -18,7 +18,13 @@ import type {
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
@@ -129,11 +135,6 @@ const billableProxy = computed({
|
||||
timeEntry.value.billable = value === 'true';
|
||||
},
|
||||
});
|
||||
|
||||
type BillableOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -196,29 +197,20 @@ type BillableOption = {
|
||||
</TagDropdown>
|
||||
</div>
|
||||
<div class="flex-col">
|
||||
<SelectDropdown
|
||||
v-model="billableProxy"
|
||||
:get-key-from-item="(item: BillableOption) => item.value"
|
||||
:get-name-for-item="(item: BillableOption) => item.label"
|
||||
:items="[
|
||||
{
|
||||
label: 'Billable',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
label: 'Non Billable',
|
||||
value: 'false',
|
||||
},
|
||||
]">
|
||||
<template #trigger>
|
||||
<Badge class="bg-input-background" tag="button" size="xlarge">
|
||||
<BillableIcon class="h-4"></BillableIcon>
|
||||
<Select v-model="billableProxy">
|
||||
<SelectTrigger size="small" :show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<BillableIcon class="h-4 text-icon-default" />
|
||||
<span>{{
|
||||
timeEntry.billable ? 'Billable' : 'Non-Billable'
|
||||
}}</span>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,13 @@ import type {
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
@@ -124,11 +130,6 @@ const billableProxy = computed({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
type BillableOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -198,34 +199,22 @@ type BillableOption = {
|
||||
</TagDropdown>
|
||||
</div>
|
||||
<div class="flex-col">
|
||||
<SelectDropdown
|
||||
v-model="billableProxy"
|
||||
:get-key-from-item="(item: BillableOption) => item.value"
|
||||
:get-name-for-item="(item: BillableOption) => item.label"
|
||||
:items="[
|
||||
{
|
||||
label: 'Billable',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
label: 'Non Billable',
|
||||
value: 'false',
|
||||
},
|
||||
]">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
class="bg-input-background"
|
||||
tag="button"
|
||||
size="xlarge">
|
||||
<BillableIcon class="h-4"></BillableIcon>
|
||||
<Select v-model="billableProxy">
|
||||
<SelectTrigger size="small" :show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<BillableIcon class="h-4 text-icon-default" />
|
||||
<span>{{
|
||||
editableTimeEntry.billable
|
||||
? 'Billable'
|
||||
: 'Non-Billable'
|
||||
}}</span>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,13 @@ import {
|
||||
type UpdateMultipleTimeEntriesChangeset,
|
||||
} from '@/packages/api/src';
|
||||
import { Badge, Checkbox } from '@/packages/ui/src';
|
||||
import SelectDropdown from '../Input/SelectDropdown.vue';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
|
||||
@@ -126,7 +132,6 @@ watch(removeAllTags, () => {
|
||||
selectedTags.value = [];
|
||||
}
|
||||
});
|
||||
type SelectOption = { label: string; value: string };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -189,32 +194,22 @@ type SelectOption = { label: string; value: string };
|
||||
<div class="space-y-2">
|
||||
<InputLabel for="project" value="Billable" />
|
||||
<div class="flex">
|
||||
<SelectDropdown
|
||||
v-model="timeEntryBillable"
|
||||
:get-key-from-item="(item: SelectOption) => item.value"
|
||||
:get-name-for-item="(item: SelectOption) => item.label"
|
||||
:items="[
|
||||
{
|
||||
label: 'Keep current billable status',
|
||||
value: 'do-not-update',
|
||||
},
|
||||
{
|
||||
label: 'Billable',
|
||||
value: 'billable',
|
||||
},
|
||||
{
|
||||
label: 'Non Billable',
|
||||
value: 'non-billable',
|
||||
},
|
||||
]">
|
||||
<template #trigger>
|
||||
<Badge tag="button" size="xlarge">
|
||||
<span v-if="billable === undefined"> Set billable status </span>
|
||||
<span v-else-if="billable === true"> Billable </span>
|
||||
<span v-else> Non Billable </span></Badge
|
||||
>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
<Select v-model="timeEntryBillable">
|
||||
<SelectTrigger size="small" :show-chevron="false">
|
||||
<SelectValue>
|
||||
<span v-if="billable === undefined">Set billable status</span>
|
||||
<span v-else-if="billable === true">Billable</span>
|
||||
<span v-else>Non Billable</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="do-not-update">
|
||||
Keep current billable status
|
||||
</SelectItem>
|
||||
<SelectItem value="billable">Billable</SelectItem>
|
||||
<SelectItem value="non-billable">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,14 +26,13 @@ import TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/Time
|
||||
import TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue';
|
||||
import TimeTrackerMoreOptionsDropdown from './TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
|
||||
import CardTitle from './CardTitle.vue';
|
||||
import SelectDropdown from './Input/SelectDropdown.vue';
|
||||
import Badge from './Badge.vue';
|
||||
import Checkbox from './Input/Checkbox.vue';
|
||||
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
|
||||
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
|
||||
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
||||
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
|
||||
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
|
||||
|
||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||
@@ -69,12 +68,10 @@ export {
|
||||
TimeTrackerControls,
|
||||
TimeTrackerMoreOptionsDropdown,
|
||||
CardTitle,
|
||||
SelectDropdown,
|
||||
Badge,
|
||||
Checkbox,
|
||||
TimeEntryGroupedTable,
|
||||
TimeEntryMassActionRow,
|
||||
MoreOptionsDropdown,
|
||||
TimeEntryCreateModal,
|
||||
TimeEntryEditModal,
|
||||
FullCalendarEventContent,
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
--color-border-secondary: oklch(0.25 0.0098 268.31);
|
||||
--color-border-tertiary: #2c2e33;
|
||||
--color-border-quaternary: #393b42;
|
||||
--color-input-border-active: rgba(255, 255, 255, 0.3);
|
||||
--color-input-border-active: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--theme-color-chart: var(--color-accent-200);
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
|
||||
--theme-color-button-primary-text: var(--color-text-primary);
|
||||
|
||||
--theme-color-input-background: transparent;
|
||||
--theme-color-input-background: var(--color-bg-secondary);
|
||||
|
||||
--theme-color-input-select-active: rgb(var(--color-accent-300));
|
||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
|
||||
|
||||
29
resources/js/utils/useAggregatedTimeEntriesQuery.ts
Normal file
29
resources/js/utils/useAggregatedTimeEntriesQuery.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from '@tanstack/vue-query';
|
||||
import { api, type AggregatedTimeEntriesQueryParams, type ReportingResponse } from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { computed, type ComputedRef, unref } from 'vue';
|
||||
|
||||
export function useAggregatedTimeEntriesQuery(
|
||||
queryKeyPrefix: string,
|
||||
filterParams: ComputedRef<AggregatedTimeEntriesQueryParams>
|
||||
) {
|
||||
const query = useQuery<ReportingResponse>({
|
||||
queryKey: computed(() => [
|
||||
'aggregatedTimeEntries',
|
||||
queryKeyPrefix,
|
||||
getCurrentOrganizationId(),
|
||||
unref(filterParams),
|
||||
]),
|
||||
queryFn: () =>
|
||||
api.getAggregatedTimeEntries({
|
||||
params: {
|
||||
organization: getCurrentOrganizationId() || '',
|
||||
},
|
||||
queries: unref(filterParams),
|
||||
}),
|
||||
enabled: computed(() => !!getCurrentOrganizationId()),
|
||||
staleTime: 1000 * 30,
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
Reference in New Issue
Block a user