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
+1
View File
@@ -16,6 +16,7 @@ class BoardColumn extends Model
{
/** @use HasFactory<BoardColumnFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
+1
View File
@@ -31,6 +31,7 @@ class NotificationAction extends Model
{
/** @use HasFactory<NotificationActionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
/**
+1
View File
@@ -26,6 +26,7 @@ class RepositoryConnection extends Model
{
/** @use HasFactory<RepositoryConnectionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
/**
+1
View File
@@ -23,6 +23,7 @@ class TicketFieldDefinition extends Model
/** @use HasFactory<TicketFieldDefinitionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
+1
View File
@@ -15,6 +15,7 @@ class TicketLinkType extends Model
{
/** @use HasFactory<TicketLinkTypeFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
+1
View File
@@ -17,6 +17,7 @@ class TicketStatus extends Model
{
/** @use HasFactory<TicketStatusFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
+1
View File
@@ -15,6 +15,7 @@ class TicketType extends Model
{
/** @use HasFactory<TicketTypeFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
-14
View File
@@ -4,7 +4,6 @@
"requires": true,
"packages": {
"": {
"name": "KanbanGit",
"dependencies": {
"@inertiajs/vue3": "^2.3.7",
"@vueuse/core": "^12.8.2",
@@ -1303,7 +1302,6 @@
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1320,7 +1318,6 @@
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.58.1",
@@ -1360,7 +1357,6 @@
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1",
@@ -2111,7 +2107,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3020,7 +3015,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3081,7 +3075,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -3208,7 +3201,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3351,7 +3343,6 @@
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"globals": "^13.24.0",
@@ -5483,7 +5474,6 @@
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -6525,7 +6515,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6591,7 +6580,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -6642,7 +6630,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
@@ -6998,7 +6985,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
+1 -3
View File
@@ -121,9 +121,7 @@ const parseChecklistLine = (
return null;
}
const indentLevel = clampIndent(
Math.floor(match.groups.indent.length / 2),
);
const indentLevel = clampIndent(Math.floor(match.groups.indent.length / 2));
let content = match.groups.content.trim();
let checked = false;
+19 -9
View File
@@ -52,12 +52,16 @@ const breadcrumbs: BreadcrumbItem[] = [
<CardContent>
<Form
v-bind="UserManagementController.store.form()"
:transform="(data) => ({
...data,
must_change_password_on_next_login:
data.must_change_password_on_next_login === true ||
data.must_change_password_on_next_login === 'on',
})"
:transform="
(data) => ({
...data,
must_change_password_on_next_login:
data.must_change_password_on_next_login ===
true ||
data.must_change_password_on_next_login ===
'on',
})
"
class="space-y-6"
v-slot="{ errors, processing }"
>
@@ -128,13 +132,19 @@ const breadcrumbs: BreadcrumbItem[] = [
<span class="block font-medium">
Must change password on next login
</span>
<span class="text-muted-foreground block text-sm">
Redirect the user to the security settings page until they save a new password.
<span
class="text-muted-foreground block text-sm"
>
Redirect the user to the security
settings page until they save a new
password.
</span>
</span>
</Label>
<InputError
:message="errors.must_change_password_on_next_login"
:message="
errors.must_change_password_on_next_login
"
/>
</div>
+40 -12
View File
@@ -15,7 +15,10 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as projectsIndex, show as projectShow } from '@/routes/projects/index';
import {
index as projectsIndex,
show as projectShow,
} from '@/routes/projects/index';
import {
create as projectTemplateCreate,
store as projectTemplateStore,
@@ -40,7 +43,9 @@ const breadcrumbs: BreadcrumbItem[] = [
];
const form = useForm({
copy_areas: props.copyAreaOptions.map((copyAreaOption) => copyAreaOption.value),
copy_areas: props.copyAreaOptions.map(
(copyAreaOption) => copyAreaOption.value,
),
name: `${props.project.name} template`,
slug: `${props.project.key.toLowerCase()}-template`,
});
@@ -48,7 +53,9 @@ const form = useForm({
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] ??
''
);
});
@@ -66,7 +73,9 @@ const submit = (): void => {
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">{{ project.key }}</Badge>
<Badge variant="outline" class="px-3 py-1">{{
project.key
}}</Badge>
<Heading
:title="`Save ${project.name} as new template`"
description="Capture reusable project configuration without copying operational ticket data."
@@ -77,7 +86,8 @@ const submit = (): void => {
<CardHeader>
<CardTitle>Template details</CardTitle>
<CardDescription>
Pick the configuration groups that should be reusable in future projects.
Pick the configuration groups that should be reusable in
future projects.
</CardDescription>
</CardHeader>
@@ -85,15 +95,26 @@ const submit = (): void => {
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Template name</Label>
<Input id="name" v-model="form.name" required placeholder="Foundation template" />
<Input
id="name"
v-model="form.name"
required
placeholder="Foundation template"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="slug">Template slug</Label>
<Input id="slug" v-model="form.slug" required placeholder="foundation-template" />
<Input
id="slug"
v-model="form.slug"
required
placeholder="foundation-template"
/>
<p class="text-muted-foreground text-sm">
Slugs are normalized automatically and used to identify reusable templates.
Slugs are normalized automatically and used to
identify reusable templates.
</p>
<InputError :message="form.errors.slug" />
</div>
@@ -102,7 +123,8 @@ const submit = (): void => {
<div>
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Only the selected groups will be stored in the template.
Only the selected groups will be stored in
the template.
</p>
</div>
@@ -119,8 +141,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>
@@ -135,7 +161,9 @@ const submit = (): void => {
</Button>
<Button as-child variant="outline">
<Link :href="projectShow(project.id)">Cancel</Link>
<Link :href="projectShow(project.id)"
>Cancel</Link
>
</Button>
</div>
</form>
+67 -19
View File
@@ -44,12 +44,16 @@ const form = useForm({
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] ??
''
);
});
const linkedProjectCount = computed(() => props.template.linked_project_count ?? 0);
const linkedProjectCount = computed(
() => props.template.linked_project_count ?? 0,
);
const submit = (): void => {
form.patch(projectTemplatesUpdate.url(props.template.id), {
@@ -65,9 +69,12 @@ const submit = (): void => {
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-3">
<Badge variant="outline" class="px-3 py-1">{{ template.slug }}</Badge>
<Badge variant="outline" class="px-3 py-1">{{
template.slug
}}</Badge>
<Badge v-if="linkedProjectCount > 0" variant="secondary">
{{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}
{{ linkedProjectCount }} linked
{{ linkedProjectCount === 1 ? 'project' : 'projects' }}
</Badge>
</div>
<Heading
@@ -81,10 +88,14 @@ const submit = (): void => {
<CardTitle>Template details</CardTitle>
<CardDescription>
<template v-if="template.source_project">
This template baseline is rebuilt from {{ template.source_project.name }} ({{ template.source_project.key }}) when you save.
This template baseline is rebuilt from
{{ template.source_project.name }} ({{
template.source_project.key
}}) when you save.
</template>
<template v-else>
This template baseline is rebuilt from its source project when you save.
This template baseline is rebuilt from its source
project when you save.
</template>
</CardDescription>
</CardHeader>
@@ -93,15 +104,25 @@ const submit = (): void => {
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Template name</Label>
<Input id="name" v-model="form.name" required placeholder="Foundation template" />
<Input
id="name"
v-model="form.name"
required
placeholder="Foundation template"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="slug">Template slug</Label>
<Input id="slug" :model-value="template.slug" disabled />
<Input
id="slug"
:model-value="template.slug"
disabled
/>
<p class="text-muted-foreground text-sm">
The slug stays locked so existing template references remain stable.
The slug stays locked so existing template
references remain stable.
</p>
</div>
@@ -109,7 +130,8 @@ const submit = (): void => {
<div>
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Saving rebuilds the selected groups from the current source-project configuration.
Saving rebuilds the selected groups from the
current source-project configuration.
</p>
</div>
@@ -126,8 +148,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>
@@ -143,19 +169,39 @@ const submit = (): void => {
<div class="space-y-1">
<div class="font-medium">Warning</div>
<p class="text-sm">
Saving this template updates the baseline for {{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}.
Inherited areas will follow the new template version, while existing overrides stay untouched.
Saving this template updates the baseline
for {{ linkedProjectCount }} linked
{{
linkedProjectCount === 1
? 'project'
: 'projects'
}}. Inherited areas will follow the new
template version, while existing overrides
stay untouched.
</p>
</div>
<Label class="flex cursor-pointer items-start gap-3">
<input v-model="form.confirm_linked_projects_update" type="checkbox" class="mt-1" />
<Label
class="flex cursor-pointer items-start gap-3"
>
<input
v-model="
form.confirm_linked_projects_update
"
type="checkbox"
class="mt-1"
/>
<span class="text-sm">
I understand that linked projects inheriting from this template will be affected.
I understand that linked projects inheriting
from this template will be affected.
</span>
</Label>
<InputError :message="form.errors.confirm_linked_projects_update" />
<InputError
:message="
form.errors.confirm_linked_projects_update
"
/>
</div>
<div class="flex items-center gap-3">
@@ -164,7 +210,9 @@ const submit = (): void => {
</Button>
<Button as-child variant="outline">
<Link :href="projectTemplatesIndex()">Cancel</Link>
<Link :href="projectTemplatesIndex()"
>Cancel</Link
>
</Button>
</div>
</form>
+40 -9
View File
@@ -16,7 +16,10 @@ import {
edit as projectTemplatesEdit,
index as projectTemplatesIndex,
} from '@/routes/project-templates';
import { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index';
import {
create as projectsCreate,
index as projectsIndex,
} from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
const props = defineProps<ProjectTemplatesIndexPageProps>();
@@ -40,7 +43,9 @@ const removeTemplate = (templateId: number): void => {
<Head title="Project templates" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<Heading
title="Project templates"
description="Reusable published project templates. Internal project base templates stay hidden here by default."
@@ -64,23 +69,43 @@ const removeTemplate = (templateId: number): void => {
{{ status }}
</div>
<div v-if="templates.length === 0" class="text-muted-foreground text-sm">
<div
v-if="templates.length === 0"
class="text-muted-foreground text-sm"
>
No published project templates have been saved yet.
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card v-for="template in templates" :key="template.id" class="h-full">
<Card
v-for="template in templates"
:key="template.id"
class="h-full"
>
<CardHeader>
<div class="flex flex-wrap items-center gap-2">
<CardTitle>{{ template.name }}</CardTitle>
<Badge variant="secondary">{{ template.slug }}</Badge>
<Badge v-if="template.linked_project_count" variant="outline">
{{ template.linked_project_count }} linked {{ template.linked_project_count === 1 ? 'project' : 'projects' }}
<Badge variant="secondary">{{
template.slug
}}</Badge>
<Badge
v-if="template.linked_project_count"
variant="outline"
>
{{ template.linked_project_count }} linked
{{
template.linked_project_count === 1
? 'project'
: 'projects'
}}
</Badge>
</div>
<CardDescription>
<template v-if="template.source_project">
Saved from {{ template.source_project.name }} ({{ template.source_project.key }}).
Saved from
{{ template.source_project.name }} ({{
template.source_project.key
}}).
</template>
<template v-else>
Saved template configuration.
@@ -106,7 +131,13 @@ const removeTemplate = (templateId: number): void => {
</Button>
<Button as-child variant="outline">
<Link :href="projectsCreate({ query: { template: template.id } })">
<Link
:href="
projectsCreate({
query: { template: template.id },
})
"
>
Use template
</Link>
</Button>
+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>
@@ -46,8 +46,9 @@ const breadcrumbItems: BreadcrumbItem[] = [
/>
<p class="text-muted-foreground text-sm">
Each user needs their own application key. Without a
matching personal connection, {{ appName }} can only show
repository names and will link you back to this page.
matching personal connection, {{ appName }} can only
show repository names and will link you back to this
page.
</p>
</div>