fixed missing features

This commit is contained in:
2026-04-15 09:56:53 +02:00
parent de461ad73a
commit cec0e397b6
44 changed files with 1102 additions and 561 deletions
+14 -202
View File
@@ -1,9 +1,7 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBacklogController from '@/actions/App/Http/Controllers/ProjectBacklogController';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -13,14 +11,11 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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 { show as showTicket } from '@/routes/projects/tickets';
import { metadata as metadataSettings } from '@/routes/projects/settings';
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';
@@ -42,11 +37,6 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const hasTicketMetadata =
props.ticketTypes.length > 0 &&
props.ticketPriorities.length > 0 &&
props.backlogTicketStatuses.length > 0;
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
@@ -92,6 +82,19 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</div>
<div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'backlog' } },
)
"
>
Create ticket
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
@@ -109,197 +112,6 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
{{ status }}
</div>
<Card v-if="canCreateTickets && hasTicketMetadata">
<CardHeader>
<CardTitle>Create ticket</CardTitle>
<CardDescription>
New tickets receive the next immutable project key and
enter the selected backlog status.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="ProjectBacklogController.store.form(project.id)"
class="grid gap-6"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
name="title"
required
autofocus
placeholder="Implement repository sync"
/>
<InputError :message="errors.title" />
</div>
<div class="flex gap-2">
<div class="flex-4/5 grid gap-2 md:gap-6">
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
id="description"
name="description"
rows="4"
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]"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError :message="errors.description" />
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria">
Acceptance criteria
</Label>
<textarea
id="acceptance_criteria"
name="acceptance_criteria"
rows="4"
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]"
placeholder="List the conditions that must be true before this ticket is done."
/>
<InputError
:message="errors.acceptance_criteria"
/>
</div>
</div>
<div
class="grid h-fit flex-1 gap-2 md:ml-4 md:gap-6"
>
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
name="ticket_type_id"
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]"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
name="ticket_priority_id"
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]"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="errors.ticket_priority_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">
Status
</Label>
<select
id="ticket_status_id"
name="ticket_status_id"
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]"
>
<option
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{ ticketStatus.name }}
</option>
</select>
<InputError
:message="errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
name="assignee_user_id"
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]"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="errors.assignee_user_id"
/>
</div>
<div
class="grid gap-2"
v-if="project.show_story_points"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
name="story_points"
type="number"
step="1"
/>
<InputError
:message="errors.story_points"
/>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="processing">
Create ticket
</Button>
</div>
</Form>
</CardContent>
</Card>
<Card v-else-if="canCreateTickets">
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
Ticket creation needs at least one type and one
priority, plus one backlog status.
</p>
<div>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Open ticket settings
</Link>
</Button>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Backlog tickets</CardTitle>
+14 -1
View File
@@ -16,7 +16,7 @@ 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 { 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';
@@ -83,6 +83,19 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</div>
<div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'board' } },
)
"
>
Create ticket
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)"
>Open backlog</Link
+4 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Form, useForm, Head, Link } from '@inertiajs/vue3';
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
@@ -38,6 +38,8 @@ import type { RepositoryConnectionModel } from '@/types/models/repository-connec
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>();
const page = usePage();
const appName = String(page.props.name);
const visibleRepositoryBranches = computed(() =>
props.repositoryBranches.filter(
@@ -270,7 +272,7 @@ watch(
branchNameValidation.value = {
is_valid: false,
message:
'KanbanGit could not validate this branch name right now.',
`${appName} could not validate this branch name right now.`,
};
} finally {
if (!abortController.signal.aborted) {
@@ -0,0 +1,308 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index';
import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectTicketCreatePageProps>();
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 breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: props.source === 'board' ? 'Board' : 'Backlog',
href: returnHref,
},
{
title: 'Create ticket',
href: ProjectTicketController.create({
project: props.project.id,
}),
},
];
const hasTicketMetadata =
props.ticketTypes.length > 0 &&
props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0;
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>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Create ${project.name} ticket`" />
<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="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
title="Create ticket"
:description="`Add a ticket to ${project.name}.`"
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="returnHref">{{ returnLabel }}</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div
v-if="status"
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"
>
{{ status }}
</div>
<Card v-if="hasTicketMetadata">
<CardHeader>
<CardTitle>Ticket details</CardTitle>
<CardDescription>
The ticket key is assigned after creation.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="ProjectTicketController.store.form(project.id)"
class="grid gap-6"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
name="title"
required
autofocus
placeholder="Implement repository sync"
/>
<InputError :message="errors.title" />
</div>
<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>
<textarea
id="description"
name="description"
rows="4"
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]"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError :message="errors.description" />
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria">
Acceptance criteria
</Label>
<textarea
id="acceptance_criteria"
name="acceptance_criteria"
rows="4"
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]"
placeholder="List the conditions that must be true before this ticket is done."
/>
<InputError
:message="errors.acceptance_criteria"
/>
</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"
name="ticket_type_id"
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]"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
name="ticket_priority_id"
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]"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="errors.ticket_priority_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">Status</Label>
<select
id="ticket_status_id"
name="ticket_status_id"
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]"
>
<option
v-for="ticketStatus in ticketStatusOptions"
:key="ticketStatus.id"
:value="ticketStatus.id"
:selected="
ticketStatus.id ===
defaultTicketStatusId
"
>
{{ ticketStatusLabel(ticketStatus) }}
</option>
</select>
<InputError
:message="errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
name="assignee_user_id"
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]"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="errors.assignee_user_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"
name="story_points"
type="number"
step="1"
/>
<InputError
:message="errors.story_points"
/>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button type="submit" :disabled="processing">
Create ticket
</Button>
<Button as-child variant="outline">
<Link :href="returnHref">
{{ returnLabel }}
</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
<Card v-else>
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
Ticket creation needs at least one type, one priority,
and one status.
</p>
<div>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Open ticket settings
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</AppLayout>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
import ProjectRepositorySettingsController from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController';
import Heading from '@/components/Heading.vue';
@@ -31,6 +31,8 @@ import type {
} from '@/types';
const props = defineProps<ProjectRepositorySettingsPageProps>();
const page = usePage();
const appName = String(page.props.name);
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -285,7 +287,7 @@ const submitUpdate = (): void => {
</Link>
</Button>
<p class="text-muted-foreground text-sm">
Without a matching personal connection, KanbanGit only
Without a matching personal connection, {{ appName }} only
shows the repository name and links you to your user
settings.
</p>
@@ -612,7 +614,7 @@ const submitUpdate = (): void => {
>
This project link is visible, but you need a
matching Git connection in your user
settings before KanbanGit can open or query
settings before {{ appName }} can open or query
this repository for you.
</p>
@@ -648,7 +650,7 @@ const submitUpdate = (): void => {
<CardHeader class="border-b">
<CardTitle>Detected personal sources</CardTitle>
<CardDescription>
KanbanGit loads repositories from your personal
{{ appName }} loads repositories from your personal
Git connections and offers them for project
linking.
</CardDescription>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Form, Head, usePage } from '@inertiajs/vue3';
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
@@ -21,6 +21,9 @@ import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
defineProps<SettingsGitConnectionsPageProps>();
const page = usePage();
const appName = String(page.props.name);
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Git connections',
@@ -43,7 +46,7 @@ const breadcrumbItems: BreadcrumbItem[] = [
/>
<p class="text-muted-foreground text-sm">
Each user needs their own application key. Without a
matching personal connection, KanbanGit can only show
matching personal connection, {{ appName }} can only show
repository names and will link you back to this page.
</p>
</div>
@@ -1,12 +1,19 @@
import type { UserModel } from '@/types/models/user-model';
export type TicketWorklogEntryType =
| 'created'
| 'link_removed'
| 'link_updated'
| 'status_changed'
| 'updated';
export type TicketWorklogEntryModel = {
id: number;
actor: UserModel | null;
actor_id: number | null;
changes: Record<string, unknown> | null;
created_at: string;
entry_type: string;
entry_type: TicketWorklogEntryType;
summary: string;
ticket_id: number;
updated_at: string;
+12
View File
@@ -73,6 +73,7 @@ export type ProjectGeneralSettingsPageProps = {
export type ProjectBoardPageProps = {
boardColumns: BoardColumnModel[];
canCreateTickets: boolean;
canEditTickets: boolean;
project: ProjectModel;
status?: string | null;
@@ -114,6 +115,17 @@ export type ProjectBacklogPageProps = {
users: UserModel[];
};
export type ProjectTicketCreatePageProps = {
defaultTicketStatusId: number | null;
project: ProjectModel;
source: 'backlog' | 'board';
status?: string | null;
ticketPriorities: TicketPriorityModel[];
ticketStatusOptions: TicketStatusModel[];
ticketTypes: TicketTypeModel[];
users: UserModel[];
};
export type ProjectTicketPageProps = {
canEditTicket: boolean;
canManageProject: boolean;