feat(KAN-40): rebuild workflow column and status settings

This commit is contained in:
2026-09-15 10:46:06 +02:00
parent a028eacec2
commit 4518de5bde
11 changed files with 1412 additions and 640 deletions
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
import {
destroy,
destroyStatus,
store,
storeStatus,
update,
updateStatus,
} from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { WorkflowEditorTarget } from '@/types/project-workflow';
const props = defineProps<{
projectId: number;
columnCount: number;
target: WorkflowEditorTarget | null;
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({ name: '', position: 1 });
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.kind === 'column') {
return t(
props.target.action === 'create'
? 'workflow.addColumn'
: props.target.action === 'edit'
? 'workflow.editColumn'
: 'workflow.deleteColumn',
);
}
return t(
props.target?.action === 'create'
? 'workflow.addStatus'
: props.target?.action === 'edit'
? 'workflow.editStatus'
: 'workflow.deleteStatus',
);
});
const description = computed(() => {
const target = props.target;
if (!target) {
return '';
}
if (target.kind === 'column') {
return t(
target.action === 'delete'
? 'workflow.deleteColumnDescription'
: target.action === 'create'
? 'workflow.addColumnDescription'
: 'workflow.editColumnDescription',
{ name: target.column?.name ?? '' },
);
}
return t(
target.action === 'delete'
? 'workflow.deleteStatusDescription'
: 'workflow.statusContext',
{
name: target.status?.name ?? '',
context: target.column?.name ?? t('workflow.backlog'),
replacement: target.replacement?.name ?? '',
},
);
});
const otherErrors = computed(() =>
Object.entries(form.errors)
.filter(([key]) => !['name', 'position'].includes(key))
.map(([, message]) => message),
);
watch(
() => props.target,
(target) => {
if (!target) {
return;
}
form.defaults({
name:
target.kind === 'column'
? (target.column?.name ?? '')
: (target.status?.name ?? ''),
position: target.column?.position ?? 1,
});
form.resetAndClearErrors();
protection.clearFeedback();
},
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (
!target ||
form.processing ||
(target.kind === 'status' &&
target.action === 'delete' &&
!target.replacement)
) {
return;
}
const route =
target.kind === 'column'
? target.action === 'create'
? store(props.projectId)
: target.action === 'edit'
? update([props.projectId, target.column.id])
: destroy([props.projectId, target.column.id])
: target.action === 'create'
? storeStatus(props.projectId)
: target.action === 'edit'
? updateStatus([props.projectId, target.status.id])
: destroyStatus([props.projectId, target.status.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.kind === 'column' && target.action === 'edit'
? data
: target.kind === 'status' && target.action === 'create'
? {
name: data.name,
board_column_id: target.column?.id ?? null,
}
: { name: data.name },
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription class="break-words">{{
description
}}</DialogDescription>
</DialogHeader>
<form
class="gap-column grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<InputError
v-for="message in otherErrors"
:key="message"
:message="message"
/>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="workflow-name">{{
t(
target?.kind === 'column'
? 'workflow.columnName'
: 'workflow.statusName',
)
}}</Label>
<Input
id="workflow-name"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
:aria-describedby="
form.errors.name
? 'workflow-name-error'
: undefined
"
/>
<InputError
id="workflow-name-error"
:message="form.errors.name"
/>
</div>
<div
v-if="
target?.kind === 'column' &&
target.action === 'edit'
"
class="grid gap-2"
>
<Label for="workflow-position">{{
t('workflow.position')
}}</Label>
<Input
id="workflow-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="columnCount"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="workflow-position-help workflow-position-error"
/>
<p
id="workflow-position-help"
class="text-caption text-muted-foreground"
>
{{
t('workflow.positionHelp', {
count: columnCount,
})
}}
</p>
<InputError
id="workflow-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { BoardColumnModel } from '@/types/models/board-column-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
import type { WorkflowEditorTarget } from '@/types/project-workflow';
defineProps<{
column: BoardColumnModel | null;
statuses: TicketStatusModel[];
}>();
defineEmits<{ edit: [target: WorkflowEditorTarget] }>();
const { t } = useLocalization();
</script>
<template>
<div class="grid min-w-0 gap-3">
<ul
class="divide-y"
:aria-label="
t('workflow.statusesIn', {
context: column?.name ?? t('workflow.backlog'),
})
"
>
<li
v-for="status in statuses"
:key="status.id"
class="flex min-w-0 flex-wrap items-center justify-between gap-2 py-3 first:pt-0"
>
<span
class="min-w-0 flex-1 basis-28 break-words text-sm font-medium"
>{{ status.name }}</span
>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('workflow.renameStatus', { name: status.name })
"
@click="
$emit('edit', {
kind: 'status',
action: 'edit',
column,
status,
})
"
><Pencil class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:disabled="statuses.length <= 1"
:aria-label="
t('workflow.removeStatus', { name: status.name })
"
:aria-describedby="
statuses.length <= 1
? `status-protection-${column?.id ?? 'backlog'}`
: undefined
"
@click="
$emit('edit', {
kind: 'status',
action: 'delete',
column,
status,
replacement: statuses.find(
(item) => item.id !== status.id,
),
})
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</li>
</ul>
<p
v-if="statuses.length <= 1"
:id="`status-protection-${column?.id ?? 'backlog'}`"
class="text-caption text-muted-foreground"
>
{{ t('workflow.lastStatus') }}
</p>
<Button
type="button"
variant="outline"
class="w-full sm:w-fit"
:aria-label="
t('workflow.addStatusTo', {
context: column?.name ?? t('workflow.backlog'),
})
"
@click="$emit('edit', { kind: 'status', action: 'create', column })"
><Plus class="size-4" aria-hidden="true" />{{
t('workflow.addStatus')
}}</Button
>
</div>
</template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
defineProps<{ projectId: number; statuses: TicketStatusModel[] }>();
const { t } = useLocalization();
</script>
<template>
<details class="bg-card min-w-0 rounded-xl border">
<summary
class="focus-visible:ring-ring/40 cursor-pointer rounded-xl p-5 font-semibold outline-none focus-visible:ring-2 sm:p-6"
>
{{ t('workflow.transitions') }}
</summary>
<div class="grid min-w-0 gap-4 border-t p-5 sm:p-6">
<p class="text-muted-foreground text-sm">
{{ t('workflow.transitionsDescription') }}
</p>
<Form
v-for="source in statuses"
:key="source.id"
v-bind="updateStatusTransitions.form([projectId, source.id])"
preserve-scroll
set-defaults-on-success
class="grid min-w-0 gap-3 rounded-lg border p-4"
v-slot="{ errors, processing, recentlySuccessful }"
>
<fieldset :disabled="processing" class="grid min-w-0 gap-3">
<legend class="mb-3 break-words font-medium">
{{ ticketStatusLabel(source, '') }}
</legend>
<p
v-if="statuses.length <= 1"
class="text-muted-foreground text-sm"
>
{{ t('workflow.noTargets') }}
</p>
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
<label
v-for="target in statuses.filter(
(item) => item.id !== source.id,
)"
:key="target.id"
class="border-input flex min-h-11 min-w-0 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
>
<input
type="checkbox"
name="allowed_ticket_status_ids[]"
:value="target.id"
:checked="
source.allowed_target_statuses?.some(
(item) => item.id === target.id,
) ?? false
"
class="border-input accent-primary focus-visible:ring-ring/40 size-4 shrink-0 rounded focus-visible:ring-2"
/>
<span class="min-w-0 break-words">{{
ticketStatusLabel(target, '')
}}</span>
</label>
</div>
</fieldset>
<InputError
v-for="(message, key) in errors"
:key="key"
:message="message"
/>
<div class="flex flex-wrap items-center gap-3">
<Button
type="submit"
variant="outline"
:loading="processing"
>{{ t('common.save') }}</Button
>
<span
v-if="recentlySuccessful"
role="status"
class="text-primary text-sm"
>{{ t('feedback.saved') }}</span
>
</div>
</Form>
</div>
</details>
</template>
+49
View File
@@ -1,4 +1,53 @@
export const en = { export const en = {
'workflow.title': 'Board workflow',
'workflow.pageTitle': '{project} · Board workflow',
'workflow.description':
'Organize your board into columns and give each stage the statuses your team needs.',
'workflow.updated': 'Workflow updated.',
'workflow.backlog': 'Backlog',
'workflow.offBoard': 'Off the board',
'workflow.backlogDescription':
'Your intake queue. Tickets with these statuses stay outside the board columns.',
'workflow.columns': 'Board columns',
'workflow.columnsDescription':
'Columns appear from left to right in this order. Edit a column to change its name or position.',
'workflow.emptyColumns': 'No board columns yet',
'workflow.emptyColumnsDescription':
'Add your first column to start organizing tickets on the board.',
'workflow.addColumn': 'Add column',
'workflow.editColumn': 'Edit column',
'workflow.deleteColumn': 'Delete column?',
'workflow.addColumnDescription':
'The column is added at the end of the board with a first status of the same name.',
'workflow.editColumnDescription':
'Rename this column or move it to a different position. Its statuses keep their names.',
'workflow.deleteColumnDescription':
'Delete “{name}”? Its statuses move to the backlog and their tickets keep their status. Remaining columns are reordered automatically.',
'workflow.addStatus': 'Add status',
'workflow.editStatus': 'Rename status',
'workflow.deleteStatus': 'Delete status?',
'workflow.statusContext': 'This status belongs to {context}.',
'workflow.deleteStatusDescription':
'Delete “{name}”? Tickets using it are assigned to “{replacement}”, the first remaining status in {context}. Its move rules are removed.',
'workflow.columnName': 'Column name',
'workflow.statusName': 'Status name',
'workflow.position': 'Position',
'workflow.positionValue': 'Position {position}',
'workflow.positionHelp':
'Choose a position from 1 to {count}. Other columns shift automatically.',
'workflow.statusesIn': 'Statuses in {context}',
'workflow.renameStatus': 'Rename status {name}',
'workflow.removeStatus': 'Delete status {name}',
'workflow.changeColumn': 'Edit column {name}',
'workflow.removeColumn': 'Delete column {name}',
'workflow.addStatusTo': 'Add status to {context}',
'workflow.lastStatus':
'Keep at least one status in this group. The last status cannot be deleted.',
'workflow.transitions': 'Status moves',
'workflow.transitionsDescription':
'Choose exactly where each status is allowed to move next.',
'workflow.noTargets': 'No other statuses exist yet.',
'workflow.confirmDelete': 'Delete',
'security.title': 'Security settings', 'security.title': 'Security settings',
'security.passwordTitle': 'Update password', 'security.passwordTitle': 'Update password',
'security.passwordDescription': 'security.passwordDescription':
+175 -640
View File
@@ -1,674 +1,209 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController'; import { Columns3, Inbox, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue'; import { computed, ref } from 'vue';
import InputError from '@/components/InputError.vue'; import FeedbackState from '@/components/FeedbackState.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue'; import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import WorkflowEditorDialog from '@/components/projects/WorkflowEditorDialog.vue';
import WorkflowStatusList from '@/components/projects/WorkflowStatusList.vue';
import WorkflowTransitionMatrix from '@/components/projects/WorkflowTransitionMatrix.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardHeader } from '@/components/ui/card';
Card, import { useLocalization } from '@/composables/useLocalization';
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 AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects/index'; import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { import { index, show } from '@/routes/projects';
board as boardSettings, import { board } from '@/routes/projects/settings';
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types'; import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model'; import type { WorkflowEditorTarget } from '@/types/project-workflow';
const props = defineProps<ProjectBoardSettingsPageProps>(); const props = defineProps<ProjectBoardSettingsPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const target = ref<WorkflowEditorTarget | null>(null);
{ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
title: 'Projects', { title: t('navigation.projects'), href: index() },
href: index(), { title: props.project.name, href: show(props.project.id) },
}, { title: t('workflow.title'), href: board(props.project.id) },
{ ]);
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> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} board settings`" /> <Head :title="t('workflow.pageTitle', { project: project.name })" />
<SettingsLayout :project-name="project.name" :project-key="project.key">
<div class="flex flex-1 flex-col gap-6 p-4"> <header class="grid gap-3">
<div <h2 class="text-title">{{ t('workflow.title') }}</h2>
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" <p class="text-muted-foreground">
> {{ t('workflow.description') }}
<div class="space-y-3"> </p>
<Badge variant="outline" class="px-3 py-1"> </header>
{{ project.key }} <FeedbackState
</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" 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" state="success"
> :title="t('workflow.updated')"
{{ status }} />
</div>
<TemplateInheritancePanel <TemplateInheritancePanel
:project-id="project.id"
:template-state="templateState"
:areas="[ :areas="[
'board_columns', 'board_columns',
'ticket_statuses', 'ticket_statuses',
'status_transitions', 'status_transitions',
]" ]"
:project-id="project.id"
:template-state="templateState"
/> />
<Card
<Card> class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
<CardHeader> >
<CardTitle>Workflow model</CardTitle> <CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<CardDescription> <div class="flex flex-wrap items-center gap-2">
Backlog is always the intake queue; board columns group <Inbox
the statuses that appear on the board. class="text-primary size-5 shrink-0"
</CardDescription> aria-hidden="true"
</CardHeader> />
<h3 class="font-semibold">
<CardContent class="flex flex-wrap gap-3"> {{ t('workflow.backlog') }}
<Badge class="px-3 py-1">Backlog</Badge> </h3>
<Badge <Badge variant="secondary">{{
v-for="ticketStatus in backlogTicketStatuses" t('workflow.offBoard')
:key="ticketStatus.id" }}</Badge>
variant="outline" </div>
class="px-3 py-1"
>
{{ ticketStatus.name }}
</Badge>
<p class="text-muted-foreground text-sm"> <p class="text-muted-foreground text-sm">
Tickets move between statuses; backlog statuses are not {{ t('workflow.backlogDescription') }}
included in a board column.
</p> </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> </CardHeader>
<CardContent class="p-5 sm:p-6"
<CardContent class="p-0"> ><WorkflowStatusList
<div class="overflow-x-auto"> :column="null"
<table :statuses="backlogTicketStatuses"
class="divide-border min-w-full divide-y text-sm" @edit="target = $event"
> /></CardContent>
<thead </Card>
class="bg-muted/40 text-muted-foreground text-left" <section
> class="grid min-w-0 gap-4"
<tr> aria-labelledby="workflow-columns"
<th class="px-4 py-3 font-medium"> >
Source status <header
</th> class="flex flex-wrap items-center justify-between gap-3"
<th class="px-4 py-3 font-medium"> >
Allowed move targets <div class="flex items-center gap-2">
</th> <Columns3
<th class="px-4 py-3 font-medium">Save</th> class="text-primary size-5"
</tr> aria-hidden="true"
</thead> />
<tbody class="divide-border divide-y"> <h3 id="workflow-columns" class="font-semibold">
<tr {{ t('workflow.columns') }}
v-for="sourceStatus in ticketStatuses" </h3>
:key="sourceStatus.id" <Badge variant="secondary">{{
class="bg-background align-top" boardColumns.length
> }}</Badge>
<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> </div>
</CardContent> <Button
</Card> type="button"
class="w-full sm:w-auto"
<Card> @click="target = { kind: 'column', action: 'create' }"
<CardHeader> ><Plus class="size-4" aria-hidden="true" />{{
<CardTitle>Backlog statuses</CardTitle> t('workflow.addColumn')
<CardDescription> }}</Button
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 </header>
v-bind=" <p class="text-muted-foreground text-sm">
ProjectBoardSettingsController.updateStatus.form( {{ t('workflow.columnsDescription') }}
[project.id, ticketStatus.id], </p>
) <FeedbackState
" v-if="boardColumns.length === 0"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]" state="empty"
v-slot="{ errors, processing }" :title="t('workflow.emptyColumns')"
:description="t('workflow.emptyColumnsDescription')"
/>
<ol
v-else
class="grid min-w-0 gap-4"
:aria-label="t('workflow.columns')"
>
<li
v-for="column in boardColumns"
:key="column.id"
class="bg-card min-w-0 overflow-hidden rounded-xl border"
:data-column-id="column.id"
>
<header
class="bg-muted/35 flex min-w-0 flex-wrap items-center gap-3 border-b p-4 sm:p-5"
> >
<div class="grid gap-2"> <span
<Label class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:for="`backlog-status-${ticketStatus.id}`" :aria-label="
> t('workflow.positionValue', {
Status name position: column.position,
</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 }" >{{ column.position }}</span
> >
<InputError <h4
:message="errors.ticket_status_id" class="min-w-0 flex-1 basis-24 break-words font-semibold"
class="mb-2" >
/> {{ column.name }}
</h4>
<div class="flex gap-1">
<Button <Button
type="submit" type="button"
variant="destructive" variant="ghost"
:disabled=" size="icon"
processing || :aria-label="
backlogTicketStatuses.length <= 1 t('workflow.changeColumn', {
name: column.name,
})
" "
> @click="
Delete target = {
</Button> kind: 'column',
</Form> action: 'edit',
</div> column,
</div> }
"
<Form ><Pencil class="size-4" aria-hidden="true"
v-bind=" /></Button>
ProjectBoardSettingsController.storeStatus.form( <Button
project.id, type="button"
) variant="ghost"
" size="icon"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]" class="text-destructive"
v-slot="{ errors, processing }" :aria-label="
> t('workflow.removeColumn', {
<div class="grid gap-2"> name: column.name,
<Label for="backlog-status-name"> })
New backlog status "
</Label> @click="
<Input target = {
id="backlog-status-name" kind: 'column',
name="name" action: 'delete',
placeholder="Ready" column,
required }
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</header>
<div class="p-4 sm:p-5">
<WorkflowStatusList
:column="column"
:statuses="column.ticket_statuses ?? []"
@edit="target = $event"
/> />
<InputError :message="errors.name" />
</div> </div>
</li>
<div class="flex items-end"> </ol>
<Button type="submit" :disabled="processing"> </section>
Add status <WorkflowTransitionMatrix
</Button> :project-id="project.id"
</div> :statuses="ticketStatuses"
</Form> />
</CardContent> <WorkflowEditorDialog
</Card> :project-id="project.id"
:column-count="boardColumns.length"
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]"> :target="target"
<Card> @close="target = null"
<CardHeader> />
<CardTitle>Add column</CardTitle> </SettingsLayout>
<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> </AppLayout>
</template> </template>
+20
View File
@@ -0,0 +1,20 @@
import type { BoardColumnModel } from '@/types/models/board-column-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
export type WorkflowEditorTarget =
| { kind: 'column'; action: 'create'; column?: never }
| { kind: 'column'; action: 'edit' | 'delete'; column: BoardColumnModel }
| {
kind: 'status';
action: 'create';
column: BoardColumnModel | null;
status?: never;
replacement?: never;
}
| {
kind: 'status';
action: 'edit' | 'delete';
column: BoardColumnModel | null;
status: TicketStatusModel;
replacement?: TicketStatusModel;
};
@@ -372,4 +372,51 @@ class ProjectBoardSettingsControllerTest extends TestCase
'id' => $backlogStatus->id, 'id' => $backlogStatus->id,
]); ]);
} }
public function test_deleting_a_used_column_preserves_statuses_tickets_and_backlog_order(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['edit']);
$backlog = TicketStatus::factory()->for($project)->create(['position' => 1]);
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
$statuses = TicketStatus::factory()->for($project)->for($column)->count(2)
->sequence(['position' => 1], ['position' => 2])->create();
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $statuses[1]->id]);
$statuses[0]->allowedTargetStatuses()->sync([$statuses[1]->id]);
$this->actingAs($user)
->delete(route('projects.settings.board.columns.destroy', [$project, $column]))
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHasNoErrors();
$this->assertModelMissing($column);
$this->assertSame($statuses[1]->id, $ticket->fresh()->ticket_status_id);
$this->assertSame([$backlog->id, ...$statuses->modelKeys()], $project->ticketStatuses()->pluck('id')->all());
$this->assertSame([1, 2, 3], $project->ticketStatuses()->pluck('position')->all());
$this->assertTrue($project->ticketStatuses()->get()->every(fn (TicketStatus $status): bool => $status->board_column_id === null));
$this->assertSame([$statuses[1]->id], $statuses[0]->allowedTargetStatuses()->pluck('ticket_statuses.id')->all());
}
public function test_last_column_status_is_protected_and_invalid_position_preserves_order(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['edit']);
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
$status = TicketStatus::factory()->for($project)->for($column)->create();
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $status->id]);
$this->actingAs($user)->from(route('projects.settings.board', $project))
->delete(route('projects.settings.board.statuses.destroy', [$project, $status]))
->assertSessionHasErrors('ticket_status_id');
$this->assertModelExists($status);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
$this->actingAs($user)->from(route('projects.settings.board', $project))
->patch(route('projects.settings.board.columns.update', [$project, $column]), ['name' => 'Invalid move', 'position' => 0])
->assertSessionHasErrors('position');
$this->assertSame(1, $column->fresh()->position);
$this->assertSame($column->name, $column->fresh()->name);
}
} }
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectBoardSettingsUiTest extends TestCase
{
public function test_workflow_renders_ordered_groups_deletion_protection_and_localization(): void
{
$process = new Process(['node', 'tests/Fixtures/project-board-settings.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Project board settings rendering checks passed.', $process->getOutput());
}
}
@@ -0,0 +1,280 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4188';
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const requests = [];
page.on('request', (request) => {
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
requests.push(request);
}
});
const button = (name) => page.getByRole('button', { name, exact: true });
const dialog = () => page.getByRole('dialog');
const result = (value) =>
context.addCookies([{ name: 'workflow_test_result', value, url: base }]);
const visit = async (query = '?reset') => {
await result('success');
await page.goto(base + '/projects/1/settings/board' + query);
await page
.getByRole('heading', { name: 'Board workflow', exact: true })
.waitFor();
};
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
const submit = async (label, method, pathname, expected) => {
const sent = page.waitForRequest((request) => request.method() === method);
await dialog().getByRole('button', { name: label, exact: true }).click();
const request = await sent;
assert.equal(new URL(request.url()).pathname, pathname);
assert.deepEqual(request.postDataJSON(), expected);
};
const closed = () => dialog().waitFor({ state: 'hidden' });
const formState = (state) =>
dialog()
.locator('[data-form-state="' + state + '"]')
.waitFor({ state: 'visible' });
try {
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await visit();
await noOverflow();
assert.equal(
await button('Delete status Backlog').isDisabled(),
true,
);
assert.equal(
await button('Delete status Ready').isDisabled(),
true,
);
assert.equal(
await button('Delete status Review').isDisabled(),
false,
);
const trigger = button('Edit column In progress');
await trigger.focus();
await page.keyboard.press('Enter');
await dialog().waitFor();
assert.equal(
await page
.getByLabel('Column name', { exact: true })
.inputValue(),
'In progress',
);
await page.keyboard.press('Tab');
assert.equal(
await dialog().evaluate((element) =>
element.contains(document.activeElement),
),
true,
);
await noOverflow();
await page.keyboard.press('Escape');
await closed();
assert.equal(
await trigger.evaluate(
(element) => document.activeElement === element,
),
true,
);
if ([390, 1440].includes(width)) {
await page.screenshot({
path: '/private/tmp/KAN-40-' + width + '-' + theme + '.png',
fullPage: true,
});
}
assert.equal(requests.length, 0);
}
}
await visit('?reset&empty');
await page.getByText('No board columns yet', { exact: true }).waitFor();
await visit();
await button('Add column').click();
await page
.getByLabel('Column name', { exact: true })
.fill('Quality assurance');
await result('validation');
await submit('Add column', 'POST', '/projects/1/settings/board/columns', {
name: 'Quality assurance',
});
await formState('validation');
assert.equal(
await page.getByLabel('Column name', { exact: true }).inputValue(),
'Quality assurance',
);
assert.equal(
await page
.getByLabel('Column name', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
await result('server');
await dialog()
.getByRole('button', { name: 'Add column', exact: true })
.click();
await formState('server');
await result('success');
await dialog()
.getByRole('button', { name: 'Try again', exact: true })
.click();
await closed();
await button('Edit column Quality assurance').waitFor();
const newColumnId = await page
.locator('[data-column-id]')
.last()
.getAttribute('data-column-id');
await button('Edit column Quality assurance').click();
await page.getByLabel('Column name', { exact: true }).fill('QA');
await page.getByLabel('Position', { exact: true }).fill('1');
await result('position');
await dialog().getByRole('button', { name: 'Save', exact: true }).click();
await formState('validation');
assert.equal(
await page
.getByLabel('Position', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
await result('success');
await submit(
'Save',
'PATCH',
'/projects/1/settings/board/columns/' + newColumnId,
{ name: 'QA', position: 1 },
);
await closed();
assert.equal(
await page
.locator('[data-column-id]')
.first()
.getAttribute('data-column-id'),
newColumnId,
);
await button('Edit column QA').click();
assert.equal(
await page.getByLabel('Position', { exact: true }).inputValue(),
'1',
);
assert.equal(
await page.getByLabel('Column name', { exact: true }).inputValue(),
'QA',
);
await page.getByLabel('Column name', { exact: true }).fill('Unsaved');
page.once('dialog', (prompt) => prompt.dismiss());
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
assert.equal(await dialog().isVisible(), true);
page.once('dialog', (prompt) => prompt.accept());
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await closed();
await button('Add status to Backlog').click();
assert.equal(
await page.getByLabel('Status name', { exact: true }).inputValue(),
'',
);
await page.getByLabel('Status name', { exact: true }).fill('Triage');
await submit('Add status', 'POST', '/projects/1/settings/board/statuses', {
name: 'Triage',
board_column_id: null,
});
await closed();
await button('Rename status Triage').waitFor();
await button('Add status to Todo').click();
await page.getByLabel('Status name', { exact: true }).fill('Refinement');
await submit('Add status', 'POST', '/projects/1/settings/board/statuses', {
name: 'Refinement',
board_column_id: 10,
});
await closed();
await button('Rename status Review').click();
await page.getByLabel('Status name', { exact: true }).fill('Peer review');
await page.route('**/statuses/4', (route) => route.abort('failed'));
await dialog().getByRole('button', { name: 'Save', exact: true }).click();
await formState('network');
await page.unroute('**/statuses/4');
await submit(
'Try again',
'PATCH',
'/projects/1/settings/board/statuses/4',
{ name: 'Peer review' },
);
await closed();
const before = requests.length;
await button('Delete status Peer review').click();
await dialog()
.getByText(/assigned to “Building”/)
.waitFor();
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await closed();
assert.equal(requests.length, before);
await button('Delete status Peer review').click();
await result('validation');
await submit(
'Delete',
'DELETE',
'/projects/1/settings/board/statuses/4',
{},
);
await formState('validation');
await dialog()
.getByText('This group needs at least one status.', { exact: true })
.waitFor();
await result('success');
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
await closed();
assert.equal(await button('Delete status Building').isDisabled(), true);
await button('Delete column In progress').click();
await dialog()
.getByText(/statuses move to the backlog/)
.waitFor();
await submit(
'Delete',
'DELETE',
'/projects/1/settings/board/columns/20',
{},
);
await closed();
await page
.getByRole('list', { name: 'Statuses in Backlog', exact: true })
.getByText('Building', { exact: true })
.waitFor();
assert.equal(await button('Edit column In progress').count(), 0);
await page.locator('summary').filter({ hasText: 'Status moves' }).click();
const transition = page.locator('details form').first();
const checkbox = transition.getByRole('checkbox').first();
await checkbox.check();
const move = page.waitForRequest((request) => request.method() === 'POST');
await transition.getByRole('button', { name: 'Save', exact: true }).click();
assert.equal(
new URL((await move).url()).pathname,
'/projects/1/settings/board/statuses/1/transitions',
);
assert.equal(
new URL((await move).url()).searchParams.get('_method'),
'PATCH',
);
assert.deepEqual((await move).postDataJSON(), {
allowed_ticket_status_ids: [await checkbox.getAttribute('value')],
});
assert.deepEqual(errors, []);
console.log(
'Workflow browser checks passed: CRUD payloads, server refresh, order, deletion protection, validation, retries, dirty forms, keyboard, responsive widths and both themes.',
);
} finally {
await browser.close();
}
@@ -0,0 +1,19 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Board from '../../resources/js/pages/projects/settings/Board.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
name === 'Board'
? Board
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+330
View File
@@ -0,0 +1,330 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { createInertiaApp } from '@inertiajs/vue3';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/project-board-settings-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4188, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const makeStatus = (id, name, column, position = 1) => ({
id,
name,
board_column_id: column,
position,
allowed_target_statuses: [],
});
function initial() {
const backlog = makeStatus(1, 'Backlog', null);
const ready = makeStatus(2, 'Ready', 10);
const active = makeStatus(3, 'Building', 20);
const review = makeStatus(4, 'Review', 20, 2);
const columns = [
{ id: 10, name: 'Todo', position: 1, ticket_statuses: [ready] },
{
id: 20,
name: 'In progress',
position: 2,
ticket_statuses: [active, review],
},
];
return {
project: { id: 1, key: 'CORE', name: 'Core platform' },
status: null,
templateState: null,
backlogTicketStatuses: [backlog],
boardColumns: columns,
ticketStatuses: [
backlog,
...columns.flatMap((column) =>
column.ticket_statuses.map((status) => ({
...status,
board_column: { id: column.id, name: column.name },
})),
),
],
};
}
function makePage(
extra = {},
url = '/projects/1/settings/board',
component = 'Board',
) {
return {
component,
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
auth: {
user: {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
avatar_color: '#10B981',
avatar_is_generated: true,
},
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: {
project: {
id: 1,
name: 'Core platform',
key: 'CORE',
canManage: true,
},
},
...initial(),
...extra,
},
};
}
if (serve) {
let data = initial();
let nextId = 100;
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/projects')) {
return next();
}
const url = new URL(request.url, 'http://localhost');
const mutation = request.method !== 'GET';
if (!mutation && url.searchParams.has('reset')) {
data = initial();
}
let errors = {};
if (mutation) {
const result =
request.headers.cookie?.match(
/workflow_test_result=(\w+)/,
)?.[1] ?? 'success';
let body = '';
for await (const chunk of request) {
body += chunk;
}
const submitted = JSON.parse(body || '{}');
if (['server', 'forbidden'].includes(result)) {
response.statusCode = result === 'server' ? 503 : 403;
response.end('Simulated request failure');
return;
}
if (result === 'validation') {
errors =
request.method === 'DELETE'
? {
ticket_status_id:
'This group needs at least one status.',
}
: { name: 'Choose a different name.' };
} else if (result === 'position') {
errors = { position: 'The position must be at least 1.' };
} else {
const segments = url.pathname.split('/');
const id = Number(segments.at(-1));
const columns = data.boardColumns;
if (url.pathname.includes('/columns')) {
if (request.method === 'POST') {
const column = {
id: nextId++,
name: submitted.name,
position: columns.length + 1,
ticket_statuses: [],
};
column.ticket_statuses.push(
makeStatus(nextId++, submitted.name, column.id),
);
columns.push(column);
} else if (request.method === 'PATCH') {
const column = columns.find((item) => item.id === id);
column.name = submitted.name;
columns.splice(columns.indexOf(column), 1);
columns.splice(
Math.max(
0,
Math.min(
Number(submitted.position) - 1,
columns.length,
),
),
0,
column,
);
} else {
const column = columns.find((item) => item.id === id);
for (const status of column.ticket_statuses) {
status.board_column_id = null;
status.position =
data.backlogTicketStatuses.length + 1;
data.backlogTicketStatuses.push(status);
}
columns.splice(columns.indexOf(column), 1);
}
columns.forEach((column, index) => {
column.position = index + 1;
});
} else if (url.pathname.endsWith('/transitions')) {
// The browser assertion checks the preserved transition endpoint and payload.
} else if (request.method === 'POST') {
const group =
submitted.board_column_id === null
? data.backlogTicketStatuses
: columns.find(
(item) =>
item.id === submitted.board_column_id,
).ticket_statuses;
group.push(
makeStatus(
nextId++,
submitted.name,
submitted.board_column_id,
group.length + 1,
),
);
} else {
const group = [
data.backlogTicketStatuses,
...columns.map((column) => column.ticket_statuses),
].find((items) => items.some((item) => item.id === id));
const status = group.find((item) => item.id === id);
if (request.method === 'PATCH') {
status.name = submitted.name;
} else {
group.splice(group.indexOf(status), 1);
}
}
data.ticketStatuses = [
...data.backlogTicketStatuses,
...columns.flatMap((column) =>
column.ticket_statuses.map((status) => ({
...status,
board_column: { id: column.id, name: column.name },
})),
),
];
data.status = 'Updated';
}
}
if (url.searchParams.has('empty')) {
data.boardColumns = [];
data.ticketStatuses = data.backlogTicketStatuses;
}
const page = makePage({ ...data, errors });
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/project-board-settings-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Project board settings fixture ready.');
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts',
);
const component = (
await server.ssrLoadModule(
'/resources/js/pages/projects/settings/Board.vue',
)
).default;
const render = async (extra = {}, locale = createLocalization()) =>
(
await createInertiaApp({
page: makePage(extra),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance()),
})
).body;
const html = await render();
assert.match(html, /Board workflow/);
assert.match(html, /Off the board/);
assert.match(html, /aria-current="page"/);
assert.ok(
html.indexOf('data-column-id="10"') <
html.indexOf('data-column-id="20"'),
);
const buttons = html.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
for (const name of ['Backlog', 'Ready']) {
assert.match(
buttons.find((button) =>
button.includes('aria-label="Delete status ' + name + '"'),
),
/aria-disabled="true"/,
);
}
assert.doesNotMatch(
buttons.find((button) =>
button.includes('aria-label="Delete status Review"'),
),
/aria-disabled="true"/,
);
assert.match(
await render({ boardColumns: [] }),
/No board columns yet/,
);
assert.match(
await render(
{},
createLocalization(
{ locale: 'de' },
{ de: { ...en, 'workflow.title': 'Board-Ablauf' } },
),
),
/Board-Ablauf/,
);
console.log('Project board settings rendering checks passed.');
} finally {
await server.close();
}
}