488 lines
22 KiB
Vue
488 lines
22 KiB
Vue
<script setup lang="ts">
|
|
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, 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 { general } from '@/routes/projects/settings';
|
|
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
|
|
|
|
const props = defineProps<ProjectGeneralSettingsPageProps>();
|
|
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: undefined,
|
|
});
|
|
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 canSwitchTemplate = computed(
|
|
() =>
|
|
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';
|
|
});
|
|
|
|
function submitSwitchTemplate(): void {
|
|
if (!canSwitchTemplate.value || processing.value) {
|
|
return;
|
|
}
|
|
switchProtection.submit(switchTemplate(props.project.id), {
|
|
preserveScroll: true,
|
|
onSuccess: () => switchTemplateForm.resetAndClearErrors(),
|
|
});
|
|
}
|
|
|
|
function submitDelete(): void {
|
|
if (!isDeleteConfirmationValid.value || processing.value) {
|
|
return;
|
|
}
|
|
deleteProtection.submit(destroy(props.project.id), {
|
|
preserveScroll: true,
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbs">
|
|
<Head
|
|
:title="t('projectSettings.pageTitle', { project: project.name })"
|
|
/>
|
|
<SettingsLayout :project-name="project.name" :project-key="project.key">
|
|
<FeedbackState
|
|
v-if="status"
|
|
state="success"
|
|
:title="t('projectSettings.updated')"
|
|
/>
|
|
<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">
|
|
<h2 class="text-title">{{ t('navigation.general') }}</h2>
|
|
<p class="text-muted-foreground">
|
|
{{ t('projectSettings.generalDescription') }}
|
|
</p>
|
|
</CardHeader>
|
|
<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>
|
|
<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
|
|
>
|
|
</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>
|
|
<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>
|
|
<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"
|
|
>
|
|
{{ 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">{{
|
|
t('projectSettings.publishedTemplate')
|
|
}}</Label>
|
|
<select
|
|
id="source_template_id"
|
|
v-model="
|
|
switchTemplateForm.source_template_id
|
|
"
|
|
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
|
|
"
|
|
aria-describedby="source-template-error"
|
|
>
|
|
<option :value="undefined" disabled>
|
|
{{
|
|
t('projectSettings.selectTemplate')
|
|
}}
|
|
</option>
|
|
<option
|
|
v-for="template in availableSwitchTemplates"
|
|
:key="template.id"
|
|
:value="template.id"
|
|
>
|
|
{{ template.name }}
|
|
</option>
|
|
</select>
|
|
<InputError
|
|
id="source-template-error"
|
|
:message="
|
|
switchTemplateForm.errors
|
|
.source_template_id
|
|
"
|
|
/>
|
|
</div>
|
|
<div class="grid gap-2">
|
|
<Label for="switch_confirmation_key">{{
|
|
t('projectSettings.confirmation')
|
|
}}</Label>
|
|
<Input
|
|
id="switch_confirmation_key"
|
|
v-model="
|
|
switchTemplateForm.confirmation_key
|
|
"
|
|
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>
|
|
<Button
|
|
type="submit"
|
|
variant="outline"
|
|
class="justify-self-start"
|
|
:disabled="!canSwitchTemplate"
|
|
>{{
|
|
t(
|
|
switchTemplateForm.processing
|
|
? 'projectSettings.switching'
|
|
: 'projectSettings.switch',
|
|
)
|
|
}}</Button
|
|
>
|
|
</fieldset>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
<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>
|