feat(KAN-39): rebuild general project settings and inheritance
This commit is contained in:
@@ -1,26 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
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';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
areas: string[];
|
||||
projectId: number;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
}>();
|
||||
|
||||
const { t } = useLocalization();
|
||||
const form = useForm<{
|
||||
areas: string[];
|
||||
status_replacements: Record<string, number | undefined>;
|
||||
@@ -28,51 +34,82 @@ const form = useForm<{
|
||||
areas: [],
|
||||
status_replacements: {},
|
||||
});
|
||||
|
||||
const activeArea = computed(() => form.areas[0]);
|
||||
|
||||
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(
|
||||
() => props.templateState?.resetRequirements?.ticket_statuses,
|
||||
const statusRequirement = computed(() =>
|
||||
pendingArea.value?.value === 'ticket_statuses'
|
||||
? props.templateState?.resetRequirements?.ticket_statuses
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const canReset = (area: ProjectTemplateStateArea): boolean => {
|
||||
if (area.value !== 'ticket_statuses') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
statusRequirement.value?.removedStatuses.every(
|
||||
(status) =>
|
||||
form.status_replacements[String(status.id)] !== undefined,
|
||||
) ?? true
|
||||
);
|
||||
};
|
||||
|
||||
const resetArea = (area: ProjectTemplateStateArea): void => {
|
||||
if (!canReset(area)) {
|
||||
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;
|
||||
}
|
||||
|
||||
form.areas = [area.value];
|
||||
form.post(ProjectController.resetTemplate.url(props.projectId), {
|
||||
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.reset(),
|
||||
onSuccess: () => {
|
||||
form.resetAndClearErrors();
|
||||
pendingArea.value = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,97 +118,208 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
|
||||
templateState?.inheritsFromTemplate &&
|
||||
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
|
||||
"
|
||||
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Template inheritance: {{ templateState.templateName }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Every setting area inherits independently. Local changes stay
|
||||
unchanged when the template is updated until that area is reset.
|
||||
</CardDescription>
|
||||
<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="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="area in inheritedAreas"
|
||||
:key="`inherited-${area.value}`"
|
||||
variant="outline"
|
||||
>
|
||||
{{ area.label }} · inherited
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="area in overriddenAreas"
|
||||
:key="`overridden-${area.value}`"
|
||||
class="space-y-3 rounded-lg border p-4"
|
||||
<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')"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<Badge>{{ area.label }} · template overridden</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="form.processing || !canReset(area)"
|
||||
@click="resetArea(area)"
|
||||
>
|
||||
{{
|
||||
activeArea === area.value && form.processing
|
||||
? 'Resetting…'
|
||||
: 'Reset to template'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="area.value === 'ticket_statuses' && statusRequirement"
|
||||
class="space-y-3"
|
||||
<li
|
||||
v-for="area in inheritedAreas"
|
||||
:key="area.value"
|
||||
class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Choose where tickets in local statuses should move
|
||||
before those statuses are removed.
|
||||
</p>
|
||||
<div
|
||||
v-for="status in statusRequirement.removedStatuses"
|
||||
:key="status.id"
|
||||
class="grid gap-2 sm:grid-cols-2 sm:items-center"
|
||||
<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
|
||||
>
|
||||
<Label :for="`replacement-${status.id}`">
|
||||
{{ status.name }} ({{ status.ticketCount }} tickets)
|
||||
</Label>
|
||||
<div class="grid gap-1">
|
||||
<select
|
||||
:id="`replacement-${status.id}`"
|
||||
v-model="
|
||||
form.status_replacements[String(status.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 focus-visible:ring-[3px]"
|
||||
>
|
||||
<option :value="undefined" disabled>
|
||||
Select replacement status
|
||||
</option>
|
||||
<option
|
||||
v-for="option in statusRequirement.replacementOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
form.errors[
|
||||
`status_replacements.${status.id}`
|
||||
]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<InputError :message="form.errors.areas" />
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user