formatted
CI/CD / test_and_check_app (push) Successful in 3m53s
CI/CD / Publish immutable image (push) Successful in 1m33s

This commit is contained in:
2026-09-08 14:24:04 +02:00
parent bd7be4e41e
commit 1d8fff1d51
20 changed files with 395 additions and 151 deletions
+4 -1
View File
@@ -15,7 +15,10 @@ import AppLayout from '@/layouts/AppLayout.vue';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index';
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
+4 -1
View File
@@ -16,7 +16,10 @@ import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects/index';
import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
+90 -33
View File
@@ -31,15 +31,19 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const defaultTemplateId = props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
const defaultTemplateId =
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
const form = useForm({
copy_areas: [] as string[],
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
key: '',
name: '',
source_project_id: props.sourceProjects[0]?.id ? String(props.sourceProjects[0].id) : '',
source_template_id: defaultTemplateId !== null ? String(defaultTemplateId) : '',
source_project_id: props.sourceProjects[0]?.id
? String(props.sourceProjects[0].id)
: '',
source_template_id:
defaultTemplateId !== null ? String(defaultTemplateId) : '',
});
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
@@ -48,7 +52,9 @@ const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1] ??
''
);
});
@@ -93,7 +99,9 @@ const submit = (): void => {
<div class="grid gap-3">
<Label>How do you want to start?</Label>
<div class="grid gap-3 sm:grid-cols-3">
<Label class="flex cursor-pointer items-start gap-3 rounded-xl border p-4">
<Label
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.creation_mode"
type="radio"
@@ -101,16 +109,25 @@ const submit = (): void => {
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">Create from scratch</div>
<p class="text-muted-foreground text-sm">
Start with the default project setup.
<div class="font-medium">
Create from scratch
</div>
<p
class="text-muted-foreground text-sm"
>
Start with the default project
setup.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="hasSourceProjects ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
:class="
hasSourceProjects
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
@@ -120,16 +137,26 @@ const submit = (): void => {
:disabled="!hasSourceProjects"
/>
<div class="space-y-1">
<div class="font-medium">Create from existing project</div>
<p class="text-muted-foreground text-sm">
Copy only the settings you explicitly select. Tickets are never duplicated.
<div class="font-medium">
Create from existing project
</div>
<p
class="text-muted-foreground text-sm"
>
Copy only the settings you
explicitly select. Tickets are never
duplicated.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="hasSourceTemplates ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
:class="
hasSourceTemplates
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
@@ -139,9 +166,14 @@ const submit = (): void => {
:disabled="!hasSourceTemplates"
/>
<div class="space-y-1">
<div class="font-medium">Create from template</div>
<p class="text-muted-foreground text-sm">
Reuse a saved configuration template.
<div class="font-medium">
Create from template
</div>
<p
class="text-muted-foreground text-sm"
>
Reuse a saved configuration
template.
</p>
</div>
</Label>
@@ -172,7 +204,9 @@ const submit = (): void => {
/>
<p class="text-muted-foreground text-sm">
Example ticket keys will look like
<span class="text-foreground font-medium">CORE-1</span>.
<span class="text-foreground font-medium"
>CORE-1</span
>.
</p>
<InputError :message="form.errors.key" />
</div>
@@ -182,33 +216,43 @@ const submit = (): void => {
class="space-y-6 rounded-xl border border-dashed p-4"
>
<div class="grid gap-2">
<Label for="source_project_id">Source project</Label>
<Label for="source_project_id"
>Source project</Label
>
<select
id="source_project_id"
v-model="form.source_project_id"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasSourceProjects"
>
<option value="">Select a source project</option>
<option value="">
Select a source project
</option>
<option
v-for="sourceProject in sourceProjects"
:key="sourceProject.id"
:value="String(sourceProject.id)"
>
{{ sourceProject.name }} ({{ sourceProject.key }})
{{ sourceProject.name }} ({{
sourceProject.key
}})
</option>
</select>
<p class="text-muted-foreground text-sm">
The new project keeps its own identity, key, and future ticket numbering.
The new project keeps its own identity, key,
and future ticket numbering.
</p>
<InputError :message="form.errors.source_project_id" />
<InputError
:message="form.errors.source_project_id"
/>
</div>
<div class="grid gap-3">
<div>
<Label>Copy options</Label>
<p class="text-muted-foreground text-sm">
Choose which settings should be copied into the new project.
Choose which settings should be copied
into the new project.
</p>
</div>
@@ -225,8 +269,12 @@ const submit = (): void => {
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">{{ copyAreaOption.label }}</div>
<p class="text-muted-foreground text-sm">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
{{ copyAreaOption.description }}
</p>
</div>
@@ -234,7 +282,8 @@ const submit = (): void => {
</div>
<p class="text-muted-foreground text-sm">
Operational ticket data is always left behind so the new project starts clean.
Operational ticket data is always left
behind so the new project starts clean.
</p>
<InputError :message="copyAreasError" />
</div>
@@ -249,7 +298,7 @@ const submit = (): void => {
<select
id="source_template_id"
v-model="form.source_template_id"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasSourceTemplates"
>
<option value="">Select a template</option>
@@ -258,21 +307,29 @@ const submit = (): void => {
:key="sourceTemplate.id"
:value="String(sourceTemplate.id)"
>
{{ sourceTemplate.name }} ({{ sourceTemplate.slug }})
{{ sourceTemplate.name }} ({{
sourceTemplate.slug
}})
</option>
</select>
<p class="text-muted-foreground text-sm">
Templates apply their saved configuration groups to the new project.
Templates apply their saved configuration
groups to the new project.
</p>
<InputError :message="form.errors.source_template_id" />
<InputError
:message="form.errors.source_template_id"
/>
</div>
</div>
<div
v-else-if="!hasSourceProjects && !hasSourceTemplates"
v-else-if="
!hasSourceProjects && !hasSourceTemplates
"
class="text-muted-foreground text-sm"
>
No accessible source projects or saved templates are available yet.
No accessible source projects or saved templates are
available yet.
</div>
<div class="flex items-center gap-3">
+3 -1
View File
@@ -117,7 +117,9 @@ const projectAreas = [
Built from template {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project keeps its template link. Inherited areas follow the template baseline; overridden areas were customized locally.
This project keeps its template link. Inherited areas
follow the template baseline; overridden areas were
customized locally.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
+45 -24
View File
@@ -7,7 +7,6 @@ import {
buildAcceptanceCriteria,
formatChecklistLines,
normalizeAcceptanceCriteria,
parseChecklistLines,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
@@ -123,7 +122,9 @@ const breadcrumbs: BreadcrumbItem[] = [
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist);
const acceptanceCriteriaChecklist = ref(
acceptanceCriteriaEditorState.checklist,
);
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
);
@@ -131,7 +132,9 @@ const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const form = useForm({
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '',
@@ -157,8 +160,8 @@ const form = useForm({
story_points: props.ticket.story_points,
});
const acceptanceCriteriaPreviewItems = computed(() =>
savedAcceptanceCriteria.value,
const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value,
);
const hasUnsavedAcceptanceCriteriaDraft = computed(
@@ -318,8 +321,7 @@ watch(
branchNameValidation.value = {
is_valid: false,
message:
`${appName} could not validate this branch name right now.`,
message: `${appName} could not validate this branch name right now.`,
};
} finally {
if (!abortController.signal.aborted) {
@@ -361,7 +363,9 @@ const submitBranchCreation = (): void => {
};
const buildAcceptanceCriteriaForSubmit = () => {
const draftItems = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
const draftItems = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
return draftItems.map((item, index) => ({
...item,
@@ -371,7 +375,9 @@ const buildAcceptanceCriteriaForSubmit = () => {
}));
};
const buildSavedTicketPayload = (acceptanceCriteria = savedAcceptanceCriteria.value) => ({
const buildSavedTicketPayload = (
acceptanceCriteria = savedAcceptanceCriteria.value,
) => ({
acceptance_criteria: acceptanceCriteria,
assignee_user_id: props.ticket.assignee_user_id,
author_user_id: props.ticket.author_user_id,
@@ -407,7 +413,9 @@ const persistAcceptanceCriteriaToggle = async (
index,
checked,
);
const previousChecklist = formatChecklistLines(savedAcceptanceCriteria.value);
const previousChecklist = formatChecklistLines(
savedAcceptanceCriteria.value,
);
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
@@ -761,14 +769,18 @@ const formatDateTime = (value: string): string => {
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.
Text changes are saved with the main ticket update.
Use <code>[ ]</code> /
<code>[x]</code> for checklist items.
Indent with two spaces for nested steps.
Text changes are saved with the main
ticket update.
</p>
<p
v-if="hasUnsavedAcceptanceCriteriaDraft"
class="text-muted-foreground text-xs"
>
Save checklist text changes before toggling completion directly.
Save checklist text changes before
toggling completion directly.
</p>
<InputError
:message="acceptanceCriteriaError"
@@ -779,7 +791,10 @@ const formatDateTime = (value: string): string => {
</div>
<div
v-if="acceptanceCriteriaPreviewItems.length > 0"
v-if="
acceptanceCriteriaPreviewItems.length >
0
"
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
@@ -787,7 +802,9 @@ const formatDateTime = (value: string): string => {
</p>
<ul class="space-y-2">
<li
v-for="(item, index) in acceptanceCriteriaPreviewItems"
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="item.id ?? index"
:style="{
marginLeft: `${item.indent * 1.25}rem`,
@@ -805,7 +822,12 @@ const formatDateTime = (value: string): string => {
hasUnsavedAcceptanceCriteriaDraft
"
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
@change="onAcceptanceCriteriaCheckboxChange(index, $event)"
@change="
onAcceptanceCriteriaCheckboxChange(
index,
$event,
)
"
/>
<label
:for="`acceptance-criteria-${item.id ?? index}`"
@@ -1530,10 +1552,7 @@ const formatDateTime = (value: string): string => {
</CardContent>
</Card>
<Card
v-if="ticket.ticket_type?.is_parent"
class="h-fit"
>
<Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
<CardHeader>
<CardTitle>Child tickets</CardTitle>
<CardDescription>
@@ -1572,10 +1591,12 @@ const formatDateTime = (value: string): string => {
<CardContent>
<Link
:href="showTicket({
project: project.id,
ticket: currentParentTicket.id,
})"
: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">
+72 -23
View File
@@ -33,7 +33,8 @@ const returnHref =
props.source === 'board'
? boardIndex(props.project.id)
: backlogIndex(props.project.id);
const returnLabel = props.source === 'board' ? 'Back to board' : 'Back to backlog';
const returnLabel =
props.source === 'board' ? 'Back to board' : 'Back to backlog';
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -64,7 +65,9 @@ const hasTicketMetadata =
const acceptanceCriteriaChecklist = ref('');
const form = useForm({
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
assignee_user_id: '',
description: '',
parent_ticket_id: '',
@@ -92,7 +95,9 @@ const acceptanceCriteriaError = computed(() => {
});
const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
form.submit(ProjectTicketController.store({ project: props.project.id }), {
preserveScroll: true,
@@ -173,7 +178,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<InputError :message="form.errors.title" />
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="description">Description</Label>
@@ -185,30 +192,46 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
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="Describe the expected behavior and relevant context."
/>
<InputError :message="form.errors.description" />
<InputError
:message="form.errors.description"
/>
</div>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="acceptance_criteria_checklist">
<Label
for="acceptance_criteria_checklist"
>
Acceptance criteria checklist
</Label>
<textarea
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
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
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" />
<InputError
:message="acceptanceCriteriaError"
/>
</div>
<div
v-if="acceptanceCriteriaPreviewItems.length > 0"
v-if="
acceptanceCriteriaPreviewItems.length >
0
"
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
@@ -216,14 +239,18 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
</p>
<ul class="space-y-2">
<li
v-for="(item, index) in acceptanceCriteriaPreviewItems"
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 class="mt-0.5">{{
item.checked ? '☑' : '☐'
}}</span>
<span>{{ item.text }}</span>
</li>
</ul>
@@ -248,7 +275,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
{{ ticketType.name }}
</option>
</select>
<InputError :message="form.errors.ticket_type_id" />
<InputError
:message="form.errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
@@ -269,7 +298,11 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
{{ ticketPriority.name }}
</option>
</select>
<InputError :message="form.errors.ticket_priority_id" />
<InputError
:message="
form.errors.ticket_priority_id
"
/>
</div>
<div class="grid gap-2">
@@ -285,10 +318,14 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
:key="ticketStatus.id"
:value="String(ticketStatus.id)"
>
{{ ticketStatusLabel(ticketStatus) }}
{{
ticketStatusLabel(ticketStatus)
}}
</option>
</select>
<InputError :message="form.errors.ticket_status_id" />
<InputError
:message="form.errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
@@ -310,7 +347,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
{{ user.name }}
</option>
</select>
<InputError :message="form.errors.assignee_user_id" />
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div class="grid gap-2">
@@ -323,16 +362,24 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
: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 value="">
No parent ticket
</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="String(parentTicketOption.id)"
:value="
String(parentTicketOption.id)
"
>
{{ `${parentTicketOption.ticket_key} - ${parentTicketOption.title}` }}
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError :message="form.errors.parent_ticket_id" />
<InputError
:message="form.errors.parent_ticket_id"
/>
</div>
<div
@@ -349,7 +396,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
step="1"
:disabled="form.processing"
/>
<InputError :message="form.errors.story_points" />
<InputError
:message="form.errors.story_points"
/>
</div>
</div>
</div>