Compare commits

..
1 Commits
Author SHA1 Message Date
Twyco 7b65d31b74 feat(KAN-30): rebuild responsive ticket detail view
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Successful in 8m55s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-11 18:40:00 +02:00
35 changed files with 1318 additions and 3631 deletions
+3
View File
@@ -33,6 +33,9 @@
vendor vendor
**/.DS_Store **/.DS_Store
.editorconfig .editorconfig
.env.example
.env.mariadb
.env.mariadb.example
.env.production .env.production
LICENSE LICENSE
README.md README.md
+4
View File
@@ -0,0 +1,4 @@
MARIADB_DATABASE=kanbangit
MARIADB_USER=kanbangit
MARIADB_PASSWORD=
MARIADB_ROOT_PASSWORD=
+67 -13
View File
@@ -1,18 +1,72 @@
name: test_and_check_app name: "Lint and Test API"
description: Build the test image and run all application checks description: "Run Pint and the Pest test suite for the Laravel API"
runs: runs:
using: composite using: composite
steps: steps:
- name: Test and check app - name: Setup PHP
shell: bash uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
run: | with:
set -Eeuo pipefail php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
docker buildx build \ - name: Verify Composer lockfile
--file Dockerfile \ run: |
--target test \ if [ ! -f composer.lock ]; then
--tag "local/kanbangit-test:${GITHUB_SHA}" \ echo "::error::api/composer.lock not found"
--progress plain \ exit 1
--load \ fi
. shell: bash
- name: Get Composer cache directory
id: composer_cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache Composer packages
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('api/composer.lock') }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Prepare environment file
run: cp .env.example .env
shell: bash
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
shell: bash
- name: Generate application key
run: php artisan key:generate
shell: bash
- name: Generate Wayfinder routes and actions
run: php artisan wayfinder:generate --with-form
shell: bash
- name: Build Vite app
run: pnpm run build
shell: bash
- name: Run ci check
run: composer run ci:check
shell: bash
+2 -4
View File
@@ -55,14 +55,12 @@ COPY --from=node_binary /usr/local/ /usr/local/
COPY . /var/www COPY . /var/www
RUN composer install --prefer-dist --no-interaction --no-progress \ RUN composer install --prefer-dist --no-interaction --no-progress \
&& corepack enable pnpm \ && pnpm ci \
&& pnpm install --frozen-lockfile \
&& pnpm run build && pnpm run build
FROM build AS test FROM build AS test
RUN cp .env.example .env \ RUN touch .env \
&& php artisan key:generate\
&& composer audit --locked --no-interaction \ && composer audit --locked --no-interaction \
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check && COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
@@ -1,209 +0,0 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type {
BacklogTicketTreeEmits,
BacklogTicketTreeProps,
} from '@/types/backlog';
import type { TicketModel } from '@/types/models/ticket-model';
const props = defineProps<BacklogTicketTreeProps>();
const emit = defineEmits<BacklogTicketTreeEmits>();
const { t } = useLocalization();
const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id,
);
</script>
<template>
<ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.ticket.id" class="min-w-0">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex justify-center pt-1">
<button
v-if="node.children.length > 0"
type="button"
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
:aria-expanded="!collapsedTicketIds.has(node.ticket.id)"
:aria-controls="`backlog-children-${node.ticket.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.ticket.id)
? 'backlog.tree.expand'
: 'backlog.tree.collapse',
{ key: node.ticket.ticket_key },
)
"
@click="emit('toggle', node.ticket.id)"
>
<ChevronRight
v-if="collapsedTicketIds.has(node.ticket.id)"
class="size-4"
aria-hidden="true"
/>
<ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div>
<div class="grid min-w-0 gap-2">
<Badge
v-if="!node.matchesFilters"
variant="secondary"
class="w-fit"
>
{{ t('backlog.filters.parentContext') }}
</Badge>
<TicketCard
:project-id="projectId"
:ticket="node.ticket"
layout="list"
:class="{ 'opacity-80': !node.matchesFilters }"
>
<template v-if="canEditTickets" #actions>
<Form
v-bind="
move.form({
project: projectId,
ticket: node.ticket.id,
})
"
v-slot="{ processing, errors }"
:error-bag="`backlog-ticket-${node.ticket.id}`"
:options="{ preserveScroll: true }"
:on-before="() => pendingTicketId === null"
class="grid min-w-0 gap-2"
@start="emit('moveStart', node.ticket.id)"
@finish="emit('moveFinish', node.ticket.id)"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<label
:for="`backlog-ticket-status-${node.ticket.id}`"
class="text-caption text-muted-foreground font-semibold"
>
{{
t('backlog.moveTicket', {
key: node.ticket.ticket_key,
})
}}
</label>
<select
:id="`backlog-ticket-status-${node.ticket.id}`"
name="ticket_status_id"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
:aria-invalid="
!!errors.ticket_status_id || undefined
"
:aria-describedby="
errors.ticket_status_id
? `backlog-ticket-status-error-${node.ticket.id}`
: undefined
"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
value=""
>
{{ t('board.noMoves') }}
</option>
<option
v-for="target in allowedTargetStatuses(
node.ticket,
)"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t('tickets.noStatus'),
)
}}
</option>
</select>
<InputError
:id="`backlog-ticket-status-error-${node.ticket.id}`"
:message="errors.ticket_status_id"
/>
<Button
type="submit"
variant="outline"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
>
{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}
</Button>
<p
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
{{ t('board.noMovesDescription') }}
</p>
</Form>
</template>
</TicketCard>
</div>
</div>
<div
v-if="
node.children.length > 0 &&
!collapsedTicketIds.has(node.ticket.id)
"
:id="`backlog-children-${node.ticket.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
>
<BacklogTicketTree
:nodes="node.children"
:project-id="projectId"
:can-edit-tickets="canEditTickets"
:collapsed-ticket-ids="collapsedTicketIds"
:pending-ticket-id="pendingTicketId"
@toggle="emit('toggle', $event)"
@move-start="emit('moveStart', $event)"
@move-finish="emit('moveFinish', $event)"
/>
</div>
</li>
</ul>
</template>
+27 -75
View File
@@ -1,93 +1,45 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { reactive } from 'vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketTreeListProps } from '@/types/ticket-tree'; import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
defineProps<TicketTreeListProps>(); defineProps<{
nodes: TicketTreeNodeModel[];
const { t } = useLocalization(); projectId: number;
const collapsedTicketIds = reactive(new Set<number>()); }>();
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
</script> </script>
<template> <template>
<ul class="grid min-w-0 gap-3"> <ul class="space-y-3">
<li v-for="node in nodes" :key="node.id" class="min-w-0"> <li v-for="node in nodes" :key="node.id">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2"> <div class="border-border rounded-xl border px-4 py-4">
<div class="flex justify-center pt-1"> <div class="flex flex-wrap items-center gap-2">
<button <Link
v-if="node.children.length > 0" :href="
type="button" showTicket({
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2" project: projectId,
:aria-expanded="!collapsedTicketIds.has(node.id)" ticket: node.id,
:aria-controls="`ticket-tree-children-${node.id}`" })
:aria-label="
t(
collapsedTicketIds.has(node.id)
? 'tickets.tree.expand'
: 'tickets.tree.collapse',
{ key: node.ticket_key },
)
" "
@click="toggleTicket(node.id)" class="text-foreground hover:text-primary font-medium transition"
> >
<ChevronRight {{ node.ticket_key }}
v-if="collapsedTicketIds.has(node.id)" </Link>
class="size-4"
aria-hidden="true" <Badge v-if="node.ticket_type_name" variant="secondary">
/> {{ node.ticket_type_name }}
<ChevronDown v-else class="size-4" aria-hidden="true" /> </Badge>
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div> </div>
<Link <div class="text-foreground mt-2 text-sm">
:href=" {{ node.title }}
showTicket({ </div>
project: projectId,
ticket: node.id,
})
"
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
{{ node.ticket_key }}
</Badge>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
</div>
<span
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ node.title }}
</span>
</Link>
</div> </div>
<div <div
v-if=" v-if="node.children.length > 0"
node.children.length > 0 && !collapsedTicketIds.has(node.id) class="border-border mt-3 border-l pl-4"
"
:id="`ticket-tree-children-${node.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
> >
<TicketTreeList <TicketTreeList
:nodes="node.children" :nodes="node.children"
@@ -1,320 +0,0 @@
<script setup lang="ts">
import {
ClipboardCheck,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from 'lucide-vue-next';
import { computed, nextTick, ref, useId, watch } from 'vue';
import { Button } from '@/components/ui/button';
import InputError from '@/components/InputError.vue';
import { useLocalization } from '@/composables/useLocalization';
import {
canIndentAcceptanceCriterion,
indentAcceptanceCriterion,
removeAcceptanceCriterion,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
const props = withDefaults(
defineProps<{
modelValue: AcceptanceCriteriaModel;
readonly?: boolean;
disabled?: boolean;
persistToggles?: boolean;
allowEditing?: boolean;
saving?: boolean;
dirty?: boolean;
error?: string;
}>(),
{
readonly: false,
disabled: false,
persistToggles: false,
allowEditing: true,
saving: undefined,
dirty: false,
error: '',
},
);
const emit = defineEmits<{
'update:modelValue': [items: AcceptanceCriteriaModel];
toggle: [index: number, checked: boolean];
}>();
const { t } = useLocalization();
const id = useId();
const root = ref<HTMLElement>();
const editing = ref(!props.persistToggles);
const completed = computed(
() => props.modelValue.filter((item) => item.checked).length,
);
const locked = computed(() => props.readonly || props.disabled);
const update = (items: AcceptanceCriteriaModel): void => {
if (!locked.value) {
emit('update:modelValue', items);
}
};
const focusRow = async (index: number): Promise<void> => {
await nextTick();
const inputs =
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
if (inputs?.[index]) {
inputs[index].focus();
} else {
root.value
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
?.focus();
}
};
const add = (): void => {
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
void focusRow(props.modelValue.length);
};
const remove = (index: number): void => {
update(removeAcceptanceCriterion(props.modelValue, index));
void focusRow(Math.max(0, index - 1));
};
watch(
() => props.modelValue,
async () => {
await nextTick();
root.value
?.querySelectorAll<HTMLTextAreaElement>('textarea')
.forEach((input) => {
input.setCustomValidity(
input.value.trim() === '' ? t('criteria.required') : '',
);
});
},
);
const changeText = (index: number, event: Event): void => {
const text = (event.target as HTMLTextAreaElement).value;
update(
props.modelValue.map((item, position) =>
position === index ? { ...item, text } : item,
),
);
};
const toggle = (index: number, event: Event): void => {
const target = event.target as HTMLInputElement;
const checked = target.checked;
target.checked = props.modelValue[index].checked;
if (locked.value) {
return;
}
if (props.persistToggles && !editing.value && !props.dirty) {
emit('toggle', index, checked);
} else {
update(
syncAcceptanceCriteriaDescendantCheckedState(
props.modelValue,
index,
checked,
),
);
}
};
const onKeydown = (index: number, event: KeyboardEvent): void => {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
return;
}
event.preventDefault();
update(
indentAcceptanceCriterion(
props.modelValue,
index,
event.key === 'ArrowRight' ? 1 : -1,
),
);
};
</script>
<template>
<section
ref="root"
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
:aria-labelledby="`${id}-title`"
:aria-busy="disabled"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<h3
:id="`${id}-title`"
class="flex items-center gap-2 font-semibold"
>
<ClipboardCheck
class="text-primary size-5"
aria-hidden="true"
/>
{{ t('criteria.title') }}
</h3>
<span class="text-muted-foreground text-sm" aria-live="polite">{{
t('criteria.progress', { completed, total: modelValue.length })
}}</span>
<Button
v-if="persistToggles && allowEditing && !readonly"
type="button"
variant="outline"
:disabled="
disabled ||
(editing &&
modelValue.some((item) => item.text.trim() === ''))
"
:aria-pressed="editing"
@click="editing = !editing"
>
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
</Button>
</div>
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
{{ t('criteria.empty') }}
</p>
<ol v-else class="space-y-3">
<li
v-for="(item, index) in modelValue"
:key="item.id ?? `new-${index}`"
:data-indent="item.indent"
class="border-border min-w-0"
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
>
<div class="flex min-w-0 items-start gap-2">
<label
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
:class="{ 'cursor-not-allowed opacity-60': locked }"
>
<input
:checked="item.checked"
type="checkbox"
:disabled="locked"
:aria-label="
t('criteria.complete', {
number: index + 1,
text: item.text,
})
"
class="border-input accent-primary size-5 rounded"
@change="toggle(index, $event)"
/>
</label>
<div class="min-w-0 flex-1">
<textarea
v-if="editing && !readonly"
:value="item.text"
:aria-label="
t('criteria.item', { number: index + 1 })
"
:aria-describedby="`${id}-hint ${id}-error`"
:disabled="disabled"
:aria-invalid="Boolean(error)"
required
rows="2"
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
@input="changeText(index, $event)"
@keydown="onKeydown(index, $event)"
/>
<p
v-else
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
:class="{
'text-muted-foreground line-through':
item.checked,
}"
>
{{ item.text }}
</p>
<div
v-if="editing && !readonly"
class="flex flex-wrap gap-1"
>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled || item.indent === 0"
:aria-label="
t('criteria.outdent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
-1,
),
)
"
><IndentDecrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="
disabled ||
!canIndentAcceptanceCriterion(
modelValue,
index,
)
"
:aria-label="
t('criteria.indent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
1,
),
)
"
><IndentIncrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled"
:aria-label="
t('criteria.remove', { number: index + 1 })
"
@click="remove(index)"
><Trash2 aria-hidden="true"
/></Button>
</div>
</div>
</div>
</li>
</ol>
<template v-if="editing && !readonly">
<Button
data-add-criterion
type="button"
variant="outline"
:disabled="disabled"
@click="add"
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
>
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
{{ t('criteria.hint') }}
</p>
</template>
<p
v-if="persistToggles && dirty"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.unsaved') }}
</p>
<p
v-if="saving ?? disabled"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.saving') }}
</p>
<InputError :id="`${id}-error`" :message="error" role="alert" />
</section>
</template>
+50 -73
View File
@@ -11,7 +11,6 @@ import type {
} from '@/types/ticket-presentation'; } from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), { withDefaults(defineProps<TicketPresentationProps>(), {
layout: 'card',
showDescription: true, showDescription: true,
showStatus: true, showStatus: true,
}); });
@@ -23,85 +22,63 @@ const { t } = useLocalization();
<template> <template>
<article <article
:class="[ :class="[
'bg-card text-card-foreground min-w-0 rounded-md border', 'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
layout === 'card' ? 'shadow-sm' : '',
ticket.is_blocked ? 'border-warning' : 'border-border', ticket.is_blocked ? 'border-warning' : 'border-border',
]" ]"
> >
<div <Link
:class=" :href="showTicket({ project: projectId, ticket: ticket.id })"
layout === 'list' :aria-label="
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch' t('tickets.open', {
: '' key: ticket.ticket_key,
title: ticket.title,
})
" "
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
@click="emit('open', ticket.id)"
> >
<Link <div class="flex min-w-0 items-start justify-between gap-3">
:href="showTicket({ project: projectId, ticket: ticket.id })" <Badge
:aria-label=" variant="outline"
t('tickets.open', { class="wrap-anywhere max-w-full whitespace-normal"
key: ticket.ticket_key,
title: ticket.title,
})
"
:class="[
'focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
layout === 'list'
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
: 'rounded-t-md',
]"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
> >
{{ ticket.title }} {{ ticket.ticket_key }}
</h3> </Badge>
<ArrowUpRight
<TicketMetadata :ticket="ticket" :show-status="showStatus" /> class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
<p />
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div
v-if="$slots.actions"
:class="[
'border-t p-4',
layout === 'list'
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
: '',
]"
>
<slot name="actions" />
</div> </div>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ ticket.title }}
</h3>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div v-if="$slots.actions" class="border-t p-4">
<slot name="actions" />
</div> </div>
</article> </article>
</template> </template>
@@ -1,89 +0,0 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
withDefaults(
defineProps<{
id: string;
label: string;
kind?: 'text' | 'number' | 'textarea' | 'select';
options?: { value: string | number; label: string }[];
error?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
autofocus?: boolean;
placeholder?: string;
rows?: number;
min?: number;
step?: string;
}>(),
{ kind: 'text', rows: 5 },
);
const model = defineModel<string | number | null>({ required: true });
const controlClass =
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
</script>
<template>
<div class="grid min-w-0 content-start gap-2">
<Label :for="id">{{ label }}</Label>
<select
v-if="kind === 'select'"
:id="id"
v-model="model"
:name="id"
:disabled="disabled"
:required="required"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<textarea
v-else-if="kind === 'textarea'"
:id="id"
v-model="model"
:name="id"
:rows="rows"
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
/>
<Input
v-else
:id="id"
v-model="model"
:name="id"
:type="kind"
:disabled="disabled"
:required="required"
:autofocus="autofocus"
:placeholder="placeholder"
:min="min"
:step="step"
:maxlength="kind === 'text' ? 255 : undefined"
:invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
/>
<p
:id="`${id}-hint`"
class="text-muted-foreground text-xs empty:hidden"
>
{{ hint }}
</p>
<InputError :id="`${id}-error`" :message="error" />
</div>
</template>
@@ -1,157 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { ProjectTicketCreatePageProps } from '@/types';
const props = defineProps<
Pick<
ProjectTicketCreatePageProps,
| 'project'
| 'ticketTypes'
| 'ticketPriorities'
| 'users'
| 'parentTicketOptions'
> & {
errors: Record<string, string>;
disabled?: boolean;
autofocus?: boolean;
ticketStatusOptions?: ProjectTicketCreatePageProps['ticketStatusOptions'];
}
>();
const title = defineModel<string>('title', { required: true });
const description = defineModel<string>('description', { required: true });
const type = defineModel<string | number>('type', { required: true });
const priority = defineModel<string | number>('priority', { required: true });
const assignee = defineModel<string | number>('assignee', { required: true });
const parent = defineModel<string | number>('parent', { required: true });
const points = defineModel<string | number | null>('points', {
required: true,
});
const status = defineModel<string | number>('status');
const { t } = useLocalization();
const typeOptions = computed(() =>
props.ticketTypes.map((item) => ({ value: item.id, label: item.name })),
);
const priorityOptions = computed(() =>
props.ticketPriorities.map((item) => ({
value: item.id,
label: item.name,
})),
);
const userOptions = computed(() => [
{ value: '', label: t('tickets.unassigned') },
...props.users.map((item) => ({ value: item.id, label: item.name })),
]);
const parentOptions = computed(() => [
{ value: '', label: t('ticketDetail.noParent') },
...props.parentTicketOptions.map((item) => ({
value: item.id,
label: `${item.ticket_key} - ${item.title}`,
})),
]);
const statusOptions = computed(
() =>
props.ticketStatusOptions?.map((item) => ({
value: item.id,
label: ticketStatusLabel(item, t('ticketDetail.notSet')),
})) ?? [],
);
</script>
<template>
<div class="grid min-w-0 gap-6">
<TicketFormField
id="title"
v-model="title"
:label="t('ticketDetail.title')"
:error="errors.title"
:disabled="disabled"
:autofocus="autofocus"
:placeholder="t('ticketForm.titlePlaceholder')"
required
/>
<TicketFormField
id="description"
v-model="description"
kind="textarea"
:label="t('ticketDetail.description')"
:error="errors.description"
:disabled="disabled"
:placeholder="t('ticketForm.descriptionPlaceholder')"
/>
<slot name="content" />
<fieldset
class="bg-muted/20 grid min-w-0 gap-5 rounded-xl border p-4 sm:p-5"
:disabled="disabled"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketDetail.metadata') }}
</legend>
<div class="grid min-w-0 gap-5 sm:grid-cols-2">
<TicketFormField
id="ticket_type_id"
v-model="type"
kind="select"
:label="t('ticketDetail.type')"
:options="typeOptions"
:error="errors.ticket_type_id"
:disabled="disabled"
required
/>
<TicketFormField
id="ticket_priority_id"
v-model="priority"
kind="select"
:label="t('ticketDetail.priority')"
:options="priorityOptions"
:error="errors.ticket_priority_id"
:disabled="disabled"
required
/>
<TicketFormField
v-if="ticketStatusOptions && status !== undefined"
id="ticket_status_id"
v-model="status"
kind="select"
:label="t('ticketDetail.status')"
:options="statusOptions"
:error="errors.ticket_status_id"
:disabled="disabled"
required
/>
<TicketFormField
id="assignee_user_id"
v-model="assignee"
kind="select"
:label="t('ticketDetail.assignee')"
:options="userOptions"
:error="errors.assignee_user_id"
:disabled="disabled"
/>
<TicketFormField
id="parent_ticket_id"
v-model="parent"
kind="select"
:label="t('ticketDetail.parent')"
:options="parentOptions"
:error="errors.parent_ticket_id"
:disabled="disabled"
/>
<TicketFormField
v-if="project.show_story_points"
id="story_points"
v-model="points"
kind="number"
:label="t('ticketDetail.storyPoints')"
:error="errors.story_points"
:disabled="disabled"
:min="0"
step="1"
/>
<slot name="metadata" />
</div>
</fieldset>
</div>
</template>
@@ -3,56 +3,51 @@ import { useLocalization } from '@/composables/useLocalization';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useForm, useRemember } from '@inertiajs/vue3'; import { useForm, useRemember } from '@inertiajs/vue3';
import { computed, ref, toRef, unref, useTemplateRef, nextTick } from 'vue'; import { computed, ref, toRef, unref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import TicketFormField from '@/components/tickets/TicketFormField.vue'; import { Input } from '@/components/ui/input';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
formatChecklistLines,
normalizeAcceptanceCriteria, normalizeAcceptanceCriteria,
syncAcceptanceCriteriaDescendantCheckedState, syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria'; } from '@/lib/acceptance-criteria';
import type { TicketDetailContentProps } from '@/types/ticket-detail'; import type { TicketDetailContentProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailContentProps>(); const props = defineProps<TicketDetailContentProps>();
const { t } = useLocalization(); const { t } = useLocalization();
const editor = useTemplateRef<HTMLDetailsElement>('editor');
async function editParent(): Promise<void> {
if (!props.canEditTicket || !editor.value) {
return;
}
editor.value.open = true;
await nextTick();
editor.value
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
?.focus();
}
defineExpose({ editParent });
const canEditAuthor = computed( const canEditAuthor = computed(
() => props.canEditTicket && props.canManageProject, () => props.canEditTicket && props.canManageProject,
); );
const canViewAuthor = computed( const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject, () => props.project.show_author || props.canManageProject,
); );
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`; const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const acceptanceCriteriaDraft = useRemember( const acceptanceCriteriaDraft = useRemember(
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) }, { checklist: acceptanceCriteriaEditorState.checklist },
`${draftKey}:criteria`, `${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
); );
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
const savedAcceptanceCriteria = ref( const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria), normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
); );
const isPersistingAcceptanceCriteriaToggle = ref(false); const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref(''); const acceptanceCriteriaToggleError = ref('');
const savedFormData = () => ({ const form = useForm(draftKey, {
acceptance_criteria: normalizeAcceptanceCriteria( acceptance_criteria: buildAcceptanceCriteria(
savedAcceptanceCriteria.value, acceptanceCriteriaChecklist.value,
), ),
assignee_user_id: props.ticket.assignee_user_id ?? '', assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '', author_user_id: props.ticket.author_user_id ?? '',
@@ -79,36 +74,21 @@ const savedFormData = () => ({
story_points: props.ticket.story_points, story_points: props.ticket.story_points,
}); });
const form = useForm(draftKey, savedFormData()); const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value,
);
const hasUnsavedAcceptanceCriteriaDraft = computed( const hasUnsavedAcceptanceCriteriaDraft = computed(
() => () =>
JSON.stringify(acceptanceCriteriaItems.value) !== acceptanceCriteriaChecklist.value !==
JSON.stringify(savedAcceptanceCriteria.value), formatChecklistLines(savedAcceptanceCriteria.value),
); );
const isDirty = computed( const protection = useFormProtection(form, {
() => isDirty: () =>
props.canEditTicket && props.canEditTicket &&
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value), (form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
); });
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
const reset = (): void => {
if (
form.processing ||
isPersistingAcceptanceCriteriaToggle.value ||
!protection.confirmLeave()
) {
return;
}
form.reset();
form.clearErrors();
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
acceptanceCriteriaToggleError.value = '';
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
@@ -120,6 +100,19 @@ const acceptanceCriteriaError = computed(() => {
); );
}); });
const buildAcceptanceCriteriaForSubmit = () => {
const draftItems = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
return draftItems.map((item, index) => ({
...item,
...(savedAcceptanceCriteria.value[index]?.id !== undefined
? { id: savedAcceptanceCriteria.value[index].id }
: {}),
}));
};
const buildSavedTicketPayload = ( const buildSavedTicketPayload = (
acceptanceCriteria = savedAcceptanceCriteria.value, acceptanceCriteria = savedAcceptanceCriteria.value,
) => ({ ) => ({
@@ -147,7 +140,6 @@ const persistAcceptanceCriteriaToggle = async (
): Promise<void> => { ): Promise<void> => {
if ( if (
!props.canEditTicket || !props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value || isPersistingAcceptanceCriteriaToggle.value ||
hasUnsavedAcceptanceCriteriaDraft.value hasUnsavedAcceptanceCriteriaDraft.value
) { ) {
@@ -159,6 +151,10 @@ const persistAcceptanceCriteriaToggle = async (
index, index,
checked, checked,
); );
const previousChecklist = formatChecklistLines(
savedAcceptanceCriteria.value,
);
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
const csrfToken = document const csrfToken = document
.querySelector('meta[name="csrf-token"]') .querySelector('meta[name="csrf-token"]')
?.getAttribute('content'); ?.getAttribute('content');
@@ -193,28 +189,38 @@ const persistAcceptanceCriteriaToggle = async (
savedAcceptanceCriteria.value = nextAcceptanceCriteria; savedAcceptanceCriteria.value = nextAcceptanceCriteria;
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria( if (acceptanceCriteriaChecklist.value === previousChecklist) {
nextAcceptanceCriteria, acceptanceCriteriaChecklist.value = nextChecklist;
); }
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
} catch (_error) { } catch (_error) {
acceptanceCriteriaToggleError.value = t('criteria.saveError'); acceptanceCriteriaToggleError.value = t(
'ticketDetail.checklistSaveFailed',
);
} finally { } finally {
isPersistingAcceptanceCriteriaToggle.value = false; isPersistingAcceptanceCriteriaToggle.value = false;
} }
}; };
const submit = (): void => { const onAcceptanceCriteriaCheckboxChange = async (
if ( index: number,
!props.canEditTicket || event: Event,
form.processing || ): Promise<void> => {
isPersistingAcceptanceCriteriaToggle.value const target = event.target;
) {
if (!(target instanceof HTMLInputElement)) {
return; return;
} }
form.acceptance_criteria = acceptanceCriteriaItems.value; await persistAcceptanceCriteriaToggle(index, target.checked);
target.checked = savedAcceptanceCriteria.value[index]?.checked ?? false;
};
const submit = (): void => {
if (!props.canEditTicket) {
return;
}
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
protection.submit( protection.submit(
ProjectTicketController.update({ ProjectTicketController.update({
@@ -227,11 +233,9 @@ const submit = (): void => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria( savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria, props.ticket.acceptance_criteria,
); );
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria( acceptanceCriteriaChecklist.value = formatChecklistLines(
savedAcceptanceCriteria.value, savedAcceptanceCriteria.value,
); );
Object.assign(form, savedFormData());
form.defaults();
}, },
}, },
); );
@@ -269,29 +273,85 @@ const submit = (): void => {
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
<div id="ticket-checklist" class="scroll-mt-6"> <section
<AcceptanceCriteriaChecklist id="ticket-checklist"
:model-value="savedAcceptanceCriteria" aria-labelledby="ticket-checklist-title"
:readonly="!canEditTicket" class="scroll-mt-6"
:disabled=" >
form.processing || <Card>
isPersistingAcceptanceCriteriaToggle || <CardHeader
hasUnsavedAcceptanceCriteriaDraft ><h2
" id="ticket-checklist-title"
:saving=" class="text-lg font-semibold"
form.processing || isPersistingAcceptanceCriteriaToggle >
" {{ t('ticketDetail.checklist') }}
:allow-editing="false" </h2></CardHeader
persist-toggles >
:dirty="hasUnsavedAcceptanceCriteriaDraft" <CardContent class="space-y-3">
:error="acceptanceCriteriaToggleError" <div
@toggle="persistAcceptanceCriteriaToggle" v-if="acceptanceCriteriaPreviewItems.length > 0"
/> class="border-border rounded-xl border p-4 text-sm"
</div> >
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="item.id ?? index"
:style="{
marginInlineStart: `${item.indent * 0.75}rem`,
}"
class="flex min-w-0 items-start gap-2"
>
<input
:id="`acceptance-criteria-${item.id ?? index}`"
:checked="item.checked"
type="checkbox"
:disabled="
!canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
class="border-input text-primary focus:ring-ring mt-3.5 size-4 shrink-0 rounded"
@change="
onAcceptanceCriteriaCheckboxChange(
index,
$event,
)
"
/>
<label
:for="`acceptance-criteria-${item.id ?? index}`"
class="min-h-control wrap-anywhere flex min-w-0 flex-1 items-center py-2"
>
{{ item.text }}
</label>
</li>
</ul>
</div>
<p v-else class="text-muted-foreground text-sm">
{{ t('ticketDetail.noChecklist') }}
</p>
<p
v-if="hasUnsavedAcceptanceCriteriaDraft"
class="text-muted-foreground text-sm"
>
{{ t('ticketDetail.saveChecklistFirst') }}
</p>
<p
v-if="acceptanceCriteriaToggleError"
role="alert"
class="text-destructive text-sm"
>
{{ acceptanceCriteriaToggleError }}
</p>
</CardContent>
</Card>
</section>
<details <details
v-if="canEditTicket" v-if="canEditTicket"
id="ticket-edit" id="ticket-edit"
ref="editor"
class="bg-card group rounded-xl border shadow-sm" class="bg-card group rounded-xl border shadow-sm"
> >
<summary <summary
@@ -299,217 +359,339 @@ const submit = (): void => {
> >
{{ t('ticketDetail.edit') }} {{ t('ticketDetail.edit') }}
</summary> </summary>
<div class="border-t p-4 sm:p-6"> <div class="border-t p-6">
<p class="text-muted-foreground mb-6 text-sm"> <p class="text-muted-foreground mb-6 text-sm">
{{ t('ticketDetail.editDescription') }} {{ t('ticketDetail.editDescription') }}
</p> </p>
<form class="grid min-w-0 gap-6" @submit.prevent="submit"> <form class="grid gap-6" @submit.prevent="submit">
<FormFeedback <FormFeedback
:state="protection.state.value" :state="protection.state.value"
@retry="submit" @retry="submit"
/> />
<TicketFormFields <div class="grid min-w-0 gap-2">
v-model:title="form.title" <Label for="title">{{ t('ticketDetail.title') }}</Label>
v-model:description="form.description" <Input
v-model:type="form.ticket_type_id" id="title"
v-model:priority="form.ticket_priority_id" v-model="form.title"
v-model:assignee="form.assignee_user_id" :invalid="Boolean(form.errors.title)"
v-model:parent="form.parent_ticket_id" aria-describedby="title-error"
v-model:points="form.story_points" :disabled="!canEditTicket || form.processing"
:project="project" />
:ticket-types="ticketTypes" <InputError
:ticket-priorities="ticketPriorities" id="title-error"
:users="users" :message="form.errors.title"
:parent-ticket-options="parentTicketOptions" />
:errors="form.errors" </div>
:disabled="
form.processing || <div class="grid gap-2 md:grid-cols-2 md:gap-6">
isPersistingAcceptanceCriteriaToggle <div class="grid min-w-0 gap-2">
" <Label for="ticket_type_id">{{
> t('ticketDetail.type')
<template #content> }}</Label>
<AcceptanceCriteriaChecklist <select
v-model="acceptanceCriteriaItems" id="ticket_type_id"
:disabled=" v-model="form.ticket_type_id"
form.processing || :disabled="!canEditTicket || form.processing"
isPersistingAcceptanceCriteriaToggle class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
" >
:error="acceptanceCriteriaError" <option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError :message="form.errors.ticket_type_id" />
</div>
<div class="grid min-w-0 gap-2">
<Label for="ticket_priority_id">{{
t('ticketDetail.priority')
}}</Label>
<select
id="ticket_priority_id"
v-model="form.ticket_priority_id"
:disabled="!canEditTicket || form.processing"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="form.errors.ticket_priority_id"
/> />
</template> </div>
<template #metadata> </div>
<TicketFormField
v-if="canViewAuthor" <div class="grid gap-2 md:grid-cols-2 md:gap-6">
<div class="grid min-w-0 gap-2">
<Label for="assignee_user_id">{{
t('ticketDetail.assignee')
}}</Label>
<select
id="assignee_user_id"
v-model="form.assignee_user_id"
:disabled="!canEditTicket || form.processing"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">
{{ t('tickets.unassigned') }}
</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div v-if="canViewAuthor" class="grid min-w-0 gap-2">
<Label for="author_user_id">{{
t('ticketDetail.author')
}}</Label>
<select
id="author_user_id" id="author_user_id"
v-model="form.author_user_id" v-model="form.author_user_id"
kind="select"
:label="t('ticketDetail.author')"
:hint="
!canEditAuthor
? t('ticketForm.authorRestricted')
: undefined
"
:options="[
{
value: '',
label: t('ticketDetail.noAuthor'),
},
...users.map((user) => ({
value: user.id,
label: user.name,
})),
]"
:error="form.errors.author_user_id"
:disabled="!canEditAuthor || form.processing" :disabled="!canEditAuthor || form.processing"
/> class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
</template> >
</TicketFormFields> <option value="">
<fieldset {{ t('ticketDetail.noAuthor') }}
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5" </option>
:disabled=" <option
form.processing || v-for="user in users"
isPersistingAcceptanceCriteriaToggle :key="user.id"
" :value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError :message="form.errors.author_user_id" />
</div>
</div>
<div
class="grid min-w-0 gap-2"
v-if="project.show_story_points"
> >
<legend class="px-2 text-sm font-semibold"> <Label for="story_points">{{
{{ t('ticketForm.blocker') }} t('ticketDetail.storyPoints')
</legend> }}</Label>
<div class="min-h-control flex items-center gap-3"> <Input
id="story_points"
name="story_points"
:disabled="!canEditTicket || form.processing"
v-model="form.story_points"
type="number"
step="1"
/>
<InputError :message="form.errors.story_points" />
</div>
<div class="grid min-w-0 gap-2">
<Label for="parent_ticket_id">{{
t('ticketDetail.parent')
}}</Label>
<select
id="parent_ticket_id"
v-model="form.parent_ticket_id"
:disabled="!canEditTicket || form.processing"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">
{{ t('ticketDetail.noParent') }}
</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="parentTicketOption.id"
>
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError :message="form.errors.parent_ticket_id" />
</div>
<div class="grid min-w-0 gap-2">
<Label for="description">{{
t('ticketDetail.description')
}}</Label>
<textarea
id="description"
v-model="form.description"
rows="5"
:disabled="!canEditTicket || form.processing"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
/>
<InputError :message="form.errors.description" />
</div>
<div class="grid gap-4">
<div class="grid min-w-0 gap-2">
<Label for="acceptance_criteria_checklist">{{
t('ticketDetail.checklistEditor')
}}</Label>
<textarea
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
rows="5"
:disabled="!canEditTicket || form.processing"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
:placeholder="
t('ticketDetail.checklistPlaceholder')
"
/>
<p class="text-muted-foreground text-xs">
{{ t('ticketDetail.checklistHelp') }}
</p>
<p
v-if="hasUnsavedAcceptanceCriteriaDraft"
class="text-muted-foreground text-xs"
>
{{ t('ticketDetail.saveChecklistFirst') }}
</p>
<InputError :message="acceptanceCriteriaError" />
<InputError
:message="acceptanceCriteriaToggleError"
/>
</div>
</div>
<div class="border-border grid gap-3 rounded-xl border p-4">
<div class="flex items-center gap-3">
<input <input
id="is_blocked" id="is_blocked"
v-model="form.is_blocked" v-model="form.is_blocked"
type="checkbox" type="checkbox"
:aria-invalid="Boolean(form.errors.is_blocked)" :disabled="!canEditTicket || form.processing"
aria-describedby="is_blocked-error" class="border-input text-primary focus:ring-ring size-4 rounded"
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
/> />
<Label for="is_blocked">{{ <Label for="is_blocked">{{
t('ticketDetail.blocked') t('ticketDetail.blocked')
}}</Label> }}</Label>
</div> </div>
<InputError
id="is_blocked-error" <div class="grid min-w-0 gap-2">
:message="form.errors.is_blocked" <Label for="blocked_comment">{{
/> t('ticketDetail.blockerComment')
<TicketFormField }}</Label>
id="blocked_comment" <textarea
v-model="form.blocked_comment" id="blocked_comment"
kind="textarea" v-model="form.blocked_comment"
:rows="3" rows="3"
:label="t('ticketDetail.blockerComment')" :disabled="!canEditTicket || form.processing"
:placeholder="t('ticketDetail.blockerPlaceholder')" class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
:error="form.errors.blocked_comment" :placeholder="
:required="form.is_blocked" t('ticketDetail.blockerPlaceholder')
/> "
</fieldset> />
<fieldset <InputError
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5" :message="form.errors.blocked_comment"
:disabled=" />
form.processing || </div>
isPersistingAcceptanceCriteriaToggle </div>
"
> <div class="grid gap-4">
<legend class="px-2 text-sm font-semibold"> <div class="space-y-1">
{{ t('ticketDetail.customFields') }} <h3 class="text-sm font-semibold">
</legend> {{ t('ticketDetail.customFields') }}
<p class="text-muted-foreground text-sm"> </h3>
{{ t('ticketDetail.customFieldsDescription') }} <p class="text-muted-foreground text-sm">
</p> {{ t('ticketDetail.customFieldsDescription') }}
<InputError :message="form.errors.field_values" /> </p>
<p </div>
v-if="!ticketFieldDefinitions.length"
class="text-muted-foreground text-sm" <div
v-if="ticketFieldDefinitions.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
> >
{{ t('ticketDetail.noCustomFields') }} {{ t('ticketDetail.noCustomFields') }}
</p> </div>
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
<template <div v-else class="grid gap-4 md:grid-cols-2">
<div
v-for="( v-for="(
definition, index ticketFieldDefinition, index
) in ticketFieldDefinitions" ) in ticketFieldDefinitions"
:key="definition.id" :key="ticketFieldDefinition.id"
class="grid min-w-0 gap-2"
> >
<TicketFormField <Label
v-if="definition.field_type === 'user'" :for="`field-${ticketFieldDefinition.id}`"
:id="`field-${definition.id}`" >
{{ ticketFieldDefinition.label }}
</Label>
<select
v-if="
ticketFieldDefinition.field_type ===
'user'
"
:id="`field-${ticketFieldDefinition.id}`"
v-model=" v-model="
form.field_values[index].value_user_id form.field_values[index].value_user_id
" "
kind="select" :disabled="
:label="definition.label" !canEditTicket || form.processing
:options="[ "
{ class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
value: '', >
label: t('tickets.unassigned'), <option value="">
}, {{ t('tickets.unassigned') }}
...users.map((user) => ({ </option>
value: user.id, <option
label: user.name, v-for="user in users"
})), :key="user.id"
]" :value="user.id"
:error=" >
{{ user.name }}
</option>
</select>
<Input
v-else
:id="`field-${ticketFieldDefinition.id}`"
v-model="
form.field_values[index].value_number
"
type="number"
step="0.1"
:disabled="
!canEditTicket || form.processing
"
/>
<InputError
:message="
form.errors[ form.errors[
`field_values.${index}.value_user_id` `field_values.${index}.value_user_id`
] ?? ] ??
form.errors[
`field_values.${index}.ticket_field_definition_id`
]
"
/>
<TicketFormField
v-else
:id="`field-${definition.id}`"
v-model="
form.field_values[index].value_number
"
kind="number"
step="0.1"
:label="definition.label"
:error="
form.errors[ form.errors[
`field_values.${index}.value_number` `field_values.${index}.value_number`
] ??
form.errors[
`field_values.${index}.ticket_field_definition_id`
] ]
" "
/> />
</template> </div>
</div> </div>
</fieldset> </div>
<div
class="flex flex-wrap items-center gap-3 border-t pt-5" <div v-if="canEditTicket" class="flex items-center gap-3">
> <Button type="submit" :disabled="form.processing">{{
<Button t('ticketDetail.saveChanges')
type="submit" }}</Button>
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>{{
form.processing
? t('ticketForm.saving')
: t('ticketDetail.saveChanges')
}}</Button
>
<Button
type="button"
variant="outline"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
!isDirty
"
@click="reset"
>{{ t('ticketForm.reset') }}</Button
>
<p role="status" class="text-muted-foreground text-sm">
{{
isDirty
? t('ticketForm.unsaved')
: t('ticketForm.saved')
}}
</p>
</div> </div>
</form> </form>
</div> </div>
@@ -1,76 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3'; import { useLocalization } from '@/composables/useLocalization';
import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next'; import {
import { computed, nextTick, useTemplateRef } from 'vue'; Card,
import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController'; CardContent,
import FormFeedback from '@/components/FormFeedback.vue'; CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Form, Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import InputError from '@/components/InputError.vue';
import TicketTreeList from '@/components/TicketTreeList.vue'; import TicketTreeList from '@/components/TicketTreeList.vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketRelationshipLink from '@/components/tickets/detail/TicketRelationshipLink.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';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
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 } from '@/types/ticket-detail';
const props = defineProps<TicketDetailRelationshipsProps>(); const props = defineProps<TicketDetailRelationshipsProps>();
defineEmits<{ editParent: [] }>();
const { t } = useLocalization(); const { t } = useLocalization();
const linksHeading = useTemplateRef<HTMLHeadingElement>('linksHeading');
const currentParentTicket = computed( const currentParentTicket = computed(
() => () =>
props.ticket.parent_ticket ?? props.ticket.parent_ticket ??
props.parentTicketOptions.find( props.parentTicketOptions.find(
(parent) => parent.id === props.ticket.parent_ticket_id, (parentTicket) => parentTicket.id === props.ticket.parent_ticket_id,
) ?? ) ??
null, null,
); );
const canAddLink = computed(
() =>
props.canEditTicket &&
props.ticketLinkTypes.length > 0 &&
props.ticketLinkOptions.length > 0,
);
const form = useForm({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
linked_ticket_id: '',
});
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 selectedTicket = computed(() =>
props.ticketLinkOptions.find(
(ticket) => String(ticket.id) === String(form.linked_ticket_id),
),
);
async function focusLinks(): Promise<void> {
await nextTick();
linksHeading.value?.focus();
}
function submit(): void {
if (!canAddLink.value) {
return;
}
protection.submit(
store({ project: props.project.id, ticket: props.ticket.id }),
{
preserveScroll: true,
onSuccess: () => {
form.linked_ticket_id = '';
form.clearErrors();
void focusLinks();
},
},
);
}
</script> </script>
<template> <template>
@@ -82,223 +39,184 @@ function submit(): void {
<h2 id="ticket-relationships-title" class="text-lg font-semibold"> <h2 id="ticket-relationships-title" class="text-lg font-semibold">
{{ t('ticketDetail.relationships') }} {{ t('ticketDetail.relationships') }}
</h2> </h2>
<Card class="min-w-0 gap-5 overflow-hidden"> <Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
<CardHeader <CardHeader>
class="flex flex-wrap items-center justify-between gap-3" <CardTitle>{{ t('ticketDetail.children') }}</CardTitle>
> <CardDescription>{{
<h3 class="flex items-center gap-2 font-semibold"> t('ticketDetail.childrenDescription')
<GitFork class="text-primary size-5" aria-hidden="true" />{{ }}</CardDescription>
t('ticketRelationships.hierarchy')
}}
</h3>
<Button
v-if="canEditTicket"
type="button"
variant="outline"
size="sm"
@click="$emit('editParent')"
>{{ t('ticketRelationships.editParent') }}</Button
>
</CardHeader> </CardHeader>
<CardContent class="grid min-w-0 gap-5">
<div class="grid min-w-0 gap-2"> <CardContent>
<h4
class="text-muted-foreground text-xs font-semibold uppercase tracking-wide"
>
{{ t('ticketDetail.parent') }}
</h4>
<Link
v-if="currentParentTicket"
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-primary/20 bg-primary/5 hover:border-primary focus-visible:ring-ring/35 group grid min-w-0 gap-2 rounded-xl border p-4 outline-none transition-colors focus-visible:ring-2"
>
<span
class="text-primary font-mono text-xs font-semibold"
>{{ currentParentTicket.ticket_key }}</span
>
<span
class="group-hover:text-primary text-sm font-medium"
>{{ currentParentTicket.title }}</span
>
</Link>
<p
v-else
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noParent') }}
</p>
<p
v-if="canEditTicket"
class="text-muted-foreground text-xs"
>
{{ t('ticketRelationships.parentHelp') }}
</p>
</div>
<div <div
v-if="ticket.ticket_type?.is_parent" v-if="childTicketTree.length === 0"
class="grid min-w-0 gap-3 border-t pt-5" class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
> >
<div class="flex flex-wrap items-center gap-2"> {{ t('ticketDetail.noChildren') }}
<h4 class="text-sm font-semibold">
{{ t('ticketDetail.children') }}
</h4>
<Badge variant="secondary">{{
childTicketTree.length
}}</Badge>
</div>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.childrenHelp') }}
</p>
<p
v-if="childTicketTree.length === 0"
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
>
{{ t('ticketDetail.noChildren') }}
</p>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</div> </div>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</CardContent> </CardContent>
</Card> </Card>
<Card class="min-w-0 gap-5 overflow-hidden">
<Card v-else-if="currentParentTicket !== null" class="h-fit">
<CardHeader> <CardHeader>
<h3 <CardTitle>{{ t('ticketDetail.parent') }}</CardTitle>
ref="linksHeading" <CardDescription>{{
tabindex="-1" t('ticketDetail.parentDescription')
class="focus-visible:ring-ring/35 flex flex-wrap items-center gap-2 rounded-md font-semibold outline-none focus-visible:ring-2" }}</CardDescription>
>
<Link2 class="text-primary size-5" aria-hidden="true" />{{
t('ticketDetail.linkedTickets')
}}<Badge variant="secondary">{{
linkedTickets.length
}}</Badge>
</h3>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.linkedTicketsDescription') }}
</p>
</CardHeader> </CardHeader>
<CardContent class="grid min-w-0 gap-5">
<p <CardContent>
<Link
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
>
<div class="font-medium">
{{ currentParentTicket.ticket_key }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ currentParentTicket.title }}
</div>
</Link>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.linkedTickets') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.linkedTicketsDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Form
v-if="
canEditTicket &&
ticketLinkTypes.length > 0 &&
ticketLinkOptions.length > 0
"
v-bind="
ProjectTicketLinkController.store.form({
project: project.id,
ticket: ticket.id,
})
"
class="border-border grid gap-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid min-w-0 gap-2">
<Label for="ticket_link_type_id">{{
t('ticketDetail.linkType')
}}</Label>
<select
id="ticket_link_type_id"
name="ticket_link_type_id"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
:value="ticketLinkType.id"
>
{{ ticketLinkType.label }}
</option>
</select>
<InputError :message="errors.ticket_link_type_id" />
</div>
<div class="grid min-w-0 gap-2">
<Label for="linked_ticket_id">{{
t('ticketDetail.linkedTicket')
}}</Label>
<select
id="linked_ticket_id"
name="linked_ticket_id"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketLinkOption in ticketLinkOptions"
:key="ticketLinkOption.id"
:value="ticketLinkOption.id"
>
{{
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
}}
</option>
</select>
<InputError :message="errors.linked_ticket_id" />
</div>
<Button type="submit" :disabled="processing">{{
t('ticketDetail.saveLink')
}}</Button>
</Form>
<div
v-if="linkedTickets.length === 0" v-if="linkedTickets.length === 0"
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm" class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
> >
{{ t('ticketDetail.noLinks') }} {{ t('ticketDetail.noLinks') }}
</p> </div>
<ul v-else class="grid min-w-0 gap-3">
<TicketRelationshipLink <div v-else class="space-y-3">
v-for="link in linkedTickets" <div
:key="link.id" v-for="linkedTicket in linkedTickets"
:link="link" :key="linkedTicket.id"
:project-id="project.id" class="border-border rounded-xl border px-4 py-4"
:ticket-id="ticket.id"
:ticket-key="ticket.ticket_key"
:can-edit="canEditTicket"
@removed="focusLinks"
/>
</ul>
<form
v-if="canAddLink"
class="bg-muted/30 grid min-w-0 gap-4 rounded-xl border p-4"
@submit.prevent="submit"
>
<h4 class="flex items-center gap-2 text-sm font-semibold">
<Plus class="size-4" aria-hidden="true" />{{
t('ticketRelationships.addLink')
}}
</h4>
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
> >
<TicketFormField <div class="flex flex-wrap items-center gap-2">
id="ticket_link_type_id" <Badge variant="secondary">
v-model="form.ticket_link_type_id" {{ linkedTicket.ticket_link_type.label }}
kind="select" </Badge>
:label="t('ticketDetail.linkType')" <Link
:options=" :href="
ticketLinkTypes.map((type) => ({ showTicket({
value: type.id, project: project.id,
label: type.label, ticket: linkedTicket.linked_ticket.id,
})) })
"
class="text-foreground hover:text-primary font-medium transition"
>
{{ linkedTicket.linked_ticket.ticket_key }}
</Link>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{ linkedTicket.linked_ticket.title }}
</p>
<Form
v-if="canEditTicket"
v-bind="
ProjectTicketLinkController.destroy.form({
project: project.id,
ticket: ticket.id,
ticketLink: linkedTicket.id,
})
" "
:error="form.errors.ticket_link_type_id" class="mt-3 flex justify-end"
:disabled="form.processing" v-slot="{ processing: deleting }"
required
/>
<TicketFormField
id="linked_ticket_id"
v-model="form.linked_ticket_id"
kind="select"
:label="t('ticketDetail.linkedTicket')"
:options="[
{
value: '',
label: t(
'ticketRelationships.selectTicket',
),
},
...ticketLinkOptions.map((ticket) => ({
value: ticket.id,
label: `${ticket.ticket_key} — ${ticket.title}`,
})),
]"
:error="form.errors.linked_ticket_id"
:disabled="form.processing"
required
/>
</fieldset>
<p
v-if="selectedType && 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>{{ selectedType.label }}</span
><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span
> >
</p> <Button
<p class="text-muted-foreground text-xs"> type="submit"
{{ t('ticketRelationships.directionHelp') }} variant="destructive"
</p> size="sm"
<Button :disabled="deleting"
type="submit" >{{ t('ticketDetail.removeLink') }}</Button
:disabled="form.processing || !selectedTicket" >
class="sm:justify-self-start" </Form>
>{{ </div>
form.processing </div>
? t('forms.saving')
: t('ticketDetail.saveLink')
}}</Button
>
</form>
<p
v-else-if="canEditTicket"
class="text-muted-foreground text-sm"
>
{{
t(
ticketLinkTypes.length === 0
? 'ticketRelationships.noTypes'
: 'ticketRelationships.noTargets',
)
}}
</p>
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
@@ -1,116 +0,0 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
import { computed } from 'vue';
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';
import { show } from '@/routes/projects/tickets';
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
const props = defineProps<{
link: TicketLinkModel;
projectId: number;
ticketId: number;
ticketKey: string;
canEdit: boolean;
}>();
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 {
if (!props.canEdit) {
return;
}
protection.submit(
destroy({
project: props.projectId,
ticket: props.ticketId,
ticketLink: props.link.id,
}),
{
preserveScroll: true,
onSuccess: () => emit('removed'),
},
);
}
</script>
<template>
<li
class="grid min-w-0 gap-3 rounded-xl border p-4"
: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>
<span class="text-sm font-medium">{{
link.ticket_link_type.label
}}</span>
</div>
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
<Link
:href="
show({ project: projectId, ticket: link.linked_ticket.id })
"
class="hover:text-primary focus-visible:ring-ring/35 grid min-w-0 flex-1 basis-40 gap-1 rounded-md outline-none transition-colors focus-visible:ring-2"
>
<span class="text-primary font-mono text-xs font-semibold">{{
link.linked_ticket.ticket_key
}}</span>
<span class="wrap-anywhere text-sm font-medium">{{
link.linked_ticket.title
}}</span>
</Link>
<form v-if="canEdit" @submit.prevent="remove">
<Button
type="submit"
variant="ghost"
size="sm"
class="text-muted-foreground hover:text-destructive"
:aria-label="
t('ticketRelationships.removeLink', {
key: link.linked_ticket.ticket_key,
})
"
:disabled="form.processing"
><Unlink class="size-4" aria-hidden="true" />{{
t('ticketDetail.removeLink')
}}</Button
>
</form>
</div>
<FormFeedback
v-if="canEdit"
:state="protection.state.value"
@retry="remove"
/>
</li>
</template>
-70
View File
@@ -5,76 +5,6 @@ import type {
const MAX_INDENT_LEVEL = 8; const MAX_INDENT_LEVEL = 8;
export const canIndentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): boolean => {
const item = items[index];
const previous = items[index - 1];
if (!item || !previous || item.indent > previous.indent) {
return false;
}
return items
.slice(index, acceptanceCriteriaSubtreeEnd(items, index))
.every((descendant) => descendant.indent < MAX_INDENT_LEVEL);
};
const acceptanceCriteriaSubtreeEnd = (
items: AcceptanceCriteriaModel,
index: number,
): number => {
let end = index + 1;
while (end < items.length && items[end].indent > items[index].indent) {
end++;
}
return end;
};
export const indentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
direction: 1 | -1,
): AcceptanceCriteriaModel => {
if (
!items[index] ||
(direction === 1
? !canIndentAcceptanceCriterion(items, index)
: items[index].indent === 0)
) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.map((item, position) =>
position >= index && position < end
? { ...item, indent: item.indent + direction }
: item,
);
};
/** Remove only the selected criterion, promoting its children without losing them. */
export const removeAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): AcceptanceCriteriaModel => {
if (!items[index]) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.flatMap((item, position) =>
position === index
? []
: [
{
...item,
indent:
position > index && position < end
? item.indent - 1
: item.indent,
},
],
);
};
export type AcceptanceCriteriaEditorState = { export type AcceptanceCriteriaEditorState = {
checklist: string; checklist: string;
}; };
-114
View File
@@ -1,114 +0,0 @@
import type {
BacklogFilterState,
BacklogTicketTreeNode,
} from '@/types/backlog';
import type { TicketModel } from '@/types/models/ticket-model';
export function buildBacklogTicketTree(
tickets: TicketModel[],
): BacklogTicketTreeNode[] {
const nodesByTicketId = new Map<number, BacklogTicketTreeNode>();
for (const ticket of tickets) {
nodesByTicketId.set(ticket.id, {
children: [],
matchesFilters: true,
ticket,
});
}
const roots: BacklogTicketTreeNode[] = [];
for (const ticket of tickets) {
const node = nodesByTicketId.get(ticket.id);
if (!node) {
continue;
}
const parentNode =
ticket.parent_ticket_id === null
? undefined
: nodesByTicketId.get(ticket.parent_ticket_id);
if (parentNode) {
parentNode.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
export function filterBacklogTicketTree(
nodes: BacklogTicketTreeNode[],
filters: BacklogFilterState,
): BacklogTicketTreeNode[] {
return nodes.flatMap((node) => {
const children = filterBacklogTicketTree(node.children, filters);
const matchesFilters = ticketMatchesFilters(node.ticket, filters);
if (!matchesFilters && children.length === 0) {
return [];
}
return [
{
...node,
children,
matchesFilters,
},
];
});
}
export function countBacklogTreeTickets(
nodes: BacklogTicketTreeNode[],
): number {
return nodes.reduce(
(count, node) => count + 1 + countBacklogTreeTickets(node.children),
0,
);
}
export function expandableBacklogTicketIds(
nodes: BacklogTicketTreeNode[],
): number[] {
return nodes.flatMap((node) => [
...(node.children.length > 0 ? [node.ticket.id] : []),
...expandableBacklogTicketIds(node.children),
]);
}
function ticketMatchesFilters(
ticket: TicketModel,
filters: BacklogFilterState,
): boolean {
const query = filters.query.trim().toLocaleLowerCase();
const searchableText = [
ticket.ticket_key,
ticket.title,
ticket.description,
ticket.ticket_type.name,
ticket.ticket_priority.name,
ticket.ticket_status?.name,
ticket.assignee?.name,
]
.filter((value): value is string => Boolean(value))
.join(' ')
.toLocaleLowerCase();
return (
(query === '' || searchableText.includes(query)) &&
(filters.typeId === '' || ticket.ticket_type_id === filters.typeId) &&
(filters.priorityId === '' ||
ticket.ticket_priority_id === filters.priorityId) &&
(filters.statusId === '' ||
ticket.ticket_status_id === filters.statusId) &&
(filters.assigneeId === '' ||
(filters.assigneeId === 'unassigned'
? ticket.assignee_user_id === null
: ticket.assignee_user_id === filters.assigneeId))
);
}
-96
View File
@@ -1,23 +1,4 @@
export const en = { export const en = {
'criteria.title': 'Acceptance criteria',
'criteria.progress': '{completed} of {total} complete',
'criteria.empty': 'No acceptance criteria yet.',
'criteria.required': 'Enter a criterion or remove this row.',
'criteria.edit': 'Edit checklist',
'criteria.preview': 'Preview checklist',
'criteria.item': 'Criterion {number}',
'criteria.complete': 'Complete criterion {number}: {text}',
'criteria.indent': 'Indent criterion {number}',
'criteria.outdent': 'Outdent criterion {number}',
'criteria.remove': 'Remove criterion {number}',
'criteria.add': 'Add criterion',
'criteria.hint':
'Use the indent buttons or Alt + Left/Right in a text field to nest criteria. Checking a parent also checks its children. Removing a parent keeps its children.',
'criteria.unsaved': 'Checklist changes will be saved with the ticket.',
'criteria.saving': 'Saving checklist…',
'criteria.saveError':
'Checklist status could not be saved. Please try again.',
'board.title': '{name} board', 'board.title': '{name} board',
'board.description': 'board.description':
'Work in your configured workflow. Tickets without a board column stay in the backlog.', 'Work in your configured workflow. Tickets without a board column stay in the backlog.',
@@ -50,40 +31,6 @@ export const en = {
'board.targetStatus': 'Destination status', 'board.targetStatus': 'Destination status',
'board.noMovesDescription': 'board.noMovesDescription':
'This status does not allow moving anywhere else.', 'This status does not allow moving anywhere else.',
'backlog.title': '{name} backlog',
'backlog.description':
'Shape incoming work without losing sight of parent and child tickets.',
'backlog.openBoard': 'Open board',
'backlog.backToProject': 'Project overview',
'backlog.moveTicket': 'Move {key}',
'backlog.filters.title': 'Find backlog tickets',
'backlog.filters.description':
'Filter this view locally. Matching child tickets keep their parent context visible.',
'backlog.filters.search': 'Search',
'backlog.filters.searchPlaceholder': 'Key, title, description, or metadata',
'backlog.filters.type': 'Type',
'backlog.filters.allTypes': 'All types',
'backlog.filters.priority': 'Priority',
'backlog.filters.allPriorities': 'All priorities',
'backlog.filters.status': 'Status',
'backlog.filters.allStatuses': 'All backlog statuses',
'backlog.filters.assignee': 'Assignee',
'backlog.filters.allAssignees': 'All assignees',
'backlog.filters.clear': 'Clear filters',
'backlog.filters.parentContext': 'Parent context',
'backlog.filters.empty.title': 'No tickets match these filters',
'backlog.filters.empty.description':
'Try a broader search or clear the filters to see the complete backlog.',
'backlog.results.title': 'Backlog tickets',
'backlog.results.count': 'Showing {count} of {total} tickets',
'backlog.empty.title': 'Your backlog is clear',
'backlog.empty.description':
'New tickets without a workflow column will appear here.',
'backlog.empty.action': 'Create the first ticket',
'backlog.tree.expand': 'Expand children of {key}',
'backlog.tree.collapse': 'Collapse children of {key}',
'backlog.tree.expandAll': 'Expand all',
'backlog.tree.collapseAll': 'Collapse all',
'projectCreation.title': 'Create a project', 'projectCreation.title': 'Create a project',
'projectCreation.description': 'projectCreation.description':
'Give your work a home. Start fresh or reuse a setup that already works for your team.', 'Give your work a home. Start fresh or reuse a setup that already works for your team.',
@@ -409,8 +356,6 @@ export const en = {
'tickets.noStatus': 'No status', 'tickets.noStatus': 'No status',
'tickets.assignedTo': 'Assigned to {name}', 'tickets.assignedTo': 'Assigned to {name}',
'tickets.unassigned': 'Unassigned', 'tickets.unassigned': 'Unassigned',
'tickets.tree.expand': 'Expand children of {key}',
'tickets.tree.collapse': 'Collapse children of {key}',
'common.save': 'Save', 'common.save': 'Save',
'common.loading': 'Loading…', 'common.loading': 'Loading…',
'common.error': 'Something went wrong.', 'common.error': 'Something went wrong.',
@@ -512,50 +457,9 @@ export const en = {
'ticketDetail.noLinks': 'ticketDetail.noLinks':
'No ticket links are configured for this ticket yet.', 'No ticket links are configured for this ticket yet.',
'ticketDetail.removeLink': 'Remove', 'ticketDetail.removeLink': 'Remove',
'ticketRelationships.hierarchy': 'Hierarchy',
'ticketRelationships.editParent': 'Change parent',
'ticketRelationships.parentHelp':
'Change parent to assign this ticket or choose “No parent ticket” to detach it.',
'ticketRelationships.childrenHelp':
'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.directionHelp':
'The selected relationship points from this ticket to the linked 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.',
'ticketRelationships.noTargets':
'No other tickets are available to link in this project.',
'ticketDetail.historyDescription': 'ticketDetail.historyDescription':
'Every tracked ticket change is recorded with actor and timestamp.', 'Every tracked ticket change is recorded with actor and timestamp.',
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.', 'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
'ticketDetail.system': 'System', 'ticketDetail.system': 'System',
'ticketDetail.sourceBranch': 'Source branch: {name}', 'ticketDetail.sourceBranch': 'Source branch: {name}',
'ticketForm.create': 'Create ticket',
'ticketForm.pageTitle': 'Create {project} ticket',
'ticketForm.createDescription': 'Add a ticket to {project}.',
'ticketForm.backToBoard': 'Back to board',
'ticketForm.backToBacklog': 'Back to backlog',
'ticketForm.details': 'Ticket details',
'ticketForm.keyAssigned': 'The ticket key is assigned after creation.',
'ticketForm.titlePlaceholder': 'Implement repository sync',
'ticketForm.descriptionPlaceholder':
'Describe the expected behavior and relevant context.',
'ticketForm.checklistHelp':
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps.',
'ticketForm.checklistPreview': 'Checklist preview',
'ticketForm.metadataRequired':
'Ticket creation needs at least one type, one priority, and one status.',
'ticketForm.openSettings': 'Open ticket settings',
'ticketForm.creating': 'Creating ticket…',
'ticketForm.saving': 'Saving changes…',
'ticketForm.reset': 'Discard changes',
'ticketForm.unsaved': 'Unsaved changes',
'ticketForm.ready': 'Ready for a new ticket',
'ticketForm.saved': 'All changes saved',
'ticketForm.authorRestricted': 'Only project admins can change the author.',
'ticketForm.blocker': 'Blocker',
} as const; } as const;
+166 -399
View File
@@ -1,155 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Form, Head, Link } from '@inertiajs/vue3';
import { import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
ArrowLeft, import Heading from '@/components/Heading.vue';
ChevronsDownUp, import { TicketListItem } from '@/components/tickets';
ChevronsUpDown,
Columns3,
Plus,
RotateCcw,
Search,
} from 'lucide-vue-next';
import { computed, reactive, ref, watch } from 'vue';
import BacklogTicketTree from '@/components/BacklogTicketTree.vue';
import FeedbackState from '@/components/FeedbackState.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';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { import {
buildBacklogTicketTree, Card,
countBacklogTreeTickets, CardContent,
expandableBacklogTicketIds, CardDescription,
filterBacklogTicketTree, CardHeader,
} from '@/lib/backlog'; CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects/index'; import { index, show } from '@/routes/projects/index';
import { create as createTicket } from '@/routes/projects/tickets'; import { create as createTicket } from '@/routes/projects/tickets';
import type { import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
BacklogFilterState, import type { TicketModel } from '@/types/models/ticket-model';
BreadcrumbItem, import type { TicketStatusModel } from '@/types/models/ticket-status-model';
ProjectBacklogPageProps,
} from '@/types';
const props = defineProps<ProjectBacklogPageProps>(); const props = defineProps<ProjectBacklogPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: projectIndex() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.backlog'), href: backlogIndex(props.project.id) },
]);
const filters = reactive<BacklogFilterState>({ const breadcrumbs: BreadcrumbItem[] = [
assigneeId: '', {
priorityId: '', title: 'Projects',
query: '', href: index(),
statusId: '',
typeId: '',
});
const collapsedTicketIds = reactive(new Set<number>());
const pendingTicketId = ref<number | null>(null);
const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
const filteredTicketTree = computed(() =>
filterBacklogTicketTree(ticketTree.value, filters),
);
const visibleTicketCount = computed(() =>
countBacklogTreeTickets(filteredTicketTree.value),
);
const expandableTicketIds = computed(() =>
expandableBacklogTicketIds(filteredTicketTree.value),
);
const hasActiveFilters = computed(
() =>
filters.query.trim() !== '' ||
filters.typeId !== '' ||
filters.priorityId !== '' ||
filters.statusId !== '' ||
filters.assigneeId !== '',
);
const allVisibleBranchesCollapsed = computed(
() =>
expandableTicketIds.value.length > 0 &&
expandableTicketIds.value.every((ticketId) =>
collapsedTicketIds.has(ticketId),
),
);
function clearFilters(): void {
filters.query = '';
filters.typeId = '';
filters.priorityId = '';
filters.statusId = '';
filters.assigneeId = '';
}
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
function toggleAllBranches(): void {
if (allVisibleBranchesCollapsed.value) {
collapsedTicketIds.clear();
return;
}
for (const ticketId of expandableTicketIds.value) {
collapsedTicketIds.add(ticketId);
}
}
function finishMove(ticketId: number): void {
if (pendingTicketId.value === ticketId) {
pendingTicketId.value = null;
}
}
watch(
() => props.project.id,
() => {
clearFilters();
collapsedTicketIds.clear();
pendingTicketId.value = null;
}, },
); {
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Backlog',
href: backlogIndex(props.project.id),
},
];
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
);
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('backlog.title', { name: project.name })" /> <Head :title="`${project.name} backlog`" />
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4"> <div class="flex flex-1 flex-col gap-6 p-4">
<header <div
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
<div class="grid min-w-0 gap-2"> <div class="space-y-3">
<div class="flex min-w-0 flex-wrap items-center gap-3"> <Badge variant="outline" class="px-3 py-1">
<Badge {{ project.key }}
variant="outline" </Badge>
class="wrap-anywhere max-w-full whitespace-normal" <Heading
> :title="`${project.name} backlog`"
{{ project.key }} description="All newly created tickets start here before they move onto the board."
</Badge> />
<p class="text-caption text-muted-foreground">
{{ t('tickets.count', { count: tickets.length }) }}
</p>
</div>
<h1
class="text-headline-mobile md:text-headline wrap-anywhere"
>
{{ t('backlog.title', { name: project.name }) }}
</h1>
<p class="text-caption text-muted-foreground max-w-2xl">
{{ t('backlog.description') }}
</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child> <Button v-if="canCreateTickets" as-child>
<Link <Link
:href=" :href="
@@ -159,286 +74,138 @@ watch(
) )
" "
> >
<Plus aria-hidden="true" /> Create ticket
{{ t('navigation.createTicket') }}
</Link> </Link>
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="boardIndex(project.id)"> <Link :href="boardIndex(project.id)">Open board</Link>
<Columns3 aria-hidden="true" />
{{ t('backlog.openBoard') }}
</Link>
</Button> </Button>
<Button as-child variant="ghost">
<Link :href="show(project.id)"> <Button as-child variant="outline">
<ArrowLeft aria-hidden="true" /> <Link :href="show(project.id)">Back to project</Link>
{{ t('backlog.backToProject') }}
</Link>
</Button> </Button>
</div> </div>
</header> </div>
<FeedbackState v-if="status" state="success" :title="status" /> <div
v-if="status"
<section class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
aria-labelledby="backlog-filter-title"
class="bg-card gap-card p-card grid min-w-0 rounded-md border"
> >
<div {{ status }}
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between" </div>
>
<div class="grid min-w-0 gap-1">
<h2 id="backlog-filter-title" class="text-title">
{{ t('backlog.filters.title') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('backlog.filters.description') }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
:disabled="!hasActiveFilters"
@click="clearFilters"
>
<RotateCcw aria-hidden="true" />
{{ t('backlog.filters.clear') }}
</Button>
</div>
<div <Card class="gap-0 overflow-hidden">
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]" <CardHeader class="border-b">
> <CardTitle>Backlog tickets</CardTitle>
<CardDescription>
Tickets remain here while their status belongs to
backlog.
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<div <div
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1" v-if="tickets.length === 0"
class="text-muted-foreground p-6 text-sm"
> >
<label No backlog tickets exist yet.
for="backlog-query"
class="text-caption font-semibold"
>
{{ t('backlog.filters.search') }}
</label>
<div class="relative min-w-0">
<Search
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="backlog-query"
v-model="filters.query"
type="search"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border py-2 pl-9 pr-3 focus-visible:outline-none focus-visible:ring-2"
:placeholder="
t('backlog.filters.searchPlaceholder')
"
/>
</div>
</div> </div>
<div class="grid min-w-0 gap-1.5"> <ul v-else class="gap-column grid p-4">
<label <TicketListItem
for="backlog-type" v-for="ticket in tickets"
class="text-caption font-semibold" :key="ticket.id"
:project-id="project.id"
:ticket="ticket"
> >
{{ t('backlog.filters.type') }} <template v-if="canEditTickets" #actions>
</label> <Form
<select v-bind="
id="backlog-type" ProjectBoardController.move.form({
v-model="filters.typeId" project: project.id,
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2" ticket: ticket.id,
> })
<option value=""> "
{{ t('backlog.filters.allTypes') }} class="flex w-full flex-col gap-2"
</option> v-slot="{ processing }"
<option >
v-for="ticketType in ticketTypes" <input
:key="ticketType.id" type="hidden"
:value="ticketType.id" name="redirect_to"
> value="backlog"
{{ ticketType.name }} />
</option> <label
</select> :for="`backlog-ticket-status-${ticket.id}`"
</div> class="text-label text-muted-foreground uppercase"
>
<div class="grid min-w-0 gap-1.5"> Move to status
<label </label>
for="backlog-priority" <div
class="text-caption font-semibold" class="flex flex-col gap-2 sm:flex-row"
> >
{{ t('backlog.filters.priority') }} <select
</label> :id="`backlog-ticket-status-${ticket.id}`"
<select name="ticket_status_id"
id="backlog-priority" :disabled="
v-model="filters.priorityId" allowedTargetStatuses(ticket)
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2" .length === 0 || processing
> "
<option value=""> class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-control min-w-0 flex-1 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
{{ t('backlog.filters.allPriorities') }} >
</option> <option
<option v-if="
v-for="priority in ticketPriorities" allowedTargetStatuses(
:key="priority.id" ticket,
:value="priority.id" ).length === 0
> "
{{ priority.name }} value=""
</option> >
</select> No allowed moves
</div> </option>
<option
<div class="grid min-w-0 gap-1.5"> v-for="ticketStatus in allowedTargetStatuses(
<label ticket,
for="backlog-status" )"
class="text-caption font-semibold" :key="ticketStatus.id"
> :value="ticketStatus.id"
{{ t('backlog.filters.status') }} >
</label> {{
<select ticketStatusLabel(
id="backlog-status" ticketStatus,
v-model="filters.statusId" 'Unknown status',
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2" )
> }}
<option value=""> </option>
{{ t('backlog.filters.allStatuses') }} </select>
</option> <Button
<option type="submit"
v-for="ticketStatus in backlogTicketStatuses" size="sm"
:key="ticketStatus.id" :disabled="
:value="ticketStatus.id" processing ||
> allowedTargetStatuses(ticket)
{{ ticketStatus.name }} .length === 0
</option> "
</select> >
</div> Move
</Button>
<div class="grid min-w-0 gap-1.5"> </div>
<label <p
for="backlog-assignee" v-if="
class="text-caption font-semibold" allowedTargetStatuses(ticket)
> .length === 0
{{ t('backlog.filters.assignee') }} "
</label> class="text-caption text-muted-foreground"
<select >
id="backlog-assignee" This status does not allow moving
v-model="filters.assigneeId" anywhere else.
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2" </p>
> </Form>
<option value=""> </template>
{{ t('backlog.filters.allAssignees') }} </TicketListItem>
</option> </ul>
<option value="unassigned"> </CardContent>
{{ t('tickets.unassigned') }} </Card>
</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
</div>
</div>
</section>
<div
v-if="tickets.length === 0"
class="grid justify-items-start gap-3"
>
<FeedbackState
state="empty"
:title="t('backlog.empty.title')"
:description="t('backlog.empty.description')"
/>
<Button v-if="canCreateTickets" as-child size="sm">
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'backlog' } },
)
"
>
<Plus aria-hidden="true" />
{{ t('backlog.empty.action') }}
</Link>
</Button>
</div>
<div
v-else-if="visibleTicketCount === 0"
class="grid justify-items-start gap-3"
>
<FeedbackState
state="empty"
:title="t('backlog.filters.empty.title')"
:description="t('backlog.filters.empty.description')"
/>
<Button
type="button"
variant="outline"
size="sm"
@click="clearFilters"
>
<RotateCcw aria-hidden="true" />
{{ t('backlog.filters.clear') }}
</Button>
</div>
<section
v-else
aria-labelledby="backlog-results-title"
class="grid min-w-0 gap-3"
>
<div
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="grid min-w-0 gap-1">
<h2 id="backlog-results-title" class="text-title">
{{ t('backlog.results.title') }}
</h2>
<p class="text-caption text-muted-foreground">
{{
t('backlog.results.count', {
count: visibleTicketCount,
total: tickets.length,
})
}}
</p>
</div>
<Button
v-if="expandableTicketIds.length > 0"
type="button"
variant="outline"
size="sm"
@click="toggleAllBranches"
>
<ChevronsUpDown
v-if="allVisibleBranchesCollapsed"
aria-hidden="true"
/>
<ChevronsDownUp v-else aria-hidden="true" />
{{
t(
allVisibleBranchesCollapsed
? 'backlog.tree.expandAll'
: 'backlog.tree.collapseAll',
)
}}
</Button>
</div>
<BacklogTicketTree
:nodes="filteredTicketTree"
:project-id="project.id"
:can-edit-tickets="canEditTickets"
:collapsed-ticket-ids="collapsedTicketIds"
:pending-ticket-id="pendingTicketId"
@toggle="toggleTicket"
@move-start="pendingTicketId = $event"
@move-finish="finishMove"
/>
</section>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+1 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed, useTemplateRef } from 'vue'; import { computed } from 'vue';
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue'; import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue'; import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue'; import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
@@ -14,7 +14,6 @@ import { show as showTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types'; import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>(); const props = defineProps<ProjectTicketPageProps>();
const { t } = useLocalization(); const { t } = useLocalization();
const ticketContent = useTemplateRef('ticketContent');
const breadcrumbs = computed<BreadcrumbItem[]>(() => [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: projectIndex() }, { title: t('navigation.projects'), href: projectIndex() },
{ title: props.project.name, href: show(props.project.id) }, { title: props.project.name, href: show(props.project.id) },
@@ -50,7 +49,6 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
class="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]" class="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"
> >
<TicketDetailContent <TicketDetailContent
ref="ticketContent"
:ticket="ticket" :ticket="ticket"
:project="project" :project="project"
:can-edit-ticket="canEditTicket" :can-edit-ticket="canEditTicket"
@@ -82,7 +80,6 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
:linked-tickets="linkedTickets" :linked-tickets="linkedTickets"
:ticket-link-types="ticketLinkTypes" :ticket-link-types="ticketLinkTypes"
:ticket-link-options="ticketLinkOptions" :ticket-link-options="ticketLinkOptions"
@edit-parent="ticketContent?.editParent()"
class="xl:col-start-1" class="xl:col-start-1"
/> />
<TicketDetailHistory :ticket="ticket" class="xl:col-start-1" /> <TicketDetailHistory :ticket="ticket" class="xl:col-start-1" />
+319 -119
View File
@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
import { useSessionStorage } from '@vueuse/core'; import { computed, toRef, unref } from 'vue';
import { computed } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue'; import Heading from '@/components/Heading.vue';
import { useLocalization } from '@/composables/useLocalization'; import InputError from '@/components/InputError.vue';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -16,30 +15,32 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue'; import {
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model'; buildAcceptanceCriteria,
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index'; import { index, show } from '@/routes/projects/index';
import { metadata as metadataSettings } from '@/routes/projects/settings'; import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types'; import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectTicketCreatePageProps>(); const props = defineProps<ProjectTicketCreatePageProps>();
const { t } = useLocalization();
const returnHref = const returnHref =
props.source === 'board' props.source === 'board'
? boardIndex(props.project.id) ? boardIndex(props.project.id)
: backlogIndex(props.project.id); : backlogIndex(props.project.id);
const returnLabel = const returnLabel =
props.source === 'board' props.source === 'board' ? 'Back to board' : 'Back to backlog';
? t('ticketForm.backToBoard')
: t('ticketForm.backToBacklog');
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
title: t('navigation.projects'), title: 'Projects',
href: index(), href: index(),
}, },
{ {
@@ -47,31 +48,36 @@ const breadcrumbs: BreadcrumbItem[] = [
href: show(props.project.id), href: show(props.project.id),
}, },
{ {
title: title: props.source === 'board' ? 'Board' : 'Backlog',
props.source === 'board'
? t('navigation.board')
: t('navigation.backlog'),
href: returnHref, href: returnHref,
}, },
{ {
title: t('ticketForm.create'), title: 'Create ticket',
href: ProjectTicketController.create({ href: ProjectTicketController.create({
project: props.project.id, project: props.project.id,
}), }),
}, },
]; ];
const hasTicketMetadata = computed( const hasTicketMetadata =
() => props.ticketTypes.length > 0 &&
props.ticketTypes.length > 0 && props.ticketPriorities.length > 0 &&
props.ticketPriorities.length > 0 && props.ticketStatusOptions.length > 0;
props.ticketStatusOptions.length > 0,
);
const draftKey = `projects:${props.project.id}:tickets:create`; const draftKey = `projects:${props.project.id}:tickets:create`;
const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0); const acceptanceCriteriaDraft = useRemember(
const form = useForm(`${draftKey}:${draftGeneration.value}`, { { checklist: '' },
acceptance_criteria: [] as AcceptanceCriteriaModel, `${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
assignee_user_id: '', assignee_user_id: '',
description: '', description: '',
parent_ticket_id: '', parent_ticket_id: '',
@@ -84,16 +90,13 @@ const form = useForm(`${draftKey}:${draftGeneration.value}`, {
title: '', title: '',
}); });
const isDirty = computed(() => form.isDirty); const protection = useFormProtection(form, {
const protection = useFormProtection(form); isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
const reset = (): void => { });
if (form.processing || !protection.confirmLeave()) {
return; const acceptanceCriteriaPreviewItems = computed(() =>
} parseChecklistLines(acceptanceCriteriaChecklist.value),
form.reset(); );
form.clearErrors();
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
@@ -106,35 +109,39 @@ const acceptanceCriteriaError = computed(() => {
}); });
const submit = (): void => { const submit = (): void => {
if (!hasTicketMetadata.value || form.processing) { form.acceptance_criteria = buildAcceptanceCriteria(
return; acceptanceCriteriaChecklist.value,
} );
protection.submit( protection.submit(
ProjectTicketController.store({ project: props.project.id }), ProjectTicketController.store({ project: props.project.id }),
{ {
preserveScroll: true, preserveScroll: true,
onSuccess: () => {
// The source page is unmounted before this callback, so its storage watcher has stopped.
window.sessionStorage.setItem(
`${draftKey}:generation`,
String(draftGeneration.value + 1),
);
draftGeneration.value++;
form.reset();
},
}, },
); );
}; };
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('ticketForm.pageTitle', { project: project.name })" /> <Head :title="`Create ${project.name} ticket`" />
<div <div class="flex flex-1 flex-col gap-6 p-4">
class="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
>
<div <div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
@@ -142,16 +149,10 @@ const submit = (): void => {
<Badge variant="outline" class="px-3 py-1"> <Badge variant="outline" class="px-3 py-1">
{{ project.key }} {{ project.key }}
</Badge> </Badge>
<h1 class="text-headline font-semibold tracking-tight"> <Heading
{{ t('ticketForm.create') }} title="Create ticket"
</h1> :description="`Add a ticket to ${project.name}.`"
<p class="text-muted-foreground text-sm"> />
{{
t('ticketForm.createDescription', {
project: project.name,
})
}}
</p>
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
@@ -159,9 +160,7 @@ const submit = (): void => {
<Link :href="returnHref">{{ returnLabel }}</Link> <Link :href="returnHref">{{ returnLabel }}</Link>
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="show(project.id)">{{ <Link :href="show(project.id)">Back to project</Link>
t('ticketDetail.backToProject')
}}</Link>
</Button> </Button>
</div> </div>
</div> </div>
@@ -175,9 +174,9 @@ const submit = (): void => {
<Card v-if="hasTicketMetadata"> <Card v-if="hasTicketMetadata">
<CardHeader> <CardHeader>
<CardTitle>{{ t('ticketForm.details') }}</CardTitle> <CardTitle>Ticket details</CardTitle>
<CardDescription> <CardDescription>
{{ t('ticketForm.keyAssigned') }} The ticket key is assigned after creation.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -187,58 +186,258 @@ const submit = (): void => {
:state="protection.state.value" :state="protection.state.value"
@retry="submit" @retry="submit"
/> />
<TicketFormFields <div class="grid gap-2">
v-model:title="form.title" <Label for="title">Title</Label>
v-model:description="form.description" <Input
v-model:type="form.ticket_type_id" id="title"
v-model:priority="form.ticket_priority_id" v-model="form.title"
v-model:status="form.ticket_status_id" :invalid="Boolean(form.errors.title)"
v-model:assignee="form.assignee_user_id" aria-describedby="title-error"
v-model:parent="form.parent_ticket_id" required
v-model:points="form.story_points" autofocus
:project="project" :disabled="form.processing"
:ticket-types="ticketTypes" placeholder="Implement repository sync"
:ticket-priorities="ticketPriorities" />
:ticket-status-options="ticketStatusOptions" <InputError
:users="users" id="title-error"
:parent-ticket-options="parentTicketOptions" :message="form.errors.title"
:errors="form.errors" />
:disabled="form.processing" </div>
autofocus
>
<template #content>
<AcceptanceCriteriaChecklist
v-model="form.acceptance_criteria"
:disabled="form.processing"
:error="acceptanceCriteriaError"
/>
</template>
</TicketFormFields>
<div <div
class="flex flex-wrap items-center gap-3 border-t pt-5" class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
> >
<Button type="submit" :disabled="form.processing">{{ <div class="grid gap-6">
form.processing <div class="grid gap-2">
? t('ticketForm.creating') <Label for="description">Description</Label>
: t('ticketForm.create') <textarea
}}</Button> id="description"
<Button v-model="form.description"
type="button" rows="4"
variant="outline" :disabled="form.processing"
:disabled="form.processing || !isDirty" class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
@click="reset" placeholder="Describe the expected behavior and relevant context."
>{{ t('ticketForm.reset') }}</Button />
> <InputError
<p :message="form.errors.description"
role="status" />
class="text-muted-foreground text-sm" </div>
>
{{ <div class="grid gap-4">
isDirty <div class="grid gap-2">
? t('ticketForm.unsaved') <Label
: t('ticketForm.ready') for="acceptance_criteria_checklist"
}} >
</p> Acceptance criteria checklist
</Label>
<textarea
id="acceptance_criteria_checklist"
v-model="
acceptanceCriteriaChecklist
"
rows="6"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
placeholder="[ ] User can sign in&#10; [x] Validation is shown for invalid credentials"
/>
<p
class="text-muted-foreground text-xs"
>
Use <code>[ ]</code> /
<code>[x]</code> for checklist
items. Indent with two spaces for
nested steps.
</p>
<InputError
:message="acceptanceCriteriaError"
/>
</div>
<div
v-if="
acceptanceCriteriaPreviewItems.length >
0
"
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
Checklist preview
</p>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="index"
:style="{
marginLeft: `${item.indent * 1.25}rem`,
}"
class="flex items-start gap-2"
>
<span class="mt-0.5">{{
item.checked ? '☑' : '☐'
}}</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</div>
</div>
<div class="grid h-fit gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
v-model="form.ticket_type_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="String(ticketType.id)"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="form.errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
v-model="form.ticket_priority_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="String(ticketPriority.id)"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="
form.errors.ticket_priority_id
"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">Status</Label>
<select
id="ticket_status_id"
v-model="form.ticket_status_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="ticketStatus in ticketStatusOptions"
:key="ticketStatus.id"
:value="String(ticketStatus.id)"
>
{{
ticketStatusLabel(ticketStatus)
}}
</option>
</select>
<InputError
:message="form.errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
v-model="form.assignee_user_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="String(user.id)"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id">
Parent ticket
</Label>
<select
id="parent_ticket_id"
v-model="form.parent_ticket_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">
No parent ticket
</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="
String(parentTicketOption.id)
"
>
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError
:message="form.errors.parent_ticket_id"
/>
</div>
<div
v-if="project.show_story_points"
class="grid gap-2"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
v-model="form.story_points"
type="number"
step="1"
:disabled="form.processing"
/>
<InputError
:message="form.errors.story_points"
/>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button type="submit" :disabled="form.processing">
Create ticket
</Button>
<Button as-child variant="outline">
<Link :href="returnHref">
{{ returnLabel }}
</Link>
</Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
@@ -247,12 +446,13 @@ const submit = (): void => {
<Card v-else> <Card v-else>
<CardContent class="flex flex-col gap-4 p-6 text-sm"> <CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground"> <p class="text-muted-foreground">
{{ t('ticketForm.metadataRequired') }} Ticket creation needs at least one type, one priority,
and one status.
</p> </p>
<div> <div>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="metadataSettings(project.id)"> <Link :href="metadataSettings(project.id)">
{{ t('ticketForm.openSettings') }} Open ticket settings
</Link> </Link>
</Button> </Button>
</div> </div>
-31
View File
@@ -1,31 +0,0 @@
import type { TicketModel } from '@/types/models/ticket-model';
export type BacklogAssigneeFilter = '' | 'unassigned' | number;
export type BacklogFilterState = {
assigneeId: BacklogAssigneeFilter;
priorityId: '' | number;
query: string;
statusId: '' | number;
typeId: '' | number;
};
export type BacklogTicketTreeNode = {
children: BacklogTicketTreeNode[];
matchesFilters: boolean;
ticket: TicketModel;
};
export type BacklogTicketTreeProps = {
canEditTickets: boolean;
collapsedTicketIds: Set<number>;
nodes: BacklogTicketTreeNode[];
pendingTicketId: number | null;
projectId: number;
};
export type BacklogTicketTreeEmits = {
moveFinish: [ticketId: number];
moveStart: [ticketId: number];
toggle: [ticketId: number];
};
-2
View File
@@ -1,6 +1,4 @@
export * from './auth'; export * from './auth';
export * from './backlog';
export * from './navigation'; export * from './navigation';
export * from './page-props'; export * from './page-props';
export * from './ticket-tree';
export * from './ui'; export * from './ui';
@@ -1,7 +1,6 @@
import type { TicketModel } from '@/types/models/ticket-model'; import type { TicketModel } from '@/types/models/ticket-model';
export type TicketPresentationProps = { export type TicketPresentationProps = {
layout?: 'card' | 'list';
projectId: number; projectId: number;
showDescription?: boolean; showDescription?: boolean;
showStatus?: boolean; showStatus?: boolean;
-6
View File
@@ -1,6 +0,0 @@
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
export type TicketTreeListProps = {
nodes: TicketTreeNodeModel[];
projectId: number;
};
@@ -1,19 +0,0 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class AcceptanceCriteriaChecklistTest extends TestCase
{
public function test_checklist_preserves_hierarchy_identity_and_accessible_controls(): void
{
$process = new Process(['node', 'tests/Fixtures/acceptance-criteria.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Acceptance criteria behavior checks passed.', $process->getOutput());
}
}
@@ -71,110 +71,6 @@ class ProjectBacklogTest extends TestCase
); );
} }
/**
* Ensure the backlog payload supports nested tickets and their allowed status moves.
*/
public function test_backlog_includes_nested_tickets_and_allowed_status_moves(): void
{
$editor = User::factory()->create();
$project = Project::factory()->create([
'key' => 'TREE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$backlogStatus = $project->ticketStatuses()
->whereNull('board_column_id')
->firstOrFail();
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Todo',
'position' => 1,
]);
$boardStatus = TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'Ready',
'position' => 1,
]);
$backlogStatus->allowedTargetStatuses()->sync([$boardStatus->id]);
$parentType = $project->ticketTypes()
->where('is_parent', true)
->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$parentTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'TREE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $parentType->id,
'title' => 'Parent ticket',
]);
$childTicket = Ticket::factory()->create([
'parent_ticket_id' => $parentTicket->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'TREE-2',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $taskType->id,
'title' => 'Child ticket',
]);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->get(route('projects.backlog.index', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Backlog')
->where('canEditTickets', true)
->has('tickets', 2)
->where('tickets.0.id', $childTicket->id)
->where('tickets.0.parent_ticket_id', $parentTicket->id)
->where('tickets.0.ticket_status.allowed_target_statuses.0.id', $boardStatus->id)
->where('tickets.1.id', $parentTicket->id)
->where('tickets.1.parent_ticket_id', null)
->where('tickets.1.ticket_status.allowed_target_statuses.0.id', $boardStatus->id),
);
$this->assertDatabaseHas('tickets', [
'id' => $parentTicket->id,
'ticket_status_id' => $backlogStatus->id,
]);
$this->assertDatabaseHas('tickets', [
'id' => $childTicket->id,
'parent_ticket_id' => $parentTicket->id,
'ticket_status_id' => $backlogStatus->id,
]);
}
/**
* Ensure projects without backlog tickets receive an empty collection.
*/
public function test_projects_without_backlog_tickets_receive_an_empty_collection(): void
{
$viewer = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.backlog.index', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Backlog')
->where('tickets', []),
);
}
/** /**
* Ensure creating tickets requires the project write permission. * Ensure creating tickets requires the project write permission.
*/ */
@@ -1511,74 +1511,6 @@ class ProjectTicketControllerTest extends TestCase
]); ]);
} }
public function test_read_only_users_cannot_open_or_submit_the_ticket_create_form(): void
{
$viewer = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.tickets.create', $project))
->assertForbidden();
$this->post(route('projects.tickets.store', $project), [
'title' => 'Unauthorized ticket',
'ticket_type_id' => $this->nonParentType($project)->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
])->assertForbidden();
$this->assertDatabaseCount('tickets', 0);
}
public function test_ticket_forms_reject_metadata_from_another_project(): void
{
$editor = User::factory()->create();
$project = Project::factory()->create();
$otherProject = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
$parent = Ticket::factory()->create([
'project_id' => $otherProject->id,
'ticket_key' => 'OTHER-1',
'ticket_type_id' => $this->parentType($otherProject)->id,
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
]);
$invalidMetadata = [
'parent_ticket_id' => $parent->id,
'ticket_type_id' => $this->nonParentType($otherProject)->id,
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
];
$this->actingAs($editor)
->post(route('projects.tickets.store', $project), [
...$invalidMetadata,
'title' => 'Rejected creation',
'ticket_status_id' => $otherProject->ticketStatuses()->firstOrFail()->id,
])
->assertSessionHasErrors([...array_keys($invalidMetadata), 'ticket_status_id']);
$this->assertSame(0, $project->tickets()->count());
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'ticket_key' => 'LOCAL-1',
'ticket_type_id' => $this->nonParentType($project)->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'title' => 'Original title',
]);
$this->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
...$invalidMetadata,
'title' => 'Rejected update',
]))->assertSessionHasErrors(array_keys($invalidMetadata));
$this->assertSame('Original title', $ticket->fresh()->title);
$this->assertNull($ticket->fresh()->parent_ticket_id);
}
private function nonParentType(Project $project): TicketType private function nonParentType(Project $project): TicketType
{ {
return $project->ticketTypes() return $project->ticketTypes()
@@ -1,182 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4176';
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
hasTouch: true,
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
page.on('dialog', (dialog) => dialog.accept());
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const checklist = () =>
page
.locator('form')
.first()
.getByRole('region', { name: 'Acceptance criteria' });
const savedChecklist = () => page.locator('#ticket-checklist');
const field = (number) =>
page.getByRole('textbox', { name: `Criterion ${number}`, exact: true });
const button = (name) => page.getByRole('button', { name, exact: true });
const setResult = (value) =>
context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
const waitState = (value) =>
page.locator(`[data-form-state="${value}"]`).first().waitFor();
const save = async () => {
await button('Save changes').click();
await waitState('success');
};
try {
await page.goto(`${base}/projects/7/tickets/31`);
await savedChecklist().waitFor();
const boxes = savedChecklist().getByRole('checkbox');
await setResult('server');
await boxes.first().focus();
await page.keyboard.press('Space');
await page
.getByText('Checklist status could not be saved. Please try again.')
.waitFor();
assert.equal(
await boxes.first().isChecked(),
false,
'Failure must restore saved checkbox',
);
await setResult('success');
let payload;
page.on('request', (request) => {
if (request.method() === 'PATCH') {
payload = request.postDataJSON();
}
});
await boxes.first().tap();
await page.waitForFunction(
() =>
document.querySelectorAll('section[aria-busy="true"]').length === 0,
);
await savedChecklist().getByText('3 of 4 complete').waitFor();
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.checked),
[true, true, true, false],
);
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.id),
[11, 12, 13, 14],
);
await page.locator('#ticket-edit > summary').click();
await field(2).fill('Edited child');
await field(2).press('Alt+ArrowLeft');
assert.deepEqual(
await checklist()
.locator('li')
.evaluateAll((items) =>
items.map((item) => Number(item.dataset.indent)),
),
[0, 0, 1, 0],
);
await button('Indent criterion 2').tap();
await button('Remove criterion 1').tap();
assert.equal(await field(1).inputValue(), 'Edited child');
assert.equal(
await field(1).evaluate((el) => el === document.activeElement),
true,
);
await button('Add criterion').focus();
await page.keyboard.press('Enter');
assert.equal(
await field(4).evaluate((el) => el === document.activeElement),
true,
);
await field(4).fill('New criterion');
await button('Indent criterion 4').click();
await setResult('validation');
await button('Save changes').click();
await waitState('validation');
assert.equal(await field(1).inputValue(), 'Edited child');
await setResult('success');
await save();
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.id),
[12, 13, 14, undefined],
);
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.indent),
[0, 1, 0, 1],
);
await page.reload();
await page.locator('#ticket-edit > summary').click();
assert.equal(await field(1).inputValue(), 'Edited child');
assert.equal(await field(4).inputValue(), 'New criterion');
// Blank/whitespace text cannot silently delete a persisted criterion.
await field(1).fill(' ');
assert.equal(await field(1).evaluate((el) => el.checkValidity()), false);
const beforeInvalidSave = payload;
await button('Save changes').click();
assert.equal(payload, beforeInvalidSave);
await button('Discard changes').click();
assert.equal(await field(1).inputValue(), 'Edited child');
assert.equal(await field(1).evaluate((el) => el.checkValidity()), true);
for (const width of [1280, 390, 320]) {
await page.setViewportSize({ width, height: 900 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await page.evaluate(
(theme) =>
document.documentElement.classList.toggle(
'dark',
theme === 'dark',
),
theme,
);
assert.equal(
await checklist().evaluate(
(el) => el.scrollWidth <= el.clientWidth,
),
true,
);
await checklist().screenshot({
path: `/private/tmp/kan32-checklist-${width}-${theme}.png`,
});
await button('Outdent criterion 4').tap();
await button('Indent criterion 4').tap();
}
}
await page.goto(`${base}/projects/7/tickets/31?readonly=1`);
await savedChecklist().waitFor();
assert.equal(await savedChecklist().getByRole('textbox').count(), 0);
assert.equal(await savedChecklist().getByRole('button').count(), 0);
assert.equal(
await savedChecklist().getByRole('checkbox').first().isDisabled(),
true,
);
await page.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('Created with criteria');
for (const text of ['First', 'Second']) {
await button('Add criterion').click();
await page.locator('textarea').last().fill(text);
}
await button('Indent criterion 2').tap();
await checklist().getByRole('checkbox').first().focus();
await page.keyboard.press('Space');
const request = page.waitForRequest(
(request) =>
request.method() === 'POST' && request.url().endsWith('/tickets'),
);
await button('Create ticket').click();
const created = (await request).postDataJSON();
assert.deepEqual(created.acceptance_criteria, [
{ text: 'First', checked: true, indent: 0 },
{ text: 'Second', checked: true, indent: 1 },
]);
await page.getByRole('heading', { name: 'Destination' }).waitFor();
assert.deepEqual(errors, []);
console.log(
'Acceptance criteria browser checks passed: persistence, errors, keyboard, touch, readonly, creation, mobile and themes.',
);
} finally {
await browser.close();
}
-103
View File
@@ -1,103 +0,0 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/acceptance-criteria-fixture',
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
});
try {
const {
canIndentAcceptanceCriterion: canIndent,
indentAcceptanceCriterion: indent,
removeAcceptanceCriterion: remove,
syncAcceptanceCriteriaDescendantCheckedState: toggle,
normalizeAcceptanceCriteria: normalize,
} = await server.ssrLoadModule('/resources/js/lib/acceptance-criteria.ts');
const items = [
{ id: 11, text: 'Parent', indent: 0, checked: false },
{ id: 12, text: 'Child', indent: 1, checked: true },
{ id: 13, text: 'Grandchild', indent: 2, checked: false },
{ id: 14, text: 'Next', indent: 0, checked: false },
];
const original = structuredClone(items);
assert.equal(canIndent(items, 0), false);
assert.equal(canIndent(items, 1), false);
assert.equal(canIndent(items, 3), true);
assert.deepEqual(
indent(items, 1, -1).map((item) => item.indent),
[0, 0, 1, 0],
);
assert.deepEqual(indent(indent(items, 1, -1), 1, 1), items);
assert.deepEqual(indent(items, 0, -1), items);
assert.deepEqual(indent(items, -1, 1), items);
assert.deepEqual(remove(items, 0), [
{ ...items[1], indent: 0 },
{ ...items[2], indent: 1 },
items[3],
]);
assert.deepEqual(remove(items, 1), [
items[0],
{ ...items[2], indent: 1 },
items[3],
]);
assert.deepEqual(remove(items, 99), items);
assert.deepEqual(
toggle(items, 0, true).map((item) => item.checked),
[true, true, true, false],
);
assert.deepEqual(
toggle(items, 1, false).map((item) => item.checked),
[false, false, false, false],
);
const deep = Array.from({ length: 9 }, (_, indent) => ({
text: 'Deep',
checked: false,
indent,
}));
assert.equal(
canIndent([{ text: 'Before', indent: 0, checked: false }, ...deep], 1),
false,
);
assert.deepEqual(
normalize(remove(items, 0)).map((item) => item.id),
[12, 13, 14],
);
assert.deepEqual(items, original, 'Editing must not mutate saved criteria');
const { default: Checklist } = await server.ssrLoadModule(
'/resources/js/components/tickets/AcceptanceCriteriaChecklist.vue',
);
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const render = (props) =>
renderToString(
createSSRApp({ render: () => h(Checklist, props) }).use(
createLocalization(),
),
);
const editable = await render({ modelValue: items });
assert.equal((editable.match(/<textarea/g) ?? []).length, 4);
assert.match(editable, /Indent criterion 4/);
assert.match(editable, /1 of 4 complete/);
const readonly = await render({ modelValue: items, readonly: true });
assert.doesNotMatch(readonly, /<textarea|Add criterion|Edit checklist/);
assert.equal((readonly.match(/type="checkbox" disabled/g) ?? []).length, 4);
assert.match(
await render({ modelValue: [], error: 'Server validation' }),
/Server validation/,
);
console.log('Acceptance criteria behavior checks passed.');
} finally {
await server.close();
}
+10 -18
View File
@@ -320,18 +320,15 @@ try {
await page.goto(`${base}/projects/7/tickets/create`); await page.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('New ticket draft'); await page.locator('#title').fill('New ticket draft');
await page.locator('#description').fill('Keep this description'); await page.locator('#description').fill('Keep this description');
await page.getByRole('button', { name: 'Add criterion' }).click();
await page await page
.getByRole('textbox', { name: 'Criterion 1', exact: true }) .locator('#acceptance_criteria_checklist')
.fill('Keep this checklist'); .fill('[ ] Keep this checklist');
await setResult('validation'); await setResult('validation');
await page.locator('form button[type="submit"]').first().click(); await page.locator('form button[type="submit"]').first().click();
await state('validation'); await state('validation');
assert.equal( assert.equal(
await page await page.locator('#acceptance_criteria_checklist').inputValue(),
.getByRole('textbox', { name: 'Criterion 1', exact: true }) '[ ] Keep this checklist',
.inputValue(),
'Keep this checklist',
); );
await page.route('**/projects/7/tickets', (route) => route.abort('failed')); await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
await page.locator('form button[type="submit"]').first().click(); await page.locator('form button[type="submit"]').first().click();
@@ -352,10 +349,8 @@ try {
await page.locator('#title').waitFor(); await page.locator('#title').waitFor();
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft'); assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal( assert.equal(
await page await page.locator('#acceptance_criteria_checklist').inputValue(),
.getByRole('textbox', { name: 'Criterion 1', exact: true }) '[ ] Keep this checklist',
.inputValue(),
'Keep this checklist',
); );
await setResult('success'); await setResult('success');
await page.locator('form button[type="submit"]').first().click(); await page.locator('form button[type="submit"]').first().click();
@@ -364,10 +359,9 @@ try {
await page.goto(`${base}/projects/7/tickets/31`); await page.goto(`${base}/projects/7/tickets/31`);
await page.locator('#ticket-edit > summary').click(); await page.locator('#ticket-edit > summary').click();
await page.locator('#title').fill('Updated ticket draft'); await page.locator('#title').fill('Updated ticket draft');
await page.getByRole('button', { name: 'Add criterion' }).click();
await page await page
.getByRole('textbox', { name: 'Criterion 1', exact: true }) .locator('#acceptance_criteria_checklist')
.fill('Edited checklist'); .fill('[ ] Edited checklist');
await setResult('server'); await setResult('server');
await page.locator('form button[type="submit"]').first().click(); await page.locator('form button[type="submit"]').first().click();
await state('server'); await state('server');
@@ -376,10 +370,8 @@ try {
'Updated ticket draft', 'Updated ticket draft',
); );
assert.equal( assert.equal(
await page await page.locator('#acceptance_criteria_checklist').inputValue(),
.getByRole('textbox', { name: 'Criterion 1', exact: true }) '[ ] Edited checklist',
.inputValue(),
'Edited checklist',
); );
await setResult('success'); await setResult('success');
await page.getByRole('button', { name: 'Try again' }).click(); await page.getByRole('button', { name: 'Try again' }).click();
+2 -15
View File
@@ -1,5 +1,4 @@
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import process from 'node:process';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite'; import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite'; import { createServer } from 'vite';
@@ -37,19 +36,7 @@ let ticket = {
ticket_status: status, ticket_status: status,
is_blocked: false, is_blocked: false,
story_points: 0, story_points: 0,
acceptance_criteria: process.env.CRITERIA_FIXTURE acceptance_criteria: [],
? [
{ id: 11, text: 'Parent criterion', indent: 0, checked: false },
{ id: 12, text: 'Nested criterion', indent: 1, checked: true },
{ id: 13, text: 'Deep criterion', indent: 2, checked: false },
{
id: 14,
text: 'Independent criterion',
indent: 0,
checked: false,
},
]
: [],
field_values: [], field_values: [],
worklog_entries: [], worklog_entries: [],
created_at: '2026-09-10T12:00:00Z', created_at: '2026-09-10T12:00:00Z',
@@ -138,7 +125,7 @@ server.middlewares.use(async (request, response, next) => {
ticketStatusOptions: [status], ticketStatusOptions: [status],
users: [], users: [],
parentTicketOptions: [], parentTicketOptions: [],
canEditTicket: !url.searchParams.has('readonly'), canEditTicket: true,
canManageProject: true, canManageProject: true,
childTicketTree: [], childTicketTree: [],
linkedTickets: [], linkedTickets: [],
+18 -39
View File
@@ -105,30 +105,22 @@ try {
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft'); await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
await page await page
.locator('#ticket-checklist') .getByRole('checkbox', { name: 'Cache requests safely', exact: true })
.getByRole('checkbox', { .check();
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.click();
await page.waitForFunction( await page.waitForFunction(
() => () => document.querySelector('#acceptance-criteria-11').checked,
document.querySelectorAll(
'#ticket-checklist input[type=checkbox]',
)[1].checked,
); );
assert.equal( assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(), await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved title draft', 'Unsaved title draft',
); );
await page await page
.getByLabel('Criterion 1', { exact: true }) .getByLabel('Acceptance criteria checklist', { exact: true })
.fill('A draft checklist item'); .fill('[ ] A draft checklist item');
assert.equal( assert.equal(
await page await page
.locator('#ticket-checklist')
.getByRole('checkbox', { .getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely', name: 'Cache requests safely',
exact: true, exact: true,
}) })
.isDisabled(), .isDisabled(),
@@ -163,8 +155,10 @@ try {
'Unsaved title draft', 'Unsaved title draft',
); );
assert.equal( assert.equal(
await page.getByLabel('Criterion 1', { exact: true }).inputValue(), await page
'A draft checklist item', .getByLabel('Acceptance criteria checklist', { exact: true })
.inputValue(),
'[ ] A draft checklist item',
); );
await visit('?reject'); await visit('?reject');
await edit().click(); await edit().click();
@@ -202,21 +196,16 @@ try {
await page.getByText('Ticket updated.', { exact: true }).waitFor(); await page.getByText('Ticket updated.', { exact: true }).waitFor();
await visit('?rejectToggle'); await visit('?rejectToggle');
await page await page
.locator('#ticket-checklist') .getByRole('checkbox', { name: 'Cache requests safely', exact: true })
.getByRole('checkbox', { .check();
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.click();
await page await page
.getByRole('alert') .getByRole('alert')
.filter({ hasText: 'Checklist status could not be saved' }) .filter({ hasText: 'Checklist status could not be saved' })
.waitFor(); .waitFor();
assert.equal( assert.equal(
await page await page
.locator('#ticket-checklist')
.getByRole('checkbox', { .getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely', name: 'Cache requests safely',
exact: true, exact: true,
}) })
.isChecked(), .isChecked(),
@@ -251,20 +240,15 @@ try {
response.url().includes('/tickets/42/links') && response.url().includes('/tickets/42/links') &&
response.request().method() === 'POST', response.request().method() === 'POST',
); );
await page.getByLabel('Linked ticket', { exact: true }).selectOption('41');
await page.getByRole('button', { name: 'Save link', exact: true }).click(); await page.getByRole('button', { name: 'Save link', exact: true }).click();
assert.equal((await linkSaved).status(), 303); assert.equal((await linkSaved).status(), 303);
await page await page.getByRole('button', { name: 'Remove', exact: true }).waitFor();
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
.waitFor();
const linkRemoved = page.waitForResponse( const linkRemoved = page.waitForResponse(
(response) => (response) =>
response.url().includes('/tickets/42/links/3') && response.url().includes('/tickets/42/links/3?_method=DELETE') &&
response.request().method() === 'DELETE', response.request().method() === 'POST',
); );
await page await page.getByRole('button', { name: 'Remove', exact: true }).click();
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
.click();
assert.equal((await linkRemoved).status(), 303); assert.equal((await linkRemoved).status(), 303);
await visit('?readonly&hideAuthor&hidePoints'); await visit('?readonly&hideAuthor&hidePoints');
assert.equal(await content().locator('form').count(), 0); assert.equal(await content().locator('form').count(), 0);
@@ -278,12 +262,7 @@ try {
.count(), .count(),
0, 0,
); );
await tabTo( await tabTo(content().getByRole('link', { name: 'ENG-41', exact: true }));
content().getByRole('link', {
name: 'ENG-41 Related infrastructure',
exact: true,
}),
);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForURL('**/tickets/41'); await page.waitForURL('**/tickets/41');
await page await page
+3 -6
View File
@@ -2,17 +2,14 @@ import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3'; import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css'; import '../../resources/css/app.css';
import Ticket from '../../resources/js/pages/projects/Ticket.vue'; import Ticket from '../../resources/js/pages/projects/Ticket.vue';
import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance'; import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization'; import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({ createInertiaApp({
resolve: (name) => resolve: (name) =>
name === 'TicketCreate' name === 'Ticket'
? TicketCreate ? Ticket
: name === 'Ticket' : { render: () => h('h1', 'Destination reached') },
? Ticket
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) => setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) }) createApp({ render: () => h(App, props) })
.use(plugin) .use(plugin)
+30 -161
View File
@@ -123,13 +123,10 @@ const fieldDefinitions = [
{ id: 1, label: 'Impact score', field_type: 'number' }, { id: 1, label: 'Impact score', field_type: 'number' },
{ id: 2, label: 'Reviewer', field_type: 'user' }, { id: 2, label: 'Reviewer', field_type: 'user' },
]; ];
let savedForm = {};
function makePage(url) { function makePage(url) {
const parsed = new URL(url, 'http://localhost'); const parsed = new URL(url, 'http://localhost');
const params = parsed.searchParams; const params = parsed.searchParams;
const creating = parsed.pathname.endsWith('/create'); const detail = parsed.pathname.startsWith('/projects/7/tickets/');
const detail =
!creating && parsed.pathname.startsWith('/projects/7/tickets/');
const data = structuredClone(ticket); const data = structuredClone(ticket);
if (parsed.pathname.endsWith('/41')) { if (parsed.pathname.endsWith('/41')) {
Object.assign(data, linked, { Object.assign(data, linked, {
@@ -139,7 +136,7 @@ function makePage(url) {
}); });
} }
if (params.has('saved')) { if (params.has('saved')) {
Object.assign(data, savedForm, { title: params.get('saved') }); data.title = params.get('saved');
} }
if (params.has('long')) { if (params.has('long')) {
data.title = 'LongUnbrokenTicketTitle'.repeat(10); data.title = 'LongUnbrokenTicketTitle'.repeat(10);
@@ -171,46 +168,16 @@ function makePage(url) {
? 'Repository temporarily unavailable.' ? 'Repository temporarily unavailable.'
: null; : null;
return { return {
component: creating component: detail ? 'Ticket' : 'Destination',
? 'TicketCreate'
: detail
? 'Ticket'
: 'Destination',
url, url,
version: null, version: null,
clearHistory: false, clearHistory: false,
encryptHistory: false, encryptHistory: false,
props: { props: {
name: 'KanbanGit', name: 'KanbanGit',
errors: params.has('linkRejected') errors: params.has('rejected')
? { ? { title: 'This title is not allowed.' }
ticket_link_type_id: 'Invalid relationship type.', : {},
linked_ticket_id: 'Choose another ticket.',
}
: params.has('rejected')
? {
title: 'This title is not allowed.',
...(params.has('allErrors')
? {
description: 'Invalid description.',
ticket_type_id: 'Invalid type.',
ticket_priority_id: 'Invalid priority.',
ticket_status_id: 'Invalid status.',
assignee_user_id: 'Invalid assignee.',
author_user_id: 'Invalid author.',
parent_ticket_id: 'Invalid parent.',
story_points: 'Invalid points.',
'acceptance_criteria.0.text':
'Invalid checklist.',
blocked_comment: 'Invalid blocker.',
'field_values.0.value_number':
'Invalid number.',
'field_values.1.value_user_id':
'Invalid reviewer.',
}
: {}),
}
: {},
sidebarOpen: true, sidebarOpen: true,
auth: { auth: {
user, user,
@@ -237,17 +204,7 @@ function makePage(url) {
canManageProject: params.has('admin'), canManageProject: params.has('admin'),
status: params.has('saved') ? 'Ticket updated.' : null, status: params.has('saved') ? 'Ticket updated.' : null,
users: [user, ticket.author], users: [user, ticket.author],
ticketTypes: params.has('noMetadata') ? [] : [data.ticket_type], ticketTypes: [data.ticket_type],
source: params.get('source') === 'board' ? 'board' : 'backlog',
defaultTicketStatusId: 3,
ticketStatusOptions: [
{
id: 3,
name: 'In progress',
board_column: { name: 'Delivery' },
},
{ id: 4, name: 'Backlog', board_column: null },
],
ticketPriorities: [data.ticket_priority], ticketPriorities: [data.ticket_priority],
ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions, ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions,
parentTicketOptions: [parent], parentTicketOptions: [parent],
@@ -255,45 +212,25 @@ function makePage(url) {
? [ ? [
{ {
...linked, ...linked,
ticket_type_name: 'Epic', ticket_type: { is_parent: false },
children: [ children: [],
{
id: 43,
ticket_key: 'ENG-43',
title: 'Nested child with a long title that must wrap on mobile',
ticket_type_name: 'Task',
children: [],
},
],
children_count: 0, children_count: 0,
}, },
] ]
: [], : [],
linkedTickets: linkedTickets: params.has('empty')
params.has('empty') || params.has('noLinks')
? []
: [
{
id: 3,
source_ticket_id: params.has('incoming')
? 41
: 42,
target_ticket_id: params.has('incoming')
? 42
: 41,
ticket_link_type: {
id: 1,
label: params.has('incoming')
? 'Blocked by'
: 'Blocks',
},
linked_ticket: linked,
},
],
ticketLinkOptions: params.has('noTargets') ? [] : [linked],
ticketLinkTypes: params.has('noTypes')
? [] ? []
: [{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' }], : [
{
id: 3,
ticket_link_type: { id: 1, label: 'Blocks' },
linked_ticket: linked,
},
],
ticketLinkOptions: [linked],
ticketLinkTypes: [
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
],
repositoryBranches: params.has('empty') repositoryBranches: params.has('empty')
? [] ? []
: [ : [
@@ -342,14 +279,9 @@ if (serve) {
for await (const chunk of request) { for await (const chunk of request) {
body += chunk; body += chunk;
} }
const data = body ? JSON.parse(body) : {}; const data = JSON.parse(body);
const previous = new URL(request.headers.referer); const previous = new URL(request.headers.referer);
const params = previous.searchParams; const params = previous.searchParams;
if (params.has('serverError')) {
response.writeHead(503);
response.end('Simulated failure');
return;
}
if (!request.headers['x-inertia']) { if (!request.headers['x-inertia']) {
assert.equal(request.method, 'PATCH'); assert.equal(request.method, 'PATCH');
assert.equal(request.url, '/projects/7/tickets/42'); assert.equal(request.url, '/projects/7/tickets/42');
@@ -375,32 +307,22 @@ if (serve) {
} else if (request.url.includes('/links')) { } else if (request.url.includes('/links')) {
assert.ok( assert.ok(
request.url.endsWith('/links/3?_method=DELETE') || request.url.endsWith('/links/3?_method=DELETE') ||
request.method === 'DELETE' ||
Number(data.linked_ticket_id) === 41, Number(data.linked_ticket_id) === 41,
); );
if (params.has('rejectLink')) {
params.set('linkRejected', '1');
} else if (request.method === 'DELETE') {
params.set('noLinks', '1');
} else {
params.delete('noLinks');
params.delete('linkRejected');
}
} else { } else {
assert.ok( assert.ok(
(request.method === 'POST' || 'field_values' in data) && 'field_values' in data &&
'acceptance_criteria' in data && 'acceptance_criteria' in data &&
'parent_ticket_id' in data, 'parent_ticket_id' in data,
); );
if (params.has('reject')) { if (params.has('reject')) {
params.set('rejected', '1'); params.set('rejected', '1');
} else { } else {
savedForm = data;
params.set('saved', data.title); params.set('saved', data.title);
} }
} }
response.writeHead(303, { response.writeHead(303, {
Location: `${params.has('rejected') && previous.pathname.endsWith('/create') ? previous.pathname : '/projects/7/tickets/42'}?${params}`, Location: `/projects/7/tickets/42?${params}`,
}); });
response.end(); response.end();
return; return;
@@ -442,15 +364,10 @@ if (serve) {
const { default: Ticket } = await server.ssrLoadModule( const { default: Ticket } = await server.ssrLoadModule(
'/resources/js/pages/projects/Ticket.vue', '/resources/js/pages/projects/Ticket.vue',
); );
const { default: TicketCreate } = await server.ssrLoadModule( const render = async (query = '') => {
'/resources/js/pages/projects/TicketCreate.vue',
);
const render = async (query = '', creating = false) => {
const result = await createInertiaApp({ const result = await createInertiaApp({
page: makePage( page: makePage(`/projects/7/tickets/42${query}`),
`/projects/7/tickets/${creating ? 'create' : '42'}${query}`, resolve: () => Ticket,
),
resolve: () => (creating ? TicketCreate : Ticket),
render: renderToString, render: renderToString,
setup: ({ App, props, plugin }) => setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) }) createSSRApp({ render: () => h(App, props) })
@@ -512,6 +429,7 @@ if (serve) {
'story_points', 'story_points',
'parent_ticket_id', 'parent_ticket_id',
'description', 'description',
'acceptance_criteria_checklist',
'is_blocked', 'is_blocked',
'blocked_comment', 'blocked_comment',
'field-1', 'field-1',
@@ -522,19 +440,8 @@ if (serve) {
`Missing editor: ${field}`, `Missing editor: ${field}`,
); );
} }
assert.match(html, /aria-label="Criterion 1"/);
assert.match(html, /Add criterion/);
assert.match(html, /Create branch/); assert.match(html, /Create branch/);
assert.match(html, /Save link/); assert.match(html, /Save link/);
assert.match(html, /Outgoing/);
const incoming = await render('?incoming');
assert.match(incoming, /Incoming/);
assert.match(incoming, /Blocked by/);
assert.match(await render('?noTypes'), /No link types are configured/);
assert.match(
await render('?noTargets'),
/No other tickets are available/,
);
assert.match(html, /Remove/); assert.match(html, /Remove/);
const readOnly = await render('?readonly'); const readOnly = await render('?readonly');
assert.doesNotMatch( assert.doesNotMatch(
@@ -549,7 +456,7 @@ if (serve) {
const empty = await render('?empty'); const empty = await render('?empty');
for (const text of [ for (const text of [
'No description has been added yet.', 'No description has been added yet.',
'No acceptance criteria yet.', 'No acceptance criteria have been added yet.',
'No status', 'No status',
'Unassigned', 'Unassigned',
'No author', 'No author',
@@ -560,50 +467,12 @@ if (serve) {
]) { ]) {
assert.ok(empty.includes(text), `Missing empty state: ${text}`); assert.ok(empty.includes(text), `Missing empty state: ${text}`);
} }
const hierarchy = await render('?parent'); assert.match(await render('?parent'), /Child tickets/);
assert.match(hierarchy, /Child tickets/);
assert.match(hierarchy, /Platform delivery/);
assert.match(hierarchy, /Nested child/);
assert.match(hierarchy, /Change parent/);
assert.match(await render('?noAccess'), /Open Git connections/); assert.match(await render('?noAccess'), /Open Git connections/);
assert.match( assert.match(
await render('?gitError'), await render('?gitError'),
/Repository temporarily unavailable/, /Repository temporarily unavailable/,
); );
const create = await render('?source=board', true);
assert.match(create, /Create ticket/);
assert.match(create, /Delivery \/ In progress/);
assert.match(create, /Back to board/);
assert.match(create, /id="ticket_status_id"/);
assert.doesNotMatch(
create,
/id="author_user_id"|id="blocked_comment"|id="field-1"/,
);
assert.doesNotMatch(
await render('?hidePoints', true),
/id="story_points"/,
);
const unavailable = await render('?noMetadata', true);
assert.doesNotMatch(unavailable, /<form/);
assert.match(unavailable, /Ticket creation needs at least one type/);
for (const creating of [false, true]) {
const invalid = await render('?rejected&allErrors', creating);
assert.match(
invalid,
/aria-describedby="[^"]+-hint [^"]+-error"|No acceptance criteria yet/,
);
for (const id of [
'title',
'description',
'ticket_type_id',
'ticket_priority_id',
'assignee_user_id',
'parent_ticket_id',
'story_points',
]) {
assert.ok(invalid.includes(`id="${id}-error"`));
}
}
console.log('Ticket detail behavior checks passed.'); console.log('Ticket detail behavior checks passed.');
} finally { } finally {
await server.close(); await server.close();
-279
View File
@@ -1,279 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(10000);
const base = 'http://127.0.0.1:4185/projects/7/tickets';
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const field = (label) => form().getByLabel(label, { exact: true });
const form = () => page.locator('form').first();
const state = (value) =>
form().locator(`[data-form-state="${value}"]`).waitFor();
const button = (label) =>
form().getByRole('button', { name: label, exact: true });
async function visit(create, query = '') {
await page.goto(`${base}/${create ? 'create' : '42'}${query}`);
await page.getByRole('heading', { level: 1 }).waitFor();
if (!create) {
await page.locator('#ticket-edit > summary').click();
}
}
async function fill(create) {
await field('Title').fill('A complete ticket');
await field('Description').fill('Description with context.');
await field('Assignee').selectOption('2');
await field('Parent ticket').selectOption('40');
await field('Estimated story points').fill('0');
if (create) {
await button('Add criterion').click();
await button('Add criterion').click();
await button('Indent criterion 2').click();
}
await field('Criterion 1').fill('Main criterion');
await field('Criterion 2').fill('Nested criterion');
await form()
.getByRole('checkbox', {
name: 'Complete criterion 2: Nested criterion',
exact: true,
})
.check();
if (create) {
await field('Status').selectOption('4');
} else {
await field('Author').selectOption('1');
await field('Blocker comment').fill('Waiting for review');
await field('Impact score').fill('2.5');
await field('Reviewer').selectOption('2');
}
}
async function submit(create) {
const request = page.waitForRequest(
(r) =>
r.method() === (create ? 'POST' : 'PATCH') &&
r.url().split('?')[0] === (create ? base : `${base}/42`),
);
await button(create ? 'Create ticket' : 'Save changes').click();
return (await request).postDataJSON();
}
try {
for (const create of [true, false]) {
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
await visit(create, '?admin');
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <= innerWidth,
),
true,
`Overflow ${create}/${width}/${theme}`,
);
await field('Title').focus();
await page.keyboard.press('Tab');
assert.equal(
await field('Description').evaluate(
(el) =>
el === document.activeElement &&
el.matches(':focus-visible'),
),
true,
);
await fill(create);
await page.screenshot({
path: `/private/tmp/kan31-${create ? 'create' : 'edit'}-${width}-${theme}.png`,
fullPage: true,
});
page.once('dialog', (dialog) => dialog.dismiss());
await button('Discard changes').click();
assert.equal(
await field('Title').inputValue(),
'A complete ticket',
);
page.once('dialog', (dialog) => dialog.accept());
await button('Discard changes').click();
assert.equal(
await button('Discard changes').isDisabled(),
true,
);
assert.equal(
await field('Title').inputValue(),
create
? ''
: 'Implement a resilient caching layer for the project API',
);
}
}
await visit(create, '?admin&reject&allErrors');
await fill(create);
const data = await submit(create);
assert.equal(data.title, 'A complete ticket');
assert.equal(data.description, 'Description with context.');
assert.equal(Number(data.ticket_type_id), 1);
assert.equal(Number(data.ticket_priority_id), 2);
assert.equal(Number(data.assignee_user_id), 2);
assert.equal(Number(data.parent_ticket_id), 40);
assert.equal(Number(data.story_points), 0);
assert.deepEqual(
data.acceptance_criteria.map(({ text, checked, indent }) => ({
text,
checked,
indent,
})),
[
{ text: 'Main criterion', checked: false, indent: 0 },
{ text: 'Nested criterion', checked: true, indent: 1 },
],
);
if (create) {
assert.equal(Number(data.ticket_status_id), 4);
assert.equal('author_user_id' in data, false);
} else {
assert.equal(Number(data.author_user_id), 1);
assert.equal(data.is_blocked, true);
assert.equal(data.blocked_comment, 'Waiting for review');
assert.equal(Number(data.field_values[0].value_number), 2.5);
assert.equal(Number(data.field_values[1].value_user_id), 2);
assert.deepEqual(
data.acceptance_criteria.map((item) => item.id),
[10, 11],
);
assert.equal('ticket_status_id' in data, false);
}
await state('validation');
const fields = [
'Title',
'Description',
'Type',
'Priority',
'Assignee',
'Parent ticket',
'Estimated story points',
'Criterion 1',
...(create
? ['Status']
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
];
for (const label of fields) {
const control = field(label);
assert.equal(
await control.getAttribute('aria-invalid'),
'true',
label,
);
const errorId = (await control.getAttribute('aria-describedby'))
.split(' ')
.at(-1);
assert.ok(
(await page.locator(`[id="${errorId}"]`).innerText()).length,
`Missing associated error: ${label}`,
);
}
assert.equal(await field('Title').inputValue(), 'A complete ticket');
await page.route(create ? `${base}` : `${base}/42`, (route) =>
route.abort('failed'),
);
await button(create ? 'Create ticket' : 'Save changes').click();
await state('network');
assert.equal(
await field('Description').inputValue(),
'Description with context.',
);
await page.unrouteAll();
await visit(create, '?admin&serverError');
await fill(create);
await submit(create);
await state('server');
assert.equal(await field('Title').inputValue(), 'A complete ticket');
let pending;
let requests = 0;
await page.route(create ? `${base}` : `${base}/42`, (route) => {
requests++;
pending = route;
});
await button('Try again').click();
await state('loading');
assert.equal(await field('Title').isDisabled(), true);
assert.equal(
await button(
create ? 'Creating ticket…' : 'Saving changes…',
).isDisabled(),
true,
);
await form().evaluate((el) =>
el.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
),
);
assert.equal(requests, 1);
await pending.abort();
await state('network');
await page.unrouteAll();
await visit(create, '?admin');
await fill(create);
page.once('dialog', (dialog) => dialog.dismiss());
await page
.locator('#main-content')
.getByRole('link', {
name: create ? 'Back to backlog' : 'Open backlog',
exact: true,
})
.click();
assert.ok(page.url().includes('/tickets/'));
const successData = await submit(create);
await page
.getByRole('heading', { name: successData.title, exact: true })
.waitFor();
if (create) {
await page.goBack();
await page
.getByRole('heading', { name: 'Create ticket', exact: true })
.waitFor();
await field('Title').waitFor();
assert.equal(await field('Title').inputValue(), '');
assert.equal(await field('Criterion 1').count(), 0);
} else {
await form()
.getByText('All changes saved', { exact: true })
.waitFor();
await field('Title').fill('Another draft');
page.once('dialog', (dialog) => dialog.accept());
await button('Discard changes').click();
assert.equal(
await field('Title').inputValue(),
'A complete ticket',
);
assert.equal(await field('Impact score').inputValue(), '2.5');
}
}
await visit(false, '?hideAuthor&hidePoints');
assert.equal(await field('Author').count(), 0);
assert.equal(await field('Estimated story points').count(), 0);
await visit(false);
assert.equal(await field('Author').isDisabled(), true);
await visit(true, '?hidePoints');
assert.equal(await field('Estimated story points').count(), 0);
await visit(true, '?noMetadata');
assert.equal(await page.locator('form').count(), 0);
assert.deepEqual(errors, []);
console.log(
'Ticket form browser checks passed: create/edit payloads, nested validation, retries, drafts, resets, permissions, keyboard, mobile and both themes.',
);
} finally {
await browser.close();
}
@@ -1,223 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage();
page.setDefaultTimeout(10000);
const base = 'http://127.0.0.1:4185/projects/7/tickets/42';
const section = () => page.locator('#ticket-relationships');
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
async function visit(query = '') {
await page.goto(base + query);
await section().waitFor();
}
const control = (label) => section().getByLabel(label, { exact: true });
const save = () =>
section().getByRole('button', { name: 'Save link', exact: true });
const remove = () =>
section().getByRole('button', {
name: 'Remove link to ENG-41',
exact: true,
});
try {
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
await visit('?parent&incoming');
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
for (const text of [
'Platform delivery',
'Child tickets',
'Nested child with a long title that must wrap on mobile',
'Incoming',
'Blocked by',
]) {
assert.equal(
await section()
.getByText(text, { exact: true })
.isVisible(),
true,
text,
);
}
const collapse = section().getByRole('button', {
name: 'Collapse children of ENG-41',
});
await collapse.focus();
await page.keyboard.press('Enter');
assert.equal(
await section()
.getByText(
'Nested child with a long title that must wrap on mobile',
{ exact: true },
)
.count(),
0,
);
await page.keyboard.press('Space');
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
await control('Link type').focus();
await page.keyboard.press('Tab');
assert.equal(
await control('Linked ticket').evaluate(
(el) =>
el === document.activeElement &&
el.matches(':focus-visible'),
),
true,
);
await control('Linked ticket').selectOption('41');
await section()
.locator('form')
.last()
.getByText('ENG-42', { exact: true })
.waitFor();
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
true,
`Overflow ${width}/${theme}`,
);
await section().screenshot({
path: `/private/tmp/kan33-relationships-${width}-${theme}.png`,
});
await section()
.getByRole('button', { name: 'Change parent', exact: true })
.click();
assert.equal(
await page
.locator('#parent_ticket_id')
.evaluate((el) => el === document.activeElement),
true,
);
assert.equal(
await page.locator('#ticket-edit').evaluate((el) => el.open),
true,
);
}
}
await visit('?noLinks');
assert.equal(await save().isDisabled(), true);
await control('Linked ticket').selectOption('41');
let request = page.waitForRequest(
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
);
await save().click();
assert.deepEqual((await request).postDataJSON(), {
ticket_link_type_id: 1,
linked_ticket_id: 41,
});
await section().getByText('Outgoing', { exact: true }).waitFor();
await page.waitForFunction(
() => document.querySelector('#linked_ticket_id')?.value === '',
);
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Linked tickets'),
);
request = page.waitForRequest(
(r) => r.method() === 'DELETE' && r.url().endsWith('/links/3'),
);
await remove().click();
await request;
await section()
.getByText('No ticket links are configured for this ticket yet.', {
exact: true,
})
.waitFor();
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Linked tickets'),
);
await visit('?rejectLink');
await control('Linked ticket').selectOption('41');
await save().click();
await section()
.getByText('Choose another ticket.', { exact: true })
.waitFor();
assert.equal(await control('Linked ticket').inputValue(), '41');
for (const label of ['Link type', 'Linked ticket']) {
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
}
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
await save().click();
await section().locator('[data-form-state="network"]').waitFor();
assert.equal(await control('Linked ticket').inputValue(), '41');
await page.unrouteAll();
await visit('?serverError');
await remove().click();
await section().locator('[data-form-state="server"]').waitFor();
assert.equal(await section().locator('[data-ticket-link]').count(), 1);
let pending;
await page.route('**/tickets/42/links/3', (route) => {
pending = route;
});
await section()
.getByRole('button', { name: 'Try again', exact: true })
.click();
await section().locator('[data-form-state="loading"]').waitFor();
assert.equal(await remove().isDisabled(), true);
await pending.abort();
await section().locator('[data-form-state="network"]').waitFor();
await page.unrouteAll();
await visit();
await section()
.getByRole('button', { name: 'Change parent', exact: true })
.click();
await page.getByLabel('Parent ticket', { exact: true }).selectOption('');
request = page.waitForRequest(
(r) => r.method() === 'PATCH' && r.url().endsWith('/tickets/42'),
);
await page
.getByRole('button', { name: 'Save changes', exact: true })
.click();
assert.equal((await request).postDataJSON().parent_ticket_id, '');
await page.getByText('Ticket updated.', { exact: true }).waitFor();
await visit('?readonly&parent&incoming');
assert.equal(await section().locator('form').count(), 0);
assert.equal(
await section()
.getByRole('button', { name: 'Change parent', exact: true })
.count(),
0,
);
assert.equal(
await section().getByText('Blocked by', { exact: true }).isVisible(),
true,
);
for (const [query, message] of [
['?noTypes', 'No link types are configured for this project.'],
[
'?noTargets',
'No other tickets are available to link in this project.',
],
]) {
await visit(query);
assert.equal(
await section().getByText(message, { exact: true }).isVisible(),
true,
);
assert.equal(await save().count(), 0);
}
assert.deepEqual(errors, []);
console.log(
'Ticket relationships browser checks passed: hierarchy, direction, add/remove, validation, retries, permissions, keyboard, mobile and themes.',
);
} finally {
await browser.close();
}