326 lines
13 KiB
Vue
326 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { useForm } from '@inertiajs/vue3';
|
|
import { Layers3, RotateCcw } from 'lucide-vue-next';
|
|
import { computed, ref } from 'vue';
|
|
import { resetTemplate } from '@/actions/App/Http/Controllers/ProjectController';
|
|
import FormFeedback from '@/components/FormFeedback.vue';
|
|
import InputError from '@/components/InputError.vue';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
|
|
|
const props = defineProps<{
|
|
areas: string[];
|
|
projectId: number;
|
|
templateState?: ProjectTemplateState | null;
|
|
}>();
|
|
const { t } = useLocalization();
|
|
const form = useForm<{
|
|
areas: string[];
|
|
status_replacements: Record<string, number | undefined>;
|
|
}>({
|
|
areas: [],
|
|
status_replacements: {},
|
|
});
|
|
const protection = useFormProtection(form);
|
|
const pendingArea = ref<ProjectTemplateStateArea | null>(null);
|
|
const inheritedAreas = computed(
|
|
() =>
|
|
props.templateState?.inheritedAreas.filter((area) =>
|
|
props.areas.includes(area.value),
|
|
) ?? [],
|
|
);
|
|
const overriddenAreas = computed(
|
|
() =>
|
|
props.templateState?.overriddenAreas.filter((area) =>
|
|
props.areas.includes(area.value),
|
|
) ?? [],
|
|
);
|
|
const statusRequirement = computed(() =>
|
|
pendingArea.value?.value === 'ticket_statuses'
|
|
? props.templateState?.resetRequirements?.ticket_statuses
|
|
: undefined,
|
|
);
|
|
const canReset = computed(
|
|
() =>
|
|
statusRequirement.value?.removedStatuses.every((status) =>
|
|
statusRequirement.value?.replacementOptions.some(
|
|
(option) =>
|
|
option.id === form.status_replacements[String(status.id)],
|
|
),
|
|
) ?? true,
|
|
);
|
|
const areaKeys = {
|
|
project_settings: 'inheritance.area.projectSettings',
|
|
board_columns: 'inheritance.area.boardColumns',
|
|
ticket_statuses: 'inheritance.area.ticketStatuses',
|
|
status_transitions: 'inheritance.area.statusTransitions',
|
|
ticket_types: 'inheritance.area.ticketTypes',
|
|
ticket_priorities: 'inheritance.area.ticketPriorities',
|
|
ticket_fields: 'inheritance.area.ticketFields',
|
|
ticket_link_types: 'inheritance.area.ticketLinkTypes',
|
|
repository_connections: 'inheritance.area.repositoryConnections',
|
|
notification_actions: 'inheritance.area.notificationActions',
|
|
} as const;
|
|
function areaLabel(area: ProjectTemplateStateArea): string {
|
|
const key = areaKeys[area.value as keyof typeof areaKeys];
|
|
return key ? t(key) : area.label;
|
|
}
|
|
function openReset(area: ProjectTemplateStateArea): void {
|
|
form.resetAndClearErrors();
|
|
protection.clearFeedback();
|
|
pendingArea.value = area;
|
|
}
|
|
function closeReset(open: boolean): void {
|
|
if (open || form.processing || !protection.confirmLeave()) {
|
|
return;
|
|
}
|
|
pendingArea.value = null;
|
|
form.resetAndClearErrors();
|
|
protection.clearFeedback();
|
|
}
|
|
function resetArea(): void {
|
|
if (
|
|
!pendingArea.value ||
|
|
!canReset.value ||
|
|
!overriddenAreas.value.some(
|
|
(area) => area.value === pendingArea.value?.value,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
form.areas = [pendingArea.value.value];
|
|
protection.submit(resetTemplate(props.projectId), {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
form.resetAndClearErrors();
|
|
pendingArea.value = null;
|
|
},
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Card
|
|
v-if="
|
|
templateState?.inheritsFromTemplate &&
|
|
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
|
|
"
|
|
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 shrink-0"
|
|
aria-hidden="true"
|
|
/>
|
|
<h2 class="text-title">{{ t('inheritance.title') }}</h2>
|
|
</div>
|
|
<p class="text-muted-foreground break-words">
|
|
{{
|
|
t('inheritance.description', {
|
|
template: templateState.templateName,
|
|
})
|
|
}}
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
|
<p class="text-caption text-muted-foreground">
|
|
{{ t('inheritance.help') }}
|
|
</p>
|
|
<ul
|
|
class="grid min-w-0 divide-y"
|
|
:aria-label="t('inheritance.areas')"
|
|
>
|
|
<li
|
|
v-for="area in inheritedAreas"
|
|
:key="area.value"
|
|
class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
|
|
>
|
|
<span class="min-w-0 break-words font-medium">{{
|
|
areaLabel(area)
|
|
}}</span>
|
|
<Badge
|
|
variant="outline"
|
|
class="border-primary/25 text-primary"
|
|
>{{ t('projectSettings.inherited') }}</Badge
|
|
>
|
|
</li>
|
|
<li
|
|
v-for="area in overriddenAreas"
|
|
:key="area.value"
|
|
class="gap-stack grid min-w-0 py-3"
|
|
>
|
|
<div
|
|
class="flex min-w-0 flex-wrap items-center justify-between gap-3"
|
|
>
|
|
<span class="min-w-0 break-words font-medium">{{
|
|
areaLabel(area)
|
|
}}</span>
|
|
<Badge variant="secondary">{{
|
|
t('projectSettings.override')
|
|
}}</Badge>
|
|
</div>
|
|
<div
|
|
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
|
|
>
|
|
<p class="text-caption text-muted-foreground">
|
|
{{ t('inheritance.overrideHelp') }}
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
class="shrink-0"
|
|
:disabled="form.processing"
|
|
:aria-label="
|
|
t('inheritance.resetArea', {
|
|
area: areaLabel(area),
|
|
})
|
|
"
|
|
@click="openReset(area)"
|
|
>
|
|
<RotateCcw aria-hidden="true" />{{
|
|
t('inheritance.reset')
|
|
}}
|
|
</Button>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</CardContent>
|
|
<Dialog :open="pendingArea !== null" @update:open="closeReset">
|
|
<DialogContent
|
|
:show-close-button="!form.processing"
|
|
@escape-key-down="form.processing && $event.preventDefault()"
|
|
@interact-outside="form.processing && $event.preventDefault()"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>{{
|
|
t('inheritance.confirmTitle')
|
|
}}</DialogTitle>
|
|
<DialogDescription>{{
|
|
t('inheritance.confirmDescription', {
|
|
area: pendingArea ? areaLabel(pendingArea) : '',
|
|
template: templateState.templateName,
|
|
})
|
|
}}</DialogDescription>
|
|
</DialogHeader>
|
|
<form
|
|
class="gap-column grid min-w-0"
|
|
:aria-busy="form.processing"
|
|
@submit.prevent="resetArea"
|
|
>
|
|
<FormFeedback
|
|
:state="protection.state.value"
|
|
:title="
|
|
form.processing
|
|
? t('inheritance.resetting')
|
|
: undefined
|
|
"
|
|
@retry="resetArea"
|
|
/>
|
|
<InputError
|
|
:message="form.errors.areas ?? form.errors['areas.0']"
|
|
/>
|
|
<fieldset
|
|
class="gap-column grid min-w-0"
|
|
:disabled="form.processing"
|
|
>
|
|
<legend class="sr-only">
|
|
{{ t('inheritance.confirmTitle') }}
|
|
</legend>
|
|
<div
|
|
v-if="statusRequirement"
|
|
class="gap-column grid min-w-0"
|
|
>
|
|
<p class="text-caption text-muted-foreground">
|
|
{{ t('inheritance.statusHelp') }}
|
|
</p>
|
|
<div
|
|
v-for="status in statusRequirement.removedStatuses"
|
|
:key="status.id"
|
|
class="grid gap-2"
|
|
>
|
|
<Label :for="'replacement-' + status.id">{{
|
|
t('inheritance.statusLabel', {
|
|
status: status.name,
|
|
count: status.ticketCount,
|
|
})
|
|
}}</Label>
|
|
<select
|
|
:id="'replacement-' + status.id"
|
|
v-model="
|
|
form.status_replacements[
|
|
String(status.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="
|
|
!!form.errors[
|
|
`status_replacements.${status.id}`
|
|
]
|
|
"
|
|
:aria-describedby="
|
|
'replacement-error-' + status.id
|
|
"
|
|
>
|
|
<option :value="undefined" disabled>
|
|
{{ t('inheritance.selectStatus') }}
|
|
</option>
|
|
<option
|
|
v-for="option in statusRequirement.replacementOptions"
|
|
:key="option.id"
|
|
:value="option.id"
|
|
>
|
|
{{ option.name }}
|
|
</option>
|
|
</select>
|
|
<InputError
|
|
:id="'replacement-error-' + status.id"
|
|
:message="
|
|
form.errors[
|
|
`status_replacements.${status.id}`
|
|
]
|
|
"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p
|
|
class="border-destructive/25 bg-destructive/8 rounded-lg border p-3 text-sm"
|
|
>
|
|
{{ t('inheritance.resetWarning') }}
|
|
</p>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
@click="closeReset(false)"
|
|
>{{ t('common.cancel') }}</Button
|
|
>
|
|
<Button type="submit" :disabled="!canReset">{{
|
|
t(
|
|
form.processing
|
|
? 'inheritance.resetting'
|
|
: 'inheritance.reset',
|
|
)
|
|
}}</Button>
|
|
</DialogFooter>
|
|
</fieldset>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Card>
|
|
</template>
|