Implement Fluid Emerald app redesign
linter / quality (pull_request) Successful in 1m18s
tests / ci (8.4) (pull_request) Successful in 1m54s

This commit is contained in:
2026-07-18 16:45:54 +02:00
parent 59817ad30f
commit a46695c2eb
62 changed files with 2165 additions and 1434 deletions
+3 -6
View File
@@ -41,10 +41,10 @@ const hasPermission = (
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} access`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -75,10 +75,7 @@ const hasPermission = (
</div>
</div>
<div
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"
>
<div v-if="status" class="status-success">
{{ status }}
</div>
+13 -14
View File
@@ -15,7 +15,10 @@ import AppLayout from '@/layouts/AppLayout.vue';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index';
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
@@ -64,10 +67,10 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} backlog`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -105,15 +108,14 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</div>
</div>
<div
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"
>
<div v-if="status" class="status-success">
{{ status }}
</div>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<Card class="gap-0 overflow-hidden py-0">
<CardHeader
class="border-outline-variant bg-surface-container-low border-b px-5 py-4"
>
<CardTitle>Backlog tickets</CardTitle>
<CardDescription>
Tickets remain here while their status belongs to
@@ -122,10 +124,7 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</CardHeader>
<CardContent class="p-0">
<div
v-if="tickets.length === 0"
class="text-muted-foreground p-6 text-sm"
>
<div v-if="tickets.length === 0" class="empty-state m-4">
No backlog tickets exist yet.
</div>
@@ -158,7 +157,7 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
<tr
v-for="ticket in tickets"
:key="ticket.id"
class="bg-background"
class="bg-surface-container-lowest"
>
<td class="px-4 py-4">
<Link
+85 -40
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { ArrowRight, CircleUserRound } from 'lucide-vue-next';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -16,7 +18,10 @@ import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects/index';
import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
@@ -65,10 +70,10 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} board`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page max-w-none">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -108,10 +113,7 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</div>
</div>
<div
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"
>
<div v-if="status" class="status-success">
{{ status }}
</div>
@@ -131,13 +133,18 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</CardContent>
</Card>
<div v-else class="grid gap-4 xl:grid-cols-3">
<div
v-else
class="-mx-4 flex snap-x snap-proximity items-start gap-4 overflow-x-auto px-4 pb-5 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"
>
<Card
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
class="flex h-full flex-col gap-0 overflow-hidden"
class="bg-surface-container-low w-[min(21rem,calc(100vw-3rem))] shrink-0 snap-start gap-0 overflow-hidden border-0 py-0 shadow-none hover:shadow-none sm:w-[21rem]"
>
<CardHeader class="bg-muted/20 border-b">
<CardHeader
class="border-outline-variant/60 sticky top-0 z-10 border-b px-4 py-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<CardTitle>{{ boardColumn.name }}</CardTitle>
@@ -163,33 +170,38 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</div>
<Badge variant="secondary">
#{{ boardColumn.position }}
{{ boardColumn.tickets?.length ?? 0 }}
</Badge>
</div>
</CardHeader>
<CardContent class="flex flex-1 flex-col gap-4 p-4">
<CardContent
class="flex min-h-48 flex-1 flex-col gap-3 p-3"
>
<div
v-if="(boardColumn.tickets?.length ?? 0) === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
class="empty-state bg-transparent"
>
No tickets are currently in this column.
</div>
<div v-else class="flex flex-col gap-4">
<div v-else class="flex flex-col gap-3">
<Card
v-for="ticket in boardColumn.tickets"
:key="ticket.id"
:class="[
'gap-0',
'shadow-xs gap-0 py-0 hover:-translate-y-0.5',
ticket.is_blocked
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
: '',
]"
>
<CardHeader class="pb-4">
<CardHeader class="gap-3 px-4 pb-3 pt-4">
<div class="flex flex-wrap gap-2">
<Badge variant="outline">
<Badge
variant="outline"
class="text-primary"
>
{{ ticket.ticket_key }}
</Badge>
<Badge
@@ -201,16 +213,6 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
<Badge variant="secondary">
{{ ticket.ticket_type.name }}
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge variant="outline">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</Badge>
</div>
<CardTitle class="text-base">
<Link
@@ -240,9 +242,56 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
>
{{ ticket.blocked_comment }}
</CardDescription>
<div
class="border-outline-variant/60 flex items-center justify-between gap-3 border-t pt-3"
>
<div
class="flex min-w-0 items-center gap-2"
>
<UserAvatar
v-if="ticket.assignee"
:user="ticket.assignee"
class="size-7"
/>
<span
v-else
class="border-outline-variant text-muted-foreground flex size-7 items-center justify-center rounded-full border border-dashed"
>
<CircleUserRound
class="size-4"
/>
</span>
<span
class="text-muted-foreground truncate text-xs"
>
{{
ticket.assignee?.name ??
'Unassigned'
}}
</span>
</div>
<div
class="flex shrink-0 items-center gap-1.5"
>
<span
class="bg-primary size-1.5 rounded-full"
/>
<span
class="text-muted-foreground text-xs font-medium"
>
{{
ticket.ticket_priority.name
}}
</span>
</div>
</div>
</CardHeader>
<CardContent v-if="canEditTickets" class="pt-0">
<CardContent
v-if="canEditTickets"
class="border-outline-variant/60 border-t px-4 py-3"
>
<Form
v-bind="
ProjectBoardController.move.form({
@@ -258,15 +307,7 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
name="redirect_to"
value="board"
/>
<label
:for="`ticket-status-${ticket.id}`"
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
>
Move ticket
</label>
<div
class="flex flex-col gap-2 sm:flex-row"
>
<div class="flex items-center gap-2">
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
@@ -275,7 +316,7 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
ticket,
).length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
class="min-w-0 flex-1 text-xs"
>
<option
v-if="
@@ -303,7 +344,8 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</select>
<Button
type="submit"
size="sm"
size="icon-sm"
variant="ghost"
:disabled="
processing ||
allowedTargetStatuses(
@@ -311,7 +353,10 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
).length === 0
"
>
Update status
<ArrowRight class="size-4" />
<span class="sr-only"
>Update status</span
>
</Button>
</div>
<p
+91 -34
View File
@@ -31,15 +31,19 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const defaultTemplateId = props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
const defaultTemplateId =
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
const form = useForm({
copy_areas: [] as string[],
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
key: '',
name: '',
source_project_id: props.sourceProjects[0]?.id ? String(props.sourceProjects[0].id) : '',
source_template_id: defaultTemplateId !== null ? String(defaultTemplateId) : '',
source_project_id: props.sourceProjects[0]?.id
? String(props.sourceProjects[0].id)
: '',
source_template_id:
defaultTemplateId !== null ? String(defaultTemplateId) : '',
});
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
@@ -48,7 +52,9 @@ const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1] ??
''
);
});
@@ -73,7 +79,7 @@ const submit = (): void => {
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="New project" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<div class="app-page-narrow max-w-3xl">
<Heading
title="Create project"
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
@@ -93,7 +99,9 @@ const submit = (): void => {
<div class="grid gap-3">
<Label>How do you want to start?</Label>
<div class="grid gap-3 sm:grid-cols-3">
<Label class="flex cursor-pointer items-start gap-3 rounded-xl border p-4">
<Label
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.creation_mode"
type="radio"
@@ -101,16 +109,25 @@ const submit = (): void => {
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">Create from scratch</div>
<p class="text-muted-foreground text-sm">
Start with the default project setup.
<div class="font-medium">
Create from scratch
</div>
<p
class="text-muted-foreground text-sm"
>
Start with the default project
setup.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="hasSourceProjects ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
:class="
hasSourceProjects
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
@@ -120,16 +137,26 @@ const submit = (): void => {
:disabled="!hasSourceProjects"
/>
<div class="space-y-1">
<div class="font-medium">Create from existing project</div>
<p class="text-muted-foreground text-sm">
Copy only the settings you explicitly select. Tickets are never duplicated.
<div class="font-medium">
Create from existing project
</div>
<p
class="text-muted-foreground text-sm"
>
Copy only the settings you
explicitly select. Tickets are never
duplicated.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="hasSourceTemplates ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
:class="
hasSourceTemplates
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
@@ -139,9 +166,14 @@ const submit = (): void => {
:disabled="!hasSourceTemplates"
/>
<div class="space-y-1">
<div class="font-medium">Create from template</div>
<p class="text-muted-foreground text-sm">
Reuse a saved configuration template.
<div class="font-medium">
Create from template
</div>
<p
class="text-muted-foreground text-sm"
>
Reuse a saved configuration
template.
</p>
</div>
</Label>
@@ -172,7 +204,9 @@ const submit = (): void => {
/>
<p class="text-muted-foreground text-sm">
Example ticket keys will look like
<span class="text-foreground font-medium">CORE-1</span>.
<span class="text-foreground font-medium"
>CORE-1</span
>.
</p>
<InputError :message="form.errors.key" />
</div>
@@ -182,33 +216,43 @@ const submit = (): void => {
class="space-y-6 rounded-xl border border-dashed p-4"
>
<div class="grid gap-2">
<Label for="source_project_id">Source project</Label>
<Label for="source_project_id"
>Source project</Label
>
<select
id="source_project_id"
v-model="form.source_project_id"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasSourceProjects"
>
<option value="">Select a source project</option>
<option value="">
Select a source project
</option>
<option
v-for="sourceProject in sourceProjects"
:key="sourceProject.id"
:value="String(sourceProject.id)"
>
{{ sourceProject.name }} ({{ sourceProject.key }})
{{ sourceProject.name }} ({{
sourceProject.key
}})
</option>
</select>
<p class="text-muted-foreground text-sm">
The new project keeps its own identity, key, and future ticket numbering.
The new project keeps its own identity, key,
and future ticket numbering.
</p>
<InputError :message="form.errors.source_project_id" />
<InputError
:message="form.errors.source_project_id"
/>
</div>
<div class="grid gap-3">
<div>
<Label>Copy options</Label>
<p class="text-muted-foreground text-sm">
Choose which settings should be copied into the new project.
Choose which settings should be copied
into the new project.
</p>
</div>
@@ -225,8 +269,12 @@ const submit = (): void => {
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">{{ copyAreaOption.label }}</div>
<p class="text-muted-foreground text-sm">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
{{ copyAreaOption.description }}
</p>
</div>
@@ -234,7 +282,8 @@ const submit = (): void => {
</div>
<p class="text-muted-foreground text-sm">
Operational ticket data is always left behind so the new project starts clean.
Operational ticket data is always left
behind so the new project starts clean.
</p>
<InputError :message="copyAreasError" />
</div>
@@ -249,7 +298,7 @@ const submit = (): void => {
<select
id="source_template_id"
v-model="form.source_template_id"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasSourceTemplates"
>
<option value="">Select a template</option>
@@ -258,21 +307,29 @@ const submit = (): void => {
:key="sourceTemplate.id"
:value="String(sourceTemplate.id)"
>
{{ sourceTemplate.name }} ({{ sourceTemplate.slug }})
{{ sourceTemplate.name }} ({{
sourceTemplate.slug
}})
</option>
</select>
<p class="text-muted-foreground text-sm">
Templates apply their saved configuration groups to the new project.
Templates apply their saved configuration
groups to the new project.
</p>
<InputError :message="form.errors.source_template_id" />
<InputError
:message="form.errors.source_template_id"
/>
</div>
</div>
<div
v-else-if="!hasSourceProjects && !hasSourceTemplates"
v-else-if="
!hasSourceProjects && !hasSourceTemplates
"
class="text-muted-foreground text-sm"
>
No accessible source projects or saved templates are available yet.
No accessible source projects or saved templates are
available yet.
</div>
<div class="flex items-center gap-3">
+7 -15
View File
@@ -29,13 +29,13 @@ const breadcrumbs: BreadcrumbItem[] = [
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Projects" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<Heading
title="Projects"
description="Create and browse project spaces that will later host boards, backlog, and settings."
description="Browse project spaces with their own board, backlog, workflow, repositories, and access."
/>
<Button
@@ -49,17 +49,11 @@ const breadcrumbs: BreadcrumbItem[] = [
</Button>
</div>
<div
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"
>
<div v-if="status" class="status-success">
{{ status }}
</div>
<div
v-if="projects.length === 0"
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
>
<div v-if="projects.length === 0" class="empty-state">
No projects exist yet. Create the first one to start building
out board, backlog, and settings areas.
</div>
@@ -71,10 +65,8 @@ const breadcrumbs: BreadcrumbItem[] = [
:href="show(project.id)"
class="group block"
>
<Card
class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
>
<CardHeader class="gap-3">
<Card class="group-hover:border-primary/40 h-full py-0">
<CardHeader class="gap-3 p-5">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<CardTitle>{{ project.name }}</CardTitle>
@@ -90,7 +82,7 @@ const breadcrumbs: BreadcrumbItem[] = [
</CardHeader>
<CardContent
class="text-muted-foreground flex items-center gap-2 text-sm"
class="border-outline-variant/60 text-muted-foreground flex items-center gap-2 border-t px-5 py-4 text-sm"
>
<Folder class="size-4" />
Open board, backlog, and settings hub
+78 -48
View File
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
import {
ArrowRight,
FolderGit2,
LayoutGrid,
ListTodo,
Settings2,
} from 'lucide-vue-next';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -38,39 +45,52 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const projectAreas = [
{
title: 'Backlog',
description:
'Create tickets, review incoming work, and move ready items into the board workflow.',
icon: ListTodo,
},
{
title: 'Board',
description:
'Track active work across the project-defined Kanban columns and update ticket status.',
icon: LayoutGrid,
},
{
title: 'Project settings',
description:
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
icon: Settings2,
},
{
title: 'Repositories',
description:
'Link project repositories so tickets can later discover related branches and pull requests.',
icon: FolderGit2,
},
];
const projectAreas = computed(() => {
const areas = [
{
title: 'Backlog',
description:
'Create tickets, review incoming work, and move ready items into the board workflow.',
icon: ListTodo,
href: backlogIndex(props.project.id),
},
{
title: 'Board',
description:
'Track active work across the project-defined Kanban columns and update ticket status.',
icon: LayoutGrid,
href: boardIndex(props.project.id),
},
];
if (props.canManageAccess) {
areas.push(
{
title: 'Project settings',
description:
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
icon: Settings2,
href: boardSettings(props.project.id),
},
{
title: 'Repositories',
description:
'Connect repositories and discover ticket branches and pull requests.',
icon: FolderGit2,
href: repositorySettings(props.project.id),
},
);
}
return areas;
});
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="project.name" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -80,7 +100,7 @@ const projectAreas = [
</Badge>
<Heading
:title="project.name"
description="Project hub for the upcoming backlog, board, and settings work."
description="Your project workspace for tickets, workflow, integrations, and access."
/>
</div>
@@ -117,7 +137,9 @@ const projectAreas = [
Built from template {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project keeps its template link. Inherited areas follow the template baseline; overridden areas were customized locally.
This project keeps its template link. Inherited areas
follow the template baseline; overridden areas were
customized locally.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
@@ -177,27 +199,35 @@ const projectAreas = [
</Button>
</div>
<div class="grid gap-4 md:grid-cols-3">
<Card
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Link
v-for="area in projectAreas"
:key="area.title"
class="h-full"
:href="area.href"
class="group"
>
<CardHeader>
<div
class="bg-muted text-foreground mb-3 flex size-10 items-center justify-center rounded-full"
<Card class="h-full py-0">
<CardHeader class="p-5">
<div
class="bg-primary/10 text-primary mb-3 flex size-10 items-center justify-center rounded-lg"
>
<component :is="area.icon" class="size-5" />
</div>
<CardTitle>{{ area.title }}</CardTitle>
<CardDescription>{{
area.description
}}</CardDescription>
</CardHeader>
<CardContent
class="text-primary flex items-center gap-2 px-5 pb-5 text-sm font-semibold"
>
<component :is="area.icon" class="size-5" />
</div>
<CardTitle>{{ area.title }}</CardTitle>
<CardDescription>{{
area.description
}}</CardDescription>
</CardHeader>
<CardContent class="text-muted-foreground text-sm">
Use the project shortcuts above to open this area.
</CardContent>
</Card>
Open area
<ArrowRight
class="size-4 transition-transform group-hover:translate-x-1"
/>
</CardContent>
</Card>
</Link>
</div>
</div>
</AppLayout>
+49 -31
View File
@@ -7,7 +7,6 @@ import {
buildAcceptanceCriteria,
formatChecklistLines,
normalizeAcceptanceCriteria,
parseChecklistLines,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
@@ -123,7 +122,9 @@ const breadcrumbs: BreadcrumbItem[] = [
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist);
const acceptanceCriteriaChecklist = ref(
acceptanceCriteriaEditorState.checklist,
);
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
);
@@ -131,7 +132,9 @@ const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const form = useForm({
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '',
@@ -157,8 +160,8 @@ const form = useForm({
story_points: props.ticket.story_points,
});
const acceptanceCriteriaPreviewItems = computed(() =>
savedAcceptanceCriteria.value,
const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value,
);
const hasUnsavedAcceptanceCriteriaDraft = computed(
@@ -318,8 +321,7 @@ watch(
branchNameValidation.value = {
is_valid: false,
message:
`${appName} could not validate this branch name right now.`,
message: `${appName} could not validate this branch name right now.`,
};
} finally {
if (!abortController.signal.aborted) {
@@ -361,7 +363,9 @@ const submitBranchCreation = (): void => {
};
const buildAcceptanceCriteriaForSubmit = () => {
const draftItems = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
const draftItems = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
return draftItems.map((item, index) => ({
...item,
@@ -371,7 +375,9 @@ const buildAcceptanceCriteriaForSubmit = () => {
}));
};
const buildSavedTicketPayload = (acceptanceCriteria = savedAcceptanceCriteria.value) => ({
const buildSavedTicketPayload = (
acceptanceCriteria = savedAcceptanceCriteria.value,
) => ({
acceptance_criteria: acceptanceCriteria,
assignee_user_id: props.ticket.assignee_user_id,
author_user_id: props.ticket.author_user_id,
@@ -407,7 +413,9 @@ const persistAcceptanceCriteriaToggle = async (
index,
checked,
);
const previousChecklist = formatChecklistLines(savedAcceptanceCriteria.value);
const previousChecklist = formatChecklistLines(
savedAcceptanceCriteria.value,
);
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
@@ -494,10 +502,10 @@ const formatDateTime = (value: string): string => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="ticket.ticket_key" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -542,10 +550,7 @@ const formatDateTime = (value: string): string => {
</div>
</div>
<div
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"
>
<div v-if="status" class="status-success">
{{ status }}
</div>
@@ -761,14 +766,18 @@ const formatDateTime = (value: string): string => {
placeholder="[ ] User can sign in&#10; [x] Validation is shown for invalid credentials"
/>
<p class="text-muted-foreground text-xs">
Use <code>[ ]</code> / <code>[x]</code> for checklist items. Indent with two spaces for nested steps.
Text changes are saved with the main ticket update.
Use <code>[ ]</code> /
<code>[x]</code> for checklist items.
Indent with two spaces for nested steps.
Text changes are saved with the main
ticket update.
</p>
<p
v-if="hasUnsavedAcceptanceCriteriaDraft"
class="text-muted-foreground text-xs"
>
Save checklist text changes before toggling completion directly.
Save checklist text changes before
toggling completion directly.
</p>
<InputError
:message="acceptanceCriteriaError"
@@ -779,7 +788,10 @@ const formatDateTime = (value: string): string => {
</div>
<div
v-if="acceptanceCriteriaPreviewItems.length > 0"
v-if="
acceptanceCriteriaPreviewItems.length >
0
"
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
@@ -787,7 +799,9 @@ const formatDateTime = (value: string): string => {
</p>
<ul class="space-y-2">
<li
v-for="(item, index) in acceptanceCriteriaPreviewItems"
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="item.id ?? index"
:style="{
marginLeft: `${item.indent * 1.25}rem`,
@@ -805,7 +819,12 @@ const formatDateTime = (value: string): string => {
hasUnsavedAcceptanceCriteriaDraft
"
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
@change="onAcceptanceCriteriaCheckboxChange(index, $event)"
@change="
onAcceptanceCriteriaCheckboxChange(
index,
$event,
)
"
/>
<label
:for="`acceptance-criteria-${item.id ?? index}`"
@@ -959,7 +978,7 @@ const formatDateTime = (value: string): string => {
</CardContent>
</Card>
<div class="space-y-6">
<div class="space-y-4 self-start xl:sticky xl:top-20">
<Card class="h-fit">
<CardHeader
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
@@ -1530,10 +1549,7 @@ const formatDateTime = (value: string): string => {
</CardContent>
</Card>
<Card
v-if="ticket.ticket_type?.is_parent"
class="h-fit"
>
<Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
<CardHeader>
<CardTitle>Child tickets</CardTitle>
<CardDescription>
@@ -1572,10 +1588,12 @@ const formatDateTime = (value: string): string => {
<CardContent>
<Link
:href="showTicket({
project: project.id,
ticket: currentParentTicket.id,
})"
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
>
<div class="font-medium">
+75 -29
View File
@@ -33,7 +33,8 @@ const returnHref =
props.source === 'board'
? boardIndex(props.project.id)
: backlogIndex(props.project.id);
const returnLabel = props.source === 'board' ? 'Back to board' : 'Back to backlog';
const returnLabel =
props.source === 'board' ? 'Back to board' : 'Back to backlog';
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -64,7 +65,9 @@ const hasTicketMetadata =
const acceptanceCriteriaChecklist = ref('');
const form = useForm({
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
assignee_user_id: '',
description: '',
parent_ticket_id: '',
@@ -92,7 +95,9 @@ const acceptanceCriteriaError = computed(() => {
});
const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
form.submit(ProjectTicketController.store({ project: props.project.id }), {
preserveScroll: true,
@@ -116,10 +121,10 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`Create ${project.name} ticket`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page-narrow">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -143,10 +148,7 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
</div>
</div>
<div
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"
>
<div v-if="status" class="status-success">
{{ status }}
</div>
@@ -173,7 +175,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<InputError :message="form.errors.title" />
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="description">Description</Label>
@@ -185,30 +189,46 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
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] disabled:cursor-not-allowed disabled:opacity-60"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError :message="form.errors.description" />
<InputError
:message="form.errors.description"
/>
</div>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="acceptance_criteria_checklist">
<Label
for="acceptance_criteria_checklist"
>
Acceptance criteria checklist
</Label>
<textarea
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
v-model="
acceptanceCriteriaChecklist
"
rows="6"
:disabled="form.processing"
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] disabled:cursor-not-allowed disabled:opacity-60"
placeholder="[ ] User can sign in&#10; [x] Validation is shown for invalid credentials"
/>
<p class="text-muted-foreground text-xs">
Use <code>[ ]</code> / <code>[x]</code> for checklist items. Indent with two spaces for nested steps.
<p
class="text-muted-foreground text-xs"
>
Use <code>[ ]</code> /
<code>[x]</code> for checklist
items. Indent with two spaces for
nested steps.
</p>
<InputError :message="acceptanceCriteriaError" />
<InputError
:message="acceptanceCriteriaError"
/>
</div>
<div
v-if="acceptanceCriteriaPreviewItems.length > 0"
v-if="
acceptanceCriteriaPreviewItems.length >
0
"
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
@@ -216,14 +236,18 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
</p>
<ul class="space-y-2">
<li
v-for="(item, index) in acceptanceCriteriaPreviewItems"
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="index"
:style="{
marginLeft: `${item.indent * 1.25}rem`,
}"
class="flex items-start gap-2"
>
<span class="mt-0.5">{{ item.checked ? '☑' : '☐' }}</span>
<span class="mt-0.5">{{
item.checked ? '☑' : '☐'
}}</span>
<span>{{ item.text }}</span>
</li>
</ul>
@@ -248,7 +272,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
{{ ticketType.name }}
</option>
</select>
<InputError :message="form.errors.ticket_type_id" />
<InputError
:message="form.errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
@@ -269,7 +295,11 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
{{ ticketPriority.name }}
</option>
</select>
<InputError :message="form.errors.ticket_priority_id" />
<InputError
:message="
form.errors.ticket_priority_id
"
/>
</div>
<div class="grid gap-2">
@@ -285,10 +315,14 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
:key="ticketStatus.id"
:value="String(ticketStatus.id)"
>
{{ ticketStatusLabel(ticketStatus) }}
{{
ticketStatusLabel(ticketStatus)
}}
</option>
</select>
<InputError :message="form.errors.ticket_status_id" />
<InputError
:message="form.errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
@@ -310,7 +344,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
{{ user.name }}
</option>
</select>
<InputError :message="form.errors.assignee_user_id" />
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div class="grid gap-2">
@@ -323,16 +359,24 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
:disabled="form.processing"
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] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">No parent ticket</option>
<option value="">
No parent ticket
</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="String(parentTicketOption.id)"
:value="
String(parentTicketOption.id)
"
>
{{ `${parentTicketOption.ticket_key} - ${parentTicketOption.title}` }}
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError :message="form.errors.parent_ticket_id" />
<InputError
:message="form.errors.parent_ticket_id"
/>
</div>
<div
@@ -349,7 +393,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
step="1"
:disabled="form.processing"
/>
<InputError :message="form.errors.story_points" />
<InputError
:message="form.errors.story_points"
/>
</div>
</div>
</div>
@@ -79,10 +79,10 @@ const statusAllowsTarget = (
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} board settings`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -131,10 +131,7 @@ const statusAllowsTarget = (
</div>
</div>
<div
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"
>
<div v-if="status" class="status-success">
{{ status }}
</div>
@@ -144,7 +141,9 @@ const statusAllowsTarget = (
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project stays linked to its template. Sections below show whether the current configuration is still inherited or has local overrides.
This project stays linked to its template. Sections
below show whether the current configuration is still
inherited or has local overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
+116 -41
View File
@@ -89,9 +89,12 @@ const submitSwitchTemplate = (): void => {
return;
}
switchTemplateForm.post(ProjectController.switchTemplate.url(props.project.id), {
preserveScroll: true,
});
switchTemplateForm.post(
ProjectController.switchTemplate.url(props.project.id),
{
preserveScroll: true,
},
);
};
const submitResetTemplate = (): void => {
@@ -99,9 +102,12 @@ const submitResetTemplate = (): void => {
return;
}
resetTemplateForm.post(ProjectController.resetTemplate.url(props.project.id), {
preserveScroll: true,
});
resetTemplateForm.post(
ProjectController.resetTemplate.url(props.project.id),
{
preserveScroll: true,
},
);
};
const submitDelete = (): void => {
@@ -116,10 +122,10 @@ const submitDelete = (): void => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} general settings`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -164,10 +170,7 @@ const submitDelete = (): void => {
</div>
</div>
<div
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"
>
<div v-if="status" class="status-success">
{{ status }}
</div>
@@ -209,18 +212,27 @@ const submitDelete = (): void => {
<CardHeader>
<CardTitle>Template baseline</CardTitle>
<CardDescription>
Switch this project to another published template or reset it back to the currently linked template baseline. Both actions can overwrite local configuration.
Switch this project to another published template or
reset it back to the currently linked template baseline.
Both actions can overwrite local configuration.
</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]">
<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
class="text-foreground mt-2 text-sm font-semibold"
>
{{
currentTemplate?.name ??
'No linked template'
}}
</p>
<p class="text-muted-foreground mt-2 text-sm">
{{
@@ -232,33 +244,52 @@ const submitDelete = (): void => {
</div>
<div class="border-border/70 rounded-xl border p-4">
<p class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]">
<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">
Template changes can replace board workflow, ticket metadata, repositories, and notifications for copied areas.
Template changes can replace board workflow,
ticket metadata, repositories, and notifications
for copied areas.
</p>
<p class="text-muted-foreground mt-2 text-sm">
Confirm each action with the exact project key to avoid accidental overwrites.
Confirm each action with the exact project key
to avoid accidental overwrites.
</p>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-2">
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitSwitchTemplate">
<form
class="space-y-4 rounded-xl border p-4"
@submit.prevent="submitSwitchTemplate"
>
<div class="space-y-1">
<h3 class="text-sm font-semibold">Switch to another template</h3>
<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.
Applying a different template replaces the
copied areas with that template's current
baseline.
</p>
</div>
<div class="grid gap-2">
<Label for="source_template_id">Published template</Label>
<Label for="source_template_id"
>Published template</Label
>
<select
id="source_template_id"
v-model="switchTemplateForm.source_template_id"
:disabled="switchTemplateForm.processing || availableSwitchTemplates.length === 0"
v-model="
switchTemplateForm.source_template_id
"
:disabled="
switchTemplateForm.processing ||
availableSwitchTemplates.length === 0
"
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]"
>
<option :value="undefined" disabled>
@@ -272,63 +303,107 @@ const submitDelete = (): void => {
{{ template.name }}
</option>
</select>
<InputError :message="switchTemplateForm.errors.source_template_id" />
<InputError
: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"
>Confirmation key</Label
>
<Input
id="switch_confirmation_key"
v-model="switchTemplateForm.confirmation_key"
v-model="
switchTemplateForm.confirmation_key
"
:disabled="switchTemplateForm.processing"
placeholder="Type the project key exactly"
/>
<InputError :message="switchTemplateForm.errors.confirmation_key" />
<InputError
:message="
switchTemplateForm.errors
.confirmation_key
"
/>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<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.
Existing project-specific configuration in
copied areas may be replaced or removed.
</p>
<Button
type="submit"
variant="outline"
:disabled="switchTemplateForm.processing || !canSwitchTemplate"
:disabled="
switchTemplateForm.processing ||
!canSwitchTemplate
"
>
Switch template
</Button>
</div>
</form>
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitResetTemplate">
<form
class="space-y-4 rounded-xl border p-4"
@submit.prevent="submitResetTemplate"
>
<div class="space-y-1">
<h3 class="text-sm font-semibold">Reset to current template baseline</h3>
<h3 class="text-sm font-semibold">
Reset to current template baseline
</h3>
<p class="text-muted-foreground text-sm">
Re-apply the currently linked template and clear local divergence inside copied areas.
Re-apply the currently linked template and
clear local divergence inside copied areas.
</p>
</div>
<div class="grid gap-2">
<Label for="reset_confirmation_key">Confirmation key</Label>
<Label for="reset_confirmation_key"
>Confirmation key</Label
>
<Input
id="reset_confirmation_key"
v-model="resetTemplateForm.confirmation_key"
:disabled="resetTemplateForm.processing || !currentTemplate"
:disabled="
resetTemplateForm.processing ||
!currentTemplate
"
placeholder="Type the project key exactly"
/>
<InputError :message="resetTemplateForm.errors.confirmation_key" />
<InputError
:message="
resetTemplateForm.errors
.confirmation_key
"
/>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm">
This restores the current template baseline for copied areas and rewinds project-level overrides there.
This restores the current template baseline
for copied areas and rewinds project-level
overrides there.
</p>
<Button
type="submit"
variant="outline"
:disabled="resetTemplateForm.processing || !isResetConfirmationValid || !currentTemplate"
:disabled="
resetTemplateForm.processing ||
!isResetConfirmationValid ||
!currentTemplate
"
>
Reset to baseline
</Button>
@@ -51,10 +51,10 @@ const replacementTicketTypes = (ticketTypeId: number) =>
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} ticket settings`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -121,7 +121,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Inherited sections stay synced with the template baseline. Local edits in these settings are treated as project-specific overrides.
Inherited sections stay synced with the template
baseline. Local edits in these settings are treated as
project-specific overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
@@ -699,7 +701,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
required
placeholder="Blocked by"
/>
<InputError :message="errors.counterpart_name" />
<InputError
:message="errors.counterpart_name"
/>
</div>
<Button type="submit" :disabled="processing">
@@ -745,7 +749,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
<Input
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
name="counterpart_name"
:default-value="ticketLinkType.counterpart_label"
:default-value="
ticketLinkType.counterpart_label
"
required
/>
<InputError
@@ -89,7 +89,8 @@ const breadcrumbs: BreadcrumbItem[] = [
let filterStateCounter = 0;
const nextFilterStateId = (): string => `notification-filter-${filterStateCounter++}`;
const nextFilterStateId = (): string =>
`notification-filter-${filterStateCounter++}`;
const parseInFilterValues = (value: string): string[] =>
value
@@ -100,7 +101,8 @@ const parseInFilterValues = (value: string): string[] =>
const supportsFilterValue = (operator: string): boolean =>
!['exists', 'not_exists'].includes(operator);
const supportsArrayFilterValue = (operator: string): boolean => operator === 'in';
const supportsArrayFilterValue = (operator: string): boolean =>
operator === 'in';
const createFilter = (): NotificationFilterFormState => ({
attribute: defaultFilterAttribute,
@@ -159,8 +161,9 @@ const resetCreateFilters = (): void => {
createFilterState.filters = [];
};
const editFilters = (notificationActionId: number): NotificationFilterFormState[] =>
editFilterState[notificationActionId] ?? [];
const editFilters = (
notificationActionId: number,
): NotificationFilterFormState[] => editFilterState[notificationActionId] ?? [];
const addEditFilter = (notificationActionId: number): void => {
editFilterState[notificationActionId] = [
@@ -169,10 +172,13 @@ const addEditFilter = (notificationActionId: number): void => {
];
};
const removeEditFilter = (notificationActionId: number, index: number): void => {
editFilterState[notificationActionId] = editFilters(notificationActionId).filter(
(_, candidateIndex) => candidateIndex !== index,
);
const removeEditFilter = (
notificationActionId: number,
index: number,
): void => {
editFilterState[notificationActionId] = editFilters(
notificationActionId,
).filter((_, candidateIndex) => candidateIndex !== index);
};
const createSerializedFilters = computed<string>(() =>
@@ -246,10 +252,10 @@ const notificationActionTimestamp = (
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} notification settings`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -298,10 +304,7 @@ const notificationActionTimestamp = (
</div>
</div>
<div
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"
>
<div v-if="status" class="status-success">
{{ status }}
</div>
@@ -311,7 +314,8 @@ const notificationActionTimestamp = (
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Notification rules can remain inherited from the template or diverge here as project-level overrides.
Notification rules can remain inherited from the
template or diverge here as project-level overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
@@ -543,7 +547,10 @@ const notificationActionTimestamp = (
/>
<div
v-if="createFilterState.filters.length === 0"
v-if="
createFilterState.filters.length ===
0
"
class="text-muted-foreground text-sm"
>
No filters configured. This action runs
@@ -552,8 +559,7 @@ const notificationActionTimestamp = (
<div
v-for="(
filter,
filterIndex
filter, filterIndex
) in createFilterState.filters"
:key="filter.id"
class="grid gap-3 rounded-md border p-3"
@@ -572,8 +578,12 @@ const notificationActionTimestamp = (
>
<option
v-for="filterAttributeOption in filterAttributeOptions"
:key="filterAttributeOption.value"
:value="filterAttributeOption.value"
:key="
filterAttributeOption.value
"
:value="
filterAttributeOption.value
"
>
{{
filterAttributeOption.label
@@ -595,8 +605,12 @@ const notificationActionTimestamp = (
>
<option
v-for="filterOperatorOption in filterOperatorOptions"
:key="filterOperatorOption.value"
:value="filterOperatorOption.value"
:key="
filterOperatorOption.value
"
:value="
filterOperatorOption.value
"
>
{{
filterOperatorOption.label
@@ -607,7 +621,11 @@ const notificationActionTimestamp = (
</div>
<div
v-if="supportsFilterValue(filter.operator)"
v-if="
supportsFilterValue(
filter.operator,
)
"
class="grid gap-2"
>
<Label
@@ -662,7 +680,9 @@ const notificationActionTimestamp = (
</div>
<InputError
:message="notificationFilterError(errors)"
:message="
notificationFilterError(errors)
"
/>
</div>
@@ -1029,8 +1049,7 @@ const notificationActionTimestamp = (
<div
v-for="(
filter,
filterIndex
filter, filterIndex
) in editFilters(notificationAction.id)"
:key="filter.id"
class="grid gap-3 rounded-md border p-3"
@@ -1049,8 +1068,12 @@ const notificationActionTimestamp = (
>
<option
v-for="filterAttributeOption in filterAttributeOptions"
:key="filterAttributeOption.value"
:value="filterAttributeOption.value"
:key="
filterAttributeOption.value
"
:value="
filterAttributeOption.value
"
>
{{
filterAttributeOption.label
@@ -1072,8 +1095,12 @@ const notificationActionTimestamp = (
>
<option
v-for="filterOperatorOption in filterOperatorOptions"
:key="filterOperatorOption.value"
:value="filterOperatorOption.value"
:key="
filterOperatorOption.value
"
:value="
filterOperatorOption.value
"
>
{{
filterOperatorOption.label
@@ -1084,7 +1111,11 @@ const notificationActionTimestamp = (
</div>
<div
v-if="supportsFilterValue(filter.operator)"
v-if="
supportsFilterValue(
filter.operator,
)
"
class="grid gap-2"
>
<Label
@@ -1144,7 +1175,9 @@ const notificationActionTimestamp = (
</div>
<InputError
:message="notificationFilterError(errors)"
:message="
notificationFilterError(errors)
"
/>
</div>
@@ -218,10 +218,10 @@ const submitUpdate = (): void => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} repository settings`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -270,10 +270,7 @@ const submitUpdate = (): void => {
</div>
</div>
<div
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"
>
<div v-if="status" class="status-success">
{{ status }}
</div>
@@ -283,7 +280,8 @@ const submitUpdate = (): void => {
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Repository links can stay inherited from the template or be replaced with project-specific overrides.
Repository links can stay inherited from the template or
be replaced with project-specific overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
@@ -320,9 +318,9 @@ const submitUpdate = (): void => {
</Link>
</Button>
<p class="text-muted-foreground text-sm">
Without a matching personal connection, {{ appName }} only
shows the repository name and links you to your user
settings.
Without a matching personal connection,
{{ appName }} only shows the repository name and links
you to your user settings.
</p>
</CardContent>
</Card>
@@ -647,8 +645,8 @@ const submitUpdate = (): void => {
>
This project link is visible, but you need a
matching Git connection in your user
settings before {{ appName }} can open or query
this repository for you.
settings before {{ appName }} can open or
query this repository for you.
</p>
<div class="mt-4 flex justify-end">
@@ -683,9 +681,9 @@ const submitUpdate = (): void => {
<CardHeader class="border-b">
<CardTitle>Detected personal sources</CardTitle>
<CardDescription>
{{ appName }} loads repositories from your personal
Git connections and offers them for project
linking.
{{ appName }} loads repositories from your
personal Git connections and offers them for
project linking.
</CardDescription>
</CardHeader>