feat(KAN-39): rebuild general project settings and inheritance
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s

This commit is contained in:
2026-09-15 16:02:41 +02:00
parent 2edee702d4
commit bfec4f464e
8 changed files with 1421 additions and 433 deletions
+393 -311
View File
@@ -1,281 +1,316 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Heading from '@/components/Heading.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { Layers3, Trash2 } from 'lucide-vue-next';
import { computed } from 'vue';
import {
destroy,
switchTemplate,
} from '@/actions/App/Http/Controllers/ProjectController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/projects/SettingsLayout.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 { general } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
import { computed } from 'vue';
const props = defineProps<ProjectGeneralSettingsPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'General settings',
href: generalSettings(props.project.id),
},
];
const switchTemplateForm = useForm({
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.general'), href: general(props.project.id) },
]);
const switchTemplateForm = useForm<{
confirmation_key: string;
source_template_id: number | undefined;
}>({
confirmation_key: '',
source_template_id: props.sourceTemplates.find(
(template) => template.id !== props.project.project_template_id,
)?.id,
source_template_id: undefined,
});
const deleteForm = useForm({
confirmation_key: '',
});
const deleteForm = useForm({ confirmation_key: '' });
const switchProtection = useFormProtection(switchTemplateForm);
const deleteProtection = useFormProtection(deleteForm);
const processing = computed(
() => switchTemplateForm.processing || deleteForm.processing,
);
const availableSwitchTemplates = computed(() =>
props.sourceTemplates.filter(
(template) => template.id !== props.project.project_template_id,
),
);
const currentTemplate = computed(() => props.project.project_template ?? null);
const isSwitchConfirmationValid = computed(
() => switchTemplateForm.confirmation_key === props.project.key,
);
const canSwitchTemplate = computed(
() =>
isSwitchConfirmationValid.value &&
switchTemplateForm.source_template_id !== undefined,
switchTemplateForm.confirmation_key === props.project.key &&
availableSwitchTemplates.value.some(
(template) => template.id === switchTemplateForm.source_template_id,
),
);
const isDeleteConfirmationValid = computed(
() => deleteForm.confirmation_key === props.project.key,
);
const inheritanceAreas = computed(() =>
[
...(props.templateState?.inheritedAreas ?? []),
...(props.templateState?.overriddenAreas ?? []),
].map((area) => area.value),
);
const settingsSource = computed(() => {
if (!props.templateState?.inheritsFromTemplate) {
return 'projectSettings.local';
}
if (
props.templateState.inheritedAreas.some(
(area) => area.value === 'project_settings',
)
) {
return 'projectSettings.inherited';
}
return props.templateState.overriddenAreas.some(
(area) => area.value === 'project_settings',
)
? 'projectSettings.override'
: 'projectSettings.local';
});
const submitSwitchTemplate = (): void => {
if (!canSwitchTemplate.value) {
function submitSwitchTemplate(): void {
if (!canSwitchTemplate.value || processing.value) {
return;
}
switchProtection.submit(switchTemplate(props.project.id), {
preserveScroll: true,
onSuccess: () => switchTemplateForm.resetAndClearErrors(),
});
}
switchTemplateForm.post(
ProjectController.switchTemplate.url(props.project.id),
{
preserveScroll: true,
},
);
};
const submitDelete = (): void => {
if (!isDeleteConfirmationValid.value) {
function submitDelete(): void {
if (!isDeleteConfirmationValid.value || processing.value) {
return;
}
deleteForm.delete(ProjectController.destroy.url(props.project.id), {
deleteProtection.submit(destroy(props.project.id), {
preserveScroll: true,
});
};
}
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} general 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} general settings`"
description="Review the core project identity and manage irreversible administrative actions."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="boardSettings(project.id)">
Board 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
<Head
:title="t('projectSettings.pageTitle', { project: project.name })"
/>
<SettingsLayout :project-name="project.name" :project-key="project.key">
<FeedbackState
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('projectSettings.updated')"
/>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
{{ status }}
</div>
<Card>
<CardHeader>
<CardTitle>Project identity</CardTitle>
<CardDescription>
These values define the project across tickets, boards,
and repository matching.
</CardDescription>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<h2 class="text-title">{{ t('navigation.general') }}</h2>
<p class="text-muted-foreground">
{{ t('projectSettings.generalDescription') }}
</p>
</CardHeader>
<CardContent class="grid gap-4 md:grid-cols-2">
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
Project name
</p>
<p class="text-foreground mt-2 text-sm font-semibold">
{{ project.name }}
</p>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold">
{{ t('projectSettings.identity') }}
</h3>
<Badge variant="secondary">{{
t('projectSettings.local')
}}</Badge>
</div>
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
<dl class="grid min-w-0 gap-4 sm:grid-cols-2">
<div
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectSettings.name') }}
</dt>
<dd class="break-words font-semibold">
{{ project.name }}
</dd>
</div>
<div
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectSettings.key') }}
</dt>
<dd class="break-all font-semibold">
{{ project.key }}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.identityDescription') }}
</p>
<div
class="flex flex-wrap items-center gap-2 border-t pt-4"
>
<h3 class="font-semibold">
{{ t('projectSettings.display') }}
</h3>
<Badge
:variant="
settingsSource === 'projectSettings.inherited'
? 'outline'
: 'secondary'
"
>{{ t(settingsSource) }}</Badge
>
Project key
</p>
<p class="text-foreground mt-2 text-sm font-semibold">
{{ project.key }}
</p>
</div>
<dl class="grid gap-3">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted-foreground">
{{ t('projectSettings.showAuthor') }}
</dt>
<dd class="font-medium">
{{
t(
project.show_author
? 'projectSettings.enabled'
: 'projectSettings.disabled',
)
}}
</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted-foreground">
{{ t('projectSettings.showStoryPoints') }}
</dt>
<dd class="font-medium">
{{
t(
project.show_story_points
? 'projectSettings.enabled'
: 'projectSettings.disabled',
)
}}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.displayDescription') }}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Template baseline</CardTitle>
<CardDescription>
Switch this project to another published template. Use
the inheritance panels to reset individual settings to
the currently linked template.
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
Current linked template
</p>
<p
class="text-foreground mt-2 text-sm font-semibold"
>
{{
currentTemplate?.name ??
'No linked template'
}}
</p>
<p class="text-muted-foreground mt-2 text-sm">
{{
templateState?.inheritsFromTemplate
? 'Inherited and overridden areas stay visible after switching.'
: 'This project currently behaves like a standalone baseline.'
}}
</p>
</div>
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
Warning
</p>
<p class="text-foreground mt-2 text-sm font-medium">
Switching templates can replace inherited board
workflow, ticket metadata, repositories, and
notifications.
</p>
<p class="text-muted-foreground mt-2 text-sm">
Confirm each action with the exact project key
to avoid accidental overwrites.
</p>
</div>
<TemplateInheritancePanel
:project-id="project.id"
:areas="inheritanceAreas"
:template-state="templateState"
/>
<Card
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
<div class="flex items-center gap-2">
<Layers3
class="text-primary size-5"
aria-hidden="true"
/>
<h2 class="text-title">
{{ t('projectSettings.template') }}
</h2>
</div>
<div class="grid gap-6">
<form
class="space-y-4 rounded-xl border p-4"
@submit.prevent="submitSwitchTemplate"
<p class="text-muted-foreground">
{{ t('projectSettings.templateDescription') }}
</p>
</CardHeader>
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
<div
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
>
<p class="text-caption text-muted-foreground">
{{ t('projectSettings.currentTemplate') }}
</p>
<p class="break-words font-semibold">
{{
currentTemplate?.name ??
t('projectSettings.noTemplate')
}}
</p>
<Badge variant="outline" class="justify-self-start">{{
t(
templateState?.inheritsFromTemplate
? 'projectSettings.linked'
: 'projectSettings.standalone',
)
}}</Badge>
<p
v-if="!templateState?.inheritsFromTemplate"
class="text-caption text-muted-foreground"
>
<div class="space-y-1">
<h3 class="text-sm font-semibold">
Switch to another template
</h3>
<p class="text-muted-foreground text-sm">
Applying a different template replaces the
copied areas with that template's current
baseline.
</p>
</div>
{{ t('projectSettings.standaloneDescription') }}
</p>
</div>
<FeedbackState
v-if="availableSwitchTemplates.length === 0"
state="empty"
:title="t('projectSettings.noAlternatives')"
:description="
t('projectSettings.noAlternativesDescription')
"
/>
<form
v-else
class="gap-column grid min-w-0"
:aria-busy="switchTemplateForm.processing"
@submit.prevent="submitSwitchTemplate"
>
<FormFeedback
:state="switchProtection.state.value"
:title="
switchTemplateForm.processing
? t('projectSettings.switching')
: undefined
"
@retry="submitSwitchTemplate"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="processing"
>
<legend class="mb-3 font-semibold">
{{ t('projectSettings.switch') }}
</legend>
<p
class="border-destructive/25 bg-destructive/8 rounded-lg border p-4"
>
{{ t('projectSettings.switchWarning') }}
</p>
<div class="grid gap-2">
<Label for="source_template_id"
>Published template</Label
>
<Label for="source_template_id">{{
t('projectSettings.publishedTemplate')
}}</Label>
<select
id="source_template_id"
v-model="
switchTemplateForm.source_template_id
"
:disabled="
switchTemplateForm.processing ||
availableSwitchTemplates.length === 0
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
:aria-invalid="
!!switchTemplateForm.errors
.source_template_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]"
aria-describedby="source-template-error"
>
<option :value="undefined" disabled>
Select a template
{{
t('projectSettings.selectTemplate')
}}
</option>
<option
v-for="template in availableSwitchTemplates"
@@ -286,120 +321,167 @@ const submitDelete = (): void => {
</option>
</select>
<InputError
id="source-template-error"
:message="
switchTemplateForm.errors
.source_template_id
"
/>
</div>
<div class="grid gap-2">
<Label for="switch_confirmation_key"
>Confirmation key</Label
>
<Label for="switch_confirmation_key">{{
t('projectSettings.confirmation')
}}</Label>
<Input
id="switch_confirmation_key"
v-model="
switchTemplateForm.confirmation_key
"
:disabled="switchTemplateForm.processing"
placeholder="Type the project key exactly"
autocomplete="off"
:placeholder="
t(
'projectSettings.confirmPlaceholder',
{ key: project.key },
)
"
:aria-invalid="
!!switchTemplateForm.errors
.confirmation_key
"
aria-describedby="switch-confirm-help switch-confirm-error"
/>
<p
id="switch-confirm-help"
class="text-caption text-muted-foreground"
>
{{
t('projectSettings.confirmHelp', {
key: project.key,
})
}}
</p>
<InputError
id="switch-confirm-error"
:message="
switchTemplateForm.errors
.confirmation_key
"
/>
</div>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm">
Existing project-specific configuration in
copied areas may be replaced or removed.
</p>
<Button
type="submit"
variant="outline"
:disabled="
switchTemplateForm.processing ||
!canSwitchTemplate
"
>
Switch template
</Button>
</div>
</form>
</div>
</CardContent>
</Card>
<Card
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
>
<CardHeader>
<CardTitle class="text-red-900 dark:text-red-100">
Danger zone
</CardTitle>
<CardDescription
class="text-red-800/80 dark:text-red-200/80"
>
Deleting this project is permanent. All tickets, board
columns, ticket metadata, repository connections, and
project permissions will be removed.
</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<div
class="bg-background/80 rounded-xl border border-red-200/80 p-4 dark:border-red-900/60"
>
<p class="text-foreground text-sm font-medium">
Confirm by typing the exact project key:
<span class="font-semibold">{{ project.key }}</span>
</p>
</div>
<form class="space-y-4" @submit.prevent="submitDelete">
<div class="grid gap-2">
<Label for="confirmation_key">
Confirmation key
</Label>
<Input
id="confirmation_key"
v-model="deleteForm.confirmation_key"
:disabled="deleteForm.processing"
placeholder="Type the project key exactly"
/>
<InputError
:message="deleteForm.errors.confirmation_key"
/>
</div>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm">
This action cannot be undone.
</p>
<Button
type="submit"
variant="destructive"
:disabled="
deleteForm.processing ||
!isDeleteConfirmationValid
"
variant="outline"
class="justify-self-start"
:disabled="!canSwitchTemplate"
>{{
t(
switchTemplateForm.processing
? 'projectSettings.switching'
: 'projectSettings.switch',
)
}}</Button
>
Delete project
</Button>
</div>
</fieldset>
</form>
</CardContent>
</Card>
</div>
<Card
class="border-destructive/30 min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
>
<CardHeader
class="bg-destructive/5 gap-stack grid-cols-1 border-b p-5 sm:p-6"
>
<div class="flex items-center gap-2">
<Trash2
class="text-destructive size-5"
aria-hidden="true"
/>
<h2 class="text-title">
{{ t('projectSettings.danger') }}
</h2>
</div>
<p class="text-muted-foreground">
{{ t('projectSettings.deleteDescription') }}
</p>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-column grid min-w-0"
:aria-busy="deleteForm.processing"
@submit.prevent="submitDelete"
>
<FormFeedback
:state="deleteProtection.state.value"
:title="
deleteForm.processing
? t('projectSettings.deleting')
: undefined
"
@retry="submitDelete"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="processing"
>
<legend class="sr-only">
{{ t('projectSettings.delete') }}
</legend>
<p class="text-caption font-medium">
{{ t('projectSettings.irreversible') }}
</p>
<div class="grid gap-2">
<Label for="confirmation_key">{{
t('projectSettings.confirmation')
}}</Label>
<Input
id="confirmation_key"
v-model="deleteForm.confirmation_key"
autocomplete="off"
:placeholder="
t(
'projectSettings.confirmPlaceholder',
{ key: project.key },
)
"
:aria-invalid="
!!deleteForm.errors.confirmation_key
"
aria-describedby="delete-confirm-help delete-confirm-error"
/>
<p
id="delete-confirm-help"
class="text-caption text-muted-foreground"
>
{{
t('projectSettings.confirmHelp', {
key: project.key,
})
}}
</p>
<InputError
id="delete-confirm-error"
:message="
deleteForm.errors.confirmation_key
"
/>
</div>
<Button
type="submit"
variant="destructive"
class="justify-self-start"
:disabled="!isDeleteConfirmationValid"
>{{
t(
deleteForm.processing
? 'projectSettings.deleting'
: 'projectSettings.delete',
)
}}</Button
>
</fieldset>
</form>
</CardContent>
</Card>
</SettingsLayout>
</AppLayout>
</template>