Compare commits

..
9 Commits
Author SHA1 Message Date
Twyco 538b17b9b8 feat(KAN-45): rebuild project notification settings
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-16 09:32:05 +02:00
Twyco b2789b4670 Merge pull request 'KAN-44: UI Rework 29: Repository-Einstellungen des Projekts' (#143) from feature/KAN-44-project-repository-settings into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #143
2026-09-16 07:26:40 +00:00
Eric 7c6981dc0a feat(KAN-44): rebuild project repository settings 2026-09-16 07:26:40 +00:00
Twyco b8409a702c Merge pull request 'KAN-49: Rebuild user administration' (#142) from feature/KAN-49-user-administration into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #142
2026-09-16 07:01:11 +00:00
Twyco 2acbdb4760 feat(KAN-49): rebuild user administration 2026-09-16 07:01:11 +00:00
Twyco 9faf02e2c4 Merge pull request 'KAN-46: Rebuild reusable project template management' (#139) from feature/KAN-46-reusable-project-templates into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 5m40s
Reviewed-on: #139
2026-09-16 07:01:03 +00:00
Twyco 0941745883 feat(KAN-46): rebuild reusable project template management 2026-09-16 07:01:03 +00:00
Twyco 5a10c85e61 Merge pull request 'KAN-43: UI Rework 28: Benutzerdefinierte Felder und Linktypen' (#138) from feature/KAN-43-custom-fields-link-types into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 5m49s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #138
2026-09-16 06:54:15 +00:00
Eric fb4faa6070 feat(KAN-43): rebuild custom fields and link types
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-15 17:50:10 +02:00
35 changed files with 4942 additions and 1980 deletions
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
DuplicateProjectConfiguration::COPY_TICKET_FIELDS, DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
]), ]),
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(), 'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(), 'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(), 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
]; ];
@@ -29,6 +29,7 @@ class UpdateProjectTemplateRequest extends FormRequest
'copy_areas' => ['required', 'array', 'min:1'], 'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [ 'confirm_linked_projects_update' => [
Rule::excludeIf(! $requiresImpactConfirmation),
Rule::requiredIf($requiresImpactConfirmation), Rule::requiredIf($requiresImpactConfirmation),
'accepted', 'accepted',
], ],
@@ -20,6 +20,7 @@ class TicketFieldDefinitionResource extends JsonResource
'label' => $this->label, 'label' => $this->label,
'position' => $this->position, 'position' => $this->position,
'project_id' => $this->project_id, 'project_id' => $this->project_id,
'ticket_field_values_count' => $this->when(isset($this->ticket_field_values_count), (int) $this->ticket_field_values_count),
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(),
]; ];
@@ -20,6 +20,7 @@ class TicketLinkTypeResource extends JsonResource
'counterpart_label' => $this->counterpart_label ?: $this->label, 'counterpart_label' => $this->counterpart_label ?: $this->label,
'position' => $this->position, 'position' => $this->position,
'project_id' => $this->project_id, 'project_id' => $this->project_id,
'ticket_links_count' => $this->when(isset($this->ticket_links_count), (int) $this->ticket_links_count),
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(),
]; ];
+369
View File
@@ -0,0 +1,369 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { KeyRound, UserRound } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import {
save,
store,
} from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { UserModel } from '@/types/models/user-model';
const props = withDefaults(
defineProps<{
mode: 'create' | 'edit';
user?: UserModel | null;
}>(),
{ user: null },
);
const emit = defineEmits<{ cancel: [] }>();
const { t } = useLocalization();
const isEdit = computed(() => props.mode === 'edit');
const confirmationOpen = ref(false);
const form = useForm(
'ManagedUser:' + props.mode + ':' + (props.user?.id ?? 'new'),
{
name: props.user?.name ?? '',
email: props.user?.email ?? '',
password: '',
password_confirmation: '',
must_change_password_on_next_login:
props.user?.must_change_password_on_next_login ?? true,
},
);
const protection = useFormProtection(form);
function performSubmit(): void {
confirmationOpen.value = false;
protection.submit(
isEdit.value && props.user ? save(props.user.id) : store(),
{ preserveScroll: true },
);
}
function requestSubmit(): void {
if (isEdit.value) {
confirmationOpen.value = true;
return;
}
performSubmit();
}
function updateConfirmation(open: boolean): void {
if (!form.processing) {
confirmationOpen.value = open;
}
}
</script>
<template>
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
<CardHeader class="gap-stack border-b p-5 sm:p-6">
<div class="flex items-start gap-3">
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<UserRound class="size-5" aria-hidden="true" />
</span>
<div class="gap-stack grid min-w-0">
<h2 class="text-title">
{{ t('adminUsers.form.detailsTitle') }}
</h2>
<p class="text-muted-foreground">
{{
t(
isEdit
? 'adminUsers.form.editDescription'
: 'adminUsers.form.createDescription',
)
}}
</p>
</div>
</div>
</CardHeader>
<CardContent class="p-5 sm:p-6">
<form
class="gap-page grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="requestSubmit"
>
<FormFeedback
:state="protection.state.value"
@retry="performSubmit"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="form.processing"
>
<legend class="sr-only">
{{ t('adminUsers.form.detailsTitle') }}
</legend>
<div class="grid gap-5 sm:grid-cols-2">
<div class="grid min-w-0 gap-2">
<Label for="managed-user-name">{{
t('adminUsers.form.name')
}}</Label>
<Input
id="managed-user-name"
v-model="form.name"
name="name"
required
autofocus
autocomplete="name"
:placeholder="
t('adminUsers.form.namePlaceholder')
"
:aria-invalid="!!form.errors.name"
aria-describedby="managed-user-name-error"
/>
<InputError
id="managed-user-name-error"
:message="form.errors.name"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="managed-user-email">{{
t('adminUsers.form.email')
}}</Label>
<Input
id="managed-user-email"
v-model="form.email"
name="email"
type="email"
required
autocomplete="email"
:placeholder="
t('adminUsers.form.emailPlaceholder')
"
:aria-invalid="!!form.errors.email"
aria-describedby="managed-user-email-error"
/>
<InputError
id="managed-user-email-error"
:message="form.errors.email"
/>
</div>
</div>
<section
aria-labelledby="managed-user-password-title"
class="gap-column grid min-w-0 border-t pt-6"
>
<div class="flex items-start gap-3">
<KeyRound
class="text-muted-foreground mt-0.5 size-5 shrink-0"
aria-hidden="true"
/>
<div class="gap-stack grid min-w-0">
<h3
id="managed-user-password-title"
class="font-semibold"
>
{{ t('adminUsers.form.passwordTitle') }}
</h3>
<p class="text-caption text-muted-foreground">
{{
t(
isEdit
? 'adminUsers.form.passwordEditHelp'
: 'adminUsers.form.passwordCreateHelp',
)
}}
</p>
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div class="grid min-w-0 gap-2">
<Label for="managed-user-password">{{
t(
isEdit
? 'adminUsers.form.newPassword'
: 'adminUsers.form.password',
)
}}</Label>
<PasswordInput
id="managed-user-password"
v-model="form.password"
name="password"
:required="!isEdit"
autocomplete="new-password"
:placeholder="
t(
isEdit
? 'adminUsers.form.passwordEditPlaceholder'
: 'adminUsers.form.passwordPlaceholder',
)
"
:aria-invalid="!!form.errors.password"
aria-describedby="managed-user-password-error"
/>
<InputError
id="managed-user-password-error"
:message="form.errors.password"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label
for="managed-user-password-confirmation"
>{{
t('adminUsers.form.confirmPassword')
}}</Label
>
<PasswordInput
id="managed-user-password-confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
:required="!isEdit"
autocomplete="new-password"
:placeholder="
t(
'adminUsers.form.confirmPasswordPlaceholder',
)
"
:aria-invalid="
!!form.errors.password_confirmation
"
aria-describedby="managed-user-password-confirmation-error"
/>
<InputError
id="managed-user-password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
</div>
</section>
<div class="grid gap-2 border-t pt-6">
<Label
for="managed-user-force-password-change"
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
>
<Checkbox
id="managed-user-force-password-change"
v-model="
form.must_change_password_on_next_login
"
name="must_change_password_on_next_login"
:invalid="
!!form.errors
.must_change_password_on_next_login
"
aria-describedby="managed-user-force-password-change-help managed-user-force-password-change-error"
/>
<span class="grid gap-1">
<span>{{
t('adminUsers.form.forcePasswordChange')
}}</span>
<span
id="managed-user-force-password-change-help"
class="text-caption text-muted-foreground font-normal"
>
{{
t(
'adminUsers.form.forcePasswordChangeHelp',
)
}}
</span>
</span>
</Label>
<InputError
id="managed-user-force-password-change-error"
:message="
form.errors.must_change_password_on_next_login
"
/>
</div>
</fieldset>
<div
class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"
>
<Button
type="button"
variant="ghost"
@click="emit('cancel')"
>
{{ t('common.cancel') }}
</Button>
<Button
type="submit"
:loading="form.processing"
class="sm:min-w-36"
data-test="managed-user-submit"
>
{{
t(
isEdit
? 'adminUsers.form.reviewChanges'
: 'adminUsers.form.createAction',
)
}}
</Button>
</div>
</form>
</CardContent>
</Card>
<Dialog :open="confirmationOpen" @update:open="updateConfirmation">
<DialogContent>
<DialogHeader>
<DialogTitle>{{
t('adminUsers.form.confirmTitle', {
name: form.name || props.user?.name || '',
})
}}</DialogTitle>
<DialogDescription>
{{ t('adminUsers.form.confirmDescription') }}
</DialogDescription>
</DialogHeader>
<div
class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
>
{{ t('adminUsers.form.confirmWarning') }}
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
:disabled="form.processing"
@click="confirmationOpen = false"
>
{{ t('common.cancel') }}
</Button>
<Button
type="button"
:loading="form.processing"
data-test="confirm-managed-user-update"
@click="performSubmit"
>
{{ t('adminUsers.form.confirmAction') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,347 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { Layers3, LockKeyhole } from 'lucide-vue-next';
import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.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 { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import { index, update } from '@/routes/project-templates';
import { show } from '@/routes/projects';
import { store } from '@/routes/projects/templates';
import type { ProjectDuplicateCopyAreaOption } from '@/types';
import type { ProjectModel } from '@/types/models/project-model';
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
const props = defineProps<{
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project?: ProjectModel;
template?: ProjectTemplateModel;
}>();
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const source = computed(() => props.template?.source_project ?? props.project);
const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
const form = useForm(
`ProjectTemplate:${props.template ? `edit:${props.template.id}` : `create:${props.project?.id}`}`,
{
name:
props.template?.name ??
t('templates.defaultName', { project: props.project?.name ?? '' }),
slug:
props.template?.slug ??
`${props.project?.key.toLowerCase()}-template`,
copy_areas: props.template
? [...props.template.copy_areas]
: props.copyAreaOptions.map((area) => area.value),
confirm_linked_projects_update: false,
},
);
const protection = useFormProtection(form);
const copyAreasError = computed(
() =>
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1],
);
const cancelTarget = computed(() =>
props.template ? index() : show(props.project!.id),
);
function submit(): void {
if (
!source.value ||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
) {
return;
}
form.transform((data) =>
props.template
? {
name: data.name,
copy_areas: data.copy_areas,
...(linkedCount.value > 0
? {
confirm_linked_projects_update:
data.confirm_linked_projects_update,
}
: {}),
}
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas },
);
protection.submit(
props.template ? update(props.template.id) : store(props.project!.id),
{ preserveScroll: true },
);
}
</script>
<template>
<form
class="gap-page grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<FeedbackState
v-if="!source"
state="error"
:title="t('templates.missingSource')"
:description="t('templates.missingSourceHelp')"
/>
<div
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]"
>
<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="template-details-title"
>
<h2 id="template-details-title" class="text-title">
{{ t('templates.details') }}
</h2>
<div class="gap-stack grid">
<Label for="name">{{ t('templates.name') }}</Label>
<Input
id="name"
v-model="form.name"
name="name"
required
maxlength="255"
:disabled="form.processing || !source"
:placeholder="t('templates.namePlaceholder')"
:invalid="!!form.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="form.errors.name"
/>
</div>
<div class="gap-stack grid">
<Label for="slug"
><LockKeyhole
v-if="template"
class="size-4"
aria-hidden="true"
/>{{ t('templates.slug') }}</Label
>
<Input
id="slug"
v-model="form.slug"
name="slug"
required
maxlength="255"
:disabled="!!template || form.processing || !source"
:placeholder="t('templates.slugPlaceholder')"
:invalid="!!form.errors.slug"
aria-describedby="slug-help slug-error"
:spellcheck="false"
autocapitalize="none"
/>
<p
id="slug-help"
class="text-caption text-muted-foreground"
>
{{
t(
template
? 'templates.slugLocked'
: 'templates.slugHelp',
)
}}
</p>
<InputError
id="slug-error"
:message="form.errors.slug"
/>
</div>
</section>
<fieldset
class="bg-card min-w-0 rounded-md border p-5"
:disabled="form.processing || !source"
aria-describedby="areas-help copy-areas-error"
>
<legend class="text-title px-1">
{{ t('templates.groups') }}
</legend>
<p
id="areas-help"
class="text-caption text-muted-foreground mb-4"
>
{{
t(
template
? 'templates.editGroupsHelp'
: 'templates.createGroupsHelp',
)
}}
</p>
<div class="gap-stack grid sm:grid-cols-2">
<label
v-for="area in copyAreaOptions"
:key="area.value"
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 min-w-0 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="area.value"
:aria-labelledby="`area-${area.value}`"
:aria-describedby="`area-${area.value}-help copy-areas-error`"
:aria-invalid="!!copyAreasError"
class="accent-primary mt-0.5 size-5 shrink-0"
/>
<span class="gap-stack grid min-w-0">
<span
:id="`area-${area.value}`"
class="text-body font-semibold"
>{{
areaText(
area.value,
'label',
area.label,
)
}}</span
>
<span
:id="`area-${area.value}-help`"
class="text-caption text-muted-foreground"
>{{
areaText(
area.value,
'description',
area.description,
)
}}</span
>
</span>
</label>
</div>
<InputError
id="copy-areas-error"
class="mt-3"
:message="copyAreasError"
/>
</fieldset>
</div>
<aside
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-impact-title"
>
<h2
id="template-impact-title"
class="text-title flex items-center gap-2"
>
<Layers3 class="text-primary size-5" aria-hidden="true" />{{
t('templates.impact')
}}
</h2>
<dl class="gap-column grid">
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.sourceProject') }}
</dt>
<dd class="break-words font-semibold">
{{
source?.name ?? t('templates.sourceUnavailable')
}}
</dd>
<dd v-if="source">
<Badge variant="outline">{{ source.key }}</Badge>
</dd>
</div>
<div class="gap-stack grid" aria-live="polite">
<dt class="text-caption text-muted-foreground">
{{ t('templates.groups') }}
</dt>
<dd>
{{
t('projectCreation.selectedCount', {
count: form.copy_areas.length,
})
}}
</dd>
</div>
<div v-if="template" class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('templates.inheritance') }}
</dt>
<dd>
{{
t('templates.linkedCount', {
count: linkedCount,
})
}}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground border-t pt-4">
{{
t(
template
? 'templates.refreshHelp'
: 'templates.cleanStart',
)
}}
</p>
</aside>
</div>
<section
v-if="linkedCount > 0"
class="border-warning/50 bg-warning/10 gap-column grid rounded-md border p-5"
aria-labelledby="linked-title"
>
<h2 id="linked-title" class="text-title">
{{ t('templates.linkedWarning') }}
</h2>
<p id="linked-help" class="text-body">
{{ t('templates.linkedImpact', { count: linkedCount }) }}
</p>
<label class="flex min-h-11 cursor-pointer items-start gap-3">
<input
id="confirm-linked"
v-model="form.confirm_linked_projects_update"
type="checkbox"
:disabled="form.processing || !source"
:aria-invalid="!!form.errors.confirm_linked_projects_update"
aria-describedby="linked-help linked-error"
class="accent-primary mt-0.5 size-5 shrink-0"
/>
<span>{{ t('templates.acknowledge') }}</span>
</label>
<InputError
id="linked-error"
:message="form.errors.confirm_linked_projects_update"
/>
</section>
<footer
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:justify-end"
>
<Button as-child variant="outline" :disabled="form.processing"
><Link :href="cancelTarget">{{
t('common.cancel')
}}</Link></Button
>
<Button
type="submit"
:loading="form.processing"
:loading-label="t('forms.saving')"
:disabled="
!source ||
(linkedCount > 0 && !form.confirm_linked_projects_update)
"
>{{
t(template ? 'templates.saveChanges' : 'templates.saveNew')
}}</Button
>
</footer>
</form>
</template>
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { CircleAlert } from 'lucide-vue-next';
import { computed, watch } from 'vue';
import {
destroyFieldDefinition,
storeFieldDefinition,
updateFieldDefinition,
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
import type { TicketFieldTypeOption } from '@/types';
const props = defineProps<{
projectId: number;
target: TicketFieldDefinitionEditorTarget | null;
definitions: TicketFieldDefinitionModel[];
fieldTypeOptions: TicketFieldTypeOption[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
label: '',
field_type: '',
position: 1,
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.action === 'create') {
return t('metadata.addField');
}
if (props.target?.action === 'edit') {
return t('metadata.editField', { name: props.target.entry.label });
}
return props.target
? t('metadata.deleteField', { name: props.target.entry.label })
: '';
});
watch(
() => props.target,
(target) => {
const values =
target?.action === 'edit'
? {
label: target.entry.label,
field_type: target.entry.field_type,
position: target.entry.position,
}
: {
label: '',
field_type: props.fieldTypeOptions[0]?.value ?? '',
position: props.definitions.length + 1,
};
form.defaults(values);
form.resetAndClearErrors();
protection.clearFeedback();
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (!target || form.processing) {
return;
}
const route =
target.action === 'create'
? storeFieldDefinition(props.projectId)
: target.action === 'edit'
? updateFieldDefinition([props.projectId, target.entry.id])
: destroyFieldDefinition([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.action === 'create'
? { label: data.label, field_type: data.field_type }
: data,
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>
{{
t(
deleting
? 'metadata.deleteFieldHelp'
: 'metadata.fieldEditorHelp',
)
}}
</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<div
v-if="deleting && target?.entry"
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
>
<CircleAlert
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{{
t('metadata.fieldDeletionImpact', {
count:
target.entry.ticket_field_values_count ?? 0,
})
}}
</p>
</div>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="field-definition-label">{{
t('metadata.fieldLabel')
}}</Label>
<Input
id="field-definition-label"
v-model="form.label"
name="label"
required
maxlength="255"
:aria-invalid="!!form.errors.label"
aria-describedby="field-definition-label-error"
/>
<InputError
id="field-definition-label-error"
:message="form.errors.label"
/>
</div>
<div class="grid gap-2">
<Label for="field-definition-type">{{
t('metadata.fieldType')
}}</Label>
<select
id="field-definition-type"
v-model="form.field_type"
name="field_type"
required
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
:aria-invalid="!!form.errors.field_type"
aria-describedby="field-definition-type-help field-definition-type-error"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<p
id="field-definition-type-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.fieldTypeHelp') }}
</p>
<InputError
id="field-definition-type-error"
:message="form.errors.field_type"
/>
</div>
<div v-if="target?.action === 'edit'" class="grid gap-2">
<Label for="field-definition-position">{{
t('workflow.position')
}}</Label>
<Input
id="field-definition-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="definitions.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="field-definition-position-help field-definition-position-error"
/>
<p
id="field-definition-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: definitions.length,
})
}}
</p>
<InputError
id="field-definition-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { Braces, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketFieldDefinitionEditorDialog from '@/components/projects/TicketFieldDefinitionEditorDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
import type { TicketFieldTypeOption } from '@/types';
const props = defineProps<{
projectId: number;
definitions: TicketFieldDefinitionModel[];
fieldTypeOptions: TicketFieldTypeOption[];
}>();
const { t } = useLocalization();
const target = ref<TicketFieldDefinitionEditorTarget | null>(null);
const fieldTypeLabels = computed(
() =>
new Map(
props.fieldTypeOptions.map((option) => [
option.value,
option.label,
]),
),
);
function fieldTypeLabel(fieldType: string): string {
return fieldTypeLabels.value.get(fieldType) ?? fieldType;
}
</script>
<template>
<section
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
aria-labelledby="metadata-custom-fields"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<Braces
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="metadata-custom-fields" class="font-semibold">
{{ t('metadata.customFields') }}
</h3>
<Badge variant="secondary">{{ definitions.length }}</Badge>
</div>
<Button
type="button"
class="w-full sm:w-auto"
@click="target = { action: 'create' }"
>
<Plus class="size-4" aria-hidden="true" />{{
t('metadata.addField')
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{ t('metadata.customFieldsDescription') }}
</p>
<FeedbackState
v-if="definitions.length === 0"
state="empty"
:title="t('metadata.noCustomFields')"
:description="t('metadata.noCustomFieldsDescription')"
/>
<ol v-else class="divide-y" :aria-label="t('metadata.customFields')">
<li
v-for="definition in definitions"
:key="definition.id"
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
:data-ticket-field-definition="definition.id"
>
<span
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:aria-label="
t('workflow.positionValue', {
position: definition.position,
})
"
>{{ definition.position }}</span
>
<div class="grid min-w-0 gap-1">
<p class="break-words font-medium">
{{ definition.label }}
</p>
<div
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
>
<Badge variant="outline">{{
fieldTypeLabel(definition.field_type)
}}</Badge>
<span>{{
t('metadata.storedValues', {
count:
definition.ticket_field_values_count ?? 0,
})
}}</span>
</div>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editField', { name: definition.label })
"
@click="target = { action: 'edit', entry: definition }"
>
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:aria-label="
t('metadata.deleteField', {
name: definition.label,
})
"
@click="
target = { action: 'delete', entry: definition }
"
>
<Trash2 class="size-4" aria-hidden="true" />
</Button>
</div>
</li>
</ol>
<TicketFieldDefinitionEditorDialog
:definitions="definitions"
:field-type-options="fieldTypeOptions"
:project-id="projectId"
:target="target"
@close="target = null"
/>
</section>
</template>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ArrowRight, CircleAlert } from 'lucide-vue-next';
import { computed, watch } from 'vue';
import {
destroyLinkType,
storeLinkType,
updateLinkType,
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
const props = defineProps<{
projectId: number;
target: TicketLinkTypeEditorTarget | null;
linkTypes: TicketLinkTypeModel[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
name: '',
counterpart_name: '',
position: 1,
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.action === 'create') {
return t('metadata.addLinkType');
}
if (props.target?.action === 'edit') {
return t('metadata.editLinkType', { name: props.target.entry.label });
}
return props.target
? t('metadata.deleteLinkType', { name: props.target.entry.label })
: '';
});
watch(
() => props.target,
(target) => {
const values =
target?.action === 'edit'
? {
name: target.entry.label,
counterpart_name: target.entry.counterpart_label,
position: target.entry.position,
}
: {
name: '',
counterpart_name: '',
position: props.linkTypes.length + 1,
};
form.defaults(values);
form.resetAndClearErrors();
protection.clearFeedback();
},
{ immediate: true },
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
protection.clearFeedback();
emit('close');
}
function submit(): void {
const target = props.target;
if (!target || form.processing) {
return;
}
const route =
target.action === 'create'
? storeLinkType(props.projectId)
: target.action === 'edit'
? updateLinkType([props.projectId, target.entry.id])
: destroyLinkType([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.action === 'create'
? {
name: data.name,
counterpart_name: data.counterpart_name,
}
: data,
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>
{{
t(
deleting
? 'metadata.deleteLinkTypeHelp'
: 'metadata.linkTypeEditorHelp',
)
}}
</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<div
v-if="deleting && target?.entry"
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
>
<CircleAlert
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{{
t('metadata.linkTypeDeletionImpact', {
count: target.entry.ticket_links_count ?? 0,
})
}}
</p>
</div>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="link-type-source-label">{{
t('metadata.sourceLabel')
}}</Label>
<Input
id="link-type-source-label"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
aria-describedby="link-type-source-label-help link-type-source-label-error"
/>
<p
id="link-type-source-label-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.sourceLabelHelp') }}
</p>
<InputError
id="link-type-source-label-error"
:message="form.errors.name"
/>
</div>
<ArrowRight
class="text-primary mx-auto size-5 rotate-90 sm:rotate-0"
aria-hidden="true"
/>
<div class="grid gap-2">
<Label for="link-type-counterpart-label">{{
t('metadata.counterpartLabel')
}}</Label>
<Input
id="link-type-counterpart-label"
v-model="form.counterpart_name"
name="counterpart_name"
required
maxlength="255"
:aria-invalid="!!form.errors.counterpart_name"
aria-describedby="link-type-counterpart-label-help link-type-counterpart-label-error"
/>
<p
id="link-type-counterpart-label-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.counterpartLabelHelp') }}
</p>
<InputError
id="link-type-counterpart-label-error"
:message="form.errors.counterpart_name"
/>
</div>
<div v-if="target?.action === 'edit'" class="grid gap-2">
<Label for="link-type-position">{{
t('workflow.position')
}}</Label>
<Input
id="link-type-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="linkTypes.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="link-type-position-help link-type-position-error"
/>
<p
id="link-type-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: linkTypes.length,
})
}}
</p>
<InputError
id="link-type-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ArrowRight, Link2, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketLinkTypeEditorDialog from '@/components/projects/TicketLinkTypeEditorDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
defineProps<{ projectId: number; linkTypes: TicketLinkTypeModel[] }>();
const { t } = useLocalization();
const target = ref<TicketLinkTypeEditorTarget | null>(null);
</script>
<template>
<section
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
aria-labelledby="metadata-link-types"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<Link2
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="metadata-link-types" class="font-semibold">
{{ t('metadata.linkTypes') }}
</h3>
<Badge variant="secondary">{{ linkTypes.length }}</Badge>
</div>
<Button
type="button"
class="w-full sm:w-auto"
@click="target = { action: 'create' }"
>
<Plus class="size-4" aria-hidden="true" />{{
t('metadata.addLinkType')
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{ t('metadata.linkTypesDescription') }}
</p>
<FeedbackState
v-if="linkTypes.length === 0"
state="empty"
:title="t('metadata.noLinkTypes')"
:description="t('metadata.noLinkTypesDescription')"
/>
<ol v-else class="divide-y" :aria-label="t('metadata.linkTypes')">
<li
v-for="linkType in linkTypes"
:key="linkType.id"
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
:data-ticket-link-type="linkType.id"
>
<span
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
:aria-label="
t('workflow.positionValue', {
position: linkType.position,
})
"
>{{ linkType.position }}</span
>
<div class="grid min-w-0 gap-2">
<div
class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
:aria-label="
t('metadata.linkDirection', {
source: linkType.label,
counterpart: linkType.counterpart_label,
})
"
>
<span class="break-words font-medium">{{
linkType.label
}}</span>
<ArrowRight
class="text-primary size-4 shrink-0"
aria-hidden="true"
/>
<span class="break-words font-medium">{{
linkType.counterpart_label
}}</span>
</div>
<p class="text-caption text-muted-foreground">
{{
t('metadata.ticketRelationships', {
count: linkType.ticket_links_count ?? 0,
})
}}
</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editLinkType', {
name: linkType.label,
})
"
@click="target = { action: 'edit', entry: linkType }"
>
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:aria-label="
t('metadata.deleteLinkType', {
name: linkType.label,
})
"
@click="target = { action: 'delete', entry: linkType }"
>
<Trash2 class="size-4" aria-hidden="true" />
</Button>
</div>
</li>
</ol>
<TicketLinkTypeEditorDialog
:link-types="linkTypes"
:project-id="projectId"
:target="target"
@close="target = null"
/>
</section>
</template>
@@ -0,0 +1,27 @@
import { useLocalization } from '@/composables/useLocalization';
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;
export function useProjectTemplateAreas() {
const { t } = useLocalization();
function areaText(
value: string,
part: 'label' | 'description',
fallback = value,
): string {
const key = areaKeys[value as keyof typeof areaKeys];
return key ? t(`${key}.${part}`) : fallback;
}
return { areaText };
}
+259
View File
@@ -108,6 +108,70 @@ export const en = {
'Previous assignee name when the event updates assignment; blank otherwise.', 'Previous assignee name when the event updates assignment; blank otherwise.',
'notifications.variable.assignee_to': 'notifications.variable.assignee_to':
'New assignee name when the event updates assignment; blank when unassigned.', 'New assignee name when the event updates assignment; blank when unassigned.',
'templates.description':
'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
'templates.updated': 'Project templates updated.',
'templates.fromProject': 'Start with an existing project',
'templates.fromProjectHelp':
'Open a project and choose “Save as new template” to capture its configuration.',
'templates.browseProjects': 'Browse projects',
'templates.empty': 'No published templates yet',
'templates.emptyHelp':
'Save a project as a template to reuse its settings. Internal project baselines are kept out of this list.',
'templates.published': 'Published project templates',
'templates.linkedCount': {
one: '{count} linked project',
other: '{count} linked projects',
},
'templates.savedFrom': 'Saved from {project} ({key}).',
'templates.sourceUnavailable': 'Source project unavailable',
'templates.edit': 'Edit',
'templates.use': 'Use template',
'templates.createTitle': 'Save {project} as a template',
'templates.createDescription':
'Choose the configuration to reuse in future projects.',
'templates.editTitle': 'Edit {name}',
'templates.editDescription':
'Refresh the template from its source project and review the impact on linked projects.',
'templates.defaultName': '{project} template',
'templates.details': 'Template details',
'templates.name': 'Template name',
'templates.namePlaceholder': 'Foundation template',
'templates.slug': 'Template slug',
'templates.slugPlaceholder': 'foundation-template',
'templates.slugHelp':
'Slugs are normalized automatically. After creation, the slug cannot be changed.',
'templates.slugLocked':
'The slug is locked so existing template references remain stable.',
'templates.groups': 'Configuration groups',
'templates.createGroupsHelp':
'Only selected groups are saved. Related groups required by the workflow are included automatically.',
'templates.editGroupsHelp':
'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.',
'templates.impact': 'Template impact',
'templates.inheritance': 'Inheritance',
'templates.cleanStart':
'Tickets and other operational ticket data are never copied. The source project stays unchanged.',
'templates.refreshHelp':
'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.',
'templates.linkedWarning': 'Linked projects will be affected',
'templates.linkedImpact':
'Saving updates the baseline for {count} linked projects. Inherited groups follow the new configuration; existing local overrides stay unchanged.',
'templates.acknowledge':
'I understand that projects inheriting from this template will be affected.',
'templates.saveNew': 'Save new template',
'templates.saveChanges': 'Save template changes',
'templates.missingSource': 'This template cannot be refreshed',
'templates.missingSourceHelp':
'The source project is no longer available. You can still use the saved template configuration to create a project.',
'templates.deleteTitle': 'Delete {name}',
'templates.delete': 'Delete template',
'templates.deleting': 'Deleting template…',
'templates.deleteHelp':
'This permanently removes the reusable template. It will no longer be available for new projects.',
'templates.deleteImpact':
'{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
'workflow.title': 'Board workflow', 'workflow.title': 'Board workflow',
'workflow.pageTitle': '{project} · Board workflow', 'workflow.pageTitle': '{project} · Board workflow',
'workflow.description': 'workflow.description':
@@ -263,6 +327,81 @@ export const en = {
'common.back': 'Back', 'common.back': 'Back',
'common.done': 'Done', 'common.done': 'Done',
'adminUsers.eyebrow': 'Workspace administration',
'adminUsers.title': 'User management',
'adminUsers.description':
'Manage workspace accounts, password requirements, and the global permissions that apply across KanbanGit.',
'adminUsers.managedAccount': 'Managed account',
'adminUsers.createAction': 'Create user',
'adminUsers.updated': 'User management updated',
'adminUsers.summary.label': 'User management summary',
'adminUsers.summary.total': 'Workspace users',
'adminUsers.summary.admins': 'Global administrators',
'adminUsers.summary.passwordChanges': 'Password changes pending',
'adminUsers.listTitle': 'Users ({count})',
'adminUsers.listDescription':
'Review account status, edit profile details, or confirm global access changes.',
'adminUsers.empty.title': 'No users yet',
'adminUsers.empty.description':
'Create the first managed account for this workspace.',
'adminUsers.passwordChangePending': 'Password change required',
'adminUsers.permissions.none': 'No direct global permissions',
'adminUsers.permissions.action': 'Manage access',
'adminUsers.permissions.available': 'Available global permissions',
'adminUsers.permissions.globalAdmin': 'Global administrator',
'adminUsers.permissions.globalAdminHelp':
'Allows access to workspace-wide administration, including this user management area.',
'adminUsers.permissions.projectCreate': 'Create projects',
'adminUsers.permissions.projectCreateHelp':
'Allows the user to create new projects without granting global administration.',
'adminUsers.permissions.title': 'Global access for {name}',
'adminUsers.permissions.description':
'Select the direct permissions this account should receive.',
'adminUsers.permissions.warning':
'These changes take effect across the workspace after confirmation.',
'adminUsers.permissions.confirm': 'Confirm access changes',
'adminUsers.create.title': 'Create user',
'adminUsers.create.description':
'Add a workspace account with an initial password and a clear first-login requirement.',
'adminUsers.edit.breadcrumb': 'Edit {name}',
'adminUsers.edit.pageTitle': 'Edit {name}',
'adminUsers.edit.title': 'Edit {name}',
'adminUsers.edit.action': 'Edit account details for {name}',
'adminUsers.edit.shortAction': 'Edit details',
'adminUsers.form.detailsTitle': 'Account details',
'adminUsers.form.createDescription':
'Set the identity and initial security state for the new account.',
'adminUsers.form.editDescription':
'Update account details and review security-sensitive changes before saving.',
'adminUsers.form.name': 'Name',
'adminUsers.form.namePlaceholder': 'Jane Admin',
'adminUsers.form.email': 'Email address',
'adminUsers.form.emailPlaceholder': 'jane@example.com',
'adminUsers.form.passwordTitle': 'Password and first sign-in',
'adminUsers.form.passwordCreateHelp':
'Set a temporary password that the user can replace after signing in.',
'adminUsers.form.passwordEditHelp':
'Leave both password fields empty to keep the current password.',
'adminUsers.form.password': 'Temporary password',
'adminUsers.form.newPassword': 'New password',
'adminUsers.form.passwordPlaceholder': 'Enter a temporary password',
'adminUsers.form.passwordEditPlaceholder':
'Leave blank to keep the current password',
'adminUsers.form.confirmPassword': 'Confirm password',
'adminUsers.form.confirmPasswordPlaceholder': 'Repeat the new password',
'adminUsers.form.forcePasswordChange':
'Require a password change on next sign-in',
'adminUsers.form.forcePasswordChangeHelp':
'The user stays in security settings until a new password has been saved.',
'adminUsers.form.createAction': 'Create user',
'adminUsers.form.reviewChanges': 'Review changes',
'adminUsers.form.confirmTitle': 'Update {name}?',
'adminUsers.form.confirmDescription':
'Confirm the account and security changes before they are applied.',
'adminUsers.form.confirmWarning':
'A new password takes effect immediately. Requiring a password change restricts the user to security settings on their next sign-in.',
'adminUsers.form.confirmAction': 'Confirm and save',
'criteria.title': 'Acceptance criteria', 'criteria.title': 'Acceptance criteria',
'criteria.progress': '{completed} of {total} complete', 'criteria.progress': '{completed} of {total} complete',
'criteria.empty': 'No acceptance criteria yet.', 'criteria.empty': 'No acceptance criteria yet.',
@@ -692,6 +831,8 @@ export const en = {
'profile.delete.confirmAction': 'Permanently delete account', 'profile.delete.confirmAction': 'Permanently delete account',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.close': 'Close', 'common.close': 'Close',
'common.edit': 'Edit',
'common.remove': 'Remove',
'common.retry': 'Try again', 'common.retry': 'Try again',
'feedback.empty': 'Nothing here yet', 'feedback.empty': 'Nothing here yet',
'feedback.saved': 'Changes saved', 'feedback.saved': 'Changes saved',
@@ -983,6 +1124,81 @@ export const en = {
'inheritance.area.ticketLinkTypes': 'Ticket link types', 'inheritance.area.ticketLinkTypes': 'Ticket link types',
'inheritance.area.repositoryConnections': 'Repository connections', 'inheritance.area.repositoryConnections': 'Repository connections',
'inheritance.area.notificationActions': 'Notification actions', 'inheritance.area.notificationActions': 'Notification actions',
'repositorySettings.pageTitle': '{project} repository settings',
'repositorySettings.title': 'Project repositories',
'repositorySettings.description':
'Choose the repositories this project uses for ticket branches and pull requests.',
'repositorySettings.projectScope':
'Repository links belong to the project. Every team member keeps using the same linked repositories, while access is granted through their own personal Git connections.',
'repositorySettings.linkedRepositories': 'Linked repositories',
'repositorySettings.linkAction': 'Link repository',
'repositorySettings.empty': 'No project repositories yet',
'repositorySettings.emptyDescription':
'Link a repository from one of your personal Git connections to make it available to project tickets.',
'repositorySettings.personalAccessReady': 'Personal access ready',
'repositorySettings.personalAccessMissing': 'Personal access needed',
'repositorySettings.ticketUsage':
'Tickets use this project link to discover matching branches and pull requests.',
'repositorySettings.openRepository':
'Open {repository} in the Git provider (opens in a new tab)',
'repositorySettings.open': 'Open repository',
'repositorySettings.connectAccess': 'Connect Git access',
'repositorySettings.editRepository':
'Change project repository {repository}',
'repositorySettings.removeRepository':
'Remove project repository {repository}',
'repositorySettings.personalSources': 'Personal Git access',
'repositorySettings.personalSourcesDescription':
'These account connections are private to you and are only used to discover repositories you can link to this project.',
'repositorySettings.manageConnections': 'Manage Git connections',
'repositorySettings.refresh': 'Refresh repositories',
'repositorySettings.refreshing': 'Loading repositories…',
'repositorySettings.refreshingDescription':
'The latest repositories are being requested from your Git providers.',
'repositorySettings.noConnections': 'No personal Git connection',
'repositorySettings.noConnectionsDescription':
'Add a personal Git connection before linking or changing a project repository.',
'repositorySettings.detectedSources': 'Detected personal sources',
'repositorySettings.unavailable': 'Provider unavailable',
'repositorySettings.repositoryCount': '{count} repositories',
'repositorySettings.linkTitle': 'Link a project repository',
'repositorySettings.linkDescription':
'Choose a personal connection, find a repository, and add it to this project.',
'repositorySettings.editTitle': 'Change project repository',
'repositorySettings.editDescription':
'Replace this project link with another repository. Tickets will use the updated link after saving.',
'repositorySettings.removeTitle': 'Remove repository',
'repositorySettings.removeDescription':
'Remove this repository from the project? Tickets will no longer discover its branches or pull requests.',
'repositorySettings.linked': 'Repository linked to the project.',
'repositorySettings.updated': 'Project repository updated.',
'repositorySettings.removed': 'Repository removed from the project.',
'repositorySettings.matchingConnectionMissing':
'Matching personal access is missing',
'repositorySettings.matchingConnectionMissingDescription':
'The project link remains available to the team, but this account cannot query it until you add a matching provider connection. You can still choose a different accessible repository below.',
'repositorySettings.personalConnection': 'Personal Git connection',
'repositorySettings.personalConnectionHelp':
'Used for discovery only; it is not shared with the project.',
'repositorySettings.providerError':
'Repositories could not be loaded from this provider',
'repositorySettings.searchLabel': 'Find a repository',
'repositorySettings.searchPlaceholder':
'Search by repository, owner, or namespace',
'repositorySettings.noRepositories': 'No repositories available',
'repositorySettings.noRepositoriesDescription':
'This connection is working, but the provider returned no repositories you can access.',
'repositorySettings.noSearchResults': 'No matching repositories',
'repositorySettings.noSearchResultsDescription':
'No repository matches “{search}”. Try a different name or clear the search.',
'repositorySettings.repositoryLabel': 'Available repositories',
'repositorySettings.forbidden': 'Project access denied',
'repositorySettings.forbiddenDescription':
'You no longer have permission to manage repositories for this project. Close this dialog and refresh the page.',
'repositorySettings.linking': 'Linking repository…',
'repositorySettings.saving': 'Saving repository…',
'repositorySettings.save': 'Save repository',
'repositorySettings.removing': 'Removing repository…',
'gitConnections.description': 'gitConnections.description':
'Connect your personal Git account to browse repositories, branches, and pull requests you can access.', 'Connect your personal Git account to browse repositories, branches, and pull requests you can access.',
'gitConnections.privacy': 'gitConnections.privacy':
@@ -1069,4 +1285,47 @@ export const en = {
'metadata.chooseType': 'Choose a type', 'metadata.chooseType': 'Choose a type',
'metadata.parentReplacement': 'metadata.parentReplacement':
'If any affected ticket has child tickets, choose a parent type. The server checks this before deleting.', 'If any affected ticket has child tickets, choose a parent type. The server checks this before deleting.',
'metadata.customFields': 'Custom fields',
'metadata.customFieldsDescription':
'Define the project-specific user and number fields shown on tickets.',
'metadata.addField': 'Add field',
'metadata.noCustomFields': 'No custom fields',
'metadata.noCustomFieldsDescription':
'Add a field when this project needs structured information beyond the standard ticket details.',
'metadata.storedValues': 'Stored values: {count}',
'metadata.editField': 'Edit {name}',
'metadata.deleteField': 'Delete {name}',
'metadata.fieldEditorHelp':
'Choose a label and one of the supported field types. Existing ticket values remain attached when this definition is edited.',
'metadata.deleteFieldHelp':
'Delete this custom field? This action cannot be undone.',
'metadata.fieldDeletionImpact':
'Deleting this field also permanently removes {count} stored ticket values.',
'metadata.fieldLabel': 'Field label',
'metadata.fieldType': 'Field type',
'metadata.fieldTypeHelp':
'Only the field types already supported by this project are available.',
'metadata.linkTypes': 'Link types',
'metadata.linkTypesDescription':
'Define the labels shown from each side of a ticket relationship.',
'metadata.addLinkType': 'Add link type',
'metadata.noLinkTypes': 'No link types',
'metadata.noLinkTypesDescription':
'Add a link type before tickets can be connected with a project-specific relationship.',
'metadata.ticketRelationships': 'Ticket relationships: {count}',
'metadata.linkDirection': '{source} points to {counterpart}',
'metadata.editLinkType': 'Edit {name}',
'metadata.deleteLinkType': 'Delete {name}',
'metadata.linkTypeEditorHelp':
'The source label is shown on the originating ticket; the counterpart label is shown on the linked ticket.',
'metadata.deleteLinkTypeHelp':
'Delete this link type? This action cannot be undone.',
'metadata.linkTypeDeletionImpact':
'Deleting this link type also permanently removes {count} ticket relationships.',
'metadata.sourceLabel': 'Source label',
'metadata.sourceLabelHelp':
'Shown when viewing the ticket where the relationship starts.',
'metadata.counterpartLabel': 'Counterpart label',
'metadata.counterpartLabelHelp':
'Shown when viewing the ticket at the other end of the relationship.',
} as const; } as const;
+24 -150
View File
@@ -1,165 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, router } from '@inertiajs/vue3';
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController'; import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import ManagedUserForm from '@/components/ManagedUserForm.vue';
import InputError from '@/components/InputError.vue'; import { useLocalization } from '@/composables/useLocalization';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { create, index } from '@/routes/admin/users'; import { create, index } from '@/routes/admin/users';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [ const { t } = useLocalization();
{ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
title: 'User management', { title: t('adminUsers.title'), href: index() },
href: index(), { title: t('adminUsers.create.title'), href: create() },
}, ]);
{
title: 'Create user',
href: create(),
},
];
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="Create user" /> <Head :title="t('adminUsers.create.title')" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4"> <div
<Heading class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
title="Create user"
description="Create a user account and optionally force a password change on the next login."
/>
<Card>
<CardHeader>
<CardTitle>User details</CardTitle>
<CardDescription>
Set an initial password that the user can replace after
their first sign-in.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="UserManagementController.store.form()"
:transform="
(data) => ({
...data,
must_change_password_on_next_login:
data.must_change_password_on_next_login ===
true ||
data.must_change_password_on_next_login ===
'on',
})
"
class="space-y-6"
v-slot="{ errors, processing }"
> >
<div class="grid gap-2"> <header class="gap-stack grid min-w-0">
<Label for="name">Name</Label> <p class="text-label text-muted-foreground uppercase">
<Input {{ t('adminUsers.eyebrow') }}
id="name" </p>
name="name" <h1 class="text-headline-mobile md:text-headline">
required {{ t('adminUsers.create.title') }}
autofocus </h1>
autocomplete="name" <p class="text-muted-foreground max-w-2xl">
placeholder="Jane Admin" {{ t('adminUsers.create.description') }}
/> </p>
<InputError :message="errors.name" /> </header>
</div>
<div class="grid gap-2"> <ManagedUserForm mode="create" @cancel="router.visit(index())" />
<Label for="email">Email</Label>
<Input
id="email"
name="email"
type="email"
required
autocomplete="email"
placeholder="jane@example.com"
/>
<InputError :message="errors.email" />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<PasswordInput
id="password"
name="password"
required
autocomplete="new-password"
placeholder="Temporary password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation"
>Confirm password</Label
>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
required
autocomplete="new-password"
placeholder="Confirm password"
/>
</div>
<div class="grid gap-2">
<Label
for="must_change_password_on_next_login"
class="flex items-start gap-3 rounded-lg border px-4 py-3"
>
<input
id="must_change_password_on_next_login"
name="must_change_password_on_next_login"
type="checkbox"
checked
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
/>
<span class="space-y-1">
<span class="block font-medium">
Must change password on next login
</span>
<span
class="text-muted-foreground block text-sm"
>
Redirect the user to the security
settings page until they save a new
password.
</span>
</span>
</Label>
<InputError
:message="
errors.must_change_password_on_next_login
"
/>
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="processing">
Create user
</Button>
<Button as-child variant="outline">
<Link :href="index()">Cancel</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+39 -149
View File
@@ -1,170 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, router } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue'; import { computed } from 'vue';
import InputError from '@/components/InputError.vue'; import ManagedUserForm from '@/components/ManagedUserForm.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Button } from '@/components/ui/button'; import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { edit, index, save } from '@/routes/admin/users'; import { edit, index } from '@/routes/admin/users';
import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types'; import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types';
const props = defineProps<AdminUserEditPageProps>(); const props = defineProps<AdminUserEditPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('adminUsers.title'), href: index() },
{ {
title: 'User management', title: t('adminUsers.edit.breadcrumb', { name: props.user.name }),
href: index(),
},
{
title: 'Edit user',
href: edit(props.user.id), href: edit(props.user.id),
}, },
]; ]);
const form = useForm({
name: props.user.name,
email: props.user.email,
password: '',
password_confirmation: '',
must_change_password_on_next_login:
props.user.must_change_password_on_next_login,
});
const submit = (): void => {
form.patch(save.url(props.user.id), {
preserveScroll: true,
});
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Edit ${user.name}`" /> <Head :title="t('adminUsers.edit.pageTitle', { name: user.name })" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4"> <div
<Heading class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
:title="`Edit ${user.name}`"
description="Update user details, reset the password if needed, and control whether the user must change it on next login."
/>
<Card>
<CardHeader>
<CardTitle>User details</CardTitle>
<CardDescription>
Leave the password fields empty if the current password
should stay unchanged.
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input
id="name"
v-model="form.name"
required
autofocus
autocomplete="name"
placeholder="Jane Admin"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
v-model="form.email"
type="email"
required
autocomplete="email"
placeholder="jane@example.com"
/>
<InputError :message="form.errors.email" />
</div>
<div class="grid gap-2">
<Label for="password">New password</Label>
<PasswordInput
id="password"
v-model="form.password"
autocomplete="new-password"
placeholder="Leave blank to keep current password"
/>
<InputError :message="form.errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation"
>Confirm new password</Label
> >
<PasswordInput <header
id="password_confirmation" class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
v-model="form.password_confirmation"
autocomplete="new-password"
placeholder="Confirm new password"
/>
<InputError
:message="form.errors.password_confirmation"
/>
</div>
<div class="grid gap-2">
<Label
for="must_change_password_on_next_login"
class="flex items-start gap-3 rounded-lg border px-4 py-3"
> >
<input <UserAvatar
id="must_change_password_on_next_login" :user="user"
v-model=" size="xl"
form.must_change_password_on_next_login rounded
" class="ring-border shrink-0 ring-1"
type="checkbox"
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
/> />
<span class="space-y-1"> <div class="gap-stack grid min-w-0">
<span class="block font-medium"> <p class="text-label text-muted-foreground uppercase">
Must change password on next login {{ t('adminUsers.eyebrow') }}
</span> </p>
<span <h1
class="text-muted-foreground block text-sm" class="text-headline-mobile md:text-headline break-words"
> >
Keep the user locked to the security {{ t('adminUsers.edit.title', { name: user.name }) }}
settings page until they save a new </h1>
password. <p class="text-muted-foreground break-all">
</span> {{ user.email }}
</span> </p>
</Label> </div>
<InputError </header>
:message="
form.errors <ManagedUserForm
.must_change_password_on_next_login mode="edit"
" :user="user"
@cancel="router.visit(index())"
/> />
</div> </div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save user
</Button>
<Button as-child variant="outline">
<Link :href="index()">Cancel</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout> </AppLayout>
</template> </template>
+376 -175
View File
@@ -1,267 +1,468 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { import {
FolderPlus, FolderPlus,
KeyRound, KeyRound,
Pencil, Pencil,
Shield, ShieldCheck,
UserPlus, UserPlus,
Users, UsersRound,
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController'; import { computed, ref } from 'vue';
import Heading from '@/components/Heading.vue'; import { update } from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.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';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
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 { import {
create as createAdminUser, create as createAdminUser,
edit as editAdminUser, edit as editAdminUser,
index as adminUsers, index as adminUsers,
} from '@/routes/admin/users'; } from '@/routes/admin/users';
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types'; import type {
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; AdminUsersPageProps,
BreadcrumbItem,
PermissionOption,
} from '@/types';
import type { UserModel } from '@/types/models/user-model';
const props = defineProps<AdminUsersPageProps>(); const props = defineProps<AdminUsersPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const permissionsOpen = ref(false);
const selectedUser = ref<UserModel | null>(null);
const permissionForm = useForm('AdminUserGlobalPermissions', {
permissions: [] as string[],
});
const permissionProtection = useFormProtection(permissionForm, {
isDirty: () => permissionsOpen.value && permissionForm.isDirty,
});
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('adminUsers.title'), href: adminUsers() },
]);
const summaryItems = computed(() => [
{ {
title: 'User management', label: t('adminUsers.summary.total'),
href: adminUsers(), value: props.users.length,
icon: UsersRound,
}, },
]; {
label: t('adminUsers.summary.admins'),
value: props.users.filter((user) =>
hasPermission(user.id, 'global:admin'),
).length,
icon: ShieldCheck,
},
{
label: t('adminUsers.summary.passwordChanges'),
value: props.users.filter(
(user) => user.must_change_password_on_next_login,
).length,
icon: KeyRound,
},
]);
const hasPermission = (actorId: number, permission: string) => function permissionsFor(userId: number): string[] {
(props.directPermissions[actorId] ?? []).includes(permission); return props.directPermissions[userId] ?? [];
}
function hasPermission(userId: number, permission: string): boolean {
return permissionsFor(userId).includes(permission);
}
function permissionLabel(option: PermissionOption): string {
if (option.value === 'global:admin') {
return t('adminUsers.permissions.globalAdmin');
}
if (option.value === 'project:create') {
return t('adminUsers.permissions.projectCreate');
}
return option.label;
}
function openPermissions(user: UserModel): void {
selectedUser.value = user;
const permissions = [...permissionsFor(user.id)];
permissionForm.defaults({ permissions });
permissionForm.resetAndClearErrors();
permissionProtection.clearFeedback();
permissionsOpen.value = true;
}
function updatePermission(value: string, checked: boolean): void {
permissionForm.permissions = checked
? [...new Set([...permissionForm.permissions, value])]
: permissionForm.permissions.filter(
(permission) => permission !== value,
);
permissionForm.clearErrors('permissions');
permissionProtection.clearFeedback();
}
function closePermissions(): void {
permissionsOpen.value = false;
selectedUser.value = null;
permissionForm.defaults({ permissions: [] });
permissionForm.resetAndClearErrors();
permissionProtection.clearFeedback();
}
function updatePermissionsOpen(open: boolean): void {
if (open) {
permissionsOpen.value = true;
return;
}
if (!permissionForm.processing && permissionProtection.confirmLeave()) {
closePermissions();
}
}
function submitPermissions(): void {
if (!selectedUser.value) {
return;
}
permissionProtection.submit(update(selectedUser.value.id), {
preserveScroll: true,
onSuccess: closePermissions,
});
}
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="User management" /> <Head :title="t('adminUsers.title')" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<div class="flex items-start justify-between gap-4"> <header
<Heading class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
title="User management" >
description="Assign the global permissions that unlock platform-wide administration and project creation." <div class="gap-stack grid min-w-0">
/> <p class="text-label text-muted-foreground uppercase">
{{ t('adminUsers.eyebrow') }}
<div class="flex items-center gap-3"> </p>
<Button as-child> <h1 class="text-headline-mobile md:text-headline">
{{ t('adminUsers.title') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('adminUsers.description') }}
</p>
</div>
<Button as-child class="self-start">
<Link :href="createAdminUser()"> <Link :href="createAdminUser()">
<UserPlus class="size-4" /> <UserPlus aria-hidden="true" />
User create {{ t('adminUsers.createAction') }}
</Link> </Link>
</Button> </Button>
</header>
<Badge variant="outline" class="gap-1.5 px-3 py-1"> <FeedbackState
<Users class="size-3.5" />
Admin-only area
</Badge>
</div>
</div>
<div
v-if="status" v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100" state="success"
:title="t('adminUsers.updated')"
:description="status"
/>
<section :aria-label="t('adminUsers.summary.label')">
<ul class="grid gap-3 sm:grid-cols-3">
<li
v-for="item in summaryItems"
:key="item.label"
class="min-w-0"
> >
{{ status }} <Card class="h-full gap-0 rounded-md py-0">
<CardContent
class="flex min-w-0 items-center justify-between gap-4 p-4"
>
<div class="min-w-0">
<p
class="text-caption text-muted-foreground break-words"
>
{{ item.label }}
</p>
<p class="text-title mt-1 tabular-nums">
{{ item.value }}
</p>
</div>
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<component
:is="item.icon"
class="size-5"
aria-hidden="true"
/>
</span>
</CardContent>
</Card>
</li>
</ul>
</section>
<section
aria-labelledby="managed-users-title"
class="gap-column grid min-w-0"
>
<div class="gap-stack grid">
<h2 id="managed-users-title" class="text-title">
{{
t('adminUsers.listTitle', {
count: users.length,
})
}}
</h2>
<p class="text-muted-foreground max-w-2xl">
{{ t('adminUsers.listDescription') }}
</p>
</div> </div>
<Card class="gap-0 overflow-hidden"> <FeedbackState
<CardHeader class="border-b">
<CardTitle>Users</CardTitle>
<CardDescription>
Global permissions are assigned directly to users and
apply across the entire application.
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<div
v-if="users.length === 0" v-if="users.length === 0"
class="text-muted-foreground p-6 text-sm" state="empty"
:title="t('adminUsers.empty.title')"
:description="t('adminUsers.empty.description')"
> >
No users exist yet. <Button as-child variant="outline">
</div> <Link :href="createAdminUser()">
<UserPlus aria-hidden="true" />
{{ t('adminUsers.createAction') }}
</Link>
</Button>
</FeedbackState>
<div v-else class="overflow-x-auto"> <ul
<table v-else
class="divide-border min-w-full divide-y text-sm" class="grid min-w-0 gap-4 lg:grid-cols-2 2xl:grid-cols-3"
> >
<thead <li
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">User</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">
Current access
</th>
<th
class="px-4 py-3 text-right font-medium"
>
Action
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="user in users" v-for="user in users"
:key="user.id" :key="user.id"
class="bg-background align-top" class="min-w-0"
data-managed-user
> >
<td class="px-4 py-4"> <article
<div class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-xl border"
class="text-foreground items-medium flex items-center"
> >
<div class="flex min-w-0 items-start gap-4 p-5">
<UserAvatar <UserAvatar
:user="user" :user="user"
class="mr-2 size-8" size="lg"
rounded rounded
class="ring-border shrink-0 ring-1"
/> />
<div class="min-w-0 flex-1">
<h3
class="text-body break-words font-semibold"
>
{{ user.name }} {{ user.name }}
</div> </h3>
</td> <p
<td class="text-muted-foreground px-4 py-4"> class="text-caption text-muted-foreground break-all"
>
{{ user.email }} {{ user.email }}
</td> </p>
<td class="px-4 py-4"> </div>
<div class="flex flex-wrap gap-2"> <Badge variant="outline" class="shrink-0">
{{ t('adminUsers.managedAccount') }}
</Badge>
</div>
<div
class="gap-stack flex min-w-0 flex-1 flex-wrap content-start border-y px-5 py-4"
>
<Badge <Badge
v-if=" v-if="
user.must_change_password_on_next_login user.must_change_password_on_next_login
" "
variant="outline" variant="outline"
class="gap-1.5" class="gap-1.5 whitespace-normal"
> >
<KeyRound class="size-3.5" /> <KeyRound
Must change password class="size-3.5"
aria-hidden="true"
/>
{{ t('adminUsers.passwordChangePending') }}
</Badge> </Badge>
<Badge <Badge
v-if=" v-for="option in permissionOptions"
hasPermission( v-show="
user.id, hasPermission(user.id, option.value)
'global:admin',
)
"
class="gap-1.5"
>
<Shield class="size-3.5" />
Global admin
</Badge>
<Badge
v-if="
hasPermission(
user.id,
'project:create',
)
" "
:key="option.value"
variant="secondary" variant="secondary"
class="gap-1.5" class="gap-1.5 whitespace-normal"
> >
<FolderPlus class="size-3.5" /> <ShieldCheck
Project create v-if="option.value === 'global:admin'"
class="size-3.5"
aria-hidden="true"
/>
<FolderPlus
v-else
class="size-3.5"
aria-hidden="true"
/>
{{ permissionLabel(option) }}
</Badge> </Badge>
<span <span
v-if=" v-if="permissionsFor(user.id).length === 0"
( class="text-caption text-muted-foreground"
directPermissions[
user.id
] ?? []
).length === 0
"
class="text-muted-foreground"
> >
No direct global permissions {{ t('adminUsers.permissions.none') }}
</span> </span>
</div> </div>
</td>
<td class="px-4 py-4">
<div class="flex justify-end">
<div <div
class="flex flex-wrap justify-end gap-3" class="grid gap-2 p-4 sm:grid-cols-2"
data-user-actions
> >
<Button <Button
as-child
size="sm"
variant="outline" variant="outline"
@click="openPermissions(user)"
> >
<ShieldCheck aria-hidden="true" />
{{ t('adminUsers.permissions.action') }}
</Button>
<Button as-child variant="ghost">
<Link <Link
:href=" :href="editAdminUser(user.id)"
editAdminUser( :aria-label="
user.id, t('adminUsers.edit.action', {
) name: user.name,
})
" "
> >
<Pencil <Pencil aria-hidden="true" />
class="size-4" {{ t('adminUsers.edit.shortAction') }}
/>
Edit
</Link> </Link>
</Button> </Button>
</div>
</article>
</li>
</ul>
</section>
</div>
<Form <Dialog :open="permissionsOpen" @update:open="updatePermissionsOpen">
v-bind=" <DialogContent>
UserManagementController.update.form( <DialogHeader>
user.id, <DialogTitle>
) {{
" t('adminUsers.permissions.title', {
class="flex flex-wrap justify-end gap-3" name: selectedUser?.name ?? '',
v-slot="{ processing }" })
}}
</DialogTitle>
<DialogDescription>
{{ t('adminUsers.permissions.description') }}
</DialogDescription>
</DialogHeader>
<form
class="gap-column grid"
:aria-busy="permissionForm.processing"
@submit.prevent="submitPermissions"
> >
<label <FormFeedback
:state="permissionProtection.state.value"
@retry="submitPermissions"
/>
<fieldset
class="grid gap-3"
:disabled="permissionForm.processing"
>
<legend class="sr-only">
{{ t('adminUsers.permissions.available') }}
</legend>
<Label
v-for="option in permissionOptions" v-for="option in permissionOptions"
:key="`${user.id}-${option.value}`" :key="option.value"
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm" :for="'permission-' + option.value"
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
> >
<input <Checkbox
type="checkbox" :id="'permission-' + option.value"
name="permissions[]"
:value="
option.value
"
:checked=" :checked="
hasPermission( permissionForm.permissions.includes(
user.id,
option.value, option.value,
) )
" "
class="border-input text-primary focus:ring-ring size-4 rounded" :value="option.value"
:invalid="!!permissionForm.errors.permissions"
:aria-describedby="
'permission-help-' + option.value
"
@update:checked="
(checked: boolean | 'indeterminate') =>
updatePermission(
option.value,
checked === true,
)
"
/> />
<span>{{ <span class="grid gap-1">
option.label <span>{{ permissionLabel(option) }}</span>
}}</span> <span
</label> :id="'permission-help-' + option.value"
class="text-caption text-muted-foreground font-normal"
>
{{
t(
option.value === 'global:admin'
? 'adminUsers.permissions.globalAdminHelp'
: 'adminUsers.permissions.projectCreateHelp',
)
}}
</span>
</span>
</Label>
<InputError
:message="permissionForm.errors.permissions"
/>
</fieldset>
<div <div
class="w-full sm:hidden" class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
/> >
{{ t('adminUsers.permissions.warning') }}
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
:disabled="permissionForm.processing"
@click="updatePermissionsOpen(false)"
>
{{ t('common.cancel') }}
</Button>
<Button <Button
type="submit" type="submit"
size="sm" :loading="permissionForm.processing"
variant="outline" data-test="confirm-user-permissions"
:disabled="processing"
> >
Save {{ t('adminUsers.permissions.confirm') }}
</Button> </Button>
</Form> </DialogFooter>
</div> </form>
</div> </DialogContent>
</td> </Dialog>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</AppLayout> </AppLayout>
</template> </template>
+24 -160
View File
@@ -1,174 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
import InputError from '@/components/InputError.vue'; import { useLocalization } from '@/composables/useLocalization';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { import { index, show } from '@/routes/projects';
index as projectsIndex, import { create } from '@/routes/projects/templates';
show as projectShow,
} from '@/routes/projects/index';
import {
create as projectTemplateCreate,
store as projectTemplateStore,
} from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types'; import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
const props = defineProps<ProjectTemplatesCreatePageProps>(); const props = defineProps<ProjectTemplatesCreatePageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.projects'), href: index() },
title: 'Projects', { title: props.project.name, href: show(props.project.id) },
href: projectsIndex(), { title: t('projects.saveTemplate'), href: create(props.project.id) },
}, ]);
{
title: props.project.name,
href: projectShow(props.project.id),
},
{
title: 'Save as new template',
href: projectTemplateCreate(props.project.id),
},
];
const form = useForm({
copy_areas: props.copyAreaOptions.map(
(copyAreaOption) => copyAreaOption.value,
),
name: `${props.project.name} template`,
slug: `${props.project.key.toLowerCase()}-template`,
});
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1] ??
''
);
});
const submit = (): void => {
form.post(projectTemplateStore.url(props.project.id), {
preserveScroll: true,
});
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Save ${project.name} as new template`" /> <Head :title="t('templates.createTitle', { project: project.name })" />
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4"> <header class="gap-stack grid">
<div class="space-y-3"> <h1 class="text-headline-mobile md:text-headline break-words">
<Badge variant="outline" class="px-3 py-1">{{ {{ t('templates.createTitle', { project: project.name }) }}
project.key </h1>
}}</Badge> <p class="text-muted-foreground">
<Heading {{ t('templates.createDescription') }}
:title="`Save ${project.name} as new template`"
description="Capture reusable project configuration without copying operational ticket data."
/>
</div>
<Card>
<CardHeader>
<CardTitle>Template details</CardTitle>
<CardDescription>
Pick the configuration groups that should be reusable in
future projects.
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Template name</Label>
<Input
id="name"
v-model="form.name"
required
placeholder="Foundation template"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="slug">Template slug</Label>
<Input
id="slug"
v-model="form.slug"
required
placeholder="foundation-template"
/>
<p class="text-muted-foreground text-sm">
Slugs are normalized automatically and used to
identify reusable templates.
</p> </p>
<InputError :message="form.errors.slug" /> </header>
</div> <ProjectTemplateForm
:key="project.id"
<div class="grid gap-3"> :project="project"
<div> :copy-area-options="copyAreaOptions"
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Only the selected groups will be stored in
the template.
</p>
</div>
<div class="grid gap-3">
<Label
v-for="copyAreaOption in copyAreaOptions"
:key="copyAreaOption.value"
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="copyAreaOption.value"
class="mt-1"
/> />
<div class="space-y-1">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<InputError :message="copyAreasError" />
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save new template
</Button>
<Button as-child variant="outline">
<Link :href="projectShow(project.id)"
>Cancel</Link
>
</Button>
</div>
</form>
</CardContent>
</Card>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+22 -209
View File
@@ -1,223 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
import InputError from '@/components/InputError.vue'; import { useLocalization } from '@/composables/useLocalization';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { import { index, edit } from '@/routes/project-templates';
edit as projectTemplatesEdit,
index as projectTemplatesIndex,
update as projectTemplatesUpdate,
} from '@/routes/project-templates';
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types'; import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
const props = defineProps<ProjectTemplatesEditPageProps>(); const props = defineProps<ProjectTemplatesEditPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.templates'), href: index() },
title: 'Project templates', { title: props.template.name, href: edit(props.template.id) },
href: projectTemplatesIndex(), ]);
},
{
title: props.template.name,
href: projectTemplatesEdit(props.template.id),
},
];
const form = useForm({
confirm_linked_projects_update: false,
copy_areas: [...props.template.copy_areas],
name: props.template.name,
});
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1] ??
''
);
});
const linkedProjectCount = computed(
() => props.template.linked_project_count ?? 0,
);
const submit = (): void => {
form.patch(projectTemplatesUpdate.url(props.template.id), {
preserveScroll: true,
});
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Edit ${template.name}`" /> <Head :title="t('templates.editTitle', { name: template.name })" />
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4"> <header class="gap-stack grid">
<div class="space-y-3"> <h1 class="text-headline-mobile md:text-headline break-words">
<div class="flex flex-wrap items-center gap-3"> {{ t('templates.editTitle', { name: template.name }) }}
<Badge variant="outline" class="px-3 py-1">{{ </h1>
template.slug <p class="text-muted-foreground">
}}</Badge> {{ t('templates.editDescription') }}
<Badge v-if="linkedProjectCount > 0" variant="secondary">
{{ linkedProjectCount }} linked
{{ linkedProjectCount === 1 ? 'project' : 'projects' }}
</Badge>
</div>
<Heading
:title="`Edit ${template.name}`"
description="Refresh the template baseline from its source project while keeping immutable identifiers protected."
/>
</div>
<Card>
<CardHeader>
<CardTitle>Template details</CardTitle>
<CardDescription>
<template v-if="template.source_project">
This template baseline is rebuilt from
{{ template.source_project.name }} ({{
template.source_project.key
}}) when you save.
</template>
<template v-else>
This template baseline is rebuilt from its source
project when you save.
</template>
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Template name</Label>
<Input
id="name"
v-model="form.name"
required
placeholder="Foundation template"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="slug">Template slug</Label>
<Input
id="slug"
:model-value="template.slug"
disabled
/>
<p class="text-muted-foreground text-sm">
The slug stays locked so existing template
references remain stable.
</p> </p>
</div> </header>
<ProjectTemplateForm
<div class="grid gap-3"> :key="template.id"
<div> :template="template"
<Label>Configuration groups</Label> :copy-area-options="copyAreaOptions"
<p class="text-muted-foreground text-sm">
Saving rebuilds the selected groups from the
current source-project configuration.
</p>
</div>
<div class="grid gap-3">
<Label
v-for="copyAreaOption in copyAreaOptions"
:key="copyAreaOption.value"
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="copyAreaOption.value"
class="mt-1"
/> />
<div class="space-y-1">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<InputError :message="copyAreasError" />
</div>
<div
v-if="linkedProjectCount > 0"
class="space-y-3 rounded-xl border border-amber-300 bg-amber-50 p-4 text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
<div class="space-y-1">
<div class="font-medium">Warning</div>
<p class="text-sm">
Saving this template updates the baseline
for {{ linkedProjectCount }} linked
{{
linkedProjectCount === 1
? 'project'
: 'projects'
}}. Inherited areas will follow the new
template version, while existing overrides
stay untouched.
</p>
</div>
<Label
class="flex cursor-pointer items-start gap-3"
>
<input
v-model="
form.confirm_linked_projects_update
"
type="checkbox"
class="mt-1"
/>
<span class="text-sm">
I understand that linked projects inheriting
from this template will be affected.
</span>
</Label>
<InputError
:message="
form.errors.confirm_linked_projects_update
"
/>
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save template changes
</Button>
<Button as-child variant="outline">
<Link :href="projectTemplatesIndex()"
>Cancel</Link
>
</Button>
</div>
</form>
</CardContent>
</Card>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+217 -117
View File
@@ -1,158 +1,258 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue'; import { ArrowRight, LayoutTemplate, Plus, Trash2 } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.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';
import { import {
Card, Dialog,
CardContent, DialogContent,
CardDescription, DialogDescription,
CardHeader, DialogFooter,
CardTitle, DialogHeader,
} from '@/components/ui/card'; DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { destroy, edit, index } from '@/routes/project-templates';
import { import {
destroy as projectTemplatesDestroy, create as createProject,
edit as projectTemplatesEdit,
index as projectTemplatesIndex,
} from '@/routes/project-templates';
import {
create as projectsCreate,
index as projectsIndex, index as projectsIndex,
} from '@/routes/projects/index'; } from '@/routes/projects';
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types'; import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
const props = defineProps<ProjectTemplatesIndexPageProps>(); const props = defineProps<ProjectTemplatesIndexPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const { areaText } = useProjectTemplateAreas();
{ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
title: 'Project templates', { title: t('navigation.templates'), href: index() },
href: projectTemplatesIndex(), ]);
}, const templates = computed(() =>
]; props.templates.filter((template) => template.is_published),
);
const removeTemplate = (templateId: number): void => { const pending = ref<ProjectTemplateModel | null>(null);
router.delete(projectTemplatesDestroy.url(templateId), { const form = useForm({});
const protection = useFormProtection(form);
function requestDelete(template: ProjectTemplateModel): void {
form.resetAndClearErrors();
protection.clearFeedback();
pending.value = template;
}
function close(open: boolean): void {
if (!open && !form.processing) {
pending.value = null;
form.clearErrors();
protection.clearFeedback();
}
}
function removeTemplate(): void {
if (!pending.value) {
return;
}
protection.submit(destroy(pending.value.id), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => {
pending.value = null;
},
}); });
}; }
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="Project templates" /> <Head :title="t('navigation.templates')" />
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-7xl">
<div class="flex flex-1 flex-col gap-6 p-4"> <header
<div class="gap-column flex flex-col sm:flex-row sm:items-start sm:justify-between"
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
<Heading <div class="gap-stack grid min-w-0">
title="Project templates" <h1 class="text-headline-mobile md:text-headline">
description="Reusable published project templates. Internal project base templates stay hidden here by default." {{ t('navigation.templates') }}
/> </h1>
<p class="text-muted-foreground max-w-2xl">
<div class="flex flex-wrap gap-3"> {{ t('templates.description') }}
<Button as-child variant="outline"> </p>
<Link :href="projectsIndex()">Back to projects</Link>
</Button>
<Button as-child>
<Link :href="projectsCreate()">Create project</Link>
</Button>
</div> </div>
</div> <Button as-child class="shrink-0"
><Link :href="createProject()"
<div ><Plus aria-hidden="true" />{{
t('projects.create')
}}</Link
></Button
>
</header>
<FeedbackState
v-if="status" v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100" state="success"
:title="t('templates.updated')"
/>
<section
class="bg-card gap-column flex min-w-0 flex-col rounded-md border p-5 sm:flex-row sm:items-center"
aria-labelledby="save-template-help"
> >
{{ status }} <LayoutTemplate
class="text-primary size-6 shrink-0"
aria-hidden="true"
/>
<div class="gap-stack grid min-w-0 flex-1">
<h2 id="save-template-help" class="text-title">
{{ t('templates.fromProject') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('templates.fromProjectHelp') }}
</p>
</div> </div>
<Button as-child variant="outline" class="shrink-0"
<div ><Link :href="projectsIndex()"
>{{ t('templates.browseProjects')
}}<ArrowRight aria-hidden="true" /></Link
></Button>
</section>
<FeedbackState
v-if="templates.length === 0" v-if="templates.length === 0"
class="text-muted-foreground text-sm" state="empty"
:title="t('templates.empty')"
:description="t('templates.emptyHelp')"
/>
<ul
v-else
class="gap-column grid min-w-0 md:grid-cols-2 xl:grid-cols-3"
:aria-label="t('templates.published')"
> >
No published project templates have been saved yet. <li
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card
v-for="template in templates" v-for="template in templates"
:key="template.id" :key="template.id"
class="h-full" class="bg-card flex min-w-0 flex-col rounded-md border"
:data-template="template.id"
> >
<CardHeader> <div class="gap-column grid min-w-0 p-5">
<div class="flex flex-wrap items-center gap-2"> <div class="gap-stack grid min-w-0">
<CardTitle>{{ template.name }}</CardTitle> <h2 class="text-title break-words">
<Badge variant="secondary">{{ {{ template.name }}
template.slug </h2>
}}</Badge> <p
class="text-caption text-muted-foreground break-all"
>
{{ template.slug }}
</p>
</div>
<Badge <Badge
v-if="template.linked_project_count" variant="secondary"
variant="outline" class="w-fit whitespace-normal"
>{{
t('templates.linkedCount', {
count: template.linked_project_count ?? 0,
})
}}</Badge
>
<p
class="text-caption text-muted-foreground break-words"
> >
{{ template.linked_project_count }} linked
{{ {{
template.linked_project_count === 1 template.source_project
? 'project' ? t('templates.savedFrom', {
: 'projects' project: template.source_project.name,
key: template.source_project.key,
})
: t('templates.sourceUnavailable')
}} }}
</Badge> </p>
</div> <ul
<CardDescription> class="flex flex-wrap gap-2"
<template v-if="template.source_project"> :aria-label="t('templates.groups')"
Saved from
{{ template.source_project.name }} ({{
template.source_project.key
}}).
</template>
<template v-else>
Saved template configuration.
</template>
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap gap-2">
<Badge
v-for="copyArea in template.copy_areas"
:key="copyArea"
variant="outline"
> >
{{ copyArea }} <li v-for="area in template.copy_areas" :key="area">
</Badge> <Badge
variant="outline"
class="whitespace-normal"
>{{ areaText(area, 'label') }}</Badge
>
</li>
</ul>
</div> </div>
<div class="gap-stack mt-auto flex flex-wrap border-t p-4">
<div class="flex flex-wrap gap-3"> <Button as-child variant="secondary"
<Button as-child variant="secondary"> ><Link
<Link :href="projectTemplatesEdit(template.id)">
Edit
</Link>
</Button>
<Button as-child variant="outline">
<Link
:href=" :href="
projectsCreate({ createProject({
query: { template: template.id }, query: { template: template.id },
}) })
" "
>{{ t('templates.use') }}</Link
></Button
>
<Button as-child variant="outline"
><Link
:href="edit(template.id)"
:aria-label="
t('templates.editTitle', {
name: template.name,
})
"
>{{ t('templates.edit') }}</Link
></Button
> >
Use template
</Link>
</Button>
<Button <Button
type="button" variant="ghost"
variant="destructive" size="icon"
@click="removeTemplate(template.id)" class="text-destructive ml-auto"
:aria-label="
t('templates.deleteTitle', {
name: template.name,
})
"
@click="requestDelete(template)"
><Trash2 aria-hidden="true"
/></Button>
</div>
</li>
</ul>
</div>
<Dialog :open="pending !== null" @update:open="close">
<DialogContent :show-close-button="!form.processing">
<DialogHeader>
<DialogTitle>{{
t('templates.deleteTitle', {
name: pending?.name ?? '',
})
}}</DialogTitle>
<DialogDescription>{{
t('templates.deleteHelp')
}}</DialogDescription>
</DialogHeader>
<p v-if="pending?.linked_project_count" class="text-body">
{{
t('templates.deleteImpact', {
count: pending.linked_project_count,
})
}}
</p>
<FormFeedback
:state="protection.state.value"
@retry="removeTemplate"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
> >
Delete <Button
</Button> variant="destructive"
</div> :loading="form.processing"
</CardContent> :loading-label="t('templates.deleting')"
</Card> @click="removeTemplate"
</div> >{{ t('templates.delete') }}</Button
</div> >
</DialogFooter>
</DialogContent>
</Dialog>
</AppLayout> </AppLayout>
</template> </template>
+10 -314
View File
@@ -1,23 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FeedbackState from '@/components/FeedbackState.vue'; import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import MetadataEditorDialog from '@/components/projects/MetadataEditorDialog.vue'; import MetadataEditorDialog from '@/components/projects/MetadataEditorDialog.vue';
import MetadataOptionList from '@/components/projects/MetadataOptionList.vue'; import MetadataOptionList from '@/components/projects/MetadataOptionList.vue';
import TicketDisplaySettings from '@/components/projects/TicketDisplaySettings.vue'; import TicketDisplaySettings from '@/components/projects/TicketDisplaySettings.vue';
import TicketFieldDefinitionSettings from '@/components/projects/TicketFieldDefinitionSettings.vue';
import TicketLinkTypeSettings from '@/components/projects/TicketLinkTypeSettings.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue'; import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue'; import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
@@ -76,309 +66,15 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
:entries="ticketPriorities" :entries="ticketPriorities"
@edit="target = $event" @edit="target = $event"
/> />
<Card class="gap-0 overflow-hidden"> <TicketFieldDefinitionSettings
<CardHeader class="border-b"> :definitions="ticketFieldDefinitions"
<CardTitle>Custom fields</CardTitle> :field-type-options="fieldTypeOptions"
<CardDescription> :project-id="project.id"
Define project-specific user or number fields for
upcoming ticket data.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeFieldDefinition.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="field-label">New field label</Label>
<Input
id="field-label"
name="label"
required
placeholder="Reviewer"
/> />
<InputError :message="errors.label" /> <TicketLinkTypeSettings
</div> :link-types="ticketLinkTypes"
:project-id="project.id"
<div class="grid gap-2">
<Label for="field-type">Field type</Label>
<select
id="field-type"
name="field_type"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<InputError :message="errors.field_type" />
</div>
<Button type="submit" :disabled="processing">
Add field
</Button>
</Form>
<div
v-for="fieldDefinition in ticketFieldDefinitions"
:key="fieldDefinition.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateFieldDefinition.form(
[project.id, fieldDefinition.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`field-label-${fieldDefinition.id}`"
>
Label
</Label>
<Input
:id="`field-label-${fieldDefinition.id}`"
name="label"
:default-value="fieldDefinition.label"
required
/> />
<InputError :message="errors.label" />
</div>
<div class="grid gap-2">
<Label
:for="`field-type-${fieldDefinition.id}`"
>
Field type
</Label>
<select
:id="`field-type-${fieldDefinition.id}`"
name="field_type"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
:selected="
option.value ===
fieldDefinition.field_type
"
>
{{ option.label }}
</option>
</select>
<InputError :message="errors.field_type" />
</div>
<div class="grid gap-2">
<Label
:for="`field-position-${fieldDefinition.id}`"
>
Position
</Label>
<Input
:id="`field-position-${fieldDefinition.id}`"
name="position"
type="number"
min="1"
:max="ticketFieldDefinitions.length"
:default-value="fieldDefinition.position"
required
/>
<InputError :message="errors.position" />
</div>
<div class="flex flex-wrap justify-between gap-3">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyFieldDefinition.form(
[project.id, fieldDefinition.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Link types</CardTitle>
<CardDescription>
Define project-specific labels for ticket-to-ticket
relationships.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeLinkType.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="link-type-name"> Source label </Label>
<Input
id="link-type-name"
name="name"
required
placeholder="Blocks"
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="link-type-counterpart-name">
Counterpart label
</Label>
<Input
id="link-type-counterpart-name"
name="counterpart_name"
required
placeholder="Blocked by"
/>
<InputError :message="errors.counterpart_name" />
</div>
<Button type="submit" :disabled="processing">
Add link type
</Button>
</Form>
<div
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateLinkType.form(
[project.id, ticketLinkType.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`link-type-name-${ticketLinkType.id}`"
>
Source label
</Label>
<Input
:id="`link-type-name-${ticketLinkType.id}`"
name="name"
:default-value="ticketLinkType.label"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`link-type-counterpart-name-${ticketLinkType.id}`"
>
Counterpart label
</Label>
<Input
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
name="counterpart_name"
:default-value="
ticketLinkType.counterpart_label
"
required
/>
<InputError
:message="errors.counterpart_name"
/>
</div>
<div class="grid gap-2">
<Label
:for="`link-type-position-${ticketLinkType.id}`"
>
Position
</Label>
<Input
:id="`link-type-position-${ticketLinkType.id}`"
name="position"
type="number"
min="1"
:max="ticketLinkTypes.length"
:default-value="ticketLinkType.position"
required
/>
<InputError :message="errors.position" />
</div>
<div class="flex flex-wrap justify-between gap-3">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyLinkType.form(
[project.id, ticketLinkType.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
<MetadataEditorDialog <MetadataEditorDialog
:project-id="project.id" :project-id="project.id"
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ export type TicketFieldDefinitionModel = {
label: string; label: string;
position: number; position: number;
project_id: number; project_id: number;
ticket_field_values_count?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -4,6 +4,7 @@ export type TicketLinkTypeModel = {
counterpart_label: string; counterpart_label: string;
position: number; position: number;
project_id: number; project_id: number;
ticket_links_count?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
+13
View File
@@ -1,4 +1,6 @@
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model'; import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model'; import type { TicketTypeModel } from '@/types/models/ticket-type-model';
export type MetadataKind = 'type' | 'priority'; export type MetadataKind = 'type' | 'priority';
@@ -8,3 +10,14 @@ export type MetadataEditorTarget = { kind: MetadataKind } & (
| { action: 'create'; entry?: never } | { action: 'create'; entry?: never }
| { action: 'edit' | 'delete'; entry: MetadataEntry } | { action: 'edit' | 'delete'; entry: MetadataEntry }
); );
export type TicketFieldDefinitionEditorTarget =
| { action: 'create'; entry?: never }
| {
action: 'edit' | 'delete';
entry: TicketFieldDefinitionModel;
};
export type TicketLinkTypeEditorTarget =
| { action: 'create'; entry?: never }
| { action: 'edit' | 'delete'; entry: TicketLinkTypeModel };
@@ -0,0 +1,25 @@
<?php
namespace Tests\Feature\Admin;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class UserManagementUiTest extends TestCase
{
public function test_user_management_pages_render_responsive_actions_and_protected_forms(): void
{
$process = new Process(['node', 'tests/Fixtures/user-management.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue(
$process->isSuccessful(),
$process->getOutput().$process->getErrorOutput(),
);
$this->assertStringContainsString(
'User management rendering checks passed.',
$process->getOutput(),
);
}
}
@@ -63,6 +63,58 @@ class ProjectRepositorySettingsControllerTest extends TestCase
); );
} }
/**
* Ensure repository discovery distinguishes empty provider results from provider failures.
*/
public function test_repository_settings_expose_personal_access_and_provider_states(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$emptyGitConnection = UserGitConnection::factory()->for($user)->create([
'account_login' => 'empty-account',
'base_url' => 'https://empty.git.test',
'provider' => GitProviderKey::Gitea,
]);
UserGitConnection::factory()->for($user)->create([
'account_login' => 'broken-account',
'base_url' => 'https://broken.git.test',
'provider' => GitProviderKey::Gitea,
]);
RepositoryConnection::factory()->for($project)->create([
'base_url' => $emptyGitConnection->base_url,
'provider' => GitProviderKey::Gitea,
]);
$this->syncProjectPermissions($project, $user, ['edit']);
Http::preventStrayRequests();
Http::fake([
'https://empty.git.test/api/v1/user/repos*' => Http::response([]),
'https://broken.git.test/api/v1/user/repos*' => Http::response([], 502),
]);
$this->actingAs($user)
->get(route('projects.settings.repositories', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/Repositories')
->has('availableRepositorySources', 2)
->where('availableRepositorySources', function ($sources): bool {
$sourcesByAccount = collect($sources)->keyBy('account_login');
$emptySource = $sourcesByAccount->get('empty-account');
$brokenSource = $sourcesByAccount->get('broken-account');
return data_get($emptySource, 'provider') === GitProviderKey::Gitea->value
&& data_get($emptySource, 'provider_label') === GitProviderKey::Gitea->label()
&& data_get($emptySource, 'repositories') === []
&& data_get($emptySource, 'error') === null
&& data_get($brokenSource, 'repositories') === []
&& data_get($brokenSource, 'error') === config('app.name').' could not load repositories from this Git connection right now.';
})
->where('repositoryConnections.0.has_personal_access', true),
);
}
/** /**
* Ensure users without edit permission cannot manage repository settings. * Ensure users without edit permission cannot manage repository settings.
*/ */
@@ -163,6 +163,26 @@ class ProjectTemplateControllerTest extends TestCase
->assertNotFound(); ->assertNotFound();
} }
public function test_unlinked_templates_can_be_updated_without_an_impact_confirmation(): void
{
$user = User::factory()->create();
$source = Project::factory()->create();
$template = ProjectTemplate::factory()->create([
'source_project_id' => $source->id,
]);
$this->syncGlobalPermissions($user, ['project:create']);
$this->actingAs($user)
->patch(route('project-templates.update', $template), [
'name' => 'Updated reusable template',
'copy_areas' => ['project_settings'],
])
->assertSessionHasNoErrors()
->assertRedirect(route('project-templates.index'));
$this->assertSame('Updated reusable template', $template->fresh()->name);
}
public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectTemplatesUiTest extends TestCase
{
public function test_template_pages_show_reusable_configuration_and_protect_linked_projects(): void
{
$process = new Process(['node', 'tests/Fixtures/project-templates.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Project template checks passed.', $process->getOutput());
}
}
@@ -36,8 +36,27 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
]); ]);
TicketLinkType::factory()->for($project)->create([ TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to', 'label' => 'Relates to',
'counterpart_label' => 'Related from',
'position' => 1, 'position' => 1,
]); ]);
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'number',
'label' => 'Complexity',
'position' => 1,
]);
$sourceTicket = Ticket::factory()->for($project)->create();
$targetTicket = Ticket::factory()->for($project)->create([
'sequence_number' => 2,
'ticket_key' => 'TEST-2',
]);
$fieldDefinition->ticketFieldValues()->create([
'ticket_id' => $sourceTicket->id,
'value_number' => 8,
]);
$project->ticketLinkTypes()->firstOrFail()->ticketLinks()->create([
'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $targetTicket->id,
]);
$template = ProjectTemplate::factory()->create([ $template = ProjectTemplate::factory()->create([
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [ 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [
@@ -71,7 +90,14 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
->where('ticketTypes.1.name', 'Release') ->where('ticketTypes.1.name', 'Release')
->where('ticketTypes.1.is_parent', true) ->where('ticketTypes.1.is_parent', true)
->where('ticketTypes.2.name', 'Bug') ->where('ticketTypes.2.name', 'Bug')
->where('fieldTypeOptions.0.value', 'user')
->where('fieldTypeOptions.1.value', 'number')
->where('ticketFieldDefinitions.0.label', 'Complexity')
->where('ticketFieldDefinitions.0.field_type', 'number')
->where('ticketFieldDefinitions.0.ticket_field_values_count', 1)
->where('ticketLinkTypes.0.label', 'Relates to') ->where('ticketLinkTypes.0.label', 'Relates to')
->where('ticketLinkTypes.0.counterpart_label', 'Related from')
->where('ticketLinkTypes.0.ticket_links_count', 1)
->where('templateState.templateName', 'Issue Tracker Template') ->where('templateState.templateName', 'Issue Tracker Template')
->where('templateState.overriddenAreas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS), ->where('templateState.overriddenAreas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS),
); );
@@ -387,6 +413,11 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'field_type' => 'number', 'field_type' => 'number',
'position' => 1, 'position' => 1,
]); ]);
$ticket = Ticket::factory()->for($project)->create();
$fieldValue = $fieldDefinition->ticketFieldValues()->create([
'ticket_id' => $ticket->id,
'value_number' => 5,
]);
$this->syncProjectPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
@@ -412,6 +443,11 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'label' => 'Risk score', 'label' => 'Risk score',
'field_type' => 'number', 'field_type' => 'number',
]); ]);
$this->assertDatabaseHas('ticket_field_values', [
'id' => $fieldValue->id,
'ticket_field_definition_id' => $fieldDefinition->id,
'value_number' => 5,
]);
} }
/** /**
@@ -446,6 +482,15 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'counterpart_label' => 'Relates to', 'counterpart_label' => 'Relates to',
'position' => 1, 'position' => 1,
]); ]);
$sourceTicket = Ticket::factory()->for($project)->create();
$targetTicket = Ticket::factory()->for($project)->create([
'sequence_number' => 2,
'ticket_key' => 'TEST-2',
]);
$ticketLink = $ticketLinkType->ticketLinks()->create([
'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $targetTicket->id,
]);
$this->syncProjectPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
@@ -477,6 +522,10 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'label' => 'Depends on', 'label' => 'Depends on',
'counterpart_label' => 'Required by', 'counterpart_label' => 'Required by',
]); ]);
$this->assertDatabaseHas('ticket_links', [
'id' => $ticketLink->id,
'ticket_link_type_id' => $ticketLinkType->id,
]);
$this->actingAs($user) $this->actingAs($user)
->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType])) ->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType]))
@@ -486,6 +535,9 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->assertDatabaseMissing('ticket_link_types', [ $this->assertDatabaseMissing('ticket_link_types', [
'id' => $ticketLinkType->id, 'id' => $ticketLinkType->id,
]); ]);
$this->assertDatabaseMissing('ticket_links', [
'id' => $ticketLink->id,
]);
} }
public function test_last_priority_is_protected_from_deletion(): void public function test_last_priority_is_protected_from_deletion(): void
@@ -0,0 +1,207 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4189';
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));
const requests = [];
page.on('request', (request) => {
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
requests.push(request);
}
});
const button = (name) => page.getByRole('button', { name, exact: true });
const dialog = () => page.getByRole('dialog');
const result = (value) =>
context.addCookies([{ name: 'metadata_test_result', value, url: base }]);
const visit = async () => {
await result('success');
await page.goto(base + '/projects/1/settings/metadata?reset');
await page
.getByRole('heading', { name: 'Ticket settings', exact: true })
.waitFor();
};
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
const submit = async (label, method, pathname, expected) => {
const sent = page.waitForRequest((request) => request.method() === method);
await dialog().getByRole('button', { name: label, exact: true }).click();
const request = await sent;
assert.equal(new URL(request.url()).pathname, pathname);
assert.deepEqual(request.postDataJSON(), expected);
};
const closed = () => dialog().waitFor({ state: 'hidden' });
const formState = (state) =>
dialog()
.locator('[data-form-state="' + state + '"]')
.waitFor({ state: 'visible' });
try {
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await visit();
await noOverflow();
await page
.getByLabel('Blocks points to Blocked by', { exact: true })
.waitFor();
const trigger = button('Edit Reviewer');
await trigger.focus();
await page.keyboard.press('Enter');
await dialog().waitFor();
assert.equal(
await page
.getByLabel('Field label', { exact: true })
.inputValue(),
'Reviewer',
);
assert.equal(
await page
.getByLabel('Field type', { exact: true })
.inputValue(),
'user',
);
await page.keyboard.press('Tab');
assert.equal(
await dialog().evaluate((element) =>
element.contains(document.activeElement),
),
true,
);
await noOverflow();
await page.keyboard.press('Escape');
await closed();
assert.equal(
await trigger.evaluate(
(element) => document.activeElement === element,
),
true,
);
}
}
await visit();
await button('Add field').click();
await page.getByLabel('Field label', { exact: true }).fill('Owner');
await page.getByLabel('Field type', { exact: true }).selectOption('user');
await result('validation');
await submit('Add field', 'POST', '/projects/1/settings/metadata/fields', {
label: 'Owner',
field_type: 'user',
});
await formState('validation');
assert.equal(
await page
.getByLabel('Field label', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
await result('server');
await dialog()
.getByRole('button', { name: 'Add field', exact: true })
.click();
await formState('server');
await result('success');
await dialog()
.getByRole('button', { name: 'Try again', exact: true })
.click();
await closed();
await button('Edit Owner').click();
await page.getByLabel('Field label', { exact: true }).fill('Effort');
await page.getByLabel('Field type', { exact: true }).selectOption('number');
await page.getByLabel('Position', { exact: true }).fill('1');
await submit('Save', 'PATCH', '/projects/1/settings/metadata/fields/100', {
label: 'Effort',
field_type: 'number',
position: 1,
});
await closed();
await button('Edit Effort').waitFor();
const beforeFieldDelete = requests.length;
await button('Delete Reviewer').click();
await dialog()
.getByText(/permanently removes 2 stored ticket values/)
.waitFor();
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await closed();
assert.equal(requests.length, beforeFieldDelete);
await button('Delete Reviewer').click();
await submit(
'Delete',
'DELETE',
'/projects/1/settings/metadata/fields/5',
{},
);
await closed();
await button('Add link type').click();
await page.getByLabel('Source label', { exact: true }).fill('Depends on');
await page
.getByLabel('Counterpart label', { exact: true })
.fill('Required by');
await submit(
'Add link type',
'POST',
'/projects/1/settings/metadata/link-types',
{ name: 'Depends on', counterpart_name: 'Required by' },
);
await closed();
await button('Edit Depends on').click();
assert.equal(
await page
.getByLabel('Counterpart label', { exact: true })
.inputValue(),
'Required by',
);
await page.getByLabel('Source label', { exact: true }).fill('Requires');
await page
.getByLabel('Counterpart label', { exact: true })
.fill('Required for');
await page.getByLabel('Position', { exact: true }).fill('1');
await submit(
'Save',
'PATCH',
'/projects/1/settings/metadata/link-types/101',
{ name: 'Requires', counterpart_name: 'Required for', position: 1 },
);
await closed();
await page
.getByLabel('Requires points to Required for', { exact: true })
.waitFor();
const beforeLinkDelete = requests.length;
await button('Delete Blocks').click();
await dialog()
.getByText(/permanently removes 1 ticket relationships/)
.waitFor();
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await closed();
assert.equal(requests.length, beforeLinkDelete);
await button('Delete Blocks').click();
await submit(
'Delete',
'DELETE',
'/projects/1/settings/metadata/link-types/7',
{},
);
await closed();
assert.deepEqual(errors, []);
console.log(
'Metadata browser checks passed: field and link-type CRUD payloads, direction labels, deletion impact, validation, retries, keyboard focus, responsive widths and both themes.',
);
} finally {
await browser.close();
}
+93 -12
View File
@@ -55,9 +55,35 @@ function initial() {
{ id: 3, name: 'Normal', position: 1 }, { id: 3, name: 'Normal', position: 1 },
{ id: 4, name: 'Urgent', position: 2 }, { id: 4, name: 'Urgent', position: 2 },
], ],
ticketFieldDefinitions: [], ticketFieldDefinitions: [
ticketLinkTypes: [], {
fieldTypeOptions: [], id: 5,
label: 'Reviewer',
field_type: 'user',
position: 1,
ticket_field_values_count: 2,
},
{
id: 6,
label: 'Complexity',
field_type: 'number',
position: 2,
ticket_field_values_count: 0,
},
],
ticketLinkTypes: [
{
id: 7,
label: 'Blocks',
counterpart_label: 'Blocked by',
position: 1,
ticket_links_count: 1,
},
],
fieldTypeOptions: [
{ label: 'User', value: 'user' },
{ label: 'Number', value: 'number' },
],
}; };
} }
function makePage( function makePage(
@@ -132,16 +158,30 @@ if (serve) {
response.end('Unavailable'); response.end('Unavailable');
return; return;
} }
const kind = url.pathname.includes('/types') ? 'type' : 'priority'; const kind = url.pathname.includes('/link-types')
? 'link'
: url.pathname.includes('/fields')
? 'field'
: url.pathname.includes('/types')
? 'type'
: 'priority';
const entries = const entries =
kind === 'type' ? data.ticketTypes : data.ticketPriorities; kind === 'type'
? data.ticketTypes
: kind === 'priority'
? data.ticketPriorities
: kind === 'field'
? data.ticketFieldDefinitions
: data.ticketLinkTypes;
const id = Number(url.pathname.split('/').at(-1)); const id = Number(url.pathname.split('/').at(-1));
if ( if (
result === 'validation' || result === 'validation' ||
(submitted.name && ((submitted.name || submitted.label) &&
entries.some( entries.some(
(entry) => (entry) =>
entry.name === submitted.name && entry.id !== id, (entry.name ?? entry.label) ===
(submitted.name ?? submitted.label) &&
entry.id !== id,
)) ))
) { ) {
errors = errors =
@@ -150,6 +190,13 @@ if (serve) {
replacement_ticket_type_id: replacement_ticket_type_id:
'Choose a parent-enabled replacement type.', 'Choose a parent-enabled replacement type.',
} }
: kind === 'field'
? { label: 'This field label is already in use.' }
: kind === 'link'
? {
counterpart_name:
'Enter a distinct counterpart label.',
}
: { name: 'This name is already in use.' }; : { name: 'This name is already in use.' };
} else if (request.method === 'DELETE') { } else if (request.method === 'DELETE') {
entries.splice( entries.splice(
@@ -159,16 +206,43 @@ if (serve) {
} else if (url.pathname.endsWith('/metadata')) { } else if (url.pathname.endsWith('/metadata')) {
Object.assign(data.project, submitted); Object.assign(data.project, submitted);
} else if (request.method === 'POST') { } else if (request.method === 'POST') {
entries.push({ const created = {
id: nextId++, id: nextId++,
position: entries.length + 1, position: entries.length + 1,
tickets_count: 0, ...(kind === 'type'
...submitted, ? { tickets_count: 0, ...submitted }
}); : kind === 'priority'
? submitted
: kind === 'field'
? {
label: submitted.label,
field_type: submitted.field_type,
ticket_field_values_count: 0,
}
: {
label: submitted.name,
counterpart_label: submitted.counterpart_name,
ticket_links_count: 0,
}),
};
entries.push(created);
} else { } else {
const item = entries.find((item) => item.id === id); const item = entries.find((item) => item.id === id);
entries.splice(entries.indexOf(item), 1); entries.splice(entries.indexOf(item), 1);
Object.assign(item, submitted); Object.assign(
item,
kind === 'field'
? {
label: submitted.label,
field_type: submitted.field_type,
}
: kind === 'link'
? {
label: submitted.name,
counterpart_label: submitted.counterpart_name,
}
: submitted,
);
entries.splice(submitted.position - 1, 0, item); entries.splice(submitted.position - 1, 0, item);
} }
entries.forEach((entry, index) => (entry.position = index + 1)); entries.forEach((entry, index) => (entry.position = index + 1));
@@ -240,6 +314,13 @@ if (serve) {
); );
assert.match(html, /Assigned tickets: 2/); assert.match(html, /Assigned tickets: 2/);
assert.match(html, /Parent type/); assert.match(html, /Parent type/);
assert.match(html, /data-ticket-field-definition="5"/);
assert.match(html, /Reviewer/);
assert.match(html, /Stored values: 2/);
assert.match(html, /data-ticket-link-type="7"/);
assert.match(html, /Blocks/);
assert.match(html, /Blocked by/);
assert.match(html, /Ticket relationships: 1/);
assert.match(html, /id="show_author"[^>]*aria-checked="true"/); assert.match(html, /id="show_author"[^>]*aria-checked="true"/);
assert.match(html, /id="show_story_points"[^>]*aria-checked="false"/); assert.match(html, /id="show_story_points"[^>]*aria-checked="false"/);
const single = await render({ const single = await render({
@@ -0,0 +1,260 @@
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 context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const base = 'http://127.0.0.1:4191';
const errors = [];
const mutations = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('request', (request) => {
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
mutations.push(request);
}
});
const button = (name) => page.getByRole('button', { name, exact: true });
const result = (value) =>
context.addCookies([{ name: 'template_test_result', value, url: base }]);
const visit = async (path) => {
await result('success');
await page.goto(base + path);
await page.locator('h1').waitFor();
};
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
const feedback = (state) =>
page
.locator('[data-form-state="' + state + '"]')
.waitFor({ state: 'visible' });
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 });
for (const path of [
'/project-templates?reset',
'/projects/1/templates/create?reset',
'/project-templates/2/edit?reset',
]) {
await visit(path);
await noOverflow();
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
if (width === 390 || width === 1440) {
await page.screenshot({
path:
'/private/tmp/KAN-46-' +
width +
'-' +
theme +
'-' +
(path.includes('/edit')
? 'edit'
: path.includes('/create')
? 'create'
: 'index') +
'.png',
fullPage: true,
});
}
}
assert.equal(await page.locator('#slug').isDisabled(), true);
assert.equal(
await button('Save template changes').isDisabled(),
true,
);
const checkbox = page.locator('#confirm-linked');
await checkbox.focus();
await page.keyboard.press('Space');
assert.equal(await checkbox.isChecked(), true);
assert.equal(
await button('Save template changes').isEnabled(),
true,
);
await visit('/project-templates?reset');
assert.equal(
await page
.getByText('Internal baseline', { exact: true })
.count(),
0,
);
const trigger = button('Delete Foundation');
await trigger.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog');
await dialog.waitFor();
await page.keyboard.press('Tab');
assert.equal(
await dialog.evaluate((el) =>
el.contains(document.activeElement),
),
true,
);
await noOverflow();
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
assert.equal(
await trigger.evaluate((el) => document.activeElement === el),
true,
);
assert.equal(mutations.length, 0);
}
}
await visit('/projects/1/templates/create?reset');
await page.locator('#name').fill('Release setup');
await page.locator('#slug').fill('release-setup');
const groups = page.locator('fieldset input[type="checkbox"]');
for (const group of await groups.all()) {
await group.uncheck();
}
await page
.getByRole('checkbox', { name: 'Project settings', exact: true })
.check();
await page
.getByRole('checkbox', { name: 'Ticket types', exact: true })
.check();
await result('validation');
await button('Save new template').click();
await feedback('validation');
assert.equal(await page.locator('#name').inputValue(), 'Release setup');
assert.equal(
await page.locator('#name').getAttribute('aria-invalid'),
'true',
);
await page
.getByText('Choose a valid configuration group.', { exact: true })
.waitFor();
assert.equal(
await page
.locator('[data-form-state="validation"]')
.evaluate((el) => el === document.activeElement),
true,
);
await result('server');
await button('Save new template').click();
await feedback('server');
await result('success');
const created = page.waitForRequest(
(request) => request.method() === 'POST',
);
await button('Try again').click();
assert.deepEqual((await created).postDataJSON(), {
name: 'Release setup',
slug: 'release-setup',
copy_areas: ['project_settings', 'ticket_types'],
});
await page
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
await visit('/project-templates/2/edit?reset');
const before = mutations.length;
await page
.locator('form')
.evaluate((el) =>
el.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
),
);
assert.equal(
mutations.length,
before,
'Linked updates must wait for acknowledgement',
);
await page.locator('#confirm-linked').check();
await page.locator('#name').fill('Updated foundation');
await page.route('**/project-templates/2', (route) =>
route.abort('failed'),
);
await button('Save template changes').click();
await feedback('network');
assert.equal(await page.locator('#confirm-linked').isChecked(), true);
await page.unroute('**/project-templates/2');
const updated = page.waitForRequest(
(request) => request.method() === 'PATCH',
);
await button('Try again').click();
assert.deepEqual((await updated).postDataJSON(), {
name: 'Updated foundation',
copy_areas: ['project_settings', 'ticket_types'],
confirm_linked_projects_update: true,
});
await page
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
await visit('/project-templates/2/edit?reset&unlinked');
assert.equal(await page.locator('#confirm-linked').count(), 0);
const unlinked = page.waitForRequest(
(request) => request.method() === 'PATCH',
);
await button('Save template changes').click();
assert.deepEqual((await unlinked).postDataJSON(), {
name: 'Foundation',
copy_areas: ['project_settings', 'ticket_types'],
});
await page
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
await visit('/project-templates/2/edit?reset&missing');
await page
.getByText('This template cannot be refreshed', { exact: true })
.waitFor();
assert.equal(await button('Save template changes').isDisabled(), true);
await visit('/projects/1/templates/create?reset');
await page.locator('#name').fill('Unsaved work');
let prompted = false;
page.once('dialog', async (dialog) => {
prompted = true;
await dialog.dismiss();
});
await page.getByRole('link', { name: 'Cancel', exact: true }).click();
assert.equal(prompted, true);
assert.equal(await page.locator('#name').inputValue(), 'Unsaved work');
// Reload starts the next isolated scenario; accept the browser's unload prompt.
page.once('dialog', (dialog) => dialog.accept());
await visit('/project-templates?reset');
await button('Delete Foundation').click();
await result('server');
await button('Delete template').click();
await feedback('server');
await result('success');
const deleted = page.waitForRequest(
(request) => request.method() === 'DELETE',
);
await button('Try again').click();
assert.equal(
new URL((await deleted).url()).pathname,
'/project-templates/2',
);
await page.getByRole('dialog').waitFor({ state: 'hidden' });
await page
.getByText('No published templates yet', { exact: true })
.waitFor();
await visit('/project-templates?reset');
await page.getByRole('link', { name: 'Use template', exact: true }).click();
await page.getByRole('heading', { name: 'Destination reached' }).waitFor();
assert.match(page.url(), /\/projects\/create\?template=2$/);
assert.deepEqual(errors, []);
console.log(
'Project template browser checks passed: create/edit payloads, confirmations, slug lock, validation/network/server retries, missing source, deletion, navigation, keyboard, 320/390/1440px and both themes.',
);
} finally {
await browser.close();
}
@@ -0,0 +1,20 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Index from '../../resources/js/pages/project-templates/Index.vue';
import Create from '../../resources/js/pages/project-templates/Create.vue';
import Edit from '../../resources/js/pages/project-templates/Edit.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
({ Index, Create, Edit })[name] ?? {
render: () => h('h1', 'Destination reached'),
},
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+288
View File
@@ -0,0 +1,288 @@
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-templates-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4191, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const project = { id: 1, name: 'Core platform', key: 'CORE' };
const areas = [
'project_settings',
'board_columns',
'ticket_statuses',
'status_transitions',
'ticket_types',
'ticket_priorities',
'ticket_fields',
'ticket_link_types',
'repository_connections',
'notification_actions',
];
const template = {
id: 2,
name: 'Foundation',
slug: 'foundation-template',
is_published: true,
source_project: project,
copy_areas: ['project_settings', 'ticket_types'],
linked_project_count: 2,
};
const initial = () => ({
project,
copyAreaOptions: areas.map((value) => ({
value,
label: value,
description: value,
})),
template: structuredClone(template),
templates: [
structuredClone(template),
{ ...template, id: 3, name: 'Internal baseline', is_published: false },
],
});
function makePage(component = 'Index', extra = {}, url = '/project-templates') {
return {
component,
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
auth: {
user: {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
avatar_color: '#10B981',
avatar_is_generated: true,
},
permissions: {
globalAdmin: false,
projectCreate: true,
canManageTeams: false,
},
},
navigation: { project: null },
...initial(),
...extra,
},
};
}
if (serve) {
let data = initial();
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/project')) {
return next();
}
const url = new URL(request.url, 'http://localhost');
let component = url.pathname.endsWith('/edit')
? 'Edit'
: url.pathname.includes('/templates/create')
? 'Create'
: url.pathname === '/project-templates'
? 'Index'
: 'Destination';
if (url.searchParams.has('reset')) {
data = initial();
}
if (url.searchParams.has('unlinked')) {
data.template.linked_project_count = 0;
}
if (url.searchParams.has('missing')) {
data.template.source_project = null;
}
if (url.searchParams.has('empty')) {
data.templates = [];
}
let errors = {};
if (request.method !== 'GET') {
let body = '';
for await (const chunk of request) {
body += chunk;
}
const submitted = JSON.parse(body || '{}');
const result =
request.headers.cookie?.match(
/template_test_result=(\w+)/,
)?.[1] ?? 'success';
if (result === 'server') {
response.statusCode = 500;
response.end('Unavailable');
return;
}
if (result === 'validation') {
component =
request.method === 'POST'
? 'Create'
: request.method === 'PATCH'
? 'Edit'
: 'Index';
errors = {
name: 'This template name is unavailable.',
'copy_areas.0': 'Choose a valid configuration group.',
};
} else {
if (request.method === 'DELETE') {
data.templates = data.templates.filter(
(item) => item.id !== 2,
);
} else {
data.templates = [{ ...data.template, ...submitted }];
}
component = 'Index';
}
}
const page = makePage(
component,
{
...data,
errors,
status:
request.method !== 'GET' && !Object.keys(errors).length
? 'Saved'
: null,
},
component === 'Index' ? '/project-templates' : request.url,
);
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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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-templates-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Project template fixture ready.');
} 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 render = async (
name,
extra = {},
locale = createLocalization(),
) => {
const component = (
await server.ssrLoadModule(
'/resources/js/pages/project-templates/' + name + '.vue',
)
).default;
return (
await createInertiaApp({
page: makePage(name, extra),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance()),
})
).body.replace(/ data-page="[^"]*"/, '');
};
const list = await render('Index');
assert.match(list, /Foundation/);
assert.doesNotMatch(list, /Internal baseline/);
assert.match(list, /2 linked projects/);
assert.match(list, /Project settings/);
assert.match(list, /href="\/projects\/create\?template=2"/);
assert.match(
await render('Index', { templates: [] }),
/No published templates yet/,
);
const create = await render('Create');
assert.match(create, /value="Core platform template"/);
assert.equal((create.match(/type="checkbox"/g) ?? []).length, 10);
assert.doesNotMatch(
create.match(/<input[^>]*id="slug"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
const edit = await render('Edit');
assert.match(
edit.match(/<input[^>]*id="slug"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
assert.match(edit, /Linked projects will be affected/);
assert.match(
edit.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
const unlinked = await render('Edit', {
template: { ...template, linked_project_count: 0 },
});
assert.doesNotMatch(unlinked, /id="confirm-linked"/);
assert.doesNotMatch(
unlinked.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
assert.match(
await render('Edit', {
template: { ...template, source_project: null },
}),
/This template cannot be refreshed/,
);
assert.match(
await render(
'Index',
{},
createLocalization(
{ locale: 'de' },
{
de: {
...en,
'navigation.templates': 'Projektvorlagen',
},
},
),
),
/Projektvorlagen/,
);
console.log('Project template checks passed.');
} finally {
await server.close();
}
}
@@ -0,0 +1,21 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { createAppearance } from '@/composables/useAppearance';
import { createLocalization } from '@/lib/localization';
import Create from '@/pages/admin/users/Create.vue';
import Edit from '@/pages/admin/users/Edit.vue';
import Index from '@/pages/admin/users/Index.vue';
import '@/../css/app.css';
const components = { Create, Edit, Index };
createInertiaApp({
resolve: (name) => components[name],
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el);
},
});
+206
View File
@@ -0,0 +1,206 @@
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/user-management-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4191, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const admin = {
id: 1,
name: 'Alex Admin',
email: 'alex@example.test',
avatar: null,
avatar_color: '#10B981',
avatar_is_generated: true,
email_verified_at: null,
must_change_password_on_next_login: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
const member = {
...admin,
id: 2,
name: 'Morgan Member',
email: 'morgan@example.test',
must_change_password_on_next_login: true,
};
const shared = {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
auth: {
user: admin,
permissions: {
globalAdmin: true,
projectCreate: true,
canManageTeams: true,
},
},
navigation: { project: null },
};
const pages = {
index: {
component: 'Index',
url: '/admin/users',
props: {
...shared,
directPermissions: {
1: ['global:admin', 'project:create'],
2: [],
},
permissionOptions: [
{ value: 'global:admin', label: 'Global admin' },
{ value: 'project:create', label: 'Create project' },
],
users: [admin, member],
status: null,
},
},
create: {
component: 'Create',
url: '/admin/users/create',
props: shared,
},
edit: {
component: 'Edit',
url: '/admin/users/2/edit',
props: { ...shared, user: member },
},
};
function inertiaPage(page) {
return {
...page,
version: null,
clearHistory: false,
encryptHistory: false,
};
}
async function render(path, page) {
const component = (await server.ssrLoadModule(path)).default;
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
return (
await createInertiaApp({
page: inertiaPage(page),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance()),
})
).body;
}
if (serve) {
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/admin/users')) {
return next();
}
const pathname = new URL(request.url, 'http://localhost').pathname;
const page = pathname.endsWith('/create')
? pages.create
: pathname.endsWith('/edit')
? pages.edit
: pages.index;
const completePage = inertiaPage(page);
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(completePage));
return;
}
const escaped = JSON.stringify(completePage)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
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/user-management-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('User management fixture ready.');
} else {
try {
const indexHtml = await render(
'/resources/js/pages/admin/users/Index.vue',
pages.index,
);
assert.match(indexHtml, /Workspace administration/);
assert.match(indexHtml, /User management/);
assert.match(indexHtml, /Workspace users/);
assert.match(indexHtml, /Global administrators/);
assert.match(indexHtml, /Password changes pending/);
assert.equal(indexHtml.match(/data-managed-user/g)?.length, 2);
assert.match(indexHtml, /Alex Admin/);
assert.match(indexHtml, /Morgan Member/);
assert.match(indexHtml, /Manage access/);
assert.match(indexHtml, /Edit account details for Morgan Member/);
assert.match(indexHtml, /Password change required/);
assert.match(indexHtml, /No direct global permissions/);
const createHtml = await render(
'/resources/js/pages/admin/users/Create.vue',
pages.create,
);
assert.match(createHtml, /Create user/);
assert.match(createHtml, /Temporary password/);
assert.match(createHtml, /Confirm password/);
assert.match(createHtml, /Require a password change on next sign-in/);
assert.match(createHtml, /data-test="managed-user-submit"/);
assert.match(createHtml, /managed-user-password-confirmation-error/);
const editHtml = await render(
'/resources/js/pages/admin/users/Edit.vue',
pages.edit,
);
assert.match(editHtml, /Edit Morgan Member/);
assert.match(editHtml, /morgan@example\.test/);
assert.match(editHtml, /Leave both password fields empty/);
assert.match(editHtml, /Review changes/);
assert.match(editHtml, /value="Morgan Member"/);
assert.match(editHtml, /value="morgan@example\.test"/);
console.log('User management rendering checks passed.');
} finally {
await server.close();
}
}