feat(KAN-20): unify dialogs feedback and protect ticket drafts
CI/CD / test_and_check_app (push) Successful in 4m48s
CI/CD / Publish immutable image (push) Skipped
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5s

This commit is contained in:
2026-09-10 09:26:12 +02:00
parent 718f6b991d
commit 7edd8daf49
27 changed files with 1793 additions and 329 deletions
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { CircleAlert, CircleCheck, Inbox, LoaderCircle } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
const props = withDefaults(
defineProps<{
state: 'empty' | 'loading' | 'error' | 'success';
title?: string;
description?: string;
retryable?: boolean;
}>(),
{ retryable: false },
);
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const icons = {
empty: Inbox,
loading: LoaderCircle,
error: CircleAlert,
success: CircleCheck,
};
const defaultTitles = {
empty: 'feedback.empty',
loading: 'common.loading',
error: 'common.error',
success: 'feedback.saved',
} as const;
const heading = computed(() => props.title ?? t(defaultTitles[props.state]));
</script>
<template>
<div
:role="state === 'error' ? 'alert' : 'status'"
:aria-busy="state === 'loading' || undefined"
:data-feedback="state"
class="gap-card bg-card p-card text-body text-card-foreground flex min-w-0 items-start rounded-md border"
:class="{
'border-destructive/40': state === 'error',
'border-success/40': state === 'success',
}"
>
<component
:is="icons[state]"
aria-hidden="true"
class="mt-0.5 size-5 shrink-0"
:class="{
'text-primary animate-spin motion-reduce:animate-none':
state === 'loading',
'text-destructive': state === 'error',
'text-success-foreground': state === 'success',
'text-muted-foreground': state === 'empty',
}"
/>
<div class="gap-stack grid min-w-0 flex-1 break-words">
<p class="font-semibold">{{ heading }}</p>
<p v-if="description" class="text-muted-foreground">
{{ description }}
</p>
<div v-if="$slots.default"><slot /></div>
<Button
v-if="retryable && state === 'error'"
type="button"
variant="outline"
class="justify-self-start"
@click="$emit('retry')"
>{{ t('common.retry') }}</Button
>
</div>
</div>
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, nextTick, useTemplateRef, watch } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { useLocalization } from '@/composables/useLocalization';
import type { FormFeedbackState } from '@/lib/form-feedback';
const props = defineProps<{ state: FormFeedbackState }>();
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const feedback = useTemplateRef<HTMLDivElement>('feedback');
watch(
() => props.state,
async (state) => {
if (['validation', 'network', 'server', 'cancelled'].includes(state)) {
await nextTick();
if (props.state === state) {
feedback.value?.focus();
}
}
},
);
const titles = {
idle: 'feedback.saved',
loading: 'forms.saving',
success: 'feedback.saved',
validation: 'forms.validationTitle',
network: 'forms.networkTitle',
server: 'forms.serverTitle',
cancelled: 'forms.cancelledTitle',
} as const;
const descriptions = {
validation: 'forms.validationDescription',
network: 'forms.networkDescription',
server: 'forms.serverDescription',
cancelled: 'forms.cancelledDescription',
} as const;
const description = computed(() =>
props.state in descriptions
? t(descriptions[props.state as keyof typeof descriptions])
: undefined,
);
</script>
<template>
<div
ref="feedback"
v-show="state !== 'idle'"
:data-form-state="state"
tabindex="-1"
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
>
<FeedbackState
v-if="state !== 'idle'"
:state="
state === 'loading' || state === 'success' ? state : 'error'
"
:title="t(titles[state])"
:description="description"
:retryable="
state === 'network' ||
state === 'server' ||
state === 'cancelled'
"
@retry="$emit('retry')"
/>
</div>
</template>
+2 -2
View File
@@ -5,8 +5,8 @@ defineProps<{
</script> </script>
<template> <template>
<div v-show="message"> <div v-show="message" role="alert">
<p class="text-sm text-red-600 dark:text-red-500"> <p class="text-body text-destructive">
{{ message }} {{ message }}
</p> </p>
</div> </div>
@@ -1,29 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import DialogOverlay from "./DialogOverlay.vue" import DialogOverlay from "./DialogOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), { const props = withDefaults(
defineProps<
DialogContentProps & {
class?: HTMLAttributes["class"];
showCloseButton?: boolean;
}
>(),
{
showCloseButton: true, showCloseButton: true,
}) },
const emits = defineEmits<DialogContentEmits>() );
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
@@ -32,21 +44,23 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DialogContent <DialogContent
data-slot="dialog-content" data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
:class=" :class="
cn( cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', 'bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-column overflow-y-auto overscroll-contain rounded-md border p-page shadow-lg duration-200 motion-reduce:animate-none sm:max-w-lg',
props.class, props.class,
)" )
"
> >
<slot /> <slot />
<DialogClose <DialogClose
v-if="showCloseButton" v-if="showCloseButton"
data-slot="dialog-close" data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<X /> <X />
<span class="sr-only">Close</span> <span class="sr-only">{{ t("common.close") }}</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
@@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<div <div
data-slot="dialog-header" data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)" :class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
> >
<slot /> <slot />
</div> </div>
@@ -1,27 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DialogContentEmits>() DialogContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
</script> </script>
<template> <template>
@@ -32,26 +38,32 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DialogContent <DialogContent
:class=" :class="
cn( cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full', 'relative z-50 my-4 grid w-full max-w-[calc(100%-2rem)] gap-column rounded-md border bg-card p-page text-card-foreground shadow-lg duration-200 sm:max-w-lg',
props.class, props.class,
) )
" "
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => { @close-auto-focus="restoreFocus"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent; const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement; const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) { if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault(); event.preventDefault();
} }
}" }
"
> >
<slot /> <slot />
<DialogClose <DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary" class="absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
> >
<X class="w-4 h-4" /> <X class="w-4 h-4" />
<span class="sr-only">Close</span> <span class="sr-only">{{ t("common.close") }}</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogOverlay> </DialogOverlay>
@@ -1,33 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuCheckboxItemEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuCheckboxItemProps,
import { Check } from "lucide-vue-next" } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import { import {
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuItemIndicator, DropdownMenuItemIndicator,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DropdownMenuCheckboxItemEmits>() DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded" v-bind="forwarded"
:class=" cn( :class="
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', cn(
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class, props.class,
)" )
"
>
<span
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
> >
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
<slot name="indicator-icon"> <slot name="indicator-icon">
<Check class="size-4" /> <Check class="size-4" />
@@ -1,29 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuContentEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuContentProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuPortal, DropdownMenuPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults( const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(), defineProps<
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
>(),
{ {
sideOffset: 4, sideOffset: 4,
}, },
) );
const emits = defineEmits<DropdownMenuContentEmits>() const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
@@ -31,7 +36,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DropdownMenuContent <DropdownMenuContent
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)" :class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
props.class,
)
"
> >
<slot /> <slot />
</DropdownMenuContent> </DropdownMenuContent>
@@ -1,21 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItemProps } from "reka-ui" import type { DropdownMenuItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuItem, useForwardProps } from "reka-ui" import { DropdownMenuItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = withDefaults(defineProps<DropdownMenuItemProps & { const props = withDefaults(
class?: HTMLAttributes["class"] defineProps<
inset?: boolean DropdownMenuItemProps & {
variant?: "default" | "destructive" class?: HTMLAttributes["class"];
}>(), { inset?: boolean;
variant?: "default" | "destructive";
}
>(),
{
variant: "default", variant: "default",
}) },
);
const delegatedProps = reactiveOmit(props, "inset", "variant", "class") const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
@@ -24,7 +29,12 @@ const forwardedProps = useForwardProps(delegatedProps)
:data-inset="inset ? '' : undefined" :data-inset="inset ? '' : undefined"
:data-variant="variant" :data-variant="variant"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)" :class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
> >
<slot /> <slot />
</DropdownMenuItem> </DropdownMenuItem>
@@ -1,34 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuRadioItemEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuRadioItemProps,
import { Circle } from "lucide-vue-next" } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Circle } from "lucide-vue-next";
import { import {
DropdownMenuItemIndicator, DropdownMenuItemIndicator,
DropdownMenuRadioItem, DropdownMenuRadioItem,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuRadioItemEmits>() const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuRadioItem <DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
v-bind="forwarded" v-bind="forwarded"
:class="cn( :class="
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', cn(
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class, props.class,
)" )
"
>
<span
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
> >
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
<slot name="indicator-icon"> <slot name="indicator-icon">
<Circle class="size-2 fill-current" /> <Circle class="size-2 fill-current" />
@@ -1,26 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuSubContentEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuSubContentProps,
import { } from "reka-ui";
DropdownMenuSubContent, import type { HTMLAttributes } from "vue";
useForwardPropsEmits, import { reactiveOmit } from "@vueuse/core";
} from "reka-ui" import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DropdownMenuSubContentEmits>() DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuSubContent <DropdownMenuSubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
v-bind="forwarded" v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)" :class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg',
props.class,
)
"
> >
<slot /> <slot />
</DropdownMenuSubContent> </DropdownMenuSubContent>
@@ -1,28 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from "reka-ui" import type { DropdownMenuSubTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { ChevronRight } from "lucide-vue-next" import { ChevronRight } from "lucide-vue-next";
import { import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
DropdownMenuSubTrigger, import { cn } from "@/lib/utils";
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>() const props = defineProps<
DropdownMenuSubTriggerProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
}
>();
const delegatedProps = reactiveOmit(props, "class", "inset") const delegatedProps = reactiveOmit(props, "class", "inset");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<DropdownMenuSubTrigger <DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn( :class="
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8', cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex min-h-control cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
props.class, props.class,
)" )
"
> >
<slot /> <slot />
<ChevronRight class="ml-auto size-4" /> <ChevronRight class="ml-auto size-4" />
@@ -1,34 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import SheetOverlay from "./SheetOverlay.vue" import SheetOverlay from "./SheetOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
interface SheetContentProps extends DialogContentProps { interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
} }
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SheetContentProps>(), { const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right", side: "right",
}) });
const emits = defineEmits<DialogContentEmits>() const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class", "side") const delegatedProps = reactiveOmit(props, "class", "side");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
</script> </script>
<template> <template>
@@ -36,26 +40,30 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<SheetOverlay /> <SheetOverlay />
<DialogContent <DialogContent
data-slot="sheet-content" data-slot="sheet-content"
:class="cn( :class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' side === 'right' &&
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' side === 'left' &&
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' side === 'top' &&
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' side === 'bottom' &&
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
props.class)" props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
> >
<slot /> <slot />
<DialogClose <DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none" class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
> >
<X class="size-4" /> <X class="size-4" />
<span class="sr-only">Close</span> <span class="sr-only">{{ t("common.close") }}</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
@@ -0,0 +1,34 @@
import { injectDialogRootContext } from 'reka-ui';
import { watch } from 'vue';
/** Reka restores registered triggers; also support dialogs opened programmatically. */
export function useDialogFocus() {
const context = injectDialogRootContext();
let previousFocus: HTMLElement | null = null;
watch(
context.open,
(open) => {
if (open && typeof document !== 'undefined') {
previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
}
},
{ flush: 'sync', immediate: true },
);
function restoreFocus(event: Event): void {
if (
!event.defaultPrevented &&
!context.triggerElement.value &&
previousFocus?.isConnected
) {
event.preventDefault();
previousFocus.focus({ preventScroll: true });
}
}
return { restoreFocus };
}
@@ -0,0 +1,114 @@
import type {
PendingVisit,
UrlMethodPair,
UseFormSubmitOptions,
} from '@inertiajs/core';
import { router } from '@inertiajs/vue3';
import type { InertiaForm } from '@inertiajs/vue3';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
import {
formRequestHeader,
isFormNetworkFailure,
matchesFormRequest,
} from '@/lib/form-feedback';
import type { FormFeedbackState } from '@/lib/form-feedback';
/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
export function useFormProtection<T extends object>(
form: InertiaForm<T>,
options: { isDirty?: () => boolean } = {},
) {
const failure = ref<FormFeedbackState | null>(null);
let ownVisit: PendingVisit | null = null;
let requestId: string | null = null;
const removeListeners: Array<() => void> = [];
const { confirmLeave } = useUnsavedChanges(
() => form.processing || (options.isDirty?.() ?? form.isDirty),
(visit) => visit === ownVisit,
);
const state = computed<FormFeedbackState>(() => {
if (form.processing) {
return 'loading';
}
if (failure.value) {
return failure.value;
}
if (Object.keys(form.errors).length > 0) {
return 'validation';
}
return form.recentlySuccessful ? 'success' : 'idle';
});
onMounted(() => {
removeListeners.push(
router.on('exception', (event) => {
if (isFormNetworkFailure(event.detail.exception, requestId)) {
event.preventDefault();
failure.value = 'network';
}
}),
router.on('invalid', (event) => {
if (
matchesFormRequest(event.detail.response.config, requestId)
) {
event.preventDefault();
failure.value = 'server';
}
}),
);
});
onUnmounted(() => removeListeners.forEach((remove) => remove()));
function submit(
target: UrlMethodPair,
visitOptions: UseFormSubmitOptions = {},
): void {
if (form.processing) {
return;
}
const nextRequestId = crypto.randomUUID();
form.submit(target, {
...visitOptions,
preserveState: true,
headers: {
...visitOptions.headers,
[formRequestHeader]: nextRequestId,
},
onBefore: (visit) => {
if (visitOptions.onBefore?.(visit) === false) {
return false;
}
ownVisit = visit;
},
onStart: (visit) => {
requestId = nextRequestId;
failure.value = null;
visitOptions.onStart?.(visit);
},
onError: (errors) => {
failure.value = 'validation';
return visitOptions.onError?.(errors);
},
onSuccess: (page) => {
failure.value = null;
return visitOptions.onSuccess?.(page);
},
onCancel: () => {
failure.value = 'cancelled';
visitOptions.onCancel?.();
},
onFinish: (visit) => {
requestId = null;
ownVisit = null;
visitOptions.onFinish?.(visit);
},
});
}
return { state, submit, confirmLeave };
}
@@ -0,0 +1,42 @@
import type { PendingVisit } from '@inertiajs/core';
import { router } from '@inertiajs/vue3';
import { onMounted, onUnmounted, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
export function useUnsavedChanges(
dirty: MaybeRefOrGetter<boolean>,
allowVisit: (visit: PendingVisit) => boolean = () => false,
) {
const { t } = useLocalization();
let removeBeforeListener: (() => void) | undefined;
const confirmLeave = (): boolean =>
!toValue(dirty) || window.confirm(t('forms.leaveConfirm'));
const beforeUnload = (event: BeforeUnloadEvent): void => {
if (toValue(dirty)) {
event.preventDefault();
event.returnValue = '';
}
};
onMounted(() => {
removeBeforeListener = router.on('before', (event) => {
if (event.defaultPrevented || allowVisit(event.detail.visit)) {
return;
}
// Keep the original visit and its callbacks; never replay a mutation.
return confirmLeave();
});
window.addEventListener('beforeunload', beforeUnload);
});
onUnmounted(() => {
removeBeforeListener?.();
window.removeEventListener('beforeunload', beforeUnload);
});
return { confirmLeave };
}
+42
View File
@@ -0,0 +1,42 @@
export type FormFeedbackState =
| 'idle'
| 'loading'
| 'success'
| 'validation'
| 'network'
| 'server'
| 'cancelled';
export const formRequestHeader = 'X-KanbanGit-Form-Request';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
/** Match failures to one submission, without intercepting other forms or polling. */
export function matchesFormRequest(
config: unknown,
requestId: string | null,
): boolean {
if (!requestId || !isRecord(config) || !isRecord(config.headers)) {
return false;
}
return config.headers[formRequestHeader] === requestId;
}
/** Runtime exceptions and cancellations are not network failures. */
export function isFormNetworkFailure(
error: unknown,
requestId: string | null,
): boolean {
return (
isRecord(error) &&
error.isAxiosError === true &&
!error.response &&
['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT'].includes(
String(error.code),
) &&
matchesFormRequest(error.config, requestId)
);
}
+24
View File
@@ -43,6 +43,30 @@ export const en = {
'appearance.dark': 'Dark', 'appearance.dark': 'Dark',
'appearance.system': 'System', 'appearance.system': 'System',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.close': 'Close',
'common.retry': 'Try again',
'feedback.empty': 'Nothing here yet',
'feedback.saved': 'Changes saved',
'forms.saving': 'Saving changes…',
'forms.leaveConfirm': 'You have unsaved changes. Leave without saving?',
'forms.validationTitle': 'Check your entries',
'forms.validationDescription':
'The server rejected some fields. Your entries are still here. Correct the highlighted errors and save again.',
'forms.networkTitle': 'Connection interrupted',
'forms.networkDescription':
'Your entries are still here, but we could not confirm whether the server saved them. Check your connection and verify the result before trying again.',
'forms.serverTitle': 'The request could not be completed',
'forms.serverDescription':
'Your entries are still here. The server returned an unexpected response; your session may have expired. Verify the result before trying again.',
'forms.cancelledTitle': 'Saving was interrupted',
'forms.cancelledDescription':
'Your entries are still here. Check whether the changes were saved before trying again.',
'tickets.noBranches': 'No matching branches',
'tickets.noBranchesDescription':
'No matching branches were found across the linked repositories.',
'tickets.noPullRequests': 'No matching pull requests',
'tickets.noPullRequestsDescription':
'No matching pull requests were found across the linked repositories.',
'common.save': 'Save', 'common.save': 'Save',
'common.loading': 'Loading…', 'common.loading': 'Loading…',
'common.error': 'Something went wrong.', 'common.error': 'Something went wrong.',
+90 -23
View File
@@ -1,7 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3'; import {
Form,
useForm,
useRemember,
Head,
Link,
usePage,
} from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next'; import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue'; import { computed, ref, toRef, unref, watch } from 'vue';
import { import {
acceptanceCriteriaToEditorState, acceptanceCriteriaToEditorState,
buildAcceptanceCriteria, buildAcceptanceCriteria,
@@ -14,6 +21,10 @@ import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectT
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import TicketTreeList from '@/components/TicketTreeList.vue'; import TicketTreeList from '@/components/TicketTreeList.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -45,6 +56,7 @@ import type { RepositoryConnectionModel } from '@/types/models/repository-connec
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types'; import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>(); const props = defineProps<ProjectTicketPageProps>();
const { t } = useLocalization();
const page = usePage(); const page = usePage();
const appName = String(page.props.name); const appName = String(page.props.name);
@@ -122,8 +134,14 @@ const breadcrumbs: BreadcrumbItem[] = [
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState( const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria, props.ticket.acceptance_criteria,
); );
const acceptanceCriteriaChecklist = ref( const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
acceptanceCriteriaEditorState.checklist, const acceptanceCriteriaDraft = useRemember(
{ checklist: acceptanceCriteriaEditorState.checklist },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
); );
const savedAcceptanceCriteria = ref( const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria), normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
@@ -131,7 +149,7 @@ const savedAcceptanceCriteria = ref(
const isPersistingAcceptanceCriteriaToggle = ref(false); const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref(''); const acceptanceCriteriaToggleError = ref('');
const form = useForm({ const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria( acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value, acceptanceCriteriaChecklist.value,
), ),
@@ -170,6 +188,12 @@ const hasUnsavedAcceptanceCriteriaDraft = computed(
formatChecklistLines(savedAcceptanceCriteria.value), formatChecklistLines(savedAcceptanceCriteria.value),
); );
const protection = useFormProtection(form, {
isDirty: () =>
props.canEditTicket &&
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
});
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
form.errors.acceptance_criteria ?? form.errors.acceptance_criteria ??
@@ -187,10 +211,20 @@ const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<{ const branchCreationForm = useForm<{
branch_name: string; branch_name: string;
repository_connection_id: string; repository_connection_id: string;
}>({ }>(`${draftKey}:branch`, {
branch_name: props.suggestedBranchName, branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value, repository_connection_id: defaultBranchRepositoryConnectionId.value,
}); });
const branchProtection = useFormProtection(branchCreationForm, {
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
});
const updateBranchDialogOpen = (open: boolean): void => {
if (!open && !branchProtection.confirmLeave()) {
return;
}
isCreateBranchDialogOpen.value = open;
};
const selectedBranchRepositoryConnection = computed( const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null => (): RepositoryConnectionModel | null =>
@@ -207,6 +241,9 @@ const selectedBranchRepositoryHasPersonalAccess = computed(
); );
const initializeBranchCreationForm = (): void => { const initializeBranchCreationForm = (): void => {
if (branchCreationForm.isDirty) {
return;
}
branchCreationForm.clearErrors(); branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName; branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id = branchCreationForm.repository_connection_id =
@@ -348,7 +385,7 @@ const submitBranchCreation = (): void => {
return; return;
} }
branchCreationForm.submit( branchProtection.submit(
ProjectTicketBranchController.store({ ProjectTicketBranchController.store({
project: props.project.id, project: props.project.id,
ticket: props.ticket.id, ticket: props.ticket.id,
@@ -482,13 +519,21 @@ const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit(); form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
form.submit( protection.submit(
ProjectTicketController.update({ ProjectTicketController.update({
project: props.project.id, project: props.project.id,
ticket: props.ticket.id, ticket: props.ticket.id,
}), }),
{ {
preserveScroll: true, preserveScroll: true,
onSuccess: () => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria,
);
acceptanceCriteriaChecklist.value = formatChecklistLines(
savedAcceptanceCriteria.value,
);
},
}, },
); );
}; };
@@ -574,16 +619,25 @@ const formatDateTime = (value: string): string => {
<CardContent> <CardContent>
<form class="grid gap-6" @submit.prevent="submit"> <form class="grid gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="title">Title</Label> <Label for="title">Title</Label>
<Input <Input
id="title" id="title"
v-model="form.title" v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
/> />
<InputError :message="form.errors.title" /> <InputError
id="title-error"
:message="form.errors.title"
/>
</div> </div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6"> <div class="grid gap-2 md:grid-cols-2 md:gap-6">
@@ -995,7 +1049,10 @@ const formatDateTime = (value: string): string => {
</CardDescription> </CardDescription>
</div> </div>
<Dialog v-model:open="isCreateBranchDialogOpen"> <Dialog
:open="isCreateBranchDialogOpen"
@update:open="updateBranchDialogOpen"
>
<Button <Button
v-if="canCreateBranches" v-if="canCreateBranches"
type="button" type="button"
@@ -1023,6 +1080,12 @@ const formatDateTime = (value: string): string => {
class="grid gap-4" class="grid gap-4"
@submit.prevent="submitBranchCreation" @submit.prevent="submitBranchCreation"
> >
<FormFeedback
:state="
branchProtection.state.value
"
@retry="submitBranchCreation"
/>
<div class="grid gap-2"> <div class="grid gap-2">
<Label <Label
for="repository_connection_id" for="repository_connection_id"
@@ -1153,7 +1216,9 @@ const formatDateTime = (value: string): string => {
type="button" type="button"
variant="secondary" variant="secondary"
@click=" @click="
isCreateBranchDialogOpen = false updateBranchDialogOpen(
false,
)
" "
> >
Cancel Cancel
@@ -1177,13 +1242,14 @@ const formatDateTime = (value: string): string => {
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div <FeedbackState
v-if="visibleRepositoryBranches.length === 0" v-if="visibleRepositoryBranches.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm" state="empty"
> :title="t('tickets.noBranches')"
No matching branches were found across the :description="
linked repositories you can access. t('tickets.noBranchesDescription')
</div> "
/>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <div
@@ -1434,15 +1500,16 @@ const formatDateTime = (value: string): string => {
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div <FeedbackState
v-if=" v-if="
visibleRepositoryPullRequests.length === 0 visibleRepositoryPullRequests.length === 0
" "
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm" state="empty"
> :title="t('tickets.noPullRequests')"
No matching pull requests were found across the :description="
linked repositories you can access. t('tickets.noPullRequestsDescription')
</div> "
/>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <div
+33 -7
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
import { computed, ref } from 'vue'; import { computed, toRef, unref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -62,9 +64,17 @@ const hasTicketMetadata =
props.ticketPriorities.length > 0 && props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0; props.ticketStatusOptions.length > 0;
const acceptanceCriteriaChecklist = ref(''); const draftKey = `projects:${props.project.id}:tickets:create`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: '' },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const form = useForm({ const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria( acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value, acceptanceCriteriaChecklist.value,
), ),
@@ -80,6 +90,10 @@ const form = useForm({
title: '', title: '',
}); });
const protection = useFormProtection(form, {
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
});
const acceptanceCriteriaPreviewItems = computed(() => const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value), parseChecklistLines(acceptanceCriteriaChecklist.value),
); );
@@ -99,9 +113,12 @@ const submit = (): void => {
acceptanceCriteriaChecklist.value, acceptanceCriteriaChecklist.value,
); );
form.submit(ProjectTicketController.store({ project: props.project.id }), { protection.submit(
ProjectTicketController.store({ project: props.project.id }),
{
preserveScroll: true, preserveScroll: true,
}); },
);
}; };
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => { const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
@@ -165,17 +182,26 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<CardContent> <CardContent>
<form class="grid gap-6" @submit.prevent="submit"> <form class="grid gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="title">Title</Label> <Label for="title">Title</Label>
<Input <Input
id="title" id="title"
v-model="form.title" v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
required required
autofocus autofocus
:disabled="form.processing" :disabled="form.processing"
placeholder="Implement repository sync" placeholder="Implement repository sync"
/> />
<InputError :message="form.errors.title" /> <InputError
id="title-error"
:message="form.errors.title"
/>
</div> </div>
<div <div
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class FormFeedbackTest extends TestCase
{
public function test_feedback_states_and_request_failure_classification(): void
{
$process = new Process(['node', 'tests/Fixtures/form-feedback.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Form feedback behavior checks passed.', $process->getOutput());
}
}
@@ -248,6 +248,8 @@ class ProjectTicketControllerTest extends TestCase
*/ */
public function test_ticket_details_include_matching_branches_pull_requests_and_inaccessible_repository_links(): void public function test_ticket_details_include_matching_branches_pull_requests_and_inaccessible_repository_links(): void
{ {
config()->set('inertia.ssr.enabled', false);
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::response([ 'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::response([
+154
View File
@@ -0,0 +1,154 @@
<script setup>
import { Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
DialogScrollContent,
} from '@/components/ui/dialog';
import {
Sheet,
SheetContent,
SheetTitle,
SheetDescription,
SheetTrigger,
} from '@/components/ui/sheet';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu';
import { useFormProtection } from '@/composables/useFormProtection';
import { index as projectsIndex } from '@/routes/projects/index';
const form = useForm('feedback-preview', { title: '' });
const protection = useFormProtection(form);
const dialogOpen = ref(false);
const menuSelection = ref('');
const submit = () =>
protection.submit(ProjectTicketController.store({ project: 7 }));
</script>
<template>
<main class="gap-column p-page mx-auto grid max-w-3xl">
<div class="gap-stack flex flex-wrap items-center justify-between">
<h1 class="text-headline">Dialogs and feedback</h1>
<AppearanceTabs />
</div>
<FeedbackState
state="empty"
description="New tickets will appear here."
/>
<FeedbackState state="loading" />
<form
class="gap-card bg-card p-card grid rounded-md border"
@submit.prevent="submit"
>
<label for="title">Title</label>
<Input
id="title"
v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
/>
<InputError id="title-error" :message="form.errors.title" />
<FormFeedback :state="protection.state.value" @retry="submit" />
<Button type="submit" :loading="form.processing">Save draft</Button>
<Button type="button" variant="outline" @click="form.cancel()"
>Cancel request</Button
>
</form>
<Link :href="projectsIndex()">Leave page</Link>
<div class="gap-stack flex flex-wrap">
<Dialog v-model:open="dialogOpen">
<Button id="programmatic-trigger" @click="dialogOpen = true"
>Open dialog</Button
>
<DialogContent>
<DialogHeader
><DialogTitle>Edit details</DialogTitle
><DialogDescription
>Keyboard and focus preview.</DialogDescription
></DialogHeader
>
<Input aria-label="Dialog title" />
<Dialog>
<DialogTrigger as-child
><Button>Open nested dialog</Button></DialogTrigger
>
<DialogContent>
<DialogHeader
><DialogTitle>Nested details</DialogTitle
><DialogDescription
>Returns focus to the parent
dialog.</DialogDescription
></DialogHeader
>
<DialogClose as-child
><Button>Done</Button></DialogClose
>
</DialogContent>
</Dialog>
<DialogClose as-child
><Button variant="outline">Cancel</Button></DialogClose
>
</DialogContent>
</Dialog>
<Dialog>
<DialogTrigger as-child
><Button>Open long dialog</Button></DialogTrigger
>
<DialogScrollContent>
<DialogHeader
><DialogTitle>Long details</DialogTitle
><DialogDescription
>Scrollable content on small
screens.</DialogDescription
></DialogHeader
>
<p v-for="item in 30" :key="item">Detail {{ item }}</p>
<DialogClose as-child
><Button>Finish reading</Button></DialogClose
>
</DialogScrollContent>
</Dialog>
<Sheet>
<SheetTrigger as-child
><Button>Open sheet</Button></SheetTrigger
>
<SheetContent class="p-page">
<SheetTitle>Sheet details</SheetTitle
><SheetDescription
>Focus returns to the trigger.</SheetDescription
>
<Input aria-label="Sheet title" />
</SheetContent>
</Sheet>
<DropdownMenu>
<DropdownMenuTrigger as-child
><Button>Open actions</Button></DropdownMenuTrigger
>
<DropdownMenuContent>
<DropdownMenuItem disabled>Unavailable</DropdownMenuItem>
<DropdownMenuItem @select="menuSelection = 'selected'"
>Select action</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu>
<span role="status">{{ menuSelection }}</span>
</div>
</main>
</template>
+404
View File
@@ -0,0 +1,404 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4176';
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const state = (value) =>
page
.locator(`[data-form-state="${value}"]`)
.first()
.waitFor({ state: 'visible' });
const setResult = (value) =>
context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
const verify = async (condition) => {
assert.equal(await condition(), true);
};
const confirmNavigation = (accepted) =>
page.once('dialog', (dialog) =>
accepted ? dialog.accept() : dialog.dismiss(),
);
try {
await page.goto(`${base}/feedback`);
await page.getByLabel('Title', { exact: true }).fill('Unsaved draft');
await verify(() =>
page.evaluate(() => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}),
);
confirmNavigation(false);
await page.getByRole('link', { name: 'Leave page' }).click();
assert.equal(new URL(page.url()).pathname, '/feedback');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
await setResult('validation');
await page.getByRole('button', { name: 'Save draft' }).click();
await state('validation');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
assert.equal(
await page
.getByLabel('Title', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
assert.equal(
await page.getByRole('button', { name: 'Try again' }).count(),
0,
);
let failedRequests = 0;
await page.route('**/projects/7/tickets', (route) => {
failedRequests++;
return route.abort('failed');
});
await page.getByRole('button', { name: 'Save draft' }).click();
await state('network');
await page.waitForFunction(
() =>
document.activeElement?.getAttribute('data-form-state') ===
'network',
);
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
assert.equal(failedRequests, 1);
await page.unroute('**/projects/7/tickets');
await setResult('server');
await page.getByRole('button', { name: 'Try again' }).click();
await state('server');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
await setResult('success');
let pending;
let submissions = 0;
await page.route('**/projects/7/tickets', (route) => {
submissions++;
pending = route;
});
await page.getByRole('button', { name: 'Try again' }).click();
await state('loading');
await verify(() =>
page.evaluate(() => {
const event = new CustomEvent('inertia:exception', {
cancelable: true,
detail: { exception: new Error('Unrelated runtime error') },
});
document.dispatchEvent(event);
return !event.defaultPrevented;
}),
);
await verify(() =>
page.getByRole('button', { name: 'Save draft' }).isDisabled(),
);
await page.locator('form').evaluate((form) => form.requestSubmit());
assert.equal(submissions, 1);
await page.getByRole('button', { name: 'Cancel request' }).click();
await state('cancelled');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
await pending.abort();
await page.unroute('**/projects/7/tickets');
await page.getByRole('button', { name: 'Try again' }).click();
await state('success');
await verify(() =>
page.evaluate(() => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return !event.defaultPrevented;
}),
);
await page.getByLabel('Title', { exact: true }).fill('History draft');
confirmNavigation(true);
await page.getByRole('link', { name: 'Leave page' }).click();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goBack();
await page.getByLabel('Title', { exact: true }).waitFor();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'History draft',
);
await page.goForward();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goBack();
await page.getByLabel('Title', { exact: true }).waitFor();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'History draft',
);
// Native history cannot be cancelled; remembered state preserves the draft.
await page.getByRole('button', { name: 'Save draft' }).click();
await state('success');
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 844 });
for (const theme of ['Light', 'Dark']) {
console.log(`Checking dialogs: ${width}px, ${theme}`);
await page
.getByRole('button', { name: theme, exact: true })
.click();
await page.waitForFunction(
(dark) =>
document.documentElement.classList.contains('dark') ===
dark,
theme === 'Dark',
);
const opener = page.getByRole('button', {
name: 'Open dialog',
exact: true,
});
await opener.focus();
await page.keyboard.press('Enter');
await page.getByRole('dialog', { name: 'Edit details' }).waitFor();
await page
.getByRole('dialog', { name: 'Edit details' })
.evaluate((element) =>
Promise.all(
[
element,
...document.querySelectorAll(
'[data-slot="dialog-overlay"]',
),
]
.flatMap((surface) => surface.getAnimations())
.map((animation) => animation.finished),
),
);
assert.equal(
await page
.getByRole('dialog', { name: 'Edit details' })
.evaluate((element) => getComputedStyle(element).opacity),
'1',
);
await page.waitForFunction(() =>
document.activeElement?.closest('[role="dialog"]'),
);
for (let step = 0; step < 7; step++) {
await page.keyboard.press('Tab');
await verify(() =>
page.evaluate(() =>
Boolean(
document.activeElement?.closest('[role="dialog"]'),
),
),
);
}
await page.keyboard.press('Shift+Tab');
await verify(() =>
page.evaluate(() =>
Boolean(document.activeElement?.closest('[role="dialog"]')),
),
);
await page.screenshot({
path: `/private/tmp/kan20-dialog-${width}-${theme.toLowerCase()}.png`,
});
await page
.getByRole('button', { name: 'Open nested dialog' })
.click();
await page
.getByRole('dialog', { name: 'Nested details' })
.waitFor();
await page.keyboard.press('Escape');
await page
.getByRole('dialog', { name: 'Nested details' })
.waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes(
'Open nested dialog',
),
);
await page.keyboard.press('Escape');
await page
.getByRole('dialog', { name: 'Edit details' })
.waitFor({ state: 'hidden' });
await page.waitForFunction(
() => document.activeElement?.id === 'programmatic-trigger',
);
await opener.click();
await page
.getByRole('dialog', { name: 'Edit details' })
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await page.waitForFunction(
() => document.activeElement?.id === 'programmatic-trigger',
);
const longOpener = page.getByRole('button', {
name: 'Open long dialog',
});
await longOpener.click();
const longDialog = page.getByRole('dialog', {
name: 'Long details',
});
await longDialog.waitFor();
const box = await longDialog.boundingBox();
assert.ok(box.x >= 0 && box.x + box.width <= width);
await longDialog
.getByRole('button', { name: 'Finish reading' })
.click();
await longDialog.waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes(
'Open long dialog',
),
);
await page.getByRole('button', { name: 'Open sheet' }).click();
await page.getByRole('dialog', { name: 'Sheet details' }).waitFor();
await page.keyboard.press('Escape');
await page
.getByRole('dialog', { name: 'Sheet details' })
.waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Open sheet'),
);
const actions = page.getByRole('button', { name: 'Open actions' });
await actions.focus();
await page.keyboard.press('Enter');
await page.getByRole('menu').waitFor();
await page
.getByRole('menu')
.evaluate((element) =>
Promise.all(
element
.getAnimations()
.map((animation) => animation.finished),
),
);
const menuItem = await page
.getByRole('menuitem', { name: 'Select action' })
.boundingBox();
assert.ok(menuItem.height >= 44);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await page.getByRole('menu').waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Open actions'),
);
assert.equal(
await page.getByText('selected', { exact: true }).count(),
1,
);
await verify(() =>
page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
);
await page.screenshot({
path: `/private/tmp/kan20-feedback-${width}-${theme.toLowerCase()}.png`,
fullPage: true,
});
}
}
// Exercise the real ticket pages as well as the isolated components.
await page.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('New ticket draft');
await page.locator('#description').fill('Keep this description');
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Keep this checklist');
await setResult('validation');
await page.locator('form button[type="submit"]').first().click();
await state('validation');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
);
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
await page.locator('form button[type="submit"]').first().click();
await state('network');
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal(
await page.locator('#description').inputValue(),
'Keep this description',
);
await page.unroute('**/projects/7/tickets');
confirmNavigation(true);
await page
.getByRole('link', { name: 'Back to backlog', exact: true })
.first()
.click();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goBack();
await page.locator('#title').waitFor();
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
);
await setResult('success');
await page.locator('form button[type="submit"]').first().click();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goto(`${base}/projects/7/tickets/31`);
await page.locator('#title').fill('Updated ticket draft');
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Edited checklist');
await setResult('server');
await page.locator('form button[type="submit"]').first().click();
await state('server');
assert.equal(
await page.locator('#title').inputValue(),
'Updated ticket draft',
);
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Edited checklist',
);
await setResult('success');
await page.getByRole('button', { name: 'Try again' }).click();
await state('success');
await verify(() =>
page.evaluate(() => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return !event.defaultPrevented;
}),
);
assert.deepEqual(errors, []);
console.log(
'Feedback browser behavior checks passed (desktop/mobile, light/dark, real ticket forms).',
);
} catch (error) {
console.error(
'Active element:',
await page.evaluate(() =>
document.activeElement?.outerHTML.slice(0, 500),
),
);
console.error('Browser errors:', errors);
await page.screenshot({
path: '/private/tmp/kan20-feedback-failure.png',
fullPage: true,
});
throw error;
} finally {
await browser.close();
}
+26
View File
@@ -0,0 +1,26 @@
import { createApp, h } from 'vue';
import { createInertiaApp, Link } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import FeedbackPreview from './FeedbackPreview.vue';
import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
import Ticket from '../../resources/js/pages/projects/Ticket.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
const Destination = {
render: () =>
h('main', [
h('h1', 'Destination'),
h(Link, { href: '/feedback' }, () => 'Feedback preview'),
]),
};
createInertiaApp({
resolve: (name) =>
({ FeedbackPreview, TicketCreate, Ticket, Destination })[name],
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+159
View File
@@ -0,0 +1,159 @@
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
const server = await createServer({
configFile: false,
plugins: [vue(), tailwind()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { host: '127.0.0.1', port: 4176, strictPort: true, hmr: false },
appType: 'custom',
});
const project = {
id: 7,
name: 'Engineering',
key: 'ENG',
show_author: true,
show_story_points: true,
};
const ticketType = { id: 1, name: 'Task', is_parent: false };
const priority = { id: 1, name: 'Medium' };
const status = { id: 1, name: 'To do', board_column: null };
let ticket = {
id: 31,
ticket_key: 'ENG-31',
title: 'Existing ticket',
description: '',
ticket_type: ticketType,
ticket_type_id: 1,
ticket_priority: priority,
ticket_priority_id: 1,
ticket_status: status,
is_blocked: false,
story_points: 0,
acceptance_criteria: [],
field_values: [],
worklog_entries: [],
};
server.middlewares.use(async (request, response, next) => {
const url = new URL(request.url, 'http://localhost');
if (!/^\/(feedback|projects|dashboard)(\/|$)/.test(url.pathname)) {
return next();
}
let path = url.pathname;
const mutation = request.method !== 'GET';
const result =
request.headers.cookie?.match(/feedback_test_result=(\w+)/)?.[1] ??
'success';
if (path.endsWith('/branches/validate')) {
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify({ is_valid: true, message: 'Available' }));
return;
}
if (mutation) {
if (result === 'server') {
response.statusCode = 503;
response.setHeader('Content-Type', 'text/html');
response.end('<h1>Temporarily unavailable</h1>');
return;
}
path = new URL(request.headers.referer).pathname;
let body = '';
for await (const chunk of request) {
body += chunk;
}
if (path === '/projects/7/tickets/31' && result === 'success') {
ticket = { ...ticket, ...JSON.parse(body) };
}
if (path === '/projects/7/tickets/create' && result === 'success') {
path = '/projects/7/backlog';
}
}
const page = {
component:
path === '/feedback'
? 'FeedbackPreview'
: path.endsWith('/tickets/create')
? 'TicketCreate'
: path === '/projects/7/tickets/31'
? 'Ticket'
: 'Destination',
url: path,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
sidebarOpen: true,
auth: {
permissions: {
globalAdmin: true,
projectCreate: true,
canManageTeams: true,
},
user: {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
},
},
navigation: {
project: {
...project,
canManage: true,
canCreateTickets: true,
},
},
errors:
mutation && result === 'validation'
? { title: 'Use a different title.' }
: {},
project,
source: 'backlog',
defaultTicketStatusId: 1,
ticketTypes: [ticketType],
ticketPriorities: [priority],
ticketStatusOptions: [status],
users: [],
parentTicketOptions: [],
canEditTicket: true,
canManageProject: true,
childTicketTree: [],
linkedTickets: [],
repositoryBranches: [],
repositoryPullRequests: [],
suggestedBranchName: 'feature/ENG-31-existing-ticket',
ticket,
ticketFieldDefinitions: [],
ticketLinkOptions: [],
ticketLinkTypes: [],
},
};
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escapedPage = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
const html =
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escapedPage +
'"></div><script type="module" src="/tests/Fixtures/feedback-preview.mjs"></script></body></html>';
response.setHeader('Content-Type', 'text/html');
response.end(await server.transformIndexHtml(request.url, html));
});
await server.listen();
console.log('Feedback fixture ready at http://127.0.0.1:4176/feedback');
+129
View File
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const server = await createServer({
configFile: false,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
try {
const { isFormNetworkFailure, matchesFormRequest, formRequestHeader } =
await server.ssrLoadModule('/resources/js/lib/form-feedback.ts');
const config = { headers: { [formRequestHeader]: 'submission-a' } };
for (const code of ['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT']) {
assert.equal(
isFormNetworkFailure(
{ isAxiosError: true, code, config },
'submission-a',
),
true,
);
}
for (const error of [
null,
new Error('Render failed'),
{ code: 'ERR_NETWORK', config },
{ isAxiosError: true, code: 'ERR_CANCELED', config },
{
isAxiosError: true,
code: 'ERR_NETWORK',
config,
response: { status: 500 },
},
{ isAxiosError: true, code: 'ERR_NETWORK', config: {} },
]) {
assert.equal(isFormNetworkFailure(error, 'submission-a'), false);
}
assert.equal(
isFormNetworkFailure(
{ isAxiosError: true, code: 'ERR_NETWORK', config },
'submission-b',
),
false,
);
assert.equal(matchesFormRequest(config, null), false);
assert.equal(matchesFormRequest(config, 'submission-a'), true);
assert.equal(matchesFormRequest(undefined, 'submission-a'), false);
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
const { default: FeedbackState } = await server.ssrLoadModule(
'/resources/js/components/FeedbackState.vue',
);
const { default: FormFeedback } = await server.ssrLoadModule(
'/resources/js/components/FormFeedback.vue',
);
const { default: InputError } = await server.ssrLoadModule(
'/resources/js/components/InputError.vue',
);
const localization = createLocalization(
{ locale: 'de' },
{
de: {
...en,
'common.retry': 'Erneut versuchen',
'feedback.empty': 'Noch keine Einträge',
},
},
);
const render = (component, props) =>
renderToString(
createSSRApp({ render: () => h(component, props) }).use(
localization,
),
);
assert.match(
await render(FeedbackState, { state: 'empty' }),
/Noch keine Einträge/,
);
const loading = await render(FormFeedback, { state: 'loading' });
assert.match(loading, /role="status"/);
assert.match(loading, /aria-busy="true"/);
assert.match(loading, /motion-reduce:animate-none/);
assert.doesNotMatch(loading, /<button/);
const validation = await render(FormFeedback, { state: 'validation' });
assert.match(validation, /role="alert"/);
assert.match(validation, /Check your entries/);
assert.doesNotMatch(validation, /<button/);
for (const state of ['network', 'server', 'cancelled']) {
const html = await render(FormFeedback, { state });
assert.match(html, /Erneut versuchen/);
assert.match(html, /type="button"/);
assert.match(html, /role="alert"/);
}
assert.match(
await render(FormFeedback, { state: 'network' }),
/Connection interrupted/,
);
assert.match(
await render(FormFeedback, { state: 'server' }),
/unexpected response/,
);
assert.match(
await render(FormFeedback, { state: 'success' }),
/Changes saved/,
);
assert.doesNotMatch(await render(FormFeedback, { state: 'idle' }), /role=/);
const fieldError = await render(InputError, {
message: '<script>alert(1)</script>',
});
assert.match(fieldError, /role="alert"/);
assert.match(fieldError, /text-destructive/);
assert.doesNotMatch(fieldError, /<script>/);
console.log('Form feedback behavior checks passed.');
} finally {
await server.close();
}