Merge pull request 'KAN-24 Project creation' (#118) from feature/KAN-24-project-creation into main
Reviewed-on: #118
This commit was merged in pull request #118.
This commit is contained in:
@@ -1,4 +1,86 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'projectCreation.title': 'Create a project',
|
||||||
|
'projectCreation.description':
|
||||||
|
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
|
||||||
|
'projectCreation.start': 'How would you like to start?',
|
||||||
|
'projectCreation.scratch': 'Start from scratch',
|
||||||
|
'projectCreation.scratchDescription':
|
||||||
|
'Create an empty project with the default setup.',
|
||||||
|
'projectCreation.duplicate': 'Duplicate a project',
|
||||||
|
'projectCreation.duplicateDescription':
|
||||||
|
'Choose an existing project and copy selected settings.',
|
||||||
|
'projectCreation.template': 'Use a template',
|
||||||
|
'projectCreation.templateDescription':
|
||||||
|
'Start with a saved, reusable project configuration.',
|
||||||
|
'projectCreation.noProjects':
|
||||||
|
'No accessible projects are available to copy yet.',
|
||||||
|
'projectCreation.noTemplates': 'No published templates are available yet.',
|
||||||
|
'projectCreation.details': 'Project details',
|
||||||
|
'projectCreation.name': 'Project name',
|
||||||
|
'projectCreation.namePlaceholder': 'Platform engineering',
|
||||||
|
'projectCreation.key': 'Project key',
|
||||||
|
'projectCreation.keyPlaceholder': 'CORE',
|
||||||
|
'projectCreation.keyHelp':
|
||||||
|
'Use up to 5 letters or numbers. Keys are unique and converted to uppercase.',
|
||||||
|
'projectCreation.copyTitle': 'Choose what to copy',
|
||||||
|
'projectCreation.copyHelp':
|
||||||
|
'Copy configuration into an independent project. Select only what you need.',
|
||||||
|
'projectCreation.sourceProject': 'Source project',
|
||||||
|
'projectCreation.selectProject': 'Select a source project',
|
||||||
|
'projectCreation.copyOptions': 'Configuration areas',
|
||||||
|
'projectCreation.copyDefaults':
|
||||||
|
'Unselected areas keep the default setup. You can also continue without copying any settings.',
|
||||||
|
'projectCreation.chooseTemplate': 'Choose a template',
|
||||||
|
'projectCreation.templateLabel': 'Template',
|
||||||
|
'projectCreation.selectTemplate': 'Select a template',
|
||||||
|
'projectCreation.templateHelp':
|
||||||
|
'The project stays linked to this template. Inherited areas follow its configuration; you can override them in project settings.',
|
||||||
|
'projectCreation.includedAreas': 'Included configuration',
|
||||||
|
'projectCreation.templateDefaults':
|
||||||
|
'This template uses the default project setup.',
|
||||||
|
'projectCreation.summary': 'Your new project',
|
||||||
|
'projectCreation.untitled': 'Add a project name',
|
||||||
|
'projectCreation.ticketPreview': 'First ticket key',
|
||||||
|
'projectCreation.setup': 'Starting point',
|
||||||
|
'projectCreation.cleanStart':
|
||||||
|
'Every project starts with an empty backlog and its own ticket numbering. Tickets and their history are never copied.',
|
||||||
|
'projectCreation.submit': 'Create project',
|
||||||
|
'projectCreation.creating': 'Creating project…',
|
||||||
|
'projectCreation.areas.projectSettings.label': 'Project settings',
|
||||||
|
'projectCreation.areas.projectSettings.description':
|
||||||
|
'Story points and author visibility.',
|
||||||
|
'projectCreation.areas.boardColumns.label': 'Board columns',
|
||||||
|
'projectCreation.areas.boardColumns.description':
|
||||||
|
'Columns and their order on the board.',
|
||||||
|
'projectCreation.areas.ticketStatuses.label': 'Ticket statuses',
|
||||||
|
'projectCreation.areas.ticketStatuses.description':
|
||||||
|
'Statuses and their board columns.',
|
||||||
|
'projectCreation.areas.statusTransitions.label': 'Status transitions',
|
||||||
|
'projectCreation.areas.statusTransitions.description':
|
||||||
|
'Allowed moves between statuses.',
|
||||||
|
'projectCreation.areas.ticketTypes.label': 'Ticket types',
|
||||||
|
'projectCreation.areas.ticketTypes.description':
|
||||||
|
'Available types, including parent types.',
|
||||||
|
'projectCreation.areas.ticketPriorities.label': 'Ticket priorities',
|
||||||
|
'projectCreation.areas.ticketPriorities.description':
|
||||||
|
'Available priority levels.',
|
||||||
|
'projectCreation.areas.ticketFields.label': 'Custom ticket fields',
|
||||||
|
'projectCreation.areas.ticketFields.description':
|
||||||
|
'Field definitions without ticket values.',
|
||||||
|
'projectCreation.areas.ticketLinkTypes.label': 'Ticket link types',
|
||||||
|
'projectCreation.areas.ticketLinkTypes.description':
|
||||||
|
'Relationship labels between tickets.',
|
||||||
|
'projectCreation.areas.repositories.label': 'Repository connections',
|
||||||
|
'projectCreation.areas.repositories.description':
|
||||||
|
'Repositories linked to the project.',
|
||||||
|
'projectCreation.areas.notifications.label': 'Notification actions',
|
||||||
|
'projectCreation.areas.notifications.description':
|
||||||
|
'Notification rules and delivery settings.',
|
||||||
|
'projectCreation.selectedCount': {
|
||||||
|
one: '{count} area selected',
|
||||||
|
other: '{count} areas selected',
|
||||||
|
},
|
||||||
|
|
||||||
'projects.workspace': 'Workspace',
|
'projects.workspace': 'Workspace',
|
||||||
'projects.description':
|
'projects.description':
|
||||||
'Your project spaces, ready to work in. Open a project or jump straight to its board and backlog.',
|
'Your project spaces, ready to work in. Open a project or jump straight to its board and backlog.',
|
||||||
|
|||||||
@@ -1,349 +1,633 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
|
||||||
import Heading from '@/components/Heading.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
ArrowLeft,
|
||||||
CardContent,
|
Check,
|
||||||
CardDescription,
|
Copy,
|
||||||
CardHeader,
|
FolderPlus,
|
||||||
CardTitle,
|
LayoutTemplate,
|
||||||
} from '@/components/ui/card';
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { store } from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { create, index } from '@/routes/projects/index';
|
import { create, index } from '@/routes/projects';
|
||||||
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectsCreatePageProps>();
|
const props = defineProps<ProjectsCreatePageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: index() },
|
||||||
title: 'Projects',
|
{ title: t('projects.create'), href: create() },
|
||||||
href: index(),
|
]);
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'New project',
|
|
||||||
href: create(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const defaultTemplateId =
|
const defaultTemplateId =
|
||||||
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
||||||
|
const form = useForm(
|
||||||
const form = useForm({
|
`ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`,
|
||||||
copy_areas: [] as string[],
|
{
|
||||||
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
||||||
key: '',
|
|
||||||
name: '',
|
name: '',
|
||||||
|
key: '',
|
||||||
source_project_id: props.sourceProjects[0]?.id
|
source_project_id: props.sourceProjects[0]?.id
|
||||||
? String(props.sourceProjects[0].id)
|
? String(props.sourceProjects[0].id)
|
||||||
: '',
|
: '',
|
||||||
source_template_id:
|
source_template_id:
|
||||||
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||||
});
|
copy_areas: [] as string[],
|
||||||
|
},
|
||||||
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
|
);
|
||||||
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
|
const protection = useFormProtection(form);
|
||||||
|
const modes = computed(() => [
|
||||||
const copyAreasError = computed(() => {
|
{
|
||||||
return (
|
value: 'scratch',
|
||||||
|
icon: FolderPlus,
|
||||||
|
title: t('projectCreation.scratch'),
|
||||||
|
description: t('projectCreation.scratchDescription'),
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'duplicate',
|
||||||
|
icon: Copy,
|
||||||
|
title: t('projectCreation.duplicate'),
|
||||||
|
description: t(
|
||||||
|
props.sourceProjects.length
|
||||||
|
? 'projectCreation.duplicateDescription'
|
||||||
|
: 'projectCreation.noProjects',
|
||||||
|
),
|
||||||
|
disabled: props.sourceProjects.length === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'template',
|
||||||
|
icon: LayoutTemplate,
|
||||||
|
title: t('projectCreation.template'),
|
||||||
|
description: t(
|
||||||
|
props.sourceTemplates.length
|
||||||
|
? 'projectCreation.templateDescription'
|
||||||
|
: 'projectCreation.noTemplates',
|
||||||
|
),
|
||||||
|
disabled: props.sourceTemplates.length === 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const areaKeys = {
|
||||||
|
project_settings: 'projectCreation.areas.projectSettings',
|
||||||
|
board_columns: 'projectCreation.areas.boardColumns',
|
||||||
|
ticket_statuses: 'projectCreation.areas.ticketStatuses',
|
||||||
|
status_transitions: 'projectCreation.areas.statusTransitions',
|
||||||
|
ticket_types: 'projectCreation.areas.ticketTypes',
|
||||||
|
ticket_priorities: 'projectCreation.areas.ticketPriorities',
|
||||||
|
ticket_fields: 'projectCreation.areas.ticketFields',
|
||||||
|
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
|
||||||
|
repository_connections: 'projectCreation.areas.repositories',
|
||||||
|
notification_actions: 'projectCreation.areas.notifications',
|
||||||
|
} as const;
|
||||||
|
function areaText(value: string, part: 'label' | 'description'): string {
|
||||||
|
const key = areaKeys[value as keyof typeof areaKeys];
|
||||||
|
return key
|
||||||
|
? t(`${key}.${part}`)
|
||||||
|
: (props.copyAreaOptions.find((area) => area.value === value)?.[part] ??
|
||||||
|
value);
|
||||||
|
}
|
||||||
|
const selectedProject = computed(() =>
|
||||||
|
props.sourceProjects.find(
|
||||||
|
(project) => String(project.id) === form.source_project_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedTemplate = computed(() =>
|
||||||
|
props.sourceTemplates.find(
|
||||||
|
(template) => String(template.id) === form.source_template_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedMode = computed(() =>
|
||||||
|
modes.value.find((mode) => mode.value === form.creation_mode),
|
||||||
|
);
|
||||||
|
const ticketKey = computed(
|
||||||
|
() => `${form.key.trim().toUpperCase() || 'CORE'}-1`,
|
||||||
|
);
|
||||||
|
const copyAreasError = computed(
|
||||||
|
() =>
|
||||||
form.errors.copy_areas ??
|
form.errors.copy_areas ??
|
||||||
Object.entries(form.errors).find(([key]) =>
|
Object.entries(form.errors).find(([key]) =>
|
||||||
key.startsWith('copy_areas.'),
|
key.startsWith('copy_areas.'),
|
||||||
)?.[1] ??
|
)?.[1],
|
||||||
''
|
|
||||||
);
|
);
|
||||||
});
|
const selectClass =
|
||||||
|
'border-input bg-card min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
|
||||||
|
|
||||||
const submit = (): void => {
|
watch(
|
||||||
if (form.creation_mode !== 'duplicate') {
|
() => form.creation_mode,
|
||||||
form.source_project_id = '';
|
() => {
|
||||||
form.copy_areas = [];
|
form.clearErrors(
|
||||||
|
'creation_mode',
|
||||||
|
'source_project_id',
|
||||||
|
'source_template_id',
|
||||||
|
'copy_areas',
|
||||||
|
);
|
||||||
|
for (const key of Object.keys(form.errors)) {
|
||||||
|
if (key.startsWith('copy_areas.')) {
|
||||||
|
form.clearErrors(key as 'copy_areas');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.creation_mode !== 'template') {
|
|
||||||
form.source_template_id = '';
|
|
||||||
}
|
}
|
||||||
|
protection.clearFeedback();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
form.post(ProjectController.store.url(), {
|
function submit(): void {
|
||||||
preserveScroll: true,
|
form.transform((data) => ({
|
||||||
});
|
...data,
|
||||||
};
|
source_project_id:
|
||||||
|
data.creation_mode === 'duplicate' ? data.source_project_id : '',
|
||||||
|
source_template_id:
|
||||||
|
data.creation_mode === 'template' ? data.source_template_id : '',
|
||||||
|
copy_areas: data.creation_mode === 'duplicate' ? data.copy_areas : [],
|
||||||
|
}));
|
||||||
|
protection.submit(store(), { preserveScroll: true });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="New project" />
|
<Head :title="t('projects.create')" />
|
||||||
|
<div
|
||||||
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
|
||||||
|
>
|
||||||
|
<header class="gap-stack grid">
|
||||||
|
<Link
|
||||||
|
:href="index()"
|
||||||
|
class="text-caption text-muted-foreground hover:text-primary mb-2 inline-flex min-h-10 items-center gap-2 justify-self-start"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="size-4" aria-hidden="true" />{{
|
||||||
|
t('projects.back')
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
|
{{ t('projects.workspace') }}
|
||||||
|
</p>
|
||||||
|
<h1 class="text-headline-mobile md:text-headline">
|
||||||
|
{{ t('projectCreation.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('projectCreation.description') }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<form
|
||||||
<Heading
|
class="gap-page grid min-w-0"
|
||||||
title="Create project"
|
:aria-busy="form.processing"
|
||||||
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:title="
|
||||||
|
form.processing
|
||||||
|
? t('projectCreation.creating')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="submit"
|
||||||
/>
|
/>
|
||||||
|
<fieldset
|
||||||
<Card>
|
class="gap-column grid min-w-0"
|
||||||
<CardHeader>
|
:disabled="form.processing"
|
||||||
<CardTitle>Project details</CardTitle>
|
aria-describedby="creation-mode-error"
|
||||||
<CardDescription>
|
>
|
||||||
The key is uppercased automatically, must be unique, and
|
<legend class="text-title mb-3">
|
||||||
can contain up to 5 characters.
|
{{ t('projectCreation.start') }}
|
||||||
</CardDescription>
|
</legend>
|
||||||
</CardHeader>
|
<div class="gap-column grid md:grid-cols-3">
|
||||||
|
<label
|
||||||
<CardContent>
|
v-for="mode in modes"
|
||||||
<form class="space-y-6" @submit.prevent="submit">
|
:key="mode.value"
|
||||||
<div class="grid gap-3">
|
class="relative min-w-0"
|
||||||
<Label>How do you want to start?</Label>
|
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
|
||||||
<Label
|
|
||||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.creation_mode"
|
v-model="form.creation_mode"
|
||||||
type="radio"
|
type="radio"
|
||||||
value="scratch"
|
name="creation_mode"
|
||||||
class="mt-1"
|
:value="mode.value"
|
||||||
|
:disabled="mode.disabled"
|
||||||
|
:aria-invalid="!!form.errors.creation_mode"
|
||||||
|
:aria-labelledby="`mode-${mode.value}-title`"
|
||||||
|
:aria-describedby="`mode-${mode.value}-description`"
|
||||||
|
class="peer sr-only"
|
||||||
/>
|
/>
|
||||||
<div class="space-y-1">
|
<span
|
||||||
<div class="font-medium">
|
class="bg-card peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack flex h-full cursor-pointer flex-col rounded-md border p-4 transition-colors peer-focus-visible:ring-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-60"
|
||||||
Create from scratch
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
Start with the default project
|
|
||||||
setup.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label
|
|
||||||
class="flex items-start gap-3 rounded-xl border p-4"
|
|
||||||
:class="
|
:class="
|
||||||
hasSourceProjects
|
!mode.disabled
|
||||||
? 'cursor-pointer'
|
? 'hover:border-primary/50'
|
||||||
: 'cursor-not-allowed opacity-60'
|
: ''
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<input
|
<span
|
||||||
v-model="form.creation_mode"
|
class="flex items-center justify-between gap-3"
|
||||||
type="radio"
|
>
|
||||||
value="duplicate"
|
<component
|
||||||
class="mt-1"
|
:is="mode.icon"
|
||||||
:disabled="!hasSourceProjects"
|
class="text-primary size-5"
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div class="space-y-1">
|
<Check
|
||||||
<div class="font-medium">
|
v-if="form.creation_mode === mode.value"
|
||||||
Create from existing project
|
class="text-primary size-4"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
Copy only the settings you
|
|
||||||
explicitly select. Tickets are never
|
|
||||||
duplicated.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label
|
|
||||||
class="flex items-start gap-3 rounded-xl border p-4"
|
|
||||||
:class="
|
|
||||||
hasSourceTemplates
|
|
||||||
? 'cursor-pointer'
|
|
||||||
: 'cursor-not-allowed opacity-60'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.creation_mode"
|
|
||||||
type="radio"
|
|
||||||
value="template"
|
|
||||||
class="mt-1"
|
|
||||||
:disabled="!hasSourceTemplates"
|
|
||||||
/>
|
/>
|
||||||
<div class="space-y-1">
|
</span>
|
||||||
<div class="font-medium">
|
<span
|
||||||
Create from template
|
:id="`mode-${mode.value}-title`"
|
||||||
</div>
|
class="font-semibold"
|
||||||
<p
|
>{{ mode.title }}</span
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
>
|
||||||
Reuse a saved configuration
|
<span
|
||||||
template.
|
:id="`mode-${mode.value}-description`"
|
||||||
</p>
|
class="text-caption text-muted-foreground"
|
||||||
</div>
|
>{{ mode.description }}</span
|
||||||
</Label>
|
>
|
||||||
</div>
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="creation-mode-error"
|
||||||
|
:message="form.errors.creation_mode"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div
|
||||||
<Label for="name">Project name</Label>
|
class="gap-page grid min-w-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:items-start"
|
||||||
|
>
|
||||||
|
<div class="gap-page grid min-w-0">
|
||||||
|
<section
|
||||||
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
|
aria-labelledby="project-details-title"
|
||||||
|
>
|
||||||
|
<h2 id="project-details-title" class="text-title">
|
||||||
|
{{ t('projectCreation.details') }}
|
||||||
|
</h2>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<Label for="name">{{
|
||||||
|
t('projectCreation.name')
|
||||||
|
}}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
required
|
required
|
||||||
autofocus
|
maxlength="255"
|
||||||
placeholder="Kanban foundation"
|
:disabled="form.processing"
|
||||||
|
:placeholder="
|
||||||
|
t('projectCreation.namePlaceholder')
|
||||||
|
"
|
||||||
|
:invalid="!!form.errors.name"
|
||||||
|
aria-describedby="name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
/>
|
/>
|
||||||
<InputError :message="form.errors.name" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="gap-stack grid">
|
||||||
<div class="grid gap-2">
|
<Label for="key">{{
|
||||||
<Label for="key">Project key</Label>
|
t('projectCreation.key')
|
||||||
|
}}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="key"
|
id="key"
|
||||||
v-model="form.key"
|
v-model="form.key"
|
||||||
|
name="key"
|
||||||
required
|
required
|
||||||
maxlength="5"
|
maxlength="5"
|
||||||
placeholder="CORE"
|
:disabled="form.processing"
|
||||||
class="uppercase"
|
:placeholder="
|
||||||
|
t('projectCreation.keyPlaceholder')
|
||||||
|
"
|
||||||
|
class="uppercase sm:max-w-48"
|
||||||
|
:invalid="!!form.errors.key"
|
||||||
|
aria-describedby="key-help key-error"
|
||||||
|
autocapitalize="characters"
|
||||||
|
:spellcheck="false"
|
||||||
/>
|
/>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p
|
||||||
Example ticket keys will look like
|
id="key-help"
|
||||||
<span class="text-foreground font-medium"
|
class="text-caption text-muted-foreground"
|
||||||
>CORE-1</span
|
>
|
||||||
>.
|
{{ t('projectCreation.keyHelp') }}
|
||||||
</p>
|
</p>
|
||||||
<InputError :message="form.errors.key" />
|
<InputError
|
||||||
|
id="key-error"
|
||||||
|
:message="form.errors.key"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div
|
<section
|
||||||
v-if="form.creation_mode === 'duplicate'"
|
v-if="form.creation_mode === 'duplicate'"
|
||||||
class="space-y-6 rounded-xl border border-dashed p-4"
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
>
|
aria-labelledby="copy-title"
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="source_project_id"
|
|
||||||
>Source project</Label
|
|
||||||
>
|
>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<h2 id="copy-title" class="text-title">
|
||||||
|
{{ t('projectCreation.copyTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.copyHelp') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid min-w-0">
|
||||||
|
<Label for="source_project_id">{{
|
||||||
|
t('projectCreation.sourceProject')
|
||||||
|
}}</Label>
|
||||||
<select
|
<select
|
||||||
id="source_project_id"
|
id="source_project_id"
|
||||||
v-model="form.source_project_id"
|
v-model="form.source_project_id"
|
||||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
required
|
||||||
:disabled="!hasSourceProjects"
|
:class="selectClass"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors.source_project_id
|
||||||
|
"
|
||||||
|
aria-describedby="source-project-error"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
Select a source project
|
{{ t('projectCreation.selectProject') }}
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="sourceProject in sourceProjects"
|
v-for="project in sourceProjects"
|
||||||
:key="sourceProject.id"
|
:key="project.id"
|
||||||
:value="String(sourceProject.id)"
|
:value="String(project.id)"
|
||||||
>
|
>
|
||||||
{{ sourceProject.name }} ({{
|
{{ project.name }} ({{ project.key }})
|
||||||
sourceProject.key
|
|
||||||
}})
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
The new project keeps its own identity, key,
|
|
||||||
and future ticket numbering.
|
|
||||||
</p>
|
|
||||||
<InputError
|
<InputError
|
||||||
|
id="source-project-error"
|
||||||
:message="form.errors.source_project_id"
|
:message="form.errors.source_project_id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<fieldset
|
||||||
<div class="grid gap-3">
|
class="min-w-0"
|
||||||
<div>
|
:disabled="form.processing"
|
||||||
<Label>Copy options</Label>
|
aria-describedby="copy-help copy-areas-error"
|
||||||
<p class="text-muted-foreground text-sm">
|
>
|
||||||
Choose which settings should be copied
|
<legend class="mb-2 font-semibold">
|
||||||
into the new project.
|
{{ t('projectCreation.copyOptions') }}
|
||||||
|
</legend>
|
||||||
|
<p
|
||||||
|
id="copy-help"
|
||||||
|
class="text-caption text-muted-foreground mb-3"
|
||||||
|
>
|
||||||
|
{{ t('projectCreation.copyDefaults') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<div class="gap-stack grid sm:grid-cols-2">
|
||||||
|
<label
|
||||||
<div class="grid gap-3">
|
v-for="area in copyAreaOptions"
|
||||||
<Label
|
:key="area.value"
|
||||||
v-for="copyAreaOption in copyAreaOptions"
|
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
|
||||||
:key="copyAreaOption.value"
|
|
||||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.copy_areas"
|
v-model="form.copy_areas"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:value="copyAreaOption.value"
|
:value="area.value"
|
||||||
class="mt-1"
|
:aria-labelledby="`copy-${area.value}-label`"
|
||||||
|
:aria-describedby="`copy-${area.value}-description copy-areas-error`"
|
||||||
|
:aria-invalid="!!copyAreasError"
|
||||||
|
class="accent-primary mt-0.5 size-5 shrink-0"
|
||||||
/>
|
/>
|
||||||
<div class="space-y-1">
|
<span class="gap-stack grid min-w-0">
|
||||||
<div class="font-medium">
|
<span
|
||||||
{{ copyAreaOption.label }}
|
:id="`copy-${area.value}-label`"
|
||||||
</div>
|
class="text-body font-semibold"
|
||||||
<p
|
>{{
|
||||||
class="text-muted-foreground text-sm"
|
areaText(
|
||||||
|
area.value,
|
||||||
|
'label',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
>
|
>
|
||||||
{{ copyAreaOption.description }}
|
<span
|
||||||
</p>
|
:id="`copy-${area.value}-description`"
|
||||||
</div>
|
class="text-caption text-muted-foreground"
|
||||||
</Label>
|
>{{
|
||||||
|
areaText(
|
||||||
|
area.value,
|
||||||
|
'description',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="copy-areas-error"
|
||||||
|
class="mt-3"
|
||||||
|
:message="copyAreasError"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p class="text-muted-foreground text-sm">
|
<section
|
||||||
Operational ticket data is always left
|
|
||||||
behind so the new project starts clean.
|
|
||||||
</p>
|
|
||||||
<InputError :message="copyAreasError" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="form.creation_mode === 'template'"
|
v-else-if="form.creation_mode === 'template'"
|
||||||
class="space-y-4 rounded-xl border border-dashed p-4"
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
|
aria-labelledby="template-title"
|
||||||
>
|
>
|
||||||
<div class="grid gap-2">
|
<h2 id="template-title" class="text-title">
|
||||||
<Label for="source_template_id">Template</Label>
|
{{ t('projectCreation.chooseTemplate') }}
|
||||||
|
</h2>
|
||||||
|
<div class="gap-stack grid min-w-0">
|
||||||
|
<Label for="source_template_id">{{
|
||||||
|
t('projectCreation.templateLabel')
|
||||||
|
}}</Label>
|
||||||
<select
|
<select
|
||||||
id="source_template_id"
|
id="source_template_id"
|
||||||
v-model="form.source_template_id"
|
v-model="form.source_template_id"
|
||||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
required
|
||||||
:disabled="!hasSourceTemplates"
|
:class="selectClass"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors.source_template_id
|
||||||
|
"
|
||||||
|
aria-describedby="template-help source-template-error"
|
||||||
>
|
>
|
||||||
<option value="">Select a template</option>
|
<option value="">
|
||||||
|
{{
|
||||||
|
t('projectCreation.selectTemplate')
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="sourceTemplate in sourceTemplates"
|
v-for="template in sourceTemplates"
|
||||||
:key="sourceTemplate.id"
|
:key="template.id"
|
||||||
:value="String(sourceTemplate.id)"
|
:value="String(template.id)"
|
||||||
>
|
>
|
||||||
{{ sourceTemplate.name }} ({{
|
{{ template.name }} ({{
|
||||||
sourceTemplate.slug
|
template.slug
|
||||||
}})
|
}})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p
|
||||||
Templates apply their saved configuration
|
id="template-help"
|
||||||
groups to the new project.
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('projectCreation.templateHelp') }}
|
||||||
</p>
|
</p>
|
||||||
<InputError
|
<InputError
|
||||||
|
id="source-template-error"
|
||||||
:message="form.errors.source_template_id"
|
:message="form.errors.source_template_id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="
|
v-if="selectedTemplate"
|
||||||
!hasSourceProjects && !hasSourceTemplates
|
class="gap-stack grid"
|
||||||
"
|
aria-live="polite"
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
>
|
||||||
No accessible source projects or saved templates are
|
<h3 class="text-body font-semibold">
|
||||||
available yet.
|
{{ t('projectCreation.includedAreas') }}
|
||||||
|
</h3>
|
||||||
|
<ul
|
||||||
|
v-if="selectedTemplate.copy_areas.length"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="area in selectedTemplate.copy_areas"
|
||||||
|
:key="area"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="whitespace-normal"
|
||||||
|
>{{
|
||||||
|
areaText(area, 'label')
|
||||||
|
}}</Badge
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('projectCreation.templateDefaults') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<aside
|
||||||
<Button type="submit" :disabled="form.processing">
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
Create project
|
aria-labelledby="summary-title"
|
||||||
</Button>
|
>
|
||||||
|
<h2 id="summary-title" class="text-title">
|
||||||
<Button as-child variant="outline">
|
{{ t('projectCreation.summary') }}
|
||||||
<Link :href="index()">Cancel</Link>
|
</h2>
|
||||||
</Button>
|
<dl class="gap-column grid min-w-0">
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.name') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words font-semibold">
|
||||||
|
{{
|
||||||
|
form.name.trim() ||
|
||||||
|
t('projectCreation.untitled')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.ticketPreview') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge variant="outline">{{
|
||||||
|
ticketKey
|
||||||
|
}}</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.setup') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words">
|
||||||
|
{{ selectedMode?.title }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="form.creation_mode === 'duplicate'"
|
||||||
|
class="gap-stack grid"
|
||||||
|
>
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.sourceProject') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words">
|
||||||
|
{{
|
||||||
|
selectedProject?.name ||
|
||||||
|
t('projectCreation.selectProject')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
<dd class="text-caption text-muted-foreground">
|
||||||
|
{{
|
||||||
|
t('projectCreation.selectedCount', {
|
||||||
|
count: form.copy_areas.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="form.creation_mode === 'template'"
|
||||||
|
class="gap-stack grid"
|
||||||
|
>
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.templateLabel') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words">
|
||||||
|
{{
|
||||||
|
selectedTemplate?.name ||
|
||||||
|
t('projectCreation.selectTemplate')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground border-t pt-4"
|
||||||
|
>
|
||||||
|
{{ t('projectCreation.cleanStart') }}
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:items-center sm:justify-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
><Link :href="index()">{{
|
||||||
|
t('common.cancel')
|
||||||
|
}}</Link></Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:loading="form.processing"
|
||||||
|
:aria-label="
|
||||||
|
t(
|
||||||
|
form.processing
|
||||||
|
? 'projectCreation.creating'
|
||||||
|
: 'projectCreation.submit',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:loading-label="t('projectCreation.creating')"
|
||||||
|
><FolderPlus
|
||||||
|
v-if="!form.processing"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{{
|
||||||
|
t(
|
||||||
|
form.processing
|
||||||
|
? 'projectCreation.creating'
|
||||||
|
: 'projectCreation.submit',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectCreationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[DataProvider('missingSources')]
|
||||||
|
public function test_creation_requires_the_selected_source(string $mode, string $field): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->admin()->create();
|
||||||
|
$this->actingAs($user)->from(route('projects.create'))
|
||||||
|
->post(route('projects.store'), ['creation_mode' => $mode, 'name' => 'New project', 'key' => 'NEW'])
|
||||||
|
->assertRedirect(route('projects.create'))->assertSessionHasErrors($field);
|
||||||
|
$this->assertDatabaseMissing('projects', ['key' => 'NEW']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, string}> */
|
||||||
|
public static function missingSources(): array
|
||||||
|
{
|
||||||
|
return ['duplicate' => ['duplicate', 'source_project_id'], 'template' => ['template', 'source_template_id']];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_unpublished_template_cannot_be_used_to_create_a_project(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->admin()->create();
|
||||||
|
$template = ProjectTemplate::factory()->create(['is_published' => false]);
|
||||||
|
$this->actingAs($user)->post(route('projects.store'), [
|
||||||
|
'creation_mode' => 'template', 'source_template_id' => $template->id, 'name' => 'New project', 'key' => 'NEW',
|
||||||
|
])->assertSessionHasErrors('source_template_id');
|
||||||
|
$this->assertDatabaseMissing('projects', ['key' => 'NEW']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creation_page_rendering_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-creation.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project creation rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
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:4179';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
const setResult = (value) =>
|
||||||
|
context.addCookies([{ name: 'creation_test_result', value, url: base }]);
|
||||||
|
const content = () => page.locator('#main-content');
|
||||||
|
const state = (value) =>
|
||||||
|
page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
|
||||||
|
const choose = async (value) => {
|
||||||
|
await page.locator(`input[name="creation_mode"][value="${value}"]`).focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
};
|
||||||
|
const fillDetails = async () => {
|
||||||
|
await page
|
||||||
|
.getByLabel('Project name', { exact: true })
|
||||||
|
.fill('Frontend workspace');
|
||||||
|
await page.getByLabel('Project key', { exact: true }).fill('web');
|
||||||
|
};
|
||||||
|
const submit = () =>
|
||||||
|
page.getByRole('button', { name: 'Create project', exact: true }).click();
|
||||||
|
const focusWithTab = async (target) => {
|
||||||
|
await content().focus();
|
||||||
|
for (let step = 0; step < 40; step++) {
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
if (
|
||||||
|
await target.evaluate(
|
||||||
|
(element) => element === document.activeElement,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
assert.equal(
|
||||||
|
await target.evaluate((element) =>
|
||||||
|
element.matches(':focus-visible'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.fail('Form control was not reachable by keyboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({
|
||||||
|
colorScheme: theme,
|
||||||
|
reducedMotion: 'reduce',
|
||||||
|
});
|
||||||
|
await page.goto(
|
||||||
|
`${base}/projects/create?viewport=${width}&theme=${theme}`,
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Create a project' })
|
||||||
|
.waitFor();
|
||||||
|
for (const mode of ['scratch', 'duplicate', 'template']) {
|
||||||
|
await choose(mode);
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((element) =>
|
||||||
|
element.classList.contains('dark'),
|
||||||
|
),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.documentElement.scrollWidth <=
|
||||||
|
window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`${mode} overflows at ${width} in ${theme}`,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/kan24-${mode}-${width}-${theme}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await choose('scratch');
|
||||||
|
const scratch = page.getByRole('radio', {
|
||||||
|
name: 'Start from scratch',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(scratch);
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('radio', {
|
||||||
|
name: 'Duplicate a project',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.isChecked(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const board = page.getByRole('checkbox', {
|
||||||
|
name: 'Board columns',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await focusWithTab(board);
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
assert.equal(await board.isChecked(), true);
|
||||||
|
const source = page.getByLabel('Source project', { exact: true });
|
||||||
|
await focusWithTab(source);
|
||||||
|
await page.keyboard.press('o');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.querySelector('#source_project_id').value === '8',
|
||||||
|
);
|
||||||
|
assert.equal(await source.inputValue(), '8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${base}/projects/create?empty`);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('radio', { name: 'Start from scratch', exact: true })
|
||||||
|
.isChecked(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('radio', { name: 'Duplicate a project', exact: true })
|
||||||
|
.isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('radio', { name: 'Use a template', exact: true })
|
||||||
|
.isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await fillDetails();
|
||||||
|
const scratchRequest = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await submit();
|
||||||
|
assert.deepEqual((await scratchRequest).postDataJSON(), {
|
||||||
|
name: 'Frontend workspace',
|
||||||
|
key: 'web',
|
||||||
|
creation_mode: 'scratch',
|
||||||
|
source_project_id: '',
|
||||||
|
source_template_id: '',
|
||||||
|
copy_areas: [],
|
||||||
|
});
|
||||||
|
await page.waitForURL('**/projects/99');
|
||||||
|
|
||||||
|
await page.goto(`${base}/projects/create?template=4`);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Template', { exact: true }).inputValue(),
|
||||||
|
'4',
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByText('This template uses the default project setup.', {
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
await fillDetails();
|
||||||
|
const templateRequest = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await submit();
|
||||||
|
const templateData = (await templateRequest).postDataJSON();
|
||||||
|
assert.equal(templateData.creation_mode, 'template');
|
||||||
|
assert.equal(templateData.source_template_id, '4');
|
||||||
|
assert.equal(templateData.source_project_id, '');
|
||||||
|
assert.deepEqual(templateData.copy_areas, []);
|
||||||
|
await page.waitForURL('**/projects/99');
|
||||||
|
|
||||||
|
await page.goto(`${base}/projects/create`);
|
||||||
|
await choose('duplicate');
|
||||||
|
await fillDetails();
|
||||||
|
await page.getByLabel('Source project', { exact: true }).selectOption('8');
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Board columns', exact: true })
|
||||||
|
.check();
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Custom ticket fields', exact: true })
|
||||||
|
.check();
|
||||||
|
await page.getByText('2 areas selected', { exact: true }).waitFor();
|
||||||
|
await page.getByText('WEB-1', { exact: true }).waitFor();
|
||||||
|
await choose('template');
|
||||||
|
await choose('scratch');
|
||||||
|
await choose('duplicate');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Source project', { exact: true }).inputValue(),
|
||||||
|
'8',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Board columns', exact: true })
|
||||||
|
.isChecked(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', {
|
||||||
|
name: 'Custom ticket fields',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.isChecked(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await setResult('validation');
|
||||||
|
const duplicateRequest = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await submit();
|
||||||
|
const duplicateData = (await duplicateRequest).postDataJSON();
|
||||||
|
assert.equal(duplicateData.source_project_id, '8');
|
||||||
|
assert.equal(duplicateData.source_template_id, '');
|
||||||
|
assert.deepEqual(duplicateData.copy_areas, [
|
||||||
|
'board_columns',
|
||||||
|
'ticket_fields',
|
||||||
|
]);
|
||||||
|
await state('validation');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Project name', { exact: true }).inputValue(),
|
||||||
|
'Frontend workspace',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Project key', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByText('The key has already been taken.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await page
|
||||||
|
.getByText('Select a valid configuration area.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Board columns', exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
|
||||||
|
await choose('scratch');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByText('Select a valid configuration area.', { exact: true })
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Project key', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await choose('duplicate');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Board columns', exact: true })
|
||||||
|
.isChecked(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Board columns', exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'false',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route('**/projects', (route) => route.abort('failed'));
|
||||||
|
await submit();
|
||||||
|
await state('network');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Project name', { exact: true }).inputValue(),
|
||||||
|
'Frontend workspace',
|
||||||
|
);
|
||||||
|
await page.unroute('**/projects');
|
||||||
|
await setResult('server');
|
||||||
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
await state('server');
|
||||||
|
await setResult('success');
|
||||||
|
let pending;
|
||||||
|
let submissions = 0;
|
||||||
|
await page.route('**/projects', (route) => {
|
||||||
|
submissions++;
|
||||||
|
pending = route;
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
await state('loading');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Creating project…', exact: true })
|
||||||
|
.isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Project name', { exact: true }).isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.locator('form').evaluate((form) => form.requestSubmit());
|
||||||
|
assert.equal(submissions, 1);
|
||||||
|
await pending.continue();
|
||||||
|
await page.waitForURL('**/projects/99');
|
||||||
|
await page.unroute('**/projects');
|
||||||
|
|
||||||
|
await page.goto(`${base}/projects/create`);
|
||||||
|
await choose('duplicate');
|
||||||
|
await fillDetails();
|
||||||
|
const emptyCopyRequest = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await submit();
|
||||||
|
assert.deepEqual((await emptyCopyRequest).postDataJSON().copy_areas, []);
|
||||||
|
await page.waitForURL('**/projects/99');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Project creation browser checks passed: all modes, preselection, payloads, retained input, validation, retry, duplicate submission protection, keyboard, 320/390/1440px and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Create from '../../resources/js/pages/projects/Create.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
name === 'Create'
|
||||||
|
? Create
|
||||||
|
: { render: () => h('h1', 'Project created') },
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/project-creation-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4179, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const areas = [
|
||||||
|
'project_settings',
|
||||||
|
'board_columns',
|
||||||
|
'ticket_statuses',
|
||||||
|
'status_transitions',
|
||||||
|
'ticket_types',
|
||||||
|
'ticket_priorities',
|
||||||
|
'ticket_fields',
|
||||||
|
'ticket_link_types',
|
||||||
|
'repository_connections',
|
||||||
|
'notification_actions',
|
||||||
|
];
|
||||||
|
function makePage(url, errors = {}) {
|
||||||
|
const query = new URL(url, 'http://localhost').searchParams;
|
||||||
|
return {
|
||||||
|
component: url.startsWith('/projects/create')
|
||||||
|
? 'Create'
|
||||||
|
: 'Destination',
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors,
|
||||||
|
sidebarOpen: true,
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: true,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
copyAreaOptions: areas.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: value,
|
||||||
|
description: 'Server description',
|
||||||
|
})),
|
||||||
|
sourceProjects: query.has('empty')
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
key: 'ENG',
|
||||||
|
name: 'Engineering · Platform and developer experience',
|
||||||
|
},
|
||||||
|
{ id: 8, key: 'OPS', name: 'Operations' },
|
||||||
|
],
|
||||||
|
sourceTemplates:
|
||||||
|
query.has('empty') || query.has('no-templates')
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Team baseline',
|
||||||
|
slug: 'team-baseline',
|
||||||
|
copy_areas: ['board_columns', 'ticket_types'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Minimal setup',
|
||||||
|
slug: 'minimal',
|
||||||
|
copy_areas: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedSourceTemplateId: query.has('template')
|
||||||
|
? Number(query.get('template'))
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
// Real Vue/Inertia form with isolated transport; PHPUnit covers persisted projects.
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/projects')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
let url = request.url;
|
||||||
|
let errors = {};
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(
|
||||||
|
/creation_test_result=(\w+)/,
|
||||||
|
)?.[1] ?? 'success';
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
if (result === 'server') {
|
||||||
|
response.statusCode = 503;
|
||||||
|
response.end('Simulated server failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
url = result === 'validation' ? '/projects/create' : '/projects/99';
|
||||||
|
if (result === 'validation') {
|
||||||
|
errors = {
|
||||||
|
key: 'The key has already been taken.',
|
||||||
|
...(data.creation_mode === 'duplicate'
|
||||||
|
? {
|
||||||
|
'copy_areas.0':
|
||||||
|
'Select a valid configuration area.',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = makePage(url, errors);
|
||||||
|
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 escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!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="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/project-creation-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log(
|
||||||
|
'Project creation fixture ready at http://127.0.0.1:4179/projects/create',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const Create = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/projects/Create.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (
|
||||||
|
url,
|
||||||
|
errors = {},
|
||||||
|
locale = createLocalization(),
|
||||||
|
) => {
|
||||||
|
const response = await createInertiaApp({
|
||||||
|
page: makePage(url, errors),
|
||||||
|
resolve: () => Create,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance('light')),
|
||||||
|
});
|
||||||
|
return response.body.split('<main')[1].split('</main>')[0];
|
||||||
|
};
|
||||||
|
const empty = await render('/projects/create?empty');
|
||||||
|
assert.match(empty, /value="scratch"[^>]*checked/);
|
||||||
|
assert.match(empty, /value="duplicate"[^>]*disabled/);
|
||||||
|
assert.match(empty, /value="template"[^>]*disabled/);
|
||||||
|
assert.match(empty, /No accessible projects/);
|
||||||
|
assert.match(empty, /No published templates/);
|
||||||
|
assert.doesNotMatch(empty, /<select/);
|
||||||
|
const scratch = await render('/projects/create?no-templates');
|
||||||
|
assert.match(scratch, /value="duplicate"[^>]*aria-invalid="false"/);
|
||||||
|
assert.doesNotMatch(scratch, /value="duplicate"[^>]*disabled/);
|
||||||
|
const template = await render('/projects/create');
|
||||||
|
assert.match(template, /value="template"[^>]*checked/);
|
||||||
|
assert.match(template, /Team baseline/);
|
||||||
|
assert.match(template, /Board columns/);
|
||||||
|
assert.match(template, /Ticket types/);
|
||||||
|
assert.match(template, /The project stays linked to this template/);
|
||||||
|
assert.doesNotMatch(template, /Server description/);
|
||||||
|
const preselected = await render('/projects/create?template=4');
|
||||||
|
assert.match(preselected, /Minimal setup/);
|
||||||
|
assert.match(
|
||||||
|
preselected,
|
||||||
|
/This template uses the default project setup/,
|
||||||
|
);
|
||||||
|
for (const id of [
|
||||||
|
'name-error',
|
||||||
|
'key-help',
|
||||||
|
'key-error',
|
||||||
|
'template-help',
|
||||||
|
'source-template-error',
|
||||||
|
'creation-mode-error',
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
template.includes(`id="${id}"`),
|
||||||
|
`Missing accessible feedback target: ${id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const translated = await render(
|
||||||
|
'/projects/create',
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'projectCreation.title': 'Projekt anlegen',
|
||||||
|
'projectCreation.areas.boardColumns.label':
|
||||||
|
'Boardspalten',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.match(translated, /Projekt anlegen/);
|
||||||
|
assert.match(translated, /Boardspalten/);
|
||||||
|
console.log('Project creation rendering checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user