Merge pull request 'KAN-42: Rebuild ticket types, priorities and display settings' (#137) from feature/KAN-42-ticket-types-priorities-display into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 22m13s
CI/CD / Publish immutable image (push) Successful in 43s

Reviewed-on: #137
This commit was merged in pull request #137.
This commit is contained in:
2026-09-15 14:05:51 +00:00
10 changed files with 1336 additions and 688 deletions
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import {
destroyPriority,
destroyType,
storePriority,
storeType,
updatePriority,
updateType,
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
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 { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { MetadataEditorTarget } from '@/types/project-metadata';
const props = defineProps<{
projectId: number;
target: MetadataEditorTarget | null;
types: TicketTypeModel[];
priorities: TicketPriorityModel[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
name: '',
position: 1,
is_parent: false,
replacement_ticket_type_id: '',
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deletionError = ref<string | null>(null);
const deleting = computed(() => props.target?.action === 'delete');
const entries = computed(() =>
props.target?.kind === 'type' ? props.types : props.priorities,
);
const replacements = computed(() =>
props.types.filter((type) => type.id !== props.target?.entry?.id),
);
const needsReplacement = computed(
() =>
deleting.value &&
props.target?.kind === 'type' &&
(props.target.entry?.tickets_count ?? 0) > 0,
);
const title = computed(() => {
const target = props.target;
if (!target) {
return '';
}
if (target.action === 'create') {
return t(
target.kind === 'type'
? 'metadata.addType'
: 'metadata.addPriority',
);
}
return t(
target.action === 'edit'
? 'metadata.editEntry'
: 'metadata.deleteEntry',
{ name: target.entry.name },
);
});
watch(
() => props.target,
(target) => {
if (!target) {
return;
}
form.defaults({
name: target.entry?.name ?? '',
position: target.entry?.position ?? 1,
is_parent: target.entry?.is_parent ?? false,
replacement_ticket_type_id: '',
});
form.resetAndClearErrors();
protection.clearFeedback();
deletionError.value = null;
},
{ immediate: true },
);
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 ||
(deleting.value && entries.value.length <= 1)
) {
return;
}
const route =
target.kind === 'type'
? target.action === 'create'
? storeType(props.projectId)
: target.action === 'edit'
? updateType([props.projectId, target.entry.id])
: destroyType([props.projectId, target.entry.id])
: target.action === 'create'
? storePriority(props.projectId)
: target.action === 'edit'
? updatePriority([props.projectId, target.entry.id])
: destroyPriority([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? target.kind === 'type'
? {
replacement_ticket_type_id:
data.replacement_ticket_type_id || null,
}
: {}
: target.action === 'edit'
? { name: data.name, position: data.position }
: target.kind === 'type'
? { name: data.name, is_parent: data.is_parent }
: { name: data.name },
);
deletionError.value = null;
protection.submit(route, {
preserveScroll: true,
onSuccess: (page) => {
if (page.props.error) {
deletionError.value =
typeof page.props.status === 'string'
? page.props.status
: t('common.error');
return;
}
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 class="break-words">{{ title }}</DialogTitle>
<DialogDescription>{{
t(
deleting
? target?.kind === 'type'
? 'metadata.deleteTypeHelp'
: 'metadata.deletePriorityHelp'
: 'metadata.editHelp',
)
}}</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback
:state="deletionError ? 'idle' : protection.state.value"
@retry="submit"
/>
<p
v-if="deletionError"
role="alert"
class="text-destructive text-sm"
>
{{ deletionError }}
</p>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<template v-if="!deleting">
<div class="grid gap-2">
<Label for="metadata-name">{{
t('metadata.name')
}}</Label>
<Input
id="metadata-name"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
aria-describedby="metadata-name-error"
/>
<InputError
id="metadata-name-error"
:message="form.errors.name"
/>
</div>
<div
v-if="target?.action === 'edit'"
class="grid gap-2"
>
<Label for="metadata-position">{{
t('workflow.position')
}}</Label>
<Input
id="metadata-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="entries.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="metadata-position-help metadata-position-error"
/>
<p
id="metadata-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: entries.length,
})
}}
</p>
<InputError
id="metadata-position-error"
:message="form.errors.position"
/>
</div>
<div v-if="target?.kind === 'type'" class="grid gap-2">
<Label
v-if="target.action === 'create'"
for="metadata-parent"
class="flex items-center gap-3"
>
<Checkbox
id="metadata-parent"
v-model="form.is_parent"
name="is_parent"
:invalid="!!form.errors.is_parent"
aria-describedby="metadata-parent-help metadata-parent-error"
/>{{ t('metadata.allowChildren') }}
</Label>
<p v-else class="text-sm font-medium">
{{
t(
form.is_parent
? 'metadata.parentType'
: 'metadata.standardType',
)
}}
</p>
<p
id="metadata-parent-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.parentFixed') }}
</p>
<InputError
id="metadata-parent-error"
:message="form.errors.is_parent"
/>
</div>
</template>
<div v-if="needsReplacement" class="grid gap-2">
<p class="text-sm">
{{
t('metadata.replacementHelp', {
count: target?.entry?.tickets_count ?? 0,
})
}}
</p>
<Label for="metadata-replacement">{{
t('metadata.replacement')
}}</Label>
<select
id="metadata-replacement"
v-model="form.replacement_ticket_type_id"
name="replacement_ticket_type_id"
required
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
:aria-invalid="
!!form.errors.replacement_ticket_type_id
"
aria-describedby="metadata-replacement-help metadata-replacement-error"
>
<option value="">
{{ t('metadata.chooseType') }}
</option>
<option
v-for="entry in replacements"
:key="entry.id"
:value="entry.id"
>
{{ entry.name
}}{{
entry.is_parent
? ` (${t('metadata.parentType')})`
: ''
}}
</option>
</select>
<p
id="metadata-replacement-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.parentReplacement') }}
</p>
</div>
<InputError
id="metadata-replacement-error"
:message="form.errors.replacement_ticket_type_id"
/>
</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"
:disabled="deleting && entries.length <= 1"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { LockKeyhole, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type {
MetadataEditorTarget,
MetadataEntry,
MetadataKind,
} from '@/types/project-metadata';
defineProps<{ kind: MetadataKind; entries: MetadataEntry[] }>();
defineEmits<{ edit: [target: MetadataEditorTarget] }>();
const { t } = useLocalization();
</script>
<template>
<section
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
:aria-labelledby="`metadata-${kind}`"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<h3 :id="`metadata-${kind}`" class="font-semibold">
{{
t(
kind === 'type'
? 'metadata.types'
: 'metadata.priorities',
)
}}
</h3>
<Badge variant="secondary">{{ entries.length }}</Badge>
</div>
<Button
type="button"
variant="outline"
class="w-full sm:w-auto"
@click="$emit('edit', { kind, action: 'create' })"
>
<Plus class="size-4" aria-hidden="true" />{{
t(
kind === 'type'
? 'metadata.addType'
: 'metadata.addPriority',
)
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{
t(
kind === 'type'
? 'metadata.typesDescription'
: 'metadata.prioritiesDescription',
)
}}
</p>
<ol
class="divide-y"
:aria-label="
t(kind === 'type' ? 'metadata.types' : 'metadata.priorities')
"
>
<li
v-for="entry in entries"
:key="entry.id"
class="flex min-w-0 flex-wrap items-center gap-3 py-4"
:data-metadata-entry="`${kind}-${entry.id}`"
>
<span
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:aria-label="
t('workflow.positionValue', {
position: entry.position,
})
"
>{{ entry.position }}</span
>
<div class="grid min-w-0 flex-1 basis-28 gap-1">
<p class="break-words font-medium">{{ entry.name }}</p>
<div
v-if="kind === 'type'"
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
>
<Badge variant="outline">{{
t(
entry.is_parent
? 'metadata.parentType'
: 'metadata.standardType',
)
}}</Badge>
<span>{{
t('metadata.assignedTickets', {
count: entry.tickets_count ?? 0,
})
}}</span>
</div>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editEntry', { name: entry.name })
"
@click="$emit('edit', { kind, action: 'edit', entry })"
><Pencil class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:disabled="entries.length <= 1"
:aria-label="
t('metadata.deleteEntry', { name: entry.name })
"
:aria-describedby="
entries.length <= 1
? `metadata-${kind}-protection`
: undefined
"
@click="
$emit('edit', { kind, action: 'delete', entry })
"
><Trash2 class="size-4" aria-hidden="true"
/></Button>
</div>
</li>
</ol>
<p
v-if="entries.length <= 1"
:id="`metadata-${kind}-protection`"
class="text-caption text-muted-foreground flex items-start gap-2"
>
<LockKeyhole class="size-4 shrink-0" aria-hidden="true" />{{
t(
kind === 'type'
? 'metadata.lastType'
: 'metadata.lastPriority',
)
}}
</p>
</section>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { watch } from 'vue';
import { update } from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { ProjectModel } from '@/types/models/project-model';
const props = defineProps<{ project: ProjectModel }>();
const { t } = useLocalization();
const form = useForm({
show_author: props.project.show_author,
show_story_points: props.project.show_story_points,
});
const protection = useFormProtection(form);
watch(
() => [props.project.show_author, props.project.show_story_points],
() => {
form.defaults({
show_author: props.project.show_author,
show_story_points: props.project.show_story_points,
});
form.resetAndClearErrors();
},
);
function submit(): void {
protection.submit(update(props.project.id), {
preserveScroll: true,
onSuccess: () => form.defaults(),
});
}
</script>
<template>
<form
class="bg-card grid min-w-0 gap-4 rounded-xl border p-4 sm:p-6"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<h3 class="font-semibold">{{ t('metadata.display') }}</h3>
<p class="text-muted-foreground text-sm">
{{ t('metadata.displayDescription') }}
</p>
<FormFeedback :state="protection.state.value" @retry="submit" />
<fieldset :disabled="form.processing" class="grid gap-4">
<div
v-for="option in ['show_author', 'show_story_points'] as const"
:key="option"
class="grid gap-2"
>
<Label
:for="option"
class="bg-muted/35 flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-4"
>
<Checkbox
:id="option"
v-model="form[option]"
:name="option"
:invalid="!!form.errors[option]"
:aria-describedby="`${option}-help ${option}-error`"
/>
<span class="grid gap-1">
<span>{{
t(
option === 'show_author'
? 'metadata.author'
: 'metadata.storyPoints',
)
}}</span>
<span
:id="`${option}-help`"
class="text-muted-foreground text-sm font-normal"
>{{
t(
option === 'show_author'
? 'metadata.authorHelp'
: 'metadata.storyPointsHelp',
)
}}</span
>
</span>
</Label>
<InputError
:id="`${option}-error`"
:message="form.errors[option]"
/>
</div>
</fieldset>
<Button
type="submit"
class="w-full sm:w-fit"
:loading="form.processing"
>{{ t('common.save') }}</Button
>
</form>
</template>
+47
View File
@@ -913,4 +913,51 @@ export const en = {
'gitConnections.added': 'Git connection added.',
'gitConnections.updated': 'Git connection updated.',
'gitConnections.removed': 'Git connection removed.',
'metadata.title': 'Ticket settings',
'metadata.pageTitle': '{project} ticket settings',
'metadata.description':
'Manage ticket types, priorities, and the information shown on your tickets.',
'metadata.updated': 'Ticket settings updated.',
'metadata.types': 'Ticket types',
'metadata.priorities': 'Priorities',
'metadata.addType': 'Add type',
'metadata.addPriority': 'Add priority',
'metadata.typesDescription':
'Define your project’s ticket types and their order. Parent types can contain child tickets.',
'metadata.prioritiesDescription':
'Define and order project-specific priorities. The last priority must be kept.',
'metadata.parentType': 'Parent type',
'metadata.standardType': 'Standard type',
'metadata.assignedTickets': 'Assigned tickets: {count}',
'metadata.editEntry': 'Edit {name}',
'metadata.deleteEntry': 'Delete {name}',
'metadata.lastType':
'The last ticket type cannot be deleted. Add another type first.',
'metadata.lastPriority':
'The last priority cannot be deleted. Add another priority first.',
'metadata.display': 'Display options',
'metadata.displayDescription':
'Choose which optional information is available on this project’s tickets.',
'metadata.author': 'Show ticket author',
'metadata.authorHelp': 'Display the author on ticket details.',
'metadata.storyPoints': 'Enable story points',
'metadata.storyPointsHelp': 'Allow estimates in story points on tickets.',
'metadata.name': 'Name',
'metadata.positionHelp':
'Choose a position from 1 to {count}. Other entries shift automatically.',
'metadata.allowChildren': 'Allow child tickets',
'metadata.parentFixed':
'Whether a type allows child tickets is fixed after creation. Its name and position remain editable.',
'metadata.editHelp':
'Choose a project-specific name. Changes apply to tickets using this entry.',
'metadata.deleteTypeHelp':
'Delete this type? Assigned tickets must be moved to a replacement type first. This action cannot be undone.',
'metadata.deletePriorityHelp':
'Delete this priority? All tickets assigned to this priority will also be permanently deleted. There is no replacement mapping for priorities. This action cannot be undone.',
'metadata.replacementHelp':
'Move {count} assigned tickets to another type before deleting this one.',
'metadata.replacement': 'Replacement ticket type',
'metadata.chooseType': 'Choose a type',
'metadata.parentReplacement':
'If any affected ticket has child tickets, choose a parent type. The server checks this before deleting.',
} as const;
+53 -462
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { Form, Head } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import Heading from '@/components/Heading.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import MetadataEditorDialog from '@/components/projects/MetadataEditorDialog.vue';
import MetadataOptionList from '@/components/projects/MetadataOptionList.vue';
import TicketDisplaySettings from '@/components/projects/TicketDisplaySettings.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -15,107 +18,42 @@ import {
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
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 SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
import { index, show } from '@/routes/projects';
import { metadata } from '@/routes/projects/settings';
import type {
BreadcrumbItem,
ProjectTicketMetadataSettingsPageProps,
} from '@/types';
import { Checkbox } from '@/components/ui/checkbox';
import type { MetadataEditorTarget } from '@/types/project-metadata';
const props = defineProps<ProjectTicketMetadataSettingsPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Ticket settings',
href: metadataSettings(props.project.id),
},
];
const replacementTicketTypes = (ticketTypeId: number) =>
props.ticketTypes.filter((ticketType) => ticketType.id !== ticketTypeId);
const { t } = useLocalization();
const target = ref<MetadataEditorTarget | null>(null);
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('metadata.title'), href: metadata(props.project.id) },
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} ticket 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} ticket settings`"
description="Configure project-specific ticket types, priorities, custom field definitions, and ticket link labels."
/>
</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="boardSettings(project.id)">
Board 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
<Head :title="t('metadata.pageTitle', { project: project.name })" />
<SettingsLayout :project-name="project.name" :project-key="project.key">
<header class="grid gap-3">
<h2 class="text-title">{{ t('metadata.title') }}</h2>
<p class="text-muted-foreground">
{{ t('metadata.description') }}
</p>
</header>
<FeedbackState
v-if="status"
:class="[
'rounded-xl border px-4 py-3 text-sm',
error
? 'border-red-200/80 bg-red-50/60 text-red-800/80 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-200/80'
: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100',
]"
>
{{ status }}
</div>
:state="error ? 'error' : 'success'"
:title="error ? status : t('metadata.updated')"
/>
<TemplateInheritancePanel
:areas="[
'project_settings',
@@ -127,361 +65,17 @@ const replacementTicketTypes = (ticketTypeId: number) =>
:project-id="project.id"
:template-state="templateState"
/>
<div class="grid gap-6 xl:grid-cols-2">
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>General ticket settings</CardTitle>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.update.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<Label for="enable-story-points">
<Checkbox
id="enable-story-points"
name="show_story_points"
:default-value="project.show_story_points"
value="1"
<TicketDisplaySettings :project="project" />
<MetadataOptionList
kind="type"
:entries="ticketTypes"
@edit="target = $event"
/>
Enable story points for tickets
</Label>
<InputError :message="errors.show_story_points" />
<Label for="enable-author">
<Checkbox
id="enable-author"
name="show_author"
:default-value="project.show_author"
value="1"
<MetadataOptionList
kind="priority"
:entries="ticketPriorities"
@edit="target = $event"
/>
Show author on ticket details
</Label>
<InputError :message="errors.show_author" />
<Button type="submit" :disabled="processing">
Save
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Ticket types</CardTitle>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeType.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="type-name">New type name</Label>
<Input
id="type-name"
name="name"
required
placeholder="Bug"
/>
<InputError :message="errors.name" />
<Label for="type-is-parent">
<Checkbox
id="type-is-parent"
name="is_parent"
value="1"
/>
Ticket type can act as a parent
</Label>
<InputError :message="errors.is_parent" />
</div>
<Button type="submit" :disabled="processing">
Add type
</Button>
</Form>
<div
v-for="ticketType in ticketTypes"
:key="ticketType.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateType.form(
[project.id, ticketType.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<div
v-if="ticketType.is_parent"
class="flex flex-wrap items-center gap-2"
>
<Badge variant="secondary">
Parent type
</Badge>
<span
class="text-muted-foreground text-xs"
>
This flag is fixed after creation.
</span>
</div>
<Label :for="`type-name-${ticketType.id}`">
Name
</Label>
<Input
:id="`type-name-${ticketType.id}`"
name="name"
:default-value="ticketType.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`type-position-${ticketType.id}`"
>
Position
</Label>
<Input
:id="`type-position-${ticketType.id}`"
name="position"
type="number"
min="1"
:max="ticketTypes.length"
:default-value="ticketType.position"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyType.form(
[project.id, ticketType.id],
)
"
class="mt-3 grid gap-3"
v-slot="{ errors, processing: deleting }"
>
<div
v-if="
(ticketType.tickets_count ?? 0) > 0 &&
ticketTypes.length > 1
"
class="grid gap-2"
>
<Label
:for="`replacement-type-${ticketType.id}`"
>
Replacement type for
{{ ticketType.tickets_count }} assigned
ticket{{
ticketType.tickets_count === 1
? ''
: 's'
}}
</Label>
<select
:id="`replacement-type-${ticketType.id}`"
name="replacement_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]"
required
>
<option value="">Choose type</option>
<option
v-for="replacementTicketType in replacementTicketTypes(
ticketType.id,
)"
:key="replacementTicketType.id"
:value="replacementTicketType.id"
>
{{ replacementTicketType.name }}
</option>
</select>
<InputError
:message="
errors.replacement_ticket_type_id
"
/>
</div>
<div
class="flex flex-wrap items-center justify-between gap-3"
>
<p
v-if="
(ticketType.tickets_count ?? 0) > 0
"
class="text-muted-foreground text-sm"
>
Assigned tickets will be moved before
this type is deleted.
</p>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</div>
</Form>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Priorities</CardTitle>
<CardDescription>
Priorities are fully project-specific and can be
reordered or removed.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storePriority.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="priority-name"
>New priority name</Label
>
<Input
id="priority-name"
name="name"
required
placeholder="Urgent"
/>
<InputError :message="errors.name" />
</div>
<Button type="submit" :disabled="processing">
Add priority
</Button>
</Form>
<div
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updatePriority.form(
[project.id, ticketPriority.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`priority-name-${ticketPriority.id}`"
>
Name
</Label>
<Input
:id="`priority-name-${ticketPriority.id}`"
name="name"
:default-value="ticketPriority.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`priority-position-${ticketPriority.id}`"
>
Position
</Label>
<Input
:id="`priority-position-${ticketPriority.id}`"
name="position"
type="number"
min="1"
:max="ticketPriorities.length"
:default-value="ticketPriority.position"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyPriority.form(
[project.id, ticketPriority.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Custom fields</CardTitle>
@@ -602,17 +196,13 @@ const replacementTicketTypes = (ticketTypeId: number) =>
type="number"
min="1"
:max="ticketFieldDefinitions.length"
:default-value="
fieldDefinition.position
"
:default-value="fieldDefinition.position"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<div class="flex flex-wrap justify-between gap-3">
<Button
type="submit"
variant="outline"
@@ -664,9 +254,7 @@ const replacementTicketTypes = (ticketTypeId: number) =>
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="link-type-name">
Source label
</Label>
<Label for="link-type-name"> Source label </Label>
<Input
id="link-type-name"
name="name"
@@ -686,9 +274,7 @@ const replacementTicketTypes = (ticketTypeId: number) =>
required
placeholder="Blocked by"
/>
<InputError
:message="errors.counterpart_name"
/>
<InputError :message="errors.counterpart_name" />
</div>
<Button type="submit" :disabled="processing">
@@ -762,9 +348,7 @@ const replacementTicketTypes = (ticketTypeId: number) =>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<div class="flex flex-wrap justify-between gap-3">
<Button
type="submit"
variant="outline"
@@ -795,7 +379,14 @@ const replacementTicketTypes = (ticketTypeId: number) =>
</div>
</CardContent>
</Card>
</div>
</div>
<MetadataEditorDialog
:project-id="project.id"
:target="target"
:types="ticketTypes"
:priorities="ticketPriorities"
@close="target = null"
/>
</SettingsLayout>
</AppLayout>
</template>
+10
View File
@@ -0,0 +1,10 @@
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
export type MetadataKind = 'type' | 'priority';
export type MetadataEntry = TicketPriorityModel &
Partial<Pick<TicketTypeModel, 'is_parent' | 'tickets_count'>>;
export type MetadataEditorTarget = { kind: MetadataKind } & (
| { action: 'create'; entry?: never }
| { action: 'edit' | 'delete'; entry: MetadataEntry }
);
@@ -487,4 +487,75 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'id' => $ticketLinkType->id,
]);
}
public function test_last_priority_is_protected_from_deletion(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$priority = TicketPriority::factory()->for($project)->create();
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority]))
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('error', true)
->assertSessionHas('status', 'The last ticket priority cannot be deleted.');
$this->assertModelExists($priority);
}
public function test_type_order_and_usage_counts_are_returned_after_reordering(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$types = $project->ticketTypes()->get();
$first = $types->first();
$last = $types->last();
Ticket::factory()->for($project)->create([
'ticket_type_id' => $last->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->patch(route('projects.settings.metadata.types.update', [$project, $last]), [
'name' => 'Customer request', 'position' => 1,
])
->assertSessionHasNoErrors();
$this->get(route('projects.settings.metadata', $project))
->assertInertia(fn (Assert $page) => $page
->where('ticketTypes.0.id', $last->id)
->where('ticketTypes.0.name', 'Customer request')
->where('ticketTypes.0.position', 1)
->where('ticketTypes.0.tickets_count', 1)
->where('ticketTypes.1.id', $first->id)
->where('ticketTypes.1.position', 2)
->where('ticketTypes.1.tickets_count', 0));
}
public function test_foreign_replacement_type_is_rejected_without_changing_tickets(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$type = $project->ticketTypes()->firstOrFail();
$foreignType = TicketType::factory()->create();
$ticket = Ticket::factory()->for($project)->create([
'ticket_type_id' => $type->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->from(route('projects.settings.metadata', $project))
->delete(route('projects.settings.metadata.types.destroy', [$project, $type]), [
'replacement_ticket_type_id' => $foreignType->id,
])
->assertSessionHasErrors('replacement_ticket_type_id');
$this->assertModelExists($type);
$this->assertSame($type->id, $ticket->fresh()->ticket_type_id);
}
}
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectTicketMetadataSettingsUiTest extends TestCase
{
public function test_metadata_settings_show_configured_values_and_deletion_protection(): void
{
$process = new Process(['node', 'tests/Fixtures/project-metadata-settings.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Metadata settings checks passed.', $process->getOutput());
}
}
@@ -0,0 +1,19 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Metadata from '../../resources/js/pages/projects/settings/Metadata.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
name === 'Metadata'
? Metadata
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
@@ -0,0 +1,281 @@
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-metadata-settings-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4189, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
function initial() {
return {
project: {
id: 1,
key: 'CORE',
name: 'Core platform',
show_author: true,
show_story_points: false,
},
status: null,
error: false,
templateState: null,
ticketTypes: [
{
id: 1,
name: 'Task',
position: 1,
is_parent: false,
tickets_count: 2,
},
{
id: 2,
name: 'Release',
position: 2,
is_parent: true,
tickets_count: 1,
},
],
ticketPriorities: [
{ id: 3, name: 'Normal', position: 1 },
{ id: 4, name: 'Urgent', position: 2 },
],
ticketFieldDefinitions: [],
ticketLinkTypes: [],
fieldTypeOptions: [],
};
}
function makePage(
extra = {},
url = '/projects/1/settings/metadata',
component = 'Metadata',
) {
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) {
let body = '';
for await (const chunk of request) {
body += chunk;
}
const submitted = JSON.parse(body || '{}');
const result =
request.headers.cookie?.match(
/metadata_test_result=(\w+)/,
)?.[1] ?? 'success';
if (result === 'server') {
response.statusCode = 500;
response.end('Unavailable');
return;
}
const kind = url.pathname.includes('/types') ? 'type' : 'priority';
const entries =
kind === 'type' ? data.ticketTypes : data.ticketPriorities;
const id = Number(url.pathname.split('/').at(-1));
if (
result === 'validation' ||
(submitted.name &&
entries.some(
(entry) =>
entry.name === submitted.name && entry.id !== id,
))
) {
errors =
request.method === 'DELETE'
? {
replacement_ticket_type_id:
'Choose a parent-enabled replacement type.',
}
: { name: 'This name is already in use.' };
} else if (request.method === 'DELETE') {
entries.splice(
entries.findIndex((item) => item.id === id),
1,
);
} else if (url.pathname.endsWith('/metadata')) {
Object.assign(data.project, submitted);
} else if (request.method === 'POST') {
entries.push({
id: nextId++,
position: entries.length + 1,
tickets_count: 0,
...submitted,
});
} else {
const item = entries.find((item) => item.id === id);
entries.splice(entries.indexOf(item), 1);
Object.assign(item, submitted);
entries.splice(submitted.position - 1, 0, item);
}
entries.forEach((entry, index) => (entry.position = index + 1));
data.status = Object.keys(errors).length ? null : 'Updated';
}
if (url.searchParams.has('single')) {
data.ticketTypes = data.ticketTypes.slice(0, 1);
data.ticketPriorities = data.ticketPriorities.slice(0, 1);
}
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-metadata-settings-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Metadata 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/Metadata.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, /Ticket settings/);
assert.match(html, /aria-current="page"/);
assert.ok(
html.indexOf('data-metadata-entry="type-1"') <
html.indexOf('data-metadata-entry="type-2"'),
);
assert.match(html, /Assigned tickets: 2/);
assert.match(html, /Parent type/);
assert.match(html, /id="show_author"[^>]*aria-checked="true"/);
assert.match(html, /id="show_story_points"[^>]*aria-checked="false"/);
const single = await render({
ticketTypes: initial().ticketTypes.slice(0, 1),
ticketPriorities: initial().ticketPriorities.slice(0, 1),
});
const buttons = single.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
for (const name of ['Task', 'Normal']) {
assert.match(
buttons.find((button) =>
button.includes('aria-label="Delete ' + name + '"'),
),
/aria-disabled="true"/,
);
}
assert.match(single, /The last ticket type cannot be deleted/);
assert.match(single, /The last priority cannot be deleted/);
assert.match(
await render(
{},
createLocalization(
{ locale: 'de' },
{ de: { ...en, 'metadata.title': 'Ticket-Einstellungen' } },
),
),
/Ticket-Einstellungen/,
);
assert.match(
await render({
status: 'The last ticket type cannot be deleted.',
error: true,
}),
/role="alert"/,
);
console.log('Metadata settings checks passed.');
} finally {
await server.close();
}
}