Files
KanbanGit/resources/js/pages/projects/settings/Board.vue
T

690 lines
32 KiB
Vue

<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
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, show } from '@/routes/projects/index';
import {
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectBoardSettingsPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Board settings',
href: boardSettings(props.project.id),
},
];
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}`;
};
const transitionTargetOptions = (
sourceStatus: TicketStatusModel,
): TicketStatusModel[] => {
return props.ticketStatuses.filter(
(ticketStatus) => ticketStatus.id !== sourceStatus.id,
);
};
const statusAllowsTarget = (
sourceStatus: TicketStatusModel,
targetStatus: TicketStatusModel,
): boolean => {
return (
sourceStatus.allowed_target_statuses?.some(
(allowedTargetStatus) => allowedTargetStatus.id === targetStatus.id,
) ?? false
);
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} board settings`" />
<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="`${project.name} board settings`"
description="Configure the ordered board columns that define valid in-board ticket statuses for this project."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
<Link :href="generalSettings(project.id)">
General settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</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="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project stays linked to its template. Sections below show whether the current configuration is still inherited or has local overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Workflow model</CardTitle>
<CardDescription>
Backlog is always the intake queue; board columns group
the statuses that appear on the board.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-3">
<Badge class="px-3 py-1">Backlog</Badge>
<Badge
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
variant="outline"
class="px-3 py-1"
>
{{ ticketStatus.name }}
</Badge>
<p class="text-muted-foreground text-sm">
Tickets move between statuses; backlog statuses are not
included in a board column.
</p>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Status moves</CardTitle>
<CardDescription>
Choose exactly where each status is allowed to move
next.
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<div class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">
Source status
</th>
<th class="px-4 py-3 font-medium">
Allowed move targets
</th>
<th class="px-4 py-3 font-medium">Save</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="sourceStatus in ticketStatuses"
:key="sourceStatus.id"
class="bg-background align-top"
>
<td class="px-4 py-4">
<Badge variant="outline">
{{
ticketStatusLabel(sourceStatus)
}}
</Badge>
</td>
<td class="px-4 py-4">
<Form
:id="`status-transitions-${sourceStatus.id}`"
v-bind="
ProjectBoardSettingsController.updateStatusTransitions.form(
[
project.id,
sourceStatus.id,
],
)
"
class="grid gap-3"
v-slot="{ errors }"
>
<div
v-if="
transitionTargetOptions(
sourceStatus,
).length === 0
"
class="text-muted-foreground"
>
No other statuses exist yet.
</div>
<div
v-else
class="grid gap-2 md:grid-cols-2 xl:grid-cols-3"
>
<label
v-for="targetStatus in transitionTargetOptions(
sourceStatus,
)"
:key="targetStatus.id"
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<input
type="checkbox"
name="allowed_ticket_status_ids[]"
:value="targetStatus.id"
:checked="
statusAllowsTarget(
sourceStatus,
targetStatus,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>{{
ticketStatusLabel(
targetStatus,
)
}}</span>
</label>
</div>
<InputError
:message="
errors.allowed_ticket_status_ids
"
/>
</Form>
</td>
<td class="px-4 py-4">
<Button
type="submit"
variant="outline"
:form="`status-transitions-${sourceStatus.id}`"
>
Save
</Button>
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Backlog statuses</CardTitle>
<CardDescription>
New backlog tickets choose one of these statuses.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
class="border-border rounded-lg border p-4"
>
<Form
v-bind="
ProjectBoardSettingsController.updateStatus.form(
[project.id, ticketStatus.id],
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`backlog-status-${ticketStatus.id}`"
>
Status name
</Label>
<Input
:id="`backlog-status-${ticketStatus.id}`"
name="name"
:default-value="ticketStatus.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroyStatus.form(
[project.id, ticketStatus.id],
)
"
v-slot="{ errors, processing }"
>
<InputError
:message="errors.ticket_status_id"
class="mb-2"
/>
<Button
type="submit"
variant="destructive"
:disabled="
processing ||
backlogTicketStatuses.length <= 1
"
>
Delete
</Button>
</Form>
</div>
</div>
<Form
v-bind="
ProjectBoardSettingsController.storeStatus.form(
project.id,
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="backlog-status-name">
New backlog status
</Label>
<Input
id="backlog-status-name"
name="name"
placeholder="Ready"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button type="submit" :disabled="processing">
Add status
</Button>
</div>
</Form>
</CardContent>
</Card>
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Add column</CardTitle>
<CardDescription>
New columns extend the project-specific workflow and
are inserted at the end unless you specify a
position later.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="
ProjectBoardSettingsController.store.form(
project.id,
)
"
class="space-y-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="name">Column name</Label>
<Input
id="name"
name="name"
required
autofocus
placeholder="In Review"
/>
<InputError :message="errors.name" />
</div>
<Button type="submit" :disabled="processing">
Add column
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Board columns</CardTitle>
<CardDescription>
Rename columns or change the numeric position to
reorder the board from left to right.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<div
v-if="boardColumns.length === 0"
class="text-muted-foreground text-sm"
>
No board columns exist yet.
</div>
<div
v-for="column in boardColumns"
:key="column.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectBoardSettingsController.update.form([
project.id,
column.id,
])
"
class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_8rem_auto_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label :for="`column-name-${column.id}`">
Name
</Label>
<Input
:id="`column-name-${column.id}`"
name="name"
:default-value="column.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`column-position-${column.id}`"
>
Position
</Label>
<Input
:id="`column-position-${column.id}`"
name="position"
type="number"
min="1"
:max="boardColumns.length"
:default-value="column.position"
required
/>
<InputError :message="errors.position" />
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroy.form(
[project.id, column.id],
)
"
v-slot="{ processing }"
>
<Button
type="submit"
variant="destructive"
:disabled="processing"
>
Delete
</Button>
</Form>
</div>
<div class="border-border mt-4 border-t pt-4">
<div class="mb-3">
<p class="text-sm font-medium">
Ticket statuses
</p>
<p class="text-muted-foreground text-sm">
Moving into this column requires a
specific status.
</p>
</div>
<div class="space-y-3">
<div
v-for="ticketStatus in column.ticket_statuses ??
[]"
:key="ticketStatus.id"
class="border-border rounded-lg border p-3"
>
<Form
v-bind="
ProjectBoardSettingsController.updateStatus.form(
[
project.id,
ticketStatus.id,
],
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`column-status-${ticketStatus.id}`"
>
Status name
</Label>
<Input
:id="`column-status-${ticketStatus.id}`"
name="name"
:default-value="
ticketStatus.name
"
required
/>
<InputError
:message="errors.name"
/>
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroyStatus.form(
[
project.id,
ticketStatus.id,
],
)
"
v-slot="{ errors, processing }"
>
<InputError
:message="
errors.ticket_status_id
"
class="mb-2"
/>
<Button
type="submit"
variant="destructive"
:disabled="
processing ||
(column.ticket_statuses
?.length ?? 0) <= 1
"
>
Delete
</Button>
</Form>
</div>
</div>
</div>
<Form
v-bind="
ProjectBoardSettingsController.storeStatus.form(
project.id,
)
"
class="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<input
type="hidden"
name="board_column_id"
:value="column.id"
/>
<div class="grid gap-2">
<Label
:for="`new-column-status-${column.id}`"
>
New status
</Label>
<Input
:id="`new-column-status-${column.id}`"
name="name"
placeholder="Ready for QA"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button
type="submit"
:disabled="processing"
>
Add status
</Button>
</div>
</Form>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>