feat(KAN-40): rebuild workflow column and status settings
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, watch } from 'vue';
|
||||
import {
|
||||
destroy,
|
||||
destroyStatus,
|
||||
store,
|
||||
storeStatus,
|
||||
update,
|
||||
updateStatus,
|
||||
} from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
columnCount: number;
|
||||
target: WorkflowEditorTarget | null;
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({ name: '', position: 1 });
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const title = computed(() => {
|
||||
if (props.target?.kind === 'column') {
|
||||
return t(
|
||||
props.target.action === 'create'
|
||||
? 'workflow.addColumn'
|
||||
: props.target.action === 'edit'
|
||||
? 'workflow.editColumn'
|
||||
: 'workflow.deleteColumn',
|
||||
);
|
||||
}
|
||||
return t(
|
||||
props.target?.action === 'create'
|
||||
? 'workflow.addStatus'
|
||||
: props.target?.action === 'edit'
|
||||
? 'workflow.editStatus'
|
||||
: 'workflow.deleteStatus',
|
||||
);
|
||||
});
|
||||
const description = computed(() => {
|
||||
const target = props.target;
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
if (target.kind === 'column') {
|
||||
return t(
|
||||
target.action === 'delete'
|
||||
? 'workflow.deleteColumnDescription'
|
||||
: target.action === 'create'
|
||||
? 'workflow.addColumnDescription'
|
||||
: 'workflow.editColumnDescription',
|
||||
{ name: target.column?.name ?? '' },
|
||||
);
|
||||
}
|
||||
return t(
|
||||
target.action === 'delete'
|
||||
? 'workflow.deleteStatusDescription'
|
||||
: 'workflow.statusContext',
|
||||
{
|
||||
name: target.status?.name ?? '',
|
||||
context: target.column?.name ?? t('workflow.backlog'),
|
||||
replacement: target.replacement?.name ?? '',
|
||||
},
|
||||
);
|
||||
});
|
||||
const otherErrors = computed(() =>
|
||||
Object.entries(form.errors)
|
||||
.filter(([key]) => !['name', 'position'].includes(key))
|
||||
.map(([, message]) => message),
|
||||
);
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
form.defaults({
|
||||
name:
|
||||
target.kind === 'column'
|
||||
? (target.column?.name ?? '')
|
||||
: (target.status?.name ?? ''),
|
||||
position: target.column?.position ?? 1,
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
},
|
||||
);
|
||||
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 ||
|
||||
(target.kind === 'status' &&
|
||||
target.action === 'delete' &&
|
||||
!target.replacement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const route =
|
||||
target.kind === 'column'
|
||||
? target.action === 'create'
|
||||
? store(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? update([props.projectId, target.column.id])
|
||||
: destroy([props.projectId, target.column.id])
|
||||
: target.action === 'create'
|
||||
? storeStatus(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updateStatus([props.projectId, target.status.id])
|
||||
: destroyStatus([props.projectId, target.status.id]);
|
||||
form.transform((data) =>
|
||||
target.action === 'delete'
|
||||
? {}
|
||||
: target.kind === 'column' && target.action === 'edit'
|
||||
? data
|
||||
: target.kind === 'status' && target.action === 'create'
|
||||
? {
|
||||
name: data.name,
|
||||
board_column_id: target.column?.id ?? null,
|
||||
}
|
||||
: { name: data.name },
|
||||
);
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
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>{{ title }}</DialogTitle>
|
||||
<DialogDescription class="break-words">{{
|
||||
description
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="gap-column grid min-w-0"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<InputError
|
||||
v-for="message in otherErrors"
|
||||
:key="message"
|
||||
:message="message"
|
||||
/>
|
||||
<fieldset
|
||||
v-if="!deleting"
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="workflow-name">{{
|
||||
t(
|
||||
target?.kind === 'column'
|
||||
? 'workflow.columnName'
|
||||
: 'workflow.statusName',
|
||||
)
|
||||
}}</Label>
|
||||
<Input
|
||||
id="workflow-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
:aria-describedby="
|
||||
form.errors.name
|
||||
? 'workflow-name-error'
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
id="workflow-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
target?.kind === 'column' &&
|
||||
target.action === 'edit'
|
||||
"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label for="workflow-position">{{
|
||||
t('workflow.position')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="workflow-position"
|
||||
v-model="form.position"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="columnCount"
|
||||
step="1"
|
||||
required
|
||||
:aria-invalid="!!form.errors.position"
|
||||
aria-describedby="workflow-position-help workflow-position-error"
|
||||
/>
|
||||
<p
|
||||
id="workflow-position-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('workflow.positionHelp', {
|
||||
count: columnCount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
id="workflow-position-error"
|
||||
:message="form.errors.position"
|
||||
/>
|
||||
</div>
|
||||
</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"
|
||||
>{{
|
||||
deleting
|
||||
? t('workflow.confirmDelete')
|
||||
: target?.action === 'create'
|
||||
? title
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||
|
||||
defineProps<{
|
||||
column: BoardColumnModel | null;
|
||||
statuses: TicketStatusModel[];
|
||||
}>();
|
||||
defineEmits<{ edit: [target: WorkflowEditorTarget] }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-3">
|
||||
<ul
|
||||
class="divide-y"
|
||||
:aria-label="
|
||||
t('workflow.statusesIn', {
|
||||
context: column?.name ?? t('workflow.backlog'),
|
||||
})
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="flex min-w-0 flex-wrap items-center justify-between gap-2 py-3 first:pt-0"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 flex-1 basis-28 break-words text-sm font-medium"
|
||||
>{{ status.name }}</span
|
||||
>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('workflow.renameStatus', { name: status.name })
|
||||
"
|
||||
@click="
|
||||
$emit('edit', {
|
||||
kind: 'status',
|
||||
action: 'edit',
|
||||
column,
|
||||
status,
|
||||
})
|
||||
"
|
||||
><Pencil class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive"
|
||||
:disabled="statuses.length <= 1"
|
||||
:aria-label="
|
||||
t('workflow.removeStatus', { name: status.name })
|
||||
"
|
||||
:aria-describedby="
|
||||
statuses.length <= 1
|
||||
? `status-protection-${column?.id ?? 'backlog'}`
|
||||
: undefined
|
||||
"
|
||||
@click="
|
||||
$emit('edit', {
|
||||
kind: 'status',
|
||||
action: 'delete',
|
||||
column,
|
||||
status,
|
||||
replacement: statuses.find(
|
||||
(item) => item.id !== status.id,
|
||||
),
|
||||
})
|
||||
"
|
||||
><Trash2 class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-if="statuses.length <= 1"
|
||||
:id="`status-protection-${column?.id ?? 'backlog'}`"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('workflow.lastStatus') }}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full sm:w-fit"
|
||||
:aria-label="
|
||||
t('workflow.addStatusTo', {
|
||||
context: column?.name ?? t('workflow.backlog'),
|
||||
})
|
||||
"
|
||||
@click="$emit('edit', { kind: 'status', action: 'create', column })"
|
||||
><Plus class="size-4" aria-hidden="true" />{{
|
||||
t('workflow.addStatus')
|
||||
}}</Button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
defineProps<{ projectId: number; statuses: TicketStatusModel[] }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="bg-card min-w-0 rounded-xl border">
|
||||
<summary
|
||||
class="focus-visible:ring-ring/40 cursor-pointer rounded-xl p-5 font-semibold outline-none focus-visible:ring-2 sm:p-6"
|
||||
>
|
||||
{{ t('workflow.transitions') }}
|
||||
</summary>
|
||||
<div class="grid min-w-0 gap-4 border-t p-5 sm:p-6">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.transitionsDescription') }}
|
||||
</p>
|
||||
<Form
|
||||
v-for="source in statuses"
|
||||
:key="source.id"
|
||||
v-bind="updateStatusTransitions.form([projectId, source.id])"
|
||||
preserve-scroll
|
||||
set-defaults-on-success
|
||||
class="grid min-w-0 gap-3 rounded-lg border p-4"
|
||||
v-slot="{ errors, processing, recentlySuccessful }"
|
||||
>
|
||||
<fieldset :disabled="processing" class="grid min-w-0 gap-3">
|
||||
<legend class="mb-3 break-words font-medium">
|
||||
{{ ticketStatusLabel(source, '') }}
|
||||
</legend>
|
||||
<p
|
||||
v-if="statuses.length <= 1"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.noTargets') }}
|
||||
</p>
|
||||
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="target in statuses.filter(
|
||||
(item) => item.id !== source.id,
|
||||
)"
|
||||
:key="target.id"
|
||||
class="border-input flex min-h-11 min-w-0 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowed_ticket_status_ids[]"
|
||||
:value="target.id"
|
||||
:checked="
|
||||
source.allowed_target_statuses?.some(
|
||||
(item) => item.id === target.id,
|
||||
) ?? false
|
||||
"
|
||||
class="border-input accent-primary focus-visible:ring-ring/40 size-4 shrink-0 rounded focus-visible:ring-2"
|
||||
/>
|
||||
<span class="min-w-0 break-words">{{
|
||||
ticketStatusLabel(target, '')
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<InputError
|
||||
v-for="(message, key) in errors"
|
||||
:key="key"
|
||||
:message="message"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:loading="processing"
|
||||
>{{ t('common.save') }}</Button
|
||||
>
|
||||
<span
|
||||
v-if="recentlySuccessful"
|
||||
role="status"
|
||||
class="text-primary text-sm"
|
||||
>{{ t('feedback.saved') }}</span
|
||||
>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
@@ -1,4 +1,53 @@
|
||||
export const en = {
|
||||
'workflow.title': 'Board workflow',
|
||||
'workflow.pageTitle': '{project} · Board workflow',
|
||||
'workflow.description':
|
||||
'Organize your board into columns and give each stage the statuses your team needs.',
|
||||
'workflow.updated': 'Workflow updated.',
|
||||
'workflow.backlog': 'Backlog',
|
||||
'workflow.offBoard': 'Off the board',
|
||||
'workflow.backlogDescription':
|
||||
'Your intake queue. Tickets with these statuses stay outside the board columns.',
|
||||
'workflow.columns': 'Board columns',
|
||||
'workflow.columnsDescription':
|
||||
'Columns appear from left to right in this order. Edit a column to change its name or position.',
|
||||
'workflow.emptyColumns': 'No board columns yet',
|
||||
'workflow.emptyColumnsDescription':
|
||||
'Add your first column to start organizing tickets on the board.',
|
||||
'workflow.addColumn': 'Add column',
|
||||
'workflow.editColumn': 'Edit column',
|
||||
'workflow.deleteColumn': 'Delete column?',
|
||||
'workflow.addColumnDescription':
|
||||
'The column is added at the end of the board with a first status of the same name.',
|
||||
'workflow.editColumnDescription':
|
||||
'Rename this column or move it to a different position. Its statuses keep their names.',
|
||||
'workflow.deleteColumnDescription':
|
||||
'Delete “{name}”? Its statuses move to the backlog and their tickets keep their status. Remaining columns are reordered automatically.',
|
||||
'workflow.addStatus': 'Add status',
|
||||
'workflow.editStatus': 'Rename status',
|
||||
'workflow.deleteStatus': 'Delete status?',
|
||||
'workflow.statusContext': 'This status belongs to {context}.',
|
||||
'workflow.deleteStatusDescription':
|
||||
'Delete “{name}”? Tickets using it are assigned to “{replacement}”, the first remaining status in {context}. Its move rules are removed.',
|
||||
'workflow.columnName': 'Column name',
|
||||
'workflow.statusName': 'Status name',
|
||||
'workflow.position': 'Position',
|
||||
'workflow.positionValue': 'Position {position}',
|
||||
'workflow.positionHelp':
|
||||
'Choose a position from 1 to {count}. Other columns shift automatically.',
|
||||
'workflow.statusesIn': 'Statuses in {context}',
|
||||
'workflow.renameStatus': 'Rename status {name}',
|
||||
'workflow.removeStatus': 'Delete status {name}',
|
||||
'workflow.changeColumn': 'Edit column {name}',
|
||||
'workflow.removeColumn': 'Delete column {name}',
|
||||
'workflow.addStatusTo': 'Add status to {context}',
|
||||
'workflow.lastStatus':
|
||||
'Keep at least one status in this group. The last status cannot be deleted.',
|
||||
'workflow.transitions': 'Status moves',
|
||||
'workflow.transitionsDescription':
|
||||
'Choose exactly where each status is allowed to move next.',
|
||||
'workflow.noTargets': 'No other statuses exist yet.',
|
||||
'workflow.confirmDelete': 'Delete',
|
||||
'security.title': 'Security settings',
|
||||
'security.passwordTitle': 'Update password',
|
||||
'security.passwordDescription':
|
||||
|
||||
@@ -1,674 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { Columns3, Inbox, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||
import WorkflowEditorDialog from '@/components/projects/WorkflowEditorDialog.vue';
|
||||
import WorkflowStatusList from '@/components/projects/WorkflowStatusList.vue';
|
||||
import WorkflowTransitionMatrix from '@/components/projects/WorkflowTransitionMatrix.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
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 { board } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||
|
||||
const props = defineProps<ProjectBoardSettingsPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Board settings',
|
||||
href: boardSettings(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
if (
|
||||
ticketStatus.board_column === null ||
|
||||
ticketStatus.board_column === undefined
|
||||
) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
||||
};
|
||||
|
||||
const transitionTargetOptions = (
|
||||
sourceStatus: TicketStatusModel,
|
||||
): TicketStatusModel[] => {
|
||||
return props.ticketStatuses.filter(
|
||||
(ticketStatus) => ticketStatus.id !== sourceStatus.id,
|
||||
);
|
||||
};
|
||||
|
||||
const statusAllowsTarget = (
|
||||
sourceStatus: TicketStatusModel,
|
||||
targetStatus: TicketStatusModel,
|
||||
): boolean => {
|
||||
return (
|
||||
sourceStatus.allowed_target_statuses?.some(
|
||||
(allowedTargetStatus) => allowedTargetStatus.id === targetStatus.id,
|
||||
) ?? false
|
||||
);
|
||||
};
|
||||
const { t } = useLocalization();
|
||||
const target = ref<WorkflowEditorTarget | null>(null);
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{ title: t('workflow.title'), href: board(props.project.id) },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} board 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} board settings`"
|
||||
description="Configure the ordered board columns that define valid in-board ticket statuses for this project."
|
||||
/>
|
||||
</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="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('workflow.pageTitle', { project: project.name })" />
|
||||
<SettingsLayout :project-name="project.name" :project-key="project.key">
|
||||
<header class="grid gap-3">
|
||||
<h2 class="text-title">{{ t('workflow.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('workflow.description') }}
|
||||
</p>
|
||||
</header>
|
||||
<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"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
state="success"
|
||||
:title="t('workflow.updated')"
|
||||
/>
|
||||
<TemplateInheritancePanel
|
||||
:project-id="project.id"
|
||||
:template-state="templateState"
|
||||
:areas="[
|
||||
'board_columns',
|
||||
'ticket_statuses',
|
||||
'status_transitions',
|
||||
]"
|
||||
:project-id="project.id"
|
||||
:template-state="templateState"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow model</CardTitle>
|
||||
<CardDescription>
|
||||
Backlog is always the intake queue; board columns group
|
||||
the statuses that appear on the board.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="flex flex-wrap gap-3">
|
||||
<Badge class="px-3 py-1">Backlog</Badge>
|
||||
<Badge
|
||||
v-for="ticketStatus in backlogTicketStatuses"
|
||||
:key="ticketStatus.id"
|
||||
variant="outline"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
{{ ticketStatus.name }}
|
||||
</Badge>
|
||||
<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 flex-wrap items-center gap-2">
|
||||
<Inbox
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 class="font-semibold">
|
||||
{{ t('workflow.backlog') }}
|
||||
</h3>
|
||||
<Badge variant="secondary">{{
|
||||
t('workflow.offBoard')
|
||||
}}</Badge>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Tickets move between statuses; backlog statuses are not
|
||||
included in a board column.
|
||||
{{ t('workflow.backlogDescription') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Status moves</CardTitle>
|
||||
<CardDescription>
|
||||
Choose exactly where each status is allowed to move
|
||||
next.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
class="divide-border min-w-full divide-y text-sm"
|
||||
>
|
||||
<thead
|
||||
class="bg-muted/40 text-muted-foreground text-left"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
Source status
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
Allowed move targets
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">Save</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-border divide-y">
|
||||
<tr
|
||||
v-for="sourceStatus in ticketStatuses"
|
||||
:key="sourceStatus.id"
|
||||
class="bg-background align-top"
|
||||
>
|
||||
<td class="px-4 py-4">
|
||||
<Badge variant="outline">
|
||||
{{
|
||||
ticketStatusLabel(sourceStatus)
|
||||
}}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Form
|
||||
:id="`status-transitions-${sourceStatus.id}`"
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.updateStatusTransitions.form(
|
||||
[
|
||||
project.id,
|
||||
sourceStatus.id,
|
||||
],
|
||||
)
|
||||
"
|
||||
class="grid gap-3"
|
||||
v-slot="{ errors }"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
transitionTargetOptions(
|
||||
sourceStatus,
|
||||
).length === 0
|
||||
"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
No other statuses exist yet.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-2 md:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<label
|
||||
v-for="targetStatus in transitionTargetOptions(
|
||||
sourceStatus,
|
||||
)"
|
||||
:key="targetStatus.id"
|
||||
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowed_ticket_status_ids[]"
|
||||
:value="targetStatus.id"
|
||||
:checked="
|
||||
statusAllowsTarget(
|
||||
sourceStatus,
|
||||
targetStatus,
|
||||
)
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>{{
|
||||
ticketStatusLabel(
|
||||
targetStatus,
|
||||
)
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
<InputError
|
||||
:message="
|
||||
errors.allowed_ticket_status_ids
|
||||
"
|
||||
/>
|
||||
</Form>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:form="`status-transitions-${sourceStatus.id}`"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<CardContent class="p-5 sm:p-6"
|
||||
><WorkflowStatusList
|
||||
:column="null"
|
||||
:statuses="backlogTicketStatuses"
|
||||
@edit="target = $event"
|
||||
/></CardContent>
|
||||
</Card>
|
||||
<section
|
||||
class="grid min-w-0 gap-4"
|
||||
aria-labelledby="workflow-columns"
|
||||
>
|
||||
<header
|
||||
class="flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Columns3
|
||||
class="text-primary size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 id="workflow-columns" class="font-semibold">
|
||||
{{ t('workflow.columns') }}
|
||||
</h3>
|
||||
<Badge variant="secondary">{{
|
||||
boardColumns.length
|
||||
}}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Backlog statuses</CardTitle>
|
||||
<CardDescription>
|
||||
New backlog tickets choose one of these statuses.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<div
|
||||
v-for="ticketStatus in backlogTicketStatuses"
|
||||
:key="ticketStatus.id"
|
||||
class="border-border rounded-lg border p-4"
|
||||
<Button
|
||||
type="button"
|
||||
class="w-full sm:w-auto"
|
||||
@click="target = { kind: 'column', action: 'create' }"
|
||||
><Plus class="size-4" aria-hidden="true" />{{
|
||||
t('workflow.addColumn')
|
||||
}}</Button
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.updateStatus.form(
|
||||
[project.id, ticketStatus.id],
|
||||
)
|
||||
"
|
||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
</header>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.columnsDescription') }}
|
||||
</p>
|
||||
<FeedbackState
|
||||
v-if="boardColumns.length === 0"
|
||||
state="empty"
|
||||
:title="t('workflow.emptyColumns')"
|
||||
:description="t('workflow.emptyColumnsDescription')"
|
||||
/>
|
||||
<ol
|
||||
v-else
|
||||
class="grid min-w-0 gap-4"
|
||||
:aria-label="t('workflow.columns')"
|
||||
>
|
||||
<li
|
||||
v-for="column in boardColumns"
|
||||
:key="column.id"
|
||||
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
||||
:data-column-id="column.id"
|
||||
>
|
||||
<header
|
||||
class="bg-muted/35 flex min-w-0 flex-wrap items-center gap-3 border-b p-4 sm:p-5"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`backlog-status-${ticketStatus.id}`"
|
||||
>
|
||||
Status name
|
||||
</Label>
|
||||
<Input
|
||||
:id="`backlog-status-${ticketStatus.id}`"
|
||||
name="name"
|
||||
:default-value="ticketStatus.name"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.destroyStatus.form(
|
||||
[project.id, ticketStatus.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: column.position,
|
||||
})
|
||||
"
|
||||
v-slot="{ errors, processing }"
|
||||
>{{ column.position }}</span
|
||||
>
|
||||
<InputError
|
||||
:message="errors.ticket_status_id"
|
||||
class="mb-2"
|
||||
/>
|
||||
<h4
|
||||
class="min-w-0 flex-1 basis-24 break-words font-semibold"
|
||||
>
|
||||
{{ column.name }}
|
||||
</h4>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="
|
||||
processing ||
|
||||
backlogTicketStatuses.length <= 1
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('workflow.changeColumn', {
|
||||
name: column.name,
|
||||
})
|
||||
"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.storeStatus.form(
|
||||
project.id,
|
||||
)
|
||||
"
|
||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="backlog-status-name">
|
||||
New backlog status
|
||||
</Label>
|
||||
<Input
|
||||
id="backlog-status-name"
|
||||
name="name"
|
||||
placeholder="Ready"
|
||||
required
|
||||
@click="
|
||||
target = {
|
||||
kind: 'column',
|
||||
action: 'edit',
|
||||
column,
|
||||
}
|
||||
"
|
||||
><Pencil class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive"
|
||||
:aria-label="
|
||||
t('workflow.removeColumn', {
|
||||
name: column.name,
|
||||
})
|
||||
"
|
||||
@click="
|
||||
target = {
|
||||
kind: 'column',
|
||||
action: 'delete',
|
||||
column,
|
||||
}
|
||||
"
|
||||
><Trash2 class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="p-4 sm:p-5">
|
||||
<WorkflowStatusList
|
||||
:column="column"
|
||||
:statuses="column.ticket_statuses ?? []"
|
||||
@edit="target = $event"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button type="submit" :disabled="processing">
|
||||
Add status
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add column</CardTitle>
|
||||
<CardDescription>
|
||||
New columns extend the project-specific workflow and
|
||||
are inserted at the end unless you specify a
|
||||
position later.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.store.form(
|
||||
project.id,
|
||||
)
|
||||
"
|
||||
class="space-y-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Column name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
autofocus
|
||||
placeholder="In Review"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
Add column
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Board columns</CardTitle>
|
||||
<CardDescription>
|
||||
Rename columns or change the numeric position to
|
||||
reorder the board from left to right.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4 p-6">
|
||||
<div
|
||||
v-if="boardColumns.length === 0"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
No board columns exist yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="column in boardColumns"
|
||||
:key="column.id"
|
||||
class="border-border rounded-xl border p-4"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.update.form([
|
||||
project.id,
|
||||
column.id,
|
||||
])
|
||||
"
|
||||
class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_8rem_auto_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label :for="`column-name-${column.id}`">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
:id="`column-name-${column.id}`"
|
||||
name="name"
|
||||
:default-value="column.name"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`column-position-${column.id}`"
|
||||
>
|
||||
Position
|
||||
</Label>
|
||||
<Input
|
||||
:id="`column-position-${column.id}`"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="boardColumns.length"
|
||||
:default-value="column.position"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.position" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.destroy.form(
|
||||
[project.id, column.id],
|
||||
)
|
||||
"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="processing"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div class="border-border mt-4 border-t pt-4">
|
||||
<div class="mb-3">
|
||||
<p class="text-sm font-medium">
|
||||
Ticket statuses
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Moving into this column requires a
|
||||
specific status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="ticketStatus in column.ticket_statuses ??
|
||||
[]"
|
||||
:key="ticketStatus.id"
|
||||
class="border-border rounded-lg border p-3"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.updateStatus.form(
|
||||
[
|
||||
project.id,
|
||||
ticketStatus.id,
|
||||
],
|
||||
)
|
||||
"
|
||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`column-status-${ticketStatus.id}`"
|
||||
>
|
||||
Status name
|
||||
</Label>
|
||||
<Input
|
||||
:id="`column-status-${ticketStatus.id}`"
|
||||
name="name"
|
||||
:default-value="
|
||||
ticketStatus.name
|
||||
"
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
:message="errors.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.destroyStatus.form(
|
||||
[
|
||||
project.id,
|
||||
ticketStatus.id,
|
||||
],
|
||||
)
|
||||
"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<InputError
|
||||
:message="
|
||||
errors.ticket_status_id
|
||||
"
|
||||
class="mb-2"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="
|
||||
processing ||
|
||||
(column.ticket_statuses
|
||||
?.length ?? 0) <= 1
|
||||
"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.storeStatus.form(
|
||||
project.id,
|
||||
)
|
||||
"
|
||||
class="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="board_column_id"
|
||||
:value="column.id"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`new-column-status-${column.id}`"
|
||||
>
|
||||
New status
|
||||
</Label>
|
||||
<Input
|
||||
:id="`new-column-status-${column.id}`"
|
||||
name="name"
|
||||
placeholder="Ready for QA"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
>
|
||||
Add status
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<WorkflowTransitionMatrix
|
||||
:project-id="project.id"
|
||||
:statuses="ticketStatuses"
|
||||
/>
|
||||
<WorkflowEditorDialog
|
||||
:project-id="project.id"
|
||||
:column-count="boardColumns.length"
|
||||
:target="target"
|
||||
@close="target = null"
|
||||
/>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
export type WorkflowEditorTarget =
|
||||
| { kind: 'column'; action: 'create'; column?: never }
|
||||
| { kind: 'column'; action: 'edit' | 'delete'; column: BoardColumnModel }
|
||||
| {
|
||||
kind: 'status';
|
||||
action: 'create';
|
||||
column: BoardColumnModel | null;
|
||||
status?: never;
|
||||
replacement?: never;
|
||||
}
|
||||
| {
|
||||
kind: 'status';
|
||||
action: 'edit' | 'delete';
|
||||
column: BoardColumnModel | null;
|
||||
status: TicketStatusModel;
|
||||
replacement?: TicketStatusModel;
|
||||
};
|
||||
Reference in New Issue
Block a user