Merge pull request 'KAN-12: Simplify links to action and ticket' (#175) from feature/KAN-12-simplify-link-actions into main
CI/CD / Publish immutable image (push) Successful in 14s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 8s

Reviewed-on: #175
This commit was merged in pull request #175.
This commit is contained in:
2026-10-01 20:36:03 +00:00
7 changed files with 161 additions and 97 deletions
@@ -12,8 +12,12 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
import { show as showTicket } from '@/routes/projects/tickets'; 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>(); const props = defineProps<TicketDetailRelationshipsProps>();
defineEmits<{ editParent: [] }>(); defineEmits<{ editParent: [] }>();
@@ -33,7 +37,7 @@ const canAddLink = computed(
props.ticketLinkTypes.length > 0 && props.ticketLinkTypes.length > 0 &&
props.ticketLinkOptions.length > 0, props.ticketLinkOptions.length > 0,
); );
const form = useForm({ const form = useForm<TicketLinkFormData>({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '', ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
link_direction: 'source', link_direction: 'source',
linked_ticket_id: '', linked_ticket_id: '',
@@ -41,10 +45,9 @@ const form = useForm({
const protection = useFormProtection(form, { const protection = useFormProtection(form, {
isDirty: () => props.canEditTicket && form.isDirty, isDirty: () => props.canEditTicket && form.isDirty,
}); });
const selectedType = computed(() => const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
props.ticketLinkTypes.find( () => props.ticketLinkTypes,
(type) => String(type.id) === String(form.ticket_link_type_id), form,
),
); );
const selectedTicket = computed(() => const selectedTicket = computed(() =>
props.ticketLinkOptions.find( props.ticketLinkOptions.find(
@@ -221,40 +224,18 @@ function submit(): void {
/> />
<fieldset <fieldset
:disabled="form.processing" :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 <TicketFormField
id="ticket_link_type_id" id="ticket_link_type_id"
v-model="form.ticket_link_type_id" v-model="selectedAction"
kind="select" kind="select"
:label="t('ticketDetail.linkType')" :label="t('ticketDetail.linkAction')"
:options=" :options="actions"
ticketLinkTypes.map((type) => ({ :error="
value: type.id, form.errors.ticket_link_type_id ||
label: `${type.label} / ${type.counterpart_label}`, 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" :disabled="form.processing"
required required
/> />
@@ -281,26 +262,19 @@ function submit(): void {
/> />
</fieldset> </fieldset>
<p <p
v-if="selectedType && selectedTicket" v-if="selectedActionLabel && selectedTicket"
aria-live="polite" aria-live="polite"
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm" class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
> >
<span class="font-mono text-xs">{{ <span class="font-mono text-xs">{{
ticket.ticket_key ticket.ticket_key
}}</span }}</span
><span>{{ ><span>{{ selectedActionLabel }}</span
form.link_direction === 'source'
? selectedType.label
: selectedType.counterpart_label
}}</span
><ArrowRight class="size-4" aria-hidden="true" /><span ><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs" class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span >{{ selectedTicket.ticket_key }}</span
> >
</p> </p>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.directionHelp') }}
</p>
<Button <Button
type="submit" type="submit"
:disabled="form.processing || !selectedTicket" :disabled="form.processing || !selectedTicket"
@@ -1,10 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3'; import { Link, useForm } from '@inertiajs/vue3';
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next'; import { Unlink } from 'lucide-vue-next';
import { computed } from 'vue';
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController'; import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
@@ -20,7 +18,6 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ removed: [] }>(); const emit = defineEmits<{ removed: [] }>();
const { t } = useLocalization(); const { t } = useLocalization();
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
const form = useForm({}); const form = useForm({});
const protection = useFormProtection(form); const protection = useFormProtection(form);
function remove(): void { function remove(): void {
@@ -47,27 +44,6 @@ function remove(): void {
:data-ticket-link="link.id" :data-ticket-link="link.id"
> >
<div class="flex min-w-0 flex-wrap items-center gap-2"> <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">{{ <span class="text-muted-foreground font-mono text-xs">{{
ticketKey ticketKey
}}</span> }}</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 };
}
+1 -6
View File
@@ -1213,7 +1213,7 @@ export const en = {
'ticketDetail.linkedTickets': 'Linked tickets', 'ticketDetail.linkedTickets': 'Linked tickets',
'ticketDetail.linkedTicketsDescription': 'ticketDetail.linkedTicketsDescription':
'Track cross-ticket relationships with project-defined link labels.', 'Track cross-ticket relationships with project-defined link labels.',
'ticketDetail.linkType': 'Link type', 'ticketDetail.linkAction': 'Action',
'ticketDetail.linkedTicket': 'Linked ticket', 'ticketDetail.linkedTicket': 'Linked ticket',
'ticketDetail.saveLink': 'Save link', 'ticketDetail.saveLink': 'Save link',
'ticketDetail.noLinks': 'ticketDetail.noLinks':
@@ -1227,11 +1227,6 @@ export const en = {
'Open a child ticket to change its parent. Expand a group to see its descendants.', 'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link', 'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket', '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.removeLink': 'Remove link to {key}',
'ticketRelationships.noTypes': 'ticketRelationships.noTypes':
'No link types are configured for this project.', 'No link types are configured for this project.',
+11
View File
@@ -67,3 +67,14 @@ export type TicketBranchFormData = {
branch_name: string; branch_name: string;
repository_connection_id: 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'];
};
+54 -4
View File
@@ -5,7 +5,7 @@ import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3'; import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite'; import { createServer } from 'vite';
import { createSSRApp, h } from 'vue'; import { createSSRApp, h, reactive, ref } from 'vue';
import { renderToString } from 'vue/server-renderer'; import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve'); const serve = process.argv.includes('--serve');
@@ -463,6 +463,40 @@ if (serve) {
); );
} else { } else {
try { try {
const { useTicketLinkActions } = await server.ssrLoadModule(
'/resources/js/composables/useTicketLinkActions.ts',
);
const linkTypes = ref([
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
{ id: 2, label: 'Relates to', counterpart_label: 'Relates to' },
]);
const linkForm = reactive({
ticket_link_type_id: 1,
link_direction: 'source',
linked_ticket_id: 41,
});
const { actions, selectedAction, selectedActionLabel } =
useTicketLinkActions(linkTypes, linkForm);
assert.deepEqual(
actions.value.map((action) => action.label),
['Blocks', 'Blocked by', 'Relates to'],
);
assert.equal(selectedActionLabel.value, 'Blocks');
selectedAction.value = '1:counterpart';
assert.equal(selectedActionLabel.value, 'Blocked by');
assert.deepEqual(
{ ...linkForm },
{
ticket_link_type_id: 1,
link_direction: 'counterpart',
linked_ticket_id: 41,
},
);
selectedAction.value = '2:source';
assert.equal(linkForm.ticket_link_type_id, 2);
assert.equal(linkForm.link_direction, 'source');
selectedAction.value = 'unknown';
assert.equal(selectedActionLabel.value, 'Relates to');
const { createLocalization } = await server.ssrLoadModule( const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts', '/resources/js/lib/localization.ts',
); );
@@ -491,8 +525,24 @@ if (serve) {
return result.body.split('id="main-content"')[1]; return result.body.split('id="main-content"')[1];
}; };
const html = await render(); const html = await render();
assert.ok(html.includes('id="link_direction"')); assert.ok(!html.includes('id="link_direction"'));
assert.ok(html.includes('Blocked by')); assert.ok(html.includes('Blocked by'));
const linkFields = [
...html.matchAll(/<fieldset\b[^>]*>[\s\S]*?<\/fieldset>/g),
].find(([fieldset]) =>
fieldset.includes('id="ticket_link_type_id"'),
)?.[0];
assert.ok(linkFields);
assert.equal((linkFields.match(/<select\b/g) ?? []).length, 2);
assert.match(
linkFields,
/<option value="1:source"[^>]*>Blocks<\/option>/,
);
assert.match(
linkFields,
/<option value="1:counterpart"[^>]*>Blocked by<\/option>/,
);
assert.doesNotMatch(linkFields, /Blocks \/ Blocked by/);
for (const text of [ for (const text of [
ticket.title, ticket.title,
'Keep every existing ticket action available.', 'Keep every existing ticket action available.',
@@ -570,9 +620,9 @@ if (serve) {
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/, /<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
); );
assert.match(html, /Save link/); assert.match(html, /Save link/);
assert.match(html, /Outgoing/); assert.doesNotMatch(html, /Outgoing/);
const incoming = await render('?incoming'); const incoming = await render('?incoming');
assert.match(incoming, /Incoming/); assert.doesNotMatch(incoming, /Incoming/);
assert.match(incoming, /Blocked by/); assert.match(incoming, /Blocked by/);
assert.match(await render('?noTypes'), /No link types are configured/); assert.match(await render('?noTypes'), /No link types are configured/);
assert.match( assert.match(
+19 -18
View File
@@ -41,8 +41,6 @@ try {
'Platform delivery', 'Platform delivery',
'Child tickets', 'Child tickets',
'Nested child with a long title that must wrap on mobile', 'Nested child with a long title that must wrap on mobile',
'Incoming',
'Blocked by',
]) { ]) {
assert.equal( assert.equal(
await section() await section()
@@ -52,6 +50,13 @@ try {
text, text,
); );
} }
assert.equal(
await section()
.locator('[data-ticket-link]')
.getByText('Blocked by', { exact: true })
.isVisible(),
true,
);
const collapse = section().getByRole('button', { const collapse = section().getByRole('button', {
name: 'Collapse children of ENG-41', name: 'Collapse children of ENG-41',
}); });
@@ -68,16 +73,7 @@ try {
); );
await page.keyboard.press('Space'); await page.keyboard.press('Space');
assert.equal(await collapse.getAttribute('aria-expanded'), 'true'); assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
await control('Link type').focus(); await control('Action').focus();
await page.keyboard.press('Tab');
assert.equal(
await control('This ticket').evaluate(
(el) =>
el === document.activeElement &&
el.matches(':focus-visible'),
),
true,
);
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
assert.equal( assert.equal(
await control('Linked ticket').evaluate( await control('Linked ticket').evaluate(
@@ -130,7 +126,10 @@ try {
link_direction: 'source', link_direction: 'source',
linked_ticket_id: 41, linked_ticket_id: 41,
}); });
await section().getByText('Outgoing', { exact: true }).waitFor(); await section()
.locator('[data-ticket-link]')
.getByText('Blocks', { exact: true })
.waitFor();
await page.waitForFunction( await page.waitForFunction(
() => document.querySelector('#linked_ticket_id')?.value === '', () => document.querySelector('#linked_ticket_id')?.value === '',
); );
@@ -152,11 +151,10 @@ try {
); );
await visit('?noLinks'); await visit('?noLinks');
await control('This ticket').selectOption('counterpart'); await control('Action').selectOption('1:counterpart');
await control('Linked ticket').selectOption('41'); await control('Linked ticket').selectOption('41');
await section() await section()
.locator('form') .locator('p[aria-live="polite"]')
.last()
.getByText('Blocked by', { exact: true }) .getByText('Blocked by', { exact: true })
.waitFor(); .waitFor();
request = page.waitForRequest( request = page.waitForRequest(
@@ -168,7 +166,10 @@ try {
link_direction: 'counterpart', link_direction: 'counterpart',
linked_ticket_id: 41, linked_ticket_id: 41,
}); });
await section().getByText('Incoming', { exact: true }).waitFor(); await section()
.locator('[data-ticket-link]')
.getByText('Blocked by', { exact: true })
.waitFor();
await visit('?rejectLink'); await visit('?rejectLink');
await control('Linked ticket').selectOption('41'); await control('Linked ticket').selectOption('41');
@@ -177,7 +178,7 @@ try {
.getByText('Choose another ticket.', { exact: true }) .getByText('Choose another ticket.', { exact: true })
.waitFor(); .waitFor();
assert.equal(await control('Linked ticket').inputValue(), '41'); assert.equal(await control('Linked ticket').inputValue(), '41');
for (const label of ['Link type', 'Linked ticket']) { for (const label of ['Action', 'Linked ticket']) {
assert.equal(await control(label).getAttribute('aria-invalid'), 'true'); assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
} }
await page.route('**/tickets/42/links', (route) => route.abort('failed')); await page.route('**/tickets/42/links', (route) => route.abort('failed'));