Add styled ticket user dropdowns
This commit is contained in:
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, ChevronDown, UserRound } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import type { UserSelectDropdownEmits, UserSelectDropdownProps } from '@/types';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<UserSelectDropdownProps>(), {
|
||||||
|
emptyLabel: 'Unassigned',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<UserSelectDropdownEmits>();
|
||||||
|
|
||||||
|
const normalizedValue = computed(() => String(props.modelValue ?? ''));
|
||||||
|
const selectedUser = computed(
|
||||||
|
() =>
|
||||||
|
props.users.find((user) => String(user.id) === normalizedValue.value) ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectUser = (value: string): void => {
|
||||||
|
if (!props.editable || value === normalizedValue.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = value === '' ? '' : Number(value);
|
||||||
|
|
||||||
|
emit('update:modelValue', nextValue);
|
||||||
|
emit('commit', nextValue);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenu v-if="editable">
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="focus-visible:ring-ring/50 data-[state=open]:bg-primary/[0.08] data-[state=open]:text-primary hover:bg-primary/[0.055] hover:text-primary group -mx-1 flex min-h-7 w-[calc(100%+0.5rem)] items-center gap-2 rounded px-1 text-left text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]"
|
||||||
|
:aria-label="`Select ${label}`"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
v-if="selectedUser"
|
||||||
|
:user="selectedUser"
|
||||||
|
class="size-6 shrink-0"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full border"
|
||||||
|
>
|
||||||
|
<UserRound class="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
|
{{ selectedUser?.name ?? emptyLabel }}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
class="text-muted-foreground size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
:side-offset="6"
|
||||||
|
class="w-(--reka-dropdown-menu-trigger-width) min-w-64 rounded p-1"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
|
||||||
|
:class="
|
||||||
|
normalizedValue === ''
|
||||||
|
? 'bg-primary/[0.055] text-primary'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
@select="selectUser('')"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full border"
|
||||||
|
>
|
||||||
|
<UserRound class="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-medium">
|
||||||
|
{{ emptyLabel }}
|
||||||
|
</span>
|
||||||
|
<Check
|
||||||
|
v-if="normalizedValue === ''"
|
||||||
|
class="text-primary size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
|
||||||
|
:class="
|
||||||
|
normalizedValue === String(user.id)
|
||||||
|
? 'bg-primary/[0.055] text-primary'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
@select="selectUser(String(user.id))"
|
||||||
|
>
|
||||||
|
<UserAvatar :user="user" class="size-7 shrink-0" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate font-medium">
|
||||||
|
{{ user.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="user.email"
|
||||||
|
class="text-muted-foreground block truncate text-[11px]"
|
||||||
|
>
|
||||||
|
{{ user.email }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Check
|
||||||
|
v-if="normalizedValue === String(user.id)"
|
||||||
|
class="text-primary size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex min-h-7 min-w-0 items-center gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
v-if="selectedUser"
|
||||||
|
:user="selectedUser"
|
||||||
|
class="size-6 shrink-0"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full border"
|
||||||
|
>
|
||||||
|
<UserRound class="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{{ selectedUser?.name ?? emptyLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -25,8 +25,8 @@ import AcceptanceCriteriaEditor from '@/components/projects/AcceptanceCriteriaEd
|
|||||||
import InlineSaveStatus from '@/components/projects/InlineSaveStatus.vue';
|
import InlineSaveStatus from '@/components/projects/InlineSaveStatus.vue';
|
||||||
import InlineTextEditor from '@/components/projects/InlineTextEditor.vue';
|
import InlineTextEditor from '@/components/projects/InlineTextEditor.vue';
|
||||||
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
||||||
|
import UserSelectDropdown from '@/components/projects/UserSelectDropdown.vue';
|
||||||
import TicketTreeList from '@/components/TicketTreeList.vue';
|
import TicketTreeList from '@/components/TicketTreeList.vue';
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
@@ -185,16 +185,6 @@ const currentTicketPriority = computed(
|
|||||||
ticketPriority.id === Number(form.ticket_priority_id),
|
ticketPriority.id === Number(form.ticket_priority_id),
|
||||||
) ?? props.ticket.ticket_priority,
|
) ?? props.ticket.ticket_priority,
|
||||||
);
|
);
|
||||||
const currentAssignee = computed(
|
|
||||||
() =>
|
|
||||||
props.users.find((user) => user.id === Number(form.assignee_user_id)) ??
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const currentAuthor = computed(
|
|
||||||
() =>
|
|
||||||
props.users.find((user) => user.id === Number(form.author_user_id)) ??
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const currentParentTicketOption = computed(
|
const currentParentTicketOption = computed(
|
||||||
() =>
|
() =>
|
||||||
props.parentTicketOptions.find(
|
props.parentTicketOptions.find(
|
||||||
@@ -853,57 +843,16 @@ const formatDateTime = (value: string): string => {
|
|||||||
>Assignee</span
|
>Assignee</span
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<select
|
<UserSelectDropdown
|
||||||
v-if="
|
|
||||||
activeSelectField === 'assignee_user_id'
|
|
||||||
"
|
|
||||||
v-model="form.assignee_user_id"
|
v-model="form.assignee_user_id"
|
||||||
autofocus
|
:users="users"
|
||||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-2 py-1.5 text-sm outline-none focus-visible:ring-[3px]"
|
:editable="canEditTicket"
|
||||||
@change="
|
label="Assignee"
|
||||||
|
empty-label="Unassigned"
|
||||||
|
@commit="
|
||||||
commitSelectField('assignee_user_id')
|
commitSelectField('assignee_user_id')
|
||||||
"
|
"
|
||||||
@blur="activeSelectField = null"
|
|
||||||
>
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
<option
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
:value="user.id"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
v-else-if="canEditTicket"
|
|
||||||
type="button"
|
|
||||||
class="hover:text-primary focus-visible:ring-ring flex min-h-7 w-full items-center gap-2 rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
|
|
||||||
@click="
|
|
||||||
activeSelectField = 'assignee_user_id'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
v-if="currentAssignee"
|
|
||||||
:user="currentAssignee"
|
|
||||||
class="size-6"
|
|
||||||
/>
|
/>
|
||||||
<span class="truncate">{{
|
|
||||||
currentAssignee?.name ?? 'Unassigned'
|
|
||||||
}}</span>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex min-h-7 items-center gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
v-if="currentAssignee"
|
|
||||||
:user="currentAssignee"
|
|
||||||
class="size-6"
|
|
||||||
/>
|
|
||||||
<span class="truncate">{{
|
|
||||||
currentAssignee?.name ?? 'Unassigned'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<InlineSaveStatus
|
<InlineSaveStatus
|
||||||
:state="saveStateFor('assignee_user_id')"
|
:state="saveStateFor('assignee_user_id')"
|
||||||
:error="errorFor('assignee_user_id')"
|
:error="errorFor('assignee_user_id')"
|
||||||
@@ -1034,58 +983,16 @@ const formatDateTime = (value: string): string => {
|
|||||||
>Author</span
|
>Author</span
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<select
|
<UserSelectDropdown
|
||||||
v-if="
|
|
||||||
activeSelectField ===
|
|
||||||
'author_user_id' && canEditAuthor
|
|
||||||
"
|
|
||||||
v-model="form.author_user_id"
|
v-model="form.author_user_id"
|
||||||
autofocus
|
:users="users"
|
||||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-2 py-1.5 text-sm outline-none focus-visible:ring-[3px]"
|
:editable="canEditAuthor"
|
||||||
@change="
|
label="Author"
|
||||||
|
empty-label="No author"
|
||||||
|
@commit="
|
||||||
commitSelectField('author_user_id')
|
commitSelectField('author_user_id')
|
||||||
"
|
"
|
||||||
@blur="activeSelectField = null"
|
|
||||||
>
|
|
||||||
<option value="">No author</option>
|
|
||||||
<option
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
:value="user.id"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
v-else-if="canEditAuthor"
|
|
||||||
type="button"
|
|
||||||
class="hover:text-primary focus-visible:ring-ring flex min-h-7 w-full items-center gap-2 rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
|
|
||||||
@click="
|
|
||||||
activeSelectField = 'author_user_id'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
v-if="currentAuthor"
|
|
||||||
:user="currentAuthor"
|
|
||||||
class="size-6"
|
|
||||||
/>
|
/>
|
||||||
<span class="truncate">{{
|
|
||||||
currentAuthor?.name ?? 'No author'
|
|
||||||
}}</span>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex min-h-7 items-center gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
v-if="currentAuthor"
|
|
||||||
:user="currentAuthor"
|
|
||||||
class="size-6"
|
|
||||||
/>
|
|
||||||
<span class="truncate">{{
|
|
||||||
currentAuthor?.name ?? 'No author'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<InlineSaveStatus
|
<InlineSaveStatus
|
||||||
:state="saveStateFor('author_user_id')"
|
:state="saveStateFor('author_user_id')"
|
||||||
:error="errorFor('author_user_id')"
|
:error="errorFor('author_user_id')"
|
||||||
@@ -1167,66 +1074,20 @@ const formatDateTime = (value: string): string => {
|
|||||||
"
|
"
|
||||||
class="min-w-0"
|
class="min-w-0"
|
||||||
>
|
>
|
||||||
<select
|
<UserSelectDropdown
|
||||||
v-if="
|
|
||||||
activeSelectField ===
|
|
||||||
`field-${ticketFieldDefinition.id}`
|
|
||||||
"
|
|
||||||
v-model="
|
v-model="
|
||||||
form.field_values[index].value_user_id
|
form.field_values[index].value_user_id
|
||||||
"
|
"
|
||||||
autofocus
|
:users="users"
|
||||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-2 py-1.5 text-sm outline-none focus-visible:ring-[3px]"
|
:editable="canEditTicket"
|
||||||
@change="
|
:label="ticketFieldDefinition.label"
|
||||||
|
empty-label="Unassigned"
|
||||||
|
@commit="
|
||||||
commitSelectField(
|
commitSelectField(
|
||||||
`field-${ticketFieldDefinition.id}`,
|
`field-${ticketFieldDefinition.id}`,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@blur="activeSelectField = null"
|
/>
|
||||||
>
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
<option
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
:value="user.id"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
v-else-if="canEditTicket"
|
|
||||||
type="button"
|
|
||||||
class="hover:text-primary focus-visible:ring-ring min-h-7 w-full truncate rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
|
|
||||||
@click="
|
|
||||||
activeSelectField = `field-${ticketFieldDefinition.id}`
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
users.find(
|
|
||||||
(user) =>
|
|
||||||
user.id ===
|
|
||||||
Number(
|
|
||||||
form.field_values[index]
|
|
||||||
.value_user_id,
|
|
||||||
),
|
|
||||||
)?.name ?? 'Unassigned'
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
<p
|
|
||||||
v-else
|
|
||||||
class="min-h-7 truncate text-sm font-medium"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
users.find(
|
|
||||||
(user) =>
|
|
||||||
user.id ===
|
|
||||||
Number(
|
|
||||||
form.field_values[index]
|
|
||||||
.value_user_id,
|
|
||||||
),
|
|
||||||
)?.name ?? 'Unassigned'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<InlineSaveStatus
|
<InlineSaveStatus
|
||||||
:state="
|
:state="
|
||||||
saveStateFor(
|
saveStateFor(
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export * from './page-props';
|
|||||||
export * from './project-navigation';
|
export * from './project-navigation';
|
||||||
export * from './ticket-inline-edit';
|
export * from './ticket-inline-edit';
|
||||||
export * from './ui';
|
export * from './ui';
|
||||||
|
export * from './user-select-dropdown';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
|
export type UserSelectDropdownValue = number | string | null;
|
||||||
|
|
||||||
|
export type UserSelectDropdownProps = {
|
||||||
|
editable: boolean;
|
||||||
|
emptyLabel?: string;
|
||||||
|
label: string;
|
||||||
|
modelValue: UserSelectDropdownValue;
|
||||||
|
users: UserModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserSelectDropdownEmits = {
|
||||||
|
commit: [value: number | string];
|
||||||
|
'update:modelValue': [value: number | string];
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user