feat(KAN-20): unify dialogs feedback and protect ticket drafts
This commit is contained in:
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,53 +1,67 @@
|
|||||||
<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(
|
||||||
showCloseButton: true,
|
defineProps<
|
||||||
})
|
DialogContentProps & {
|
||||||
const emits = defineEmits<DialogContentEmits>()
|
class?: HTMLAttributes["class"];
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
{
|
||||||
|
showCloseButton: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="
|
@close-auto-focus="restoreFocus"
|
||||||
cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
props.class,
|
'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,
|
||||||
>
|
)
|
||||||
<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>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<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>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,59 +1,71 @@
|
|||||||
<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>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay
|
<DialogOverlay
|
||||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
>
|
|
||||||
<DialogContent
|
|
||||||
:class="
|
|
||||||
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',
|
|
||||||
props.class,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
|
||||||
@pointer-down-outside="(event) => {
|
|
||||||
const originalEvent = event.detail.originalEvent;
|
|
||||||
const target = originalEvent.target as HTMLElement;
|
|
||||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
|
|
||||||
<DialogClose
|
|
||||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" />
|
<DialogContent
|
||||||
<span class="sr-only">Close</span>
|
:class="
|
||||||
</DialogClose>
|
cn(
|
||||||
</DialogContent>
|
'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',
|
||||||
</DialogOverlay>
|
props.class,
|
||||||
</DialogPortal>
|
)
|
||||||
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
@close-auto-focus="restoreFocus"
|
||||||
|
@pointer-down-outside="
|
||||||
|
(event) => {
|
||||||
|
const originalEvent = event.detail.originalEvent;
|
||||||
|
const target = originalEvent.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
originalEvent.offsetX > target.clientWidth ||
|
||||||
|
originalEvent.offsetY > target.clientHeight
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
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" />
|
||||||
|
<span class="sr-only">{{ t("common.close") }}</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,39 +1,48 @@
|
|||||||
<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(
|
||||||
props.class,
|
'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,
|
||||||
>
|
)
|
||||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
"
|
||||||
<DropdownMenuItemIndicator>
|
>
|
||||||
<slot name="indicator-icon">
|
<span
|
||||||
<Check class="size-4" />
|
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
||||||
</slot>
|
>
|
||||||
</DropdownMenuItemIndicator>
|
<DropdownMenuItemIndicator>
|
||||||
</span>
|
<slot name="indicator-icon">
|
||||||
<slot />
|
<Check class="size-4" />
|
||||||
</DropdownMenuCheckboxItem>
|
</slot>
|
||||||
|
</DropdownMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,39 +1,49 @@
|
|||||||
<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>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<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(
|
||||||
<slot />
|
'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',
|
||||||
</DropdownMenuContent>
|
props.class,
|
||||||
</DropdownMenuPortal>
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,31 +1,41 @@
|
|||||||
<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",
|
variant?: "default" | "destructive";
|
||||||
})
|
}
|
||||||
|
>(),
|
||||||
|
{
|
||||||
|
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>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-slot="dropdown-menu-item"
|
data-slot="dropdown-menu-item"
|
||||||
: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(
|
||||||
<slot />
|
'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',
|
||||||
</DropdownMenuItem>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,40 +1,49 @@
|
|||||||
<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(
|
||||||
props.class,
|
'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,
|
||||||
>
|
)
|
||||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
"
|
||||||
<DropdownMenuItemIndicator>
|
>
|
||||||
<slot name="indicator-icon">
|
<span
|
||||||
<Circle class="size-2 fill-current" />
|
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
||||||
</slot>
|
>
|
||||||
</DropdownMenuItemIndicator>
|
<DropdownMenuItemIndicator>
|
||||||
</span>
|
<slot name="indicator-icon">
|
||||||
<slot />
|
<Circle class="size-2 fill-current" />
|
||||||
</DropdownMenuRadioItem>
|
</slot>
|
||||||
|
</DropdownMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
<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(
|
||||||
<slot />
|
'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',
|
||||||
</DropdownMenuSubContent>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuSubContent>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
<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(
|
||||||
props.class,
|
'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,
|
||||||
>
|
)
|
||||||
<slot />
|
"
|
||||||
<ChevronRight class="ml-auto size-4" />
|
>
|
||||||
</DropdownMenuSubTrigger>
|
<slot />
|
||||||
|
<ChevronRight class="ml-auto size-4" />
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,62 +1,70 @@
|
|||||||
<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>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
:class="cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
side === 'right'
|
'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',
|
||||||
&& '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 === 'right' &&
|
||||||
side === 'left'
|
'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-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 === 'left' &&
|
||||||
side === 'top'
|
'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-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
side === 'top' &&
|
||||||
side === 'bottom'
|
'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-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
side === 'bottom' &&
|
||||||
props.class)"
|
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
props.class,
|
||||||
>
|
)
|
||||||
<slot />
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
@close-auto-focus="restoreFocus"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
preserveScroll: true,
|
ProjectTicketController.store({ project: props.project.id }),
|
||||||
});
|
{
|
||||||
|
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
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
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');
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user