feat: simplify ticket links to action and ticket (KAN-12)
This commit is contained in:
@@ -12,8 +12,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
||||
import type {
|
||||
TicketDetailRelationshipsProps,
|
||||
TicketLinkFormData,
|
||||
} from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||
defineEmits<{ editParent: [] }>();
|
||||
@@ -33,7 +37,7 @@ const canAddLink = computed(
|
||||
props.ticketLinkTypes.length > 0 &&
|
||||
props.ticketLinkOptions.length > 0,
|
||||
);
|
||||
const form = useForm({
|
||||
const form = useForm<TicketLinkFormData>({
|
||||
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||
link_direction: 'source',
|
||||
linked_ticket_id: '',
|
||||
@@ -41,10 +45,9 @@ const form = useForm({
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.canEditTicket && form.isDirty,
|
||||
});
|
||||
const selectedType = computed(() =>
|
||||
props.ticketLinkTypes.find(
|
||||
(type) => String(type.id) === String(form.ticket_link_type_id),
|
||||
),
|
||||
const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
|
||||
() => props.ticketLinkTypes,
|
||||
form,
|
||||
);
|
||||
const selectedTicket = computed(() =>
|
||||
props.ticketLinkOptions.find(
|
||||
@@ -221,40 +224,18 @@ function submit(): void {
|
||||
/>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4 sm:grid-cols-3"
|
||||
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
|
||||
>
|
||||
<TicketFormField
|
||||
id="ticket_link_type_id"
|
||||
v-model="form.ticket_link_type_id"
|
||||
v-model="selectedAction"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.linkType')"
|
||||
:options="
|
||||
ticketLinkTypes.map((type) => ({
|
||||
value: type.id,
|
||||
label: `${type.label} / ${type.counterpart_label}`,
|
||||
}))
|
||||
:label="t('ticketDetail.linkAction')"
|
||||
:options="actions"
|
||||
:error="
|
||||
form.errors.ticket_link_type_id ||
|
||||
form.errors.link_direction
|
||||
"
|
||||
:error="form.errors.ticket_link_type_id"
|
||||
:disabled="form.processing"
|
||||
required
|
||||
/>
|
||||
<TicketFormField
|
||||
id="link_direction"
|
||||
v-model="form.link_direction"
|
||||
kind="select"
|
||||
:label="t('ticketRelationships.direction')"
|
||||
:options="[
|
||||
{
|
||||
value: 'source',
|
||||
label: selectedType?.label ?? '',
|
||||
},
|
||||
{
|
||||
value: 'counterpart',
|
||||
label:
|
||||
selectedType?.counterpart_label ?? '',
|
||||
},
|
||||
]"
|
||||
:error="form.errors.link_direction"
|
||||
:disabled="form.processing"
|
||||
required
|
||||
/>
|
||||
@@ -281,26 +262,19 @@ function submit(): void {
|
||||
/>
|
||||
</fieldset>
|
||||
<p
|
||||
v-if="selectedType && selectedTicket"
|
||||
v-if="selectedActionLabel && selectedTicket"
|
||||
aria-live="polite"
|
||||
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||
>
|
||||
<span class="font-mono text-xs">{{
|
||||
ticket.ticket_key
|
||||
}}</span
|
||||
><span>{{
|
||||
form.link_direction === 'source'
|
||||
? selectedType.label
|
||||
: selectedType.counterpart_label
|
||||
}}</span
|
||||
><span>{{ selectedActionLabel }}</span
|
||||
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||
class="font-mono text-xs"
|
||||
>{{ selectedTicket.ticket_key }}</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ t('ticketRelationships.directionHelp') }}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing || !selectedTicket"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, useForm } from '@inertiajs/vue3';
|
||||
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Unlink } from 'lucide-vue-next';
|
||||
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
@@ -20,7 +18,6 @@ const props = defineProps<{
|
||||
}>();
|
||||
const emit = defineEmits<{ removed: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
||||
const form = useForm({});
|
||||
const protection = useFormProtection(form);
|
||||
function remove(): void {
|
||||
@@ -47,27 +44,6 @@ function remove(): void {
|
||||
:data-ticket-link="link.id"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-muted-foreground max-w-full whitespace-normal"
|
||||
>
|
||||
<ArrowUpRight
|
||||
v-if="outgoing"
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/><ArrowDownLeft
|
||||
v-else
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{
|
||||
t(
|
||||
outgoing
|
||||
? 'ticketRelationships.outgoing'
|
||||
: 'ticketRelationships.incoming',
|
||||
)
|
||||
}}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground font-mono text-xs">{{
|
||||
ticketKey
|
||||
}}</span>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||
import type {
|
||||
TicketLinkActionOption,
|
||||
TicketLinkFormData,
|
||||
} from '@/types/ticket-detail';
|
||||
|
||||
export function useTicketLinkActions(
|
||||
ticketLinkTypes: MaybeRefOrGetter<TicketLinkTypeModel[]>,
|
||||
form: TicketLinkFormData,
|
||||
) {
|
||||
const actions = computed<TicketLinkActionOption[]>(() =>
|
||||
toValue(ticketLinkTypes).flatMap((type) => {
|
||||
const choices: TicketLinkActionOption[] = [
|
||||
{
|
||||
value: `${type.id}:source`,
|
||||
label: type.label,
|
||||
ticketLinkTypeId: type.id,
|
||||
direction: 'source',
|
||||
},
|
||||
];
|
||||
if (
|
||||
type.counterpart_label &&
|
||||
type.counterpart_label !== type.label
|
||||
) {
|
||||
choices.push({
|
||||
value: `${type.id}:counterpart`,
|
||||
label: type.counterpart_label,
|
||||
ticketLinkTypeId: type.id,
|
||||
direction: 'counterpart',
|
||||
});
|
||||
}
|
||||
return choices;
|
||||
}),
|
||||
);
|
||||
const selectedAction = computed({
|
||||
get: () => `${form.ticket_link_type_id}:${form.link_direction}`,
|
||||
set: (value: string | number | null) => {
|
||||
const action = actions.value.find(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
if (action) {
|
||||
form.ticket_link_type_id = action.ticketLinkTypeId;
|
||||
form.link_direction = action.direction;
|
||||
}
|
||||
},
|
||||
});
|
||||
const selectedActionLabel = computed(
|
||||
() =>
|
||||
actions.value.find(
|
||||
(action) => action.value === selectedAction.value,
|
||||
)?.label,
|
||||
);
|
||||
|
||||
return { actions, selectedAction, selectedActionLabel };
|
||||
}
|
||||
@@ -1213,7 +1213,7 @@ export const en = {
|
||||
'ticketDetail.linkedTickets': 'Linked tickets',
|
||||
'ticketDetail.linkedTicketsDescription':
|
||||
'Track cross-ticket relationships with project-defined link labels.',
|
||||
'ticketDetail.linkType': 'Link type',
|
||||
'ticketDetail.linkAction': 'Action',
|
||||
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||
'ticketDetail.saveLink': 'Save link',
|
||||
'ticketDetail.noLinks':
|
||||
@@ -1227,11 +1227,6 @@ export const en = {
|
||||
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||
'ticketRelationships.addLink': 'Add or update a link',
|
||||
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||
'ticketRelationships.direction': 'This ticket',
|
||||
'ticketRelationships.directionHelp':
|
||||
'Choose how this ticket relates to the linked ticket. The reverse label appears on the other ticket. Saving an existing pair updates its type and direction.',
|
||||
'ticketRelationships.outgoing': 'Outgoing',
|
||||
'ticketRelationships.incoming': 'Incoming',
|
||||
'ticketRelationships.removeLink': 'Remove link to {key}',
|
||||
'ticketRelationships.noTypes':
|
||||
'No link types are configured for this project.',
|
||||
|
||||
@@ -67,3 +67,14 @@ export type TicketBranchFormData = {
|
||||
branch_name: string;
|
||||
repository_connection_id: string;
|
||||
};
|
||||
export type TicketLinkFormData = {
|
||||
ticket_link_type_id: number | string;
|
||||
link_direction: 'source' | 'counterpart';
|
||||
linked_ticket_id: number | string;
|
||||
};
|
||||
export type TicketLinkActionOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
ticketLinkTypeId: number;
|
||||
direction: TicketLinkFormData['link_direction'];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user